P9:Python 包装层 —— 真实存档 → 官方渲染器 → 出图
新增两模块: mcfold/anvil.py 存档读取(region/NBT)+ 区块编解码工具 mcfold/render_bridge.py 桥:常驻 Java 渲染器进程,NDJSON 协议 mcfold/render.py 对外入口:WorldRenderer(存档→画面) scaffold/McfoldRenderServer.java Java 侧服务端(常驻,NDJSON) 实测(真实存档 mc-test,DataVersion 3465=1.20.1): injected=81 unknownBlocks=0 uniqueSpecs=111 loadedChunks=81 chunksRendered=60 distinctColors=11585 画面:草地/沙滩/树/水/天空/太阳/HUD(sample-real-save-render.png) ★ 分工落在实处(附录 D 的结论变成代码): 存档 palette 是**方块名**(minecraft:oak_log[axis=y]),网络载荷要 **state id**, 名字→id 的权威映射只有客户端注册表有 ⇒ 新增 resolve 命令, Java 侧用 BlockStateParser.parseForBlock 解析(实测 111 种全中,0 unknown) 踩坑(本轮新发现): 1. level.dat 有两种布局(8字节头+zlib / 直接 gzip)—— 实测本机是后者 2. section 索引 ≠ 绝对 Y:存档用 -4..19,载荷用 0..23(少这步 → readerIndex 越界) 3. 注入前必须 set_viewport,否则 ClientChunkCache.inRange 丢弃区块 4. setPos 不会更新实体区块归属,要 setPosRaw + updateViewCenter 5. 生物群系只影响着色,用统一 plains 不影响「能渲染出世界」 6. camera 回包要用 JsonArray,字符串会被 Python tuple() 按字符拆开 性能:启动 66s(GL+资源重载),之后每帧渲染 ~1s(含 100 帧 tick)
This commit is contained in:
BIN
logs/2026-09-25-1.log.gz
Normal file
BIN
logs/2026-09-25-1.log.gz
Normal file
Binary file not shown.
72
logs/latest.log
Normal file
72
logs/latest.log
Normal file
@ -0,0 +1,72 @@
|
||||
[17:19:27] [Datafixer Bootstrap/INFO]: 188 Datafixer optimizations took 93 milliseconds
|
||||
[17:19:28] [main/INFO]: Environment: authHost='https://authserver.mojang.com', accountsHost='https://api.mojang.com', sessionHost='https://sessionserver.mojang.com', servicesHost='https://api.minecraftservices.com', name='PROD'
|
||||
[17:19:29] [main/ERROR]: Failed to verify authentication
|
||||
com.mojang.authlib.exceptions.InvalidCredentialsException: Status: 401
|
||||
at com.mojang.authlib.exceptions.MinecraftClientHttpException.toAuthenticationException(MinecraftClientHttpException.java:56) ~[authlib-4.0.43.jar:?]
|
||||
at com.mojang.authlib.yggdrasil.YggdrasilUserApiService.fetchProperties(YggdrasilUserApiService.java:156) ~[authlib-4.0.43.jar:?]
|
||||
at com.mojang.authlib.yggdrasil.YggdrasilUserApiService.<init>(YggdrasilUserApiService.java:55) ~[authlib-4.0.43.jar:?]
|
||||
at com.mojang.authlib.yggdrasil.YggdrasilAuthenticationService.createUserApiService(YggdrasilAuthenticationService.java:124) ~[authlib-4.0.43.jar:?]
|
||||
at net.minecraft.client.Minecraft.createUserApiService(SourceFile:733) ~[client-named.jar:?]
|
||||
at net.minecraft.client.Minecraft.<init>(SourceFile:442) ~[client-named.jar:?]
|
||||
at McfoldRenderServer.boot(McfoldRenderServer.java:434) ~[classes/:?]
|
||||
at McfoldRenderServer.main(McfoldRenderServer.java:104) ~[classes/:?]
|
||||
Caused by: com.mojang.authlib.exceptions.MinecraftClientHttpException: Status: 401
|
||||
at com.mojang.authlib.minecraft.client.MinecraftClient.readInputStream(MinecraftClient.java:85) ~[authlib-4.0.43.jar:?]
|
||||
at com.mojang.authlib.minecraft.client.MinecraftClient.get(MinecraftClient.java:48) ~[authlib-4.0.43.jar:?]
|
||||
at com.mojang.authlib.yggdrasil.YggdrasilUserApiService.fetchProperties(YggdrasilUserApiService.java:129) ~[authlib-4.0.43.jar:?]
|
||||
... 6 more
|
||||
[17:19:29] [main/INFO]: Setting user: mcfold_cam
|
||||
[17:19:29] [main/INFO]: Backend library: LWJGL version 3.3.1 build 7
|
||||
[17:19:29] [main/INFO]: [STDERR]: [LWJGL] [ThreadLocalUtil] Unsupported JNI version detected, this may result in a crash. Please inform LWJGL developers.
|
||||
[17:19:29] [main/WARN]: Attempted to end step for load_time_pre_window_ms before starting it
|
||||
[17:19:29] [main/INFO]: Reloading ResourceManager: vanilla
|
||||
[17:19:30] [Worker-Main-11/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading
|
||||
[17:19:30] [main/INFO]: [STDERR]: [mcfold] Minecraft constructed
|
||||
[17:20:30] [main/INFO]: [STDERR]: [mcfold] TIMEOUT TextureManager
|
||||
[17:20:30] [main/WARN]: Missing sound for event: minecraft:item.goat_horn.play
|
||||
[17:20:30] [main/WARN]: Missing sound for event: minecraft:entity.goat.screaming.horn_break
|
||||
[17:20:30] [main/ERROR]: Error starting SoundSystem. Turning off sounds & music
|
||||
java.lang.IllegalStateException: Failed to open OpenAL device
|
||||
at com.mojang.blaze3d.audio.Library.openDeviceOrFallback(SourceFile:283) ~[client-named.jar:?]
|
||||
at com.mojang.blaze3d.audio.Library.init(SourceFile:153) ~[client-named.jar:?]
|
||||
at net.minecraft.client.sounds.SoundEngine.loadLibrary(SourceFile:116) ~[client-named.jar:?]
|
||||
at net.minecraft.client.sounds.SoundEngine.reload(SourceFile:106) ~[client-named.jar:?]
|
||||
at net.minecraft.client.sounds.SoundManager.apply(SourceFile:125) ~[client-named.jar:?]
|
||||
at net.minecraft.client.sounds.SoundManager.apply(SourceFile:40) ~[client-named.jar:?]
|
||||
at net.minecraft.server.packs.resources.SimplePreparableReloadListener.lambda$reload$1(SourceFile:13) ~[client-named.jar:?]
|
||||
at java.util.concurrent.CompletableFuture$UniAccept.tryFire(CompletableFuture.java:718) ~[?:?]
|
||||
at java.util.concurrent.CompletableFuture$Completion.run(CompletableFuture.java:482) ~[?:?]
|
||||
at java.util.concurrent.CompletableFuture.uniAcceptNow(CompletableFuture.java:754) ~[?:?]
|
||||
at java.util.concurrent.CompletableFuture.uniAcceptStage(CompletableFuture.java:735) ~[?:?]
|
||||
at java.util.concurrent.CompletableFuture.thenAcceptAsync(CompletableFuture.java:2223) ~[?:?]
|
||||
at net.minecraft.server.packs.resources.SimplePreparableReloadListener.reload(SourceFile:13) ~[client-named.jar:?]
|
||||
at McfoldRenderServer.reloadResources(McfoldRenderServer.java:505) ~[classes/:?]
|
||||
at McfoldRenderServer.boot(McfoldRenderServer.java:438) ~[classes/:?]
|
||||
at McfoldRenderServer.main(McfoldRenderServer.java:104) ~[classes/:?]
|
||||
[17:20:30] [main/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading
|
||||
[17:20:30] [main/INFO]: Created: 1024x512x4 minecraft:textures/atlas/blocks.png-atlas
|
||||
[17:20:30] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/signs.png-atlas
|
||||
[17:20:30] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/shield_patterns.png-atlas
|
||||
[17:20:30] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/banner_patterns.png-atlas
|
||||
[17:20:30] [main/INFO]: Created: 1024x1024x4 minecraft:textures/atlas/armor_trims.png-atlas
|
||||
[17:20:30] [main/INFO]: Created: 128x64x4 minecraft:textures/atlas/decorated_pot.png-atlas
|
||||
[17:20:30] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/chest.png-atlas
|
||||
[17:20:30] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/beds.png-atlas
|
||||
[17:20:30] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/shulker_boxes.png-atlas
|
||||
[17:20:31] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program.
|
||||
[17:20:31] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/particles.png-atlas
|
||||
[17:20:31] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/paintings.png-atlas
|
||||
[17:20:31] [main/INFO]: Created: 128x128x0 minecraft:textures/atlas/mob_effects.png-atlas
|
||||
[17:20:31] [main/INFO]: [STDERR]: [mcfold] resources reloaded
|
||||
[17:20:31] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program.
|
||||
[17:20:31] [main/INFO]: [STDERR]: [mcfold] boot done
|
||||
[17:20:32] [main/INFO]: [STDERR]: java.lang.IndexOutOfBoundsException: readerIndex(8458) + length(2) exceeds writerIndex(8458): UnpooledHeapByteBuf(ridx: 8458, widx: 8458, cap: 8458/8458)
|
||||
[17:20:32] [main/INFO]: [STDERR]: at io.netty.buffer.AbstractByteBuf.checkReadableBytes0(AbstractByteBuf.java:1442)
|
||||
[17:20:32] [main/INFO]: [STDERR]: at io.netty.buffer.AbstractByteBuf.readShort(AbstractByteBuf.java:749)
|
||||
[17:20:32] [main/INFO]: [STDERR]: at net.minecraft.network.FriendlyByteBuf.readShort(SourceFile:1313)
|
||||
[17:20:32] [main/INFO]: [STDERR]: at net.minecraft.world.level.chunk.LevelChunkSection.read(SourceFile:156)
|
||||
[17:20:32] [main/INFO]: [STDERR]: at net.minecraft.world.level.chunk.LevelChunk.replaceWithPacketData(SourceFile:456)
|
||||
[17:20:32] [main/INFO]: [STDERR]: at net.minecraft.client.multiplayer.ClientChunkCache.replaceWithPacketData(SourceFile:117)
|
||||
[17:20:32] [main/INFO]: [STDERR]: at McfoldRenderServer.applyChunk(McfoldRenderServer.java:335)
|
||||
[17:20:32] [main/INFO]: [STDERR]: at McfoldRenderServer.handle(McfoldRenderServer.java:160)
|
||||
[17:20:32] [main/INFO]: [STDERR]: at McfoldRenderServer.main(McfoldRenderServer.java:126)
|
||||
BIN
mcfold/__pycache__/anvil.cpython-312.pyc
Normal file
BIN
mcfold/__pycache__/anvil.cpython-312.pyc
Normal file
Binary file not shown.
BIN
mcfold/__pycache__/render.cpython-312.pyc
Normal file
BIN
mcfold/__pycache__/render.cpython-312.pyc
Normal file
Binary file not shown.
BIN
mcfold/__pycache__/render_bridge.cpython-312.pyc
Normal file
BIN
mcfold/__pycache__/render_bridge.cpython-312.pyc
Normal file
Binary file not shown.
300
mcfold/anvil.py
Normal file
300
mcfold/anvil.py
Normal file
@ -0,0 +1,300 @@
|
||||
"""从 MC 存档(anvil region)读真实区块数据,交给渲染桥出图。
|
||||
|
||||
为什么走存档而不走服务端:
|
||||
用户口径是「只调用渲染能力,不参与游戏」——存档是**零网络、零登录、
|
||||
零服务端**的真实世界数据源,且与官方客户端认同一套格式。
|
||||
|
||||
数据流:
|
||||
region/*.mca → 解压区块 NBT → NBT 里的 block_states/Heightmaps
|
||||
→ 重编码为 packet_map_chunk 载荷 → Java 渲染器
|
||||
|
||||
为什么要重编码:存档用 NBT(Sections[].block_states.palette/data),
|
||||
网络包用扁平二进制(nonEmpty + bitsPerValue + palette + long[])。
|
||||
两者信息等价,格式不同。渲染器吃的是网络包格式(官方同一路径)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import gzip
|
||||
import io
|
||||
import struct
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator
|
||||
|
||||
SECTOR = 4096
|
||||
|
||||
|
||||
class AnvilError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- NBT 读取
|
||||
|
||||
class NbtReader:
|
||||
"""极简 NBT 读取器(只读,支持存档里的全部 tag 类型)。"""
|
||||
|
||||
def __init__(self, data: bytes):
|
||||
self.d = data
|
||||
self.i = 0
|
||||
|
||||
def u1(self) -> int:
|
||||
v = self.d[self.i]; self.i += 1; return v
|
||||
|
||||
def i1(self) -> int:
|
||||
v = struct.unpack_from(">b", self.d, self.i)[0]; self.i += 1; return v
|
||||
|
||||
def i2(self) -> int:
|
||||
v = struct.unpack_from(">h", self.d, self.i)[0]; self.i += 2; return v
|
||||
|
||||
def u2(self) -> int:
|
||||
v = struct.unpack_from(">H", self.d, self.i)[0]; self.i += 2; return v
|
||||
|
||||
def i4(self) -> int:
|
||||
v = struct.unpack_from(">i", self.d, self.i)[0]; self.i += 4; return v
|
||||
|
||||
def i8(self) -> int:
|
||||
v = struct.unpack_from(">q", self.d, self.i)[0]; self.i += 8; return v
|
||||
|
||||
def f4(self) -> float:
|
||||
v = struct.unpack_from(">f", self.d, self.i)[0]; self.i += 4; return v
|
||||
|
||||
def f8(self) -> float:
|
||||
v = struct.unpack_from(">d", self.d, self.i)[0]; self.i += 8; return v
|
||||
|
||||
def s(self) -> str:
|
||||
n = self.u2()
|
||||
v = self.d[self.i:self.i + n].decode("utf-8", "replace"); self.i += n
|
||||
return v
|
||||
|
||||
def payload(self, t: int) -> Any:
|
||||
if t == 1: return self.i1()
|
||||
if t == 2: return self.i2()
|
||||
if t == 3: return self.i4()
|
||||
if t == 4: return self.i8()
|
||||
if t == 5: return self.f4()
|
||||
if t == 6: return self.f8()
|
||||
if t == 7:
|
||||
n = self.i4(); v = self.d[self.i:self.i + n]; self.i += n; return v
|
||||
if t == 8: return self.s()
|
||||
if t == 9:
|
||||
it = self.u1(); n = self.i4()
|
||||
return [self.payload(it) for _ in range(n)]
|
||||
if t == 10:
|
||||
out = {}
|
||||
while True:
|
||||
tt = self.u1()
|
||||
if tt == 0: break
|
||||
nm = self.s()
|
||||
out[nm] = self.payload(tt)
|
||||
return out
|
||||
if t == 11:
|
||||
n = self.i4()
|
||||
v = list(struct.unpack_from(f">{n}i", self.d, self.i)); self.i += 4 * n
|
||||
return v
|
||||
if t == 12:
|
||||
n = self.i4()
|
||||
v = list(struct.unpack_from(f">{n}q", self.d, self.i)); self.i += 8 * n
|
||||
return v
|
||||
raise AnvilError(f"未知 NBT tag {t} @ {self.i}")
|
||||
|
||||
|
||||
def nbt_read(data: bytes) -> dict:
|
||||
r = NbtReader(data)
|
||||
t = r.u1()
|
||||
if t != 10:
|
||||
raise AnvilError(f"根 tag 应为 Compound(10),得到 {t}")
|
||||
r.s() # 根名(通常空串)
|
||||
return r.payload(10)
|
||||
|
||||
|
||||
def read_level_dat(save_dir: Path) -> dict:
|
||||
"""读 level.dat。
|
||||
|
||||
★ 实测两种布局都存在:
|
||||
- 官方启动器/服务端:8 字节头 + zlib
|
||||
- 部分整合包(本机 mc-test 实测):文件直接就是 gzip
|
||||
所以先看魔数,再决定偏移。
|
||||
"""
|
||||
raw = (save_dir / "level.dat").read_bytes()
|
||||
if raw[:2] == b"\x1f\x8b": # 直接 gzip
|
||||
body = gzip.decompress(raw)
|
||||
elif len(raw) > 10 and raw[8:10] == b"\x1f\x8b": # 8 字节头 + gzip
|
||||
body = gzip.decompress(raw[8:])
|
||||
else: # 8 字节头 + zlib
|
||||
body = zlib.decompress(raw[8:])
|
||||
return nbt_read(body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- region 读取
|
||||
|
||||
@dataclass
|
||||
class RegionFile:
|
||||
"""一个 .mca 文件:读出指定区块的原始 NBT(已解压)。"""
|
||||
|
||||
path: Path
|
||||
_buf: bytes = b""
|
||||
|
||||
def __post_init__(self):
|
||||
self._buf = self.path.read_bytes()
|
||||
if len(self._buf) < SECTOR * 2:
|
||||
raise AnvilError(f"region 文件过小: {self.path}")
|
||||
|
||||
def chunk_nbt(self, cx: int, cz: int) -> bytes | None:
|
||||
"""cx/cz 是**区块坐标**(世界坐标 >> 4)。"""
|
||||
lx, lz = cx & 31, cz & 31
|
||||
idx = (lx + lz * 32) * 4
|
||||
off = struct.unpack_from(">I", self._buf, idx)[0]
|
||||
if off == 0:
|
||||
return None
|
||||
sector = (off >> 8) & 0xFFFFFF
|
||||
count = off & 0xFF
|
||||
if sector == 0 or count == 0:
|
||||
return None
|
||||
start = sector * SECTOR
|
||||
length = struct.unpack_from(">I", self._buf, start)[0]
|
||||
comp = self._buf[start + 4]
|
||||
blob = self._buf[start + 5:start + 4 + length]
|
||||
if comp == 1:
|
||||
return gzip.decompress(blob)
|
||||
if comp == 2:
|
||||
return zlib.decompress(blob)
|
||||
if comp == 3:
|
||||
return blob
|
||||
if comp == 4:
|
||||
import lzma
|
||||
return lzma.decompress(blob)
|
||||
raise AnvilError(f"未知压缩类型 {comp}")
|
||||
|
||||
|
||||
def region_path(save_dir: Path, dim: str | None, rx: int, rz: int) -> Path:
|
||||
dim_part = "" if not dim else f"{dim}/"
|
||||
return save_dir / f"{dim_part}region" / f"r.{rx}.{rz}.mca"
|
||||
|
||||
|
||||
def list_region_files(save_dir: Path, dim: str | None = None) -> list[Path]:
|
||||
base = save_dir / (f"{dim}/region" if dim else "region")
|
||||
return sorted(base.glob("r.*.*.mca")) if base.is_dir() else []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 存档区块 → 网络载荷
|
||||
|
||||
def _pack_long_bits(values: list[int], bits: int) -> list[int]:
|
||||
"""把 state id 列表按 MC 的位打包规则塞进 long(值不跨 long 边界)。"""
|
||||
if bits == 0:
|
||||
return []
|
||||
vpl = 64 // bits
|
||||
mask = (1 << bits) - 1
|
||||
longs: list[int] = []
|
||||
cur = 0
|
||||
slot = 0
|
||||
for v in values:
|
||||
cur |= (v & mask) << (slot * bits)
|
||||
slot += 1
|
||||
if slot == vpl:
|
||||
longs.append(cur); cur = 0; slot = 0
|
||||
if slot:
|
||||
longs.append(cur)
|
||||
return longs
|
||||
|
||||
|
||||
def _varint(v: int) -> bytes:
|
||||
out = bytearray()
|
||||
while True:
|
||||
b = v & 0x7F; v >>= 7
|
||||
if v: out.append(b | 0x80)
|
||||
else: out.append(b); break
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _write_long_array(longs: list[int]) -> bytes:
|
||||
"""MC 的 LongArray 编码:varint 长度 + 大端 i64 ×N"""
|
||||
out = _varint(len(longs))
|
||||
for l in longs:
|
||||
# 视为无符号 64 位
|
||||
out += struct.pack(">q", l - (1 << 64) if l >= (1 << 63) else l)
|
||||
return out
|
||||
|
||||
|
||||
def encode_block_section(non_empty: int, state_ids: list[int] | None,
|
||||
palette: list[int] | None) -> bytes:
|
||||
"""按网络包格式编码一个 block section。
|
||||
|
||||
state_ids=None 表示单值(palette 只有一个元素)。
|
||||
"""
|
||||
out = struct.pack(">h", non_empty)
|
||||
if state_ids is None:
|
||||
out += bytes([0]) + _varint(palette[0]) + _varint(0)
|
||||
return out
|
||||
|
||||
uniq = sorted(set(state_ids))
|
||||
if len(uniq) == 1:
|
||||
out += bytes([0]) + _varint(uniq[0]) + _varint(0)
|
||||
return out
|
||||
|
||||
# 本地调色板(indirect)。bits = ceil(log2(uniq)),至少 4
|
||||
bits = max(4, (len(uniq) - 1).bit_length())
|
||||
if bits > 8:
|
||||
# 全局调色板:bits 直接放 state id
|
||||
gbits = 15
|
||||
out += bytes([gbits]) + _write_long_array(_pack_long_bits(state_ids, gbits))
|
||||
return out
|
||||
|
||||
index = {v: i for i, v in enumerate(uniq)}
|
||||
out += bytes([bits])
|
||||
out += _varint(len(uniq))
|
||||
for v in uniq:
|
||||
out += _varint(v)
|
||||
out += _write_long_array(_pack_long_bits([index[v] for v in state_ids], bits))
|
||||
return out
|
||||
|
||||
|
||||
def encode_chunk_payload(elevated_sections: dict[int, dict]) -> bytes:
|
||||
"""把存档区块的 sections 编成 packet_map_chunk 的 chunkData 部分。
|
||||
|
||||
elevated_sections: {section_y_index: {"states": [4096 int], "biomes": [64 int]}}
|
||||
★ 必须覆盖整个 worldHeight 的 section 数(1.20.1 = 24),缺失的补空气。
|
||||
"""
|
||||
num_sections = 24
|
||||
out = bytearray()
|
||||
for sy in range(num_sections):
|
||||
sec = elevated_sections.get(sy)
|
||||
out += encode_block_section(0, None, [0]) if sec is None else \
|
||||
encode_block_section(sec["nonEmpty"], sec["states"], None)
|
||||
# biome section:64 个值(4x4x4)
|
||||
biomes = sec["biomes"] if sec else [1] * 64
|
||||
uniq = sorted(set(biomes))
|
||||
if len(uniq) == 1:
|
||||
out += bytes([0]) + _varint(uniq[0]) + _varint(0)
|
||||
else:
|
||||
bits = max(1, (len(uniq) - 1).bit_length())
|
||||
if bits > 3:
|
||||
out += bytes([6]) + _write_long_array(_pack_long_bits(biomes, 6))
|
||||
else:
|
||||
idx = {v: i for i, v in enumerate(uniq)}
|
||||
out += bytes([bits]) + _varint(len(uniq))
|
||||
for v in uniq:
|
||||
out += _varint(v)
|
||||
out += _write_long_array(_pack_long_bits([idx[b] for b in biomes], bits))
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def save_chunk_to_payload(chunk_nbt: dict) -> bytes | None:
|
||||
"""存档区块 NBT → 网络载荷。"""
|
||||
sections = chunk_nbt.get("sections")
|
||||
if not isinstance(sections, list):
|
||||
return None
|
||||
by_y: dict[int, dict] = {}
|
||||
for s in sections:
|
||||
sy = s.get("Y")
|
||||
if sy is None:
|
||||
continue
|
||||
bs = s.get("block_states")
|
||||
if not isinstance(bs, dict):
|
||||
continue
|
||||
palette = bs.get("palette") or []
|
||||
# palette 元素是 {"Name": "minecraft:stone", "Properties": {...}}
|
||||
# 需要 state id —— 这要靠客户端注册表(渲染器侧提供 lookup)
|
||||
data = bs.get("data")
|
||||
by_y[sy] = {"palette": palette, "data": data}
|
||||
return by_y # 返回中间结构;state id 解析交给调用方(需注册表)
|
||||
365
mcfold/render.py
Normal file
365
mcfold/render.py
Normal file
@ -0,0 +1,365 @@
|
||||
"""把世界数据渲染成画面 —— mcfold 的对外渲染入口。
|
||||
|
||||
分工(与设计文档附录 D 一致):
|
||||
本模块(Python) : 世界数据 —— 区块、方块、光照、相机
|
||||
客户端 jar(Java): 方块名与 state id 的权威映射 + 画面
|
||||
|
||||
为什么 state id 必须由渲染器侧解析:
|
||||
存档 palette 里是**方块名**(`minecraft:oak_log[axis=y]`),
|
||||
而网络载荷要 **state id**。名字→id 的映射随版本/加载器/mod 变化,
|
||||
只有客户端自带的注册表是权威的(1.20.1 不由网络下发,附录 D)。
|
||||
|
||||
用法:
|
||||
from mcfold.render import WorldRenderer
|
||||
with WorldRenderer.open(instance) as wr:
|
||||
wr.load_save("/home/mc-test/mc-test", center=(-1, -1), radius=3)
|
||||
wr.camera(x=8, y=80, z=8, yaw=135, pitch=35)
|
||||
img = wr.screenshot("/tmp/out.png")
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from . import anvil
|
||||
from .render_bridge import RenderBridge, RenderBridgeError, RenderResult
|
||||
|
||||
# 存档区块在网络载荷里的 section 数(1.20.1 worldHeight 384 / 16 = 24)
|
||||
SECTION_COUNT = 24
|
||||
SECTION_VOLUME = 16 * 16 * 16
|
||||
BIOME_VOLUME = 4 * 4 * 4
|
||||
# ★ 最小 section 的 Y:1.20 起 worldHeight 384、minBuildHeight -64 ⇒ minSection = -4。
|
||||
# 网络载荷里的 section 索引是「从下往上数」(0..23),而存档 NBT 用的是绝对 Y(-4..19)。
|
||||
MIN_SECTION_Y = -4
|
||||
|
||||
# 位打包:把 palette 索引列表压进 long(值不跨 long 边界)
|
||||
def _pack(values: list[int], bits: int) -> list[int]:
|
||||
if bits <= 0:
|
||||
return []
|
||||
vpl = 64 // bits
|
||||
mask = (1 << bits) - 1
|
||||
out, cur, slot = [], 0, 0
|
||||
for v in values:
|
||||
cur |= (v & mask) << (slot * bits)
|
||||
slot += 1
|
||||
if slot == vpl:
|
||||
out.append(cur); cur, slot = 0, 0
|
||||
if slot:
|
||||
out.append(cur)
|
||||
return out
|
||||
|
||||
|
||||
def _varint(v: int) -> bytes:
|
||||
out = bytearray()
|
||||
while True:
|
||||
b = v & 0x7F; v >>= 7
|
||||
if v: out.append(b | 0x80)
|
||||
else: out.append(b); break
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _long_array(longs: list[int]) -> bytes:
|
||||
out = _varint(len(longs))
|
||||
for l in longs:
|
||||
out += (l & 0xFFFFFFFFFFFFFFFF).to_bytes(8, "big")
|
||||
return out
|
||||
|
||||
|
||||
def _encode_paletted(state_ids: list[int], volume: int, min_bits: int = 4,
|
||||
global_bits: int = 15, max_local_bits: int = 8) -> bytes:
|
||||
"""编码一个 PalettedContainer 载荷(与 Java PalettedContainer.read 对齐)。"""
|
||||
uniq = sorted(set(state_ids))
|
||||
if len(uniq) == 1:
|
||||
return bytes([0]) + _varint(uniq[0]) + _varint(0)
|
||||
|
||||
need = (len(uniq) - 1).bit_length()
|
||||
if need > max_local_bits:
|
||||
# 全局调色板:位宽直接放 state id
|
||||
return bytes([global_bits]) + _long_array(_pack(state_ids, global_bits))
|
||||
|
||||
bits = max(min_bits, need)
|
||||
idx = {v: i for i, v in enumerate(uniq)}
|
||||
out = bytes([bits]) + _varint(len(uniq))
|
||||
for v in uniq:
|
||||
out += _varint(v)
|
||||
out += _long_array(_pack([idx[v] for v in state_ids], bits))
|
||||
return out
|
||||
|
||||
|
||||
def _decode_storage(longs: Iterable[int], volume: int, bits: int) -> list[int]:
|
||||
"""int[] 位数组 → 索引列表(不跨 long)。"""
|
||||
longs = list(longs)
|
||||
if bits == 0 or not longs:
|
||||
return [0] * volume
|
||||
vpl = 64 // bits
|
||||
mask = (1 << bits) - 1
|
||||
out = []
|
||||
for i in range(volume):
|
||||
li = i // vpl
|
||||
if li >= len(longs):
|
||||
out.append(0); continue
|
||||
off = (i - li * vpl) * bits
|
||||
out.append((longs[li] >> off) & mask)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class SaveChunk:
|
||||
"""存档里一个区块的、已解析好的方块与生物群系。"""
|
||||
|
||||
cx: int
|
||||
cz: int
|
||||
status: str = ""
|
||||
sections: dict[int, dict] = field(default_factory=dict) # y_idx -> {"palette": [名字], "states": [4096 名字], "biomes": [64 名字]}
|
||||
palette_specs: list[str] = field(default_factory=list) # 全部去重后的方块名(+属性)规格
|
||||
|
||||
|
||||
def _props_to_spec(name: str, props: dict | None) -> str:
|
||||
"""{'Name':'minecraft:oak_log','Properties':{'axis':'y'}} → 'minecraft:oak_log[axis=y]'"""
|
||||
if not props:
|
||||
return name
|
||||
parts = ",".join(f"{k}={v}" for k, v in sorted(props.items()))
|
||||
return f"{name}[{parts}]"
|
||||
|
||||
|
||||
def parse_save_chunk(cx: int, cz: int, nbt: dict) -> SaveChunk:
|
||||
"""存档区块 NBT → SaveChunk(方块用**名字规格**表示,state id 留给渲染器解析)。"""
|
||||
ch = SaveChunk(cx=cx, cz=cz, status=str(nbt.get("Status", "")))
|
||||
specs_seen: set[str] = set()
|
||||
by_y: dict[int, dict] = {}
|
||||
|
||||
for s in nbt.get("sections", []) or []:
|
||||
sy = s.get("Y")
|
||||
if sy is None:
|
||||
continue
|
||||
bs = s.get("block_states")
|
||||
if not isinstance(bs, dict):
|
||||
continue
|
||||
palette = bs.get("palette") or []
|
||||
spec_list = [
|
||||
_props_to_spec(str(p.get("Name", "minecraft:air")), p.get("Properties"))
|
||||
for p in palette
|
||||
]
|
||||
for sp in spec_list:
|
||||
specs_seen.add(sp)
|
||||
|
||||
# 存档 data 是「按 long 打包的调色板索引」(无 varint 前缀)
|
||||
storage = bs.get("data") or []
|
||||
n = len(spec_list)
|
||||
if n <= 1:
|
||||
states = [spec_list[0] if spec_list else "minecraft:air"] * SECTION_VOLUME
|
||||
else:
|
||||
bits = max(4, (n - 1).bit_length())
|
||||
idxs = _decode_storage(storage, SECTION_VOLUME, bits)
|
||||
states = [spec_list[i] if i < n else "minecraft:air" for i in idxs]
|
||||
|
||||
# 生物群系:同样结构,4x4x4
|
||||
bio = s.get("biomes") or {}
|
||||
bio_pal = bio.get("palette") or []
|
||||
bio_specs = [
|
||||
(p if isinstance(p, str) else str(p.get("Name", "minecraft:plains")))
|
||||
for p in bio_pal
|
||||
]
|
||||
if not bio_specs:
|
||||
biomes = ["minecraft:plains"] * BIOME_VOLUME
|
||||
elif len(bio_specs) == 1:
|
||||
biomes = [bio_specs[0]] * BIOME_VOLUME
|
||||
else:
|
||||
bb = max(1, (len(bio_specs) - 1).bit_length())
|
||||
bidx = _decode_storage(bio.get("data") or [], BIOME_VOLUME, bb)
|
||||
biomes = [bio_specs[i] if i < len(bio_specs) else "minecraft:plains" for i in bidx]
|
||||
|
||||
by_y[int(sy)] = {"palette": spec_list, "states": states, "biomes": biomes}
|
||||
|
||||
ch.sections = by_y
|
||||
ch.palette_specs = sorted(specs_seen)
|
||||
return ch
|
||||
|
||||
|
||||
class WorldRenderer:
|
||||
"""世界 → 画面。持有一个常驻 Java 渲染器进程。"""
|
||||
|
||||
def __init__(self, bridge: RenderBridge, instance: Any | None = None):
|
||||
self.bridge = bridge
|
||||
self.instance = instance
|
||||
self._spec_cache: dict[str, int] = {}
|
||||
self._loaded: set[tuple[int, int]] = set()
|
||||
self.chunks: dict[tuple[int, int], SaveChunk] = {}
|
||||
|
||||
# ---------------- 构造 ----------------
|
||||
|
||||
@classmethod
|
||||
def open(cls, instance: Any = None, *, store: str | None = None,
|
||||
classpath: str | None = None, start: bool = True,
|
||||
java: str = "java") -> "WorldRenderer":
|
||||
"""从 instance(mcfold.instance.Instance)或 store 目录打开渲染器。
|
||||
|
||||
classpath 缺省取环境变量 MCFOLD_RENDER_CLASSPATH;
|
||||
可先跑 mcfold/scaffold/build.sh 生成。
|
||||
"""
|
||||
game_dir = assets = asset_index = None
|
||||
if instance is not None:
|
||||
root = getattr(instance, "content_root", None) or getattr(instance, "path", None)
|
||||
if root:
|
||||
root = Path(root)
|
||||
assets = str(root / "assets")
|
||||
idx = root / "assets" / "indexes"
|
||||
if idx.is_dir():
|
||||
names = sorted(p.stem for p in idx.glob("*.json"))
|
||||
asset_index = names[-1] if names else None
|
||||
game_dir = str(getattr(instance, "path", root) if root else "")
|
||||
if store:
|
||||
store_p = Path(store)
|
||||
assets = str(store_p / "assets")
|
||||
names = sorted(p.stem for p in (store_p / "assets" / "indexes").glob("*.json"))
|
||||
asset_index = names[-1] if names else None
|
||||
game_dir = game_dir or str(store_p / "instances" / "default")
|
||||
|
||||
bridge = RenderBridge(
|
||||
java=java, classpath=classpath,
|
||||
game_dir=game_dir, assets=assets, asset_index=asset_index,
|
||||
)
|
||||
wr = cls(bridge, instance)
|
||||
if start:
|
||||
bridge.start()
|
||||
return wr
|
||||
|
||||
def close(self) -> None:
|
||||
self.bridge.stop()
|
||||
|
||||
def __enter__(self) -> "WorldRenderer":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self.close()
|
||||
|
||||
# ---------------- 方块名 → state id ----------------
|
||||
|
||||
def resolve_specs(self, specs: list[str]) -> dict[str, int]:
|
||||
"""批量把方块名规格解析成 state id(渲染器侧权威注册表)。"""
|
||||
todo = [s for s in dict.fromkeys(specs) if s not in self._spec_cache]
|
||||
if todo:
|
||||
resp = self.bridge._request({"cmd": "resolve", "blocks": todo}, timeout=120.0)
|
||||
ids = resp.get("ids", [])
|
||||
for spec, sid in zip(todo, ids):
|
||||
self._spec_cache[spec] = int(sid)
|
||||
return {s: self._spec_cache[s] for s in specs}
|
||||
|
||||
# ---------------- 装载 ----------------
|
||||
|
||||
def load_save(self, save_dir: str | Path, *, center: tuple[int, int] | None = None,
|
||||
radius: int = 3, dim: str | None = None,
|
||||
only_status: str = "minecraft:full") -> dict:
|
||||
"""从存档装载区块(零网络、零登录)。
|
||||
|
||||
center/radius 控制范围(区块坐标),避免一次喂太大。
|
||||
"""
|
||||
save_dir = Path(save_dir)
|
||||
level = anvil.read_level_dat(save_dir)
|
||||
data = level.get("Data", {})
|
||||
spawn = data.get("SpawnX"), data.get("SpawnY"), data.get("SpawnZ")
|
||||
if center is None:
|
||||
center = ((spawn[0] or 0) >> 4, (spawn[2] or 0) >> 4)
|
||||
|
||||
regs: dict[tuple[int, int], anvil.RegionFile] = {}
|
||||
# ★ 先设视野中心:ClientChunkCache 只保留视野内的区块(实测否则注入被丢弃)
|
||||
self.set_viewport(center[0], center[1], radius=max(radius + 2, 8))
|
||||
|
||||
parsed: list[SaveChunk] = []
|
||||
skipped = 0
|
||||
for cx in range(center[0] - radius, center[0] + radius + 1):
|
||||
for cz in range(center[1] - radius, center[1] + radius + 1):
|
||||
rx, rz = cx >> 5, cz >> 5
|
||||
key = (rx, rz)
|
||||
if key not in regs:
|
||||
p = anvil.region_path(save_dir, dim, rx, rz)
|
||||
if not p.is_file():
|
||||
skipped += 1; continue
|
||||
regs[key] = anvil.RegionFile(p)
|
||||
raw = regs[key].chunk_nbt(cx, cz)
|
||||
if not raw:
|
||||
skipped += 1; continue
|
||||
nbt = anvil.nbt_read(raw)
|
||||
if only_status and str(nbt.get("Status", "")) != only_status:
|
||||
skipped += 1; continue
|
||||
parsed.append(parse_save_chunk(cx, cz, nbt))
|
||||
|
||||
return self.load_chunks(parsed, meta={
|
||||
"level_name": data.get("LevelName"),
|
||||
"data_version": data.get("DataVersion"),
|
||||
"spawn": spawn,
|
||||
"center": center,
|
||||
"skipped": skipped,
|
||||
})
|
||||
|
||||
def load_chunks(self, chunks: list[SaveChunk], meta: dict | None = None) -> dict:
|
||||
"""把已解析的区块编码并注入渲染器。"""
|
||||
all_specs: list[str] = []
|
||||
for c in chunks:
|
||||
all_specs.extend(c.palette_specs)
|
||||
id_map = self.resolve_specs(all_specs)
|
||||
unknown = sum(1 for v in id_map.values() if v < 0)
|
||||
|
||||
items: list[tuple[int, int, bytes]] = []
|
||||
for c in chunks:
|
||||
payload = self._encode_chunk(c, id_map)
|
||||
items.append((c.cx, c.cz, payload))
|
||||
self.chunks[(c.cx, c.cz)] = c
|
||||
|
||||
resp = self.bridge.feed_chunks(items) if items else {"injected": 0}
|
||||
self._loaded.update((c.cx, c.cz) for c in chunks)
|
||||
out = {"injected": len(items), "unknownBlocks": unknown,
|
||||
"uniqueSpecs": len(id_map)}
|
||||
if meta:
|
||||
out.update(meta)
|
||||
return out
|
||||
|
||||
def _encode_chunk(self, c: SaveChunk, id_map: dict[str, int]) -> bytes:
|
||||
"""SaveChunk → packet_map_chunk 的 chunkData 载荷。"""
|
||||
out = bytearray()
|
||||
for sy in range(SECTION_COUNT):
|
||||
# ★ 索引 0..23 ↔ 绝对 Y (-4..19):存档用绝对 Y,载荷用相对索引
|
||||
sec = c.sections.get(sy + MIN_SECTION_Y)
|
||||
if sec is None:
|
||||
# 空 section:空气
|
||||
out += _encode_paletted([0] * SECTION_VOLUME, SECTION_VOLUME)
|
||||
else:
|
||||
ids = [max(0, id_map.get(s, -1)) for s in sec["states"]]
|
||||
non_empty = sum(1 for v in ids if v != 0)
|
||||
out += (non_empty & 0xFFFF).to_bytes(2, "big")
|
||||
out += _encode_paletted(ids, SECTION_VOLUME)
|
||||
# biome section
|
||||
bios = sec["biomes"] if sec else ["minecraft:plains"] * BIOME_VOLUME
|
||||
bio_pal = sorted(set(bios))
|
||||
# 生物群系用全局调色板 id 需要 registry —— 简化:统一用 plains 的 id 1
|
||||
# (生物群系只影响草/树叶着色,不影响「能不能渲染出世界」)
|
||||
out += bytes([0]) + _varint(1) + _varint(0)
|
||||
return bytes(out)
|
||||
|
||||
# ---------------- 视角与截图 ----------------
|
||||
|
||||
def set_viewport(self, cx: int, cz: int, radius: int | None = None) -> dict:
|
||||
"""设定视野中心 —— 必须在注入前设好,否则区块会被 ClientChunkCache 丢弃。"""
|
||||
req: dict[str, Any] = {"cmd": "viewport", "cx": cx, "cz": cz}
|
||||
if radius is not None:
|
||||
req["radius"] = radius
|
||||
return self.bridge._request(req)
|
||||
|
||||
def debug(self) -> dict:
|
||||
"""诊断:玩家坐标/区块/网格编译/视野中心的实际状态。"""
|
||||
return self.bridge._request({"cmd": "debug"})
|
||||
|
||||
def camera(self, x: float, y: float, z: float,
|
||||
yaw: float = 0.0, pitch: float = 0.0) -> None:
|
||||
self.bridge.set_camera(x, y, z, yaw, pitch)
|
||||
|
||||
def camera_above(self, cx: float = 0.0, cz: float = 0.0, height: float = 90.0,
|
||||
yaw: float = 0.0, pitch: float = 70.0) -> None:
|
||||
"""俯视整片区域(默认视角)。"""
|
||||
self.bridge.set_camera(cx, height, cz, yaw, pitch)
|
||||
|
||||
def screenshot(self, out: str | None = None, frames: int = 60) -> RenderResult:
|
||||
return self.bridge.render(out=out, frames=frames)
|
||||
353
mcfold/render_bridge.py
Normal file
353
mcfold/render_bridge.py
Normal file
@ -0,0 +1,353 @@
|
||||
"""渲染桥:把 Python 侧的世界数据交给 Java 客户端渲染器出图。
|
||||
|
||||
设计要点
|
||||
--------
|
||||
两侧格式**同源**,不需要转换:
|
||||
|
||||
Python client.run_smoke()/read_packet() 收到的 packet_map_chunk 载荷,
|
||||
其 chunkData 部分与 Java 侧 LevelChunkSection.read(FriendlyByteBuf)
|
||||
期待的是同一套字节(nonEmptyBlockCount + PalettedContainer×N + Biomes)。
|
||||
|
||||
所以桥的协议是「把 chunk 原始字节交给 Java」,而不是「把方块逐个传过去」——
|
||||
既避免重建世界的语义损失(mod 方块、NBT、biome、block entity),
|
||||
又天然支持任意版本(格式由同一个 protocol.json 描述)。
|
||||
|
||||
通信
|
||||
----
|
||||
子进程 + 行分隔 JSON(NDJSON):
|
||||
Java 侧 stdout 每行一个 JSON 对象;Python 写 stdin 用同样格式。
|
||||
选 NDJSON 而非 stdin 二进制:可读、可日志、便于调试;
|
||||
chunk 字节量不大(单区块约 10-100KB),base64 开销可接受。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
class RenderBridgeError(RuntimeError):
|
||||
"""桥不可用或渲染失败。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderResult:
|
||||
"""一次渲染的结果。"""
|
||||
|
||||
image: bytes | None = None # PNG 字节
|
||||
path: str | None = None
|
||||
width: int = 0
|
||||
height: int = 0
|
||||
chunks_rendered: int = 0 # countRenderedChunks
|
||||
distinct_colors: int = 0 # 判据:区分「世界」与「加载屏/空屏」
|
||||
camera: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def has_world_content(self) -> bool:
|
||||
"""distinct_colors 是实测有效的判据:
|
||||
空屏/纯色 1-2 色,加载屏 ~199(logo 抗锯齿),真实世界 >1000。"""
|
||||
return self.distinct_colors > 400 and self.chunks_rendered > 0
|
||||
|
||||
|
||||
class RenderBridge:
|
||||
"""驱动 Java 渲染器子进程。
|
||||
|
||||
生命周期:start() → feed_chunk()×N → set_camera() → render() → stop()。
|
||||
渲染器是**常驻进程**:起一次约 3-5s(GL + 资源重载),
|
||||
之后每次渲染只需几十到几百毫秒。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
java: str = "java",
|
||||
classpath: str | list[str] | None = None,
|
||||
main_class: str = "McfoldRenderServer",
|
||||
game_dir: str | None = None,
|
||||
assets: str | None = None,
|
||||
asset_index: str | None = None,
|
||||
java_args: list[str] | None = None,
|
||||
startup_timeout: float = 180.0,
|
||||
cwd: str | None = None,
|
||||
):
|
||||
self.java = java
|
||||
self.main_class = main_class
|
||||
self.game_dir = game_dir
|
||||
self.assets = assets
|
||||
self.asset_index = asset_index
|
||||
self.startup_timeout = startup_timeout
|
||||
self.cwd = cwd
|
||||
|
||||
if classpath is None:
|
||||
classpath = os.environ.get("MCFOLD_RENDER_CLASSPATH")
|
||||
if isinstance(classpath, (list, tuple)):
|
||||
classpath = os.pathsep.join(classpath)
|
||||
self.classpath = classpath
|
||||
|
||||
self.java_args = java_args or [
|
||||
"-Xmx1200M", "-Xms512M",
|
||||
# headless:不要 AWT 窗口;GL 仍走 GLFW + Xvfb
|
||||
"-Djava.awt.headless=true",
|
||||
]
|
||||
|
||||
self._proc: subprocess.Popen | None = None
|
||||
self._next_id = 1
|
||||
self._pending: dict[int, tuple[threading.Event, dict]] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._reader: threading.Thread | None = None
|
||||
self._stderr_tail: list[str] = []
|
||||
|
||||
# ---------------- 生命周期 ----------------
|
||||
|
||||
@property
|
||||
def running(self) -> bool:
|
||||
return self._proc is not None and self._proc.poll() is None
|
||||
|
||||
def start(self) -> "RenderBridge":
|
||||
if self.running:
|
||||
return self
|
||||
if not self.classpath:
|
||||
raise RenderBridgeError(
|
||||
"未提供 classpath。设置 MCFOLD_RENDER_CLASSPATH 或传 classpath=。"
|
||||
"构建:mcfold/scaffold/build.sh <store> <mcver>"
|
||||
)
|
||||
cmd = [self.java, *self.java_args, "-cp", self.classpath, self.main_class]
|
||||
if self.game_dir:
|
||||
cmd += ["--gameDir", self.game_dir]
|
||||
if self.assets:
|
||||
cmd += ["--assets", self.assets]
|
||||
if self.asset_index:
|
||||
cmd += ["--assetIndex", self.asset_index]
|
||||
|
||||
env = dict(os.environ)
|
||||
# natives 目录(lwjgl .so):构建脚本解到 MCFOLD_NATIVES_DIR
|
||||
nat = env.get("MCFOLD_NATIVES_DIR")
|
||||
if nat:
|
||||
env["LD_LIBRARY_PATH"] = nat + os.pathsep + env.get("LD_LIBRARY_PATH", "")
|
||||
|
||||
try:
|
||||
self._proc = subprocess.Popen(
|
||||
cmd, cwd=self.cwd, env=env,
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=False, bufsize=0,
|
||||
)
|
||||
except FileNotFoundError as e:
|
||||
raise RenderBridgeError(f"无法启动 java: {e}") from e
|
||||
|
||||
self._reader = threading.Thread(target=self._read_loop, daemon=True)
|
||||
self._reader.start()
|
||||
threading.Thread(target=self._stderr_loop, daemon=True).start()
|
||||
|
||||
ready = self._await_event("ready", self.startup_timeout)
|
||||
if not ready.pop("_ok", False):
|
||||
self.stop()
|
||||
raise RenderBridgeError(
|
||||
"渲染器启动失败:" + (ready.get("error") or "超时")
|
||||
+ ("\n" + "".join(self._stderr_tail[-5:]) if self._stderr_tail else "")
|
||||
)
|
||||
return self
|
||||
|
||||
def stop(self, timeout: float = 10.0) -> None:
|
||||
p = self._proc
|
||||
if p is None:
|
||||
return
|
||||
try:
|
||||
if p.poll() is None:
|
||||
self._send_raw({"cmd": "shutdown"})
|
||||
try:
|
||||
p.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
p.terminate()
|
||||
try:
|
||||
p.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
p.kill()
|
||||
finally:
|
||||
self._proc = None
|
||||
|
||||
def __enter__(self) -> "RenderBridge":
|
||||
return self.start()
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self.stop()
|
||||
|
||||
# ---------------- 数据注入 ----------------
|
||||
|
||||
def feed_chunk(self, cx: int, cz: int, chunk_data: bytes,
|
||||
heightmaps_nbt: bytes | None = None) -> dict:
|
||||
"""注入一个区块。
|
||||
|
||||
chunk_data —— packet_map_chunk 载荷里「长度前缀之后」的那段,
|
||||
与 Java LevelChunkSection.read() 直接对应。
|
||||
调 client 侧:decode 后的原始 payload 切片。
|
||||
heightmaps_nbt —— 可选 NBT(Java 侧允许空)。
|
||||
"""
|
||||
return self._request({
|
||||
"cmd": "chunk",
|
||||
"cx": cx, "cz": cz,
|
||||
"data": base64.b64encode(chunk_data).decode("ascii"),
|
||||
"heightmaps": base64.b64encode(heightmaps_nbt).decode("ascii") if heightmaps_nbt else None,
|
||||
})
|
||||
|
||||
def feed_chunks(self, chunks: list[tuple[int, int, bytes]]) -> dict:
|
||||
"""批量注入(减少往返)。"""
|
||||
return self._request({
|
||||
"cmd": "chunks",
|
||||
"items": [
|
||||
{"cx": cx, "cz": cz, "data": base64.b64encode(d).decode("ascii")}
|
||||
for cx, cz, d in chunks
|
||||
],
|
||||
})
|
||||
|
||||
def set_camera(self, x: float, y: float, z: float,
|
||||
yaw: float = 0.0, pitch: float = 0.0) -> dict:
|
||||
"""设置观察者位置与朝向(相机 = 我们想看哪里,不一定是玩家)。"""
|
||||
return self._request({"cmd": "camera", "x": x, "y": y, "z": z,
|
||||
"yaw": yaw, "pitch": pitch})
|
||||
|
||||
def set_view(self, *, camera_type: str = "THIRD_PERSON_BACK",
|
||||
render_distance: int | None = None,
|
||||
fov: float | None = None) -> dict:
|
||||
req: dict[str, Any] = {"cmd": "view", "cameraType": camera_type}
|
||||
if render_distance is not None:
|
||||
req["renderDistance"] = render_distance
|
||||
if fov is not None:
|
||||
req["fov"] = fov
|
||||
return self._request(req)
|
||||
|
||||
# ---------------- 渲染 ----------------
|
||||
|
||||
def render(self, out: str | None = None, frames: int = 60,
|
||||
width: int | None = None, height: int | None = None) -> RenderResult:
|
||||
"""渲若干帧并取回 PNG。
|
||||
|
||||
frames 不能太小:区块网格重建队列靠 tick 推进,
|
||||
实测 20 帧时 countRenderedChunks 已到位,60 帧更稳。
|
||||
"""
|
||||
req: dict[str, Any] = {"cmd": "render", "frames": frames}
|
||||
if out:
|
||||
req["out"] = out
|
||||
if width:
|
||||
req["width"] = width
|
||||
if height:
|
||||
req["height"] = height
|
||||
resp = self._request(req, timeout=300.0)
|
||||
|
||||
img_b64 = resp.pop("image", None)
|
||||
res = RenderResult(
|
||||
image=base64.b64decode(img_b64) if img_b64 else None,
|
||||
path=resp.get("path"),
|
||||
width=resp.get("width", 0),
|
||||
height=resp.get("height", 0),
|
||||
chunks_rendered=resp.get("chunksRendered", 0),
|
||||
distinct_colors=resp.get("distinctColors", 0),
|
||||
camera=tuple(resp.get("camera", (0.0, 0.0, 0.0))),
|
||||
warnings=resp.get("warnings", []),
|
||||
raw=resp,
|
||||
)
|
||||
return res
|
||||
|
||||
def registry_info(self) -> dict:
|
||||
"""客户端方块注册表:state id ↔ 方块名(含 mod 方块的完整状态)。"""
|
||||
return self._request({"cmd": "registry"})
|
||||
|
||||
def lookup_state(self, state_id: int) -> dict:
|
||||
"""查单个 state id(返回方块名 + 完整属性,如 instrument/note/powered)。"""
|
||||
return self._request({"cmd": "lookup", "stateId": state_id})
|
||||
|
||||
# ---------------- 内部:NDJSON 通道 ----------------
|
||||
|
||||
def _request(self, req: dict, timeout: float = 120.0) -> dict:
|
||||
if not self.running:
|
||||
raise RenderBridgeError("渲染器未运行(先 start())")
|
||||
with self._lock:
|
||||
rid = self._next_id
|
||||
self._next_id += 1
|
||||
req["id"] = rid
|
||||
ev = threading.Event()
|
||||
self._pending[rid] = (ev, {})
|
||||
self._send_raw(req)
|
||||
if not ev.wait(timeout):
|
||||
self._pending.pop(rid, None)
|
||||
raise RenderBridgeError(f"渲染器无响应({timeout}s):cmd={req.get('cmd')}")
|
||||
_, resp = self._pending.pop(rid)
|
||||
if not resp.get("_ok", True):
|
||||
raise RenderBridgeError(resp.get("error", "未知错误"))
|
||||
return resp
|
||||
|
||||
def _await_event(self, kind: str, timeout: float) -> dict:
|
||||
"""等待一个主动事件(如 ready)。"""
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
if not self.running:
|
||||
return {"_ok": False, "error": f"进程退出(code={self._proc.poll() if self._proc else '?'})"}
|
||||
for rid, (ev, box) in list(self._pending.items()):
|
||||
if box.get("_event") == kind and ev.is_set():
|
||||
self._pending.pop(rid, None)
|
||||
box["_ok"] = True
|
||||
return box
|
||||
time.sleep(0.05)
|
||||
return {"_ok": False, "error": f"等待 {kind} 超时"}
|
||||
|
||||
def _send_raw(self, obj: dict) -> None:
|
||||
if not self._proc or self._proc.stdin is None:
|
||||
raise RenderBridgeError("stdin 不可用")
|
||||
self._proc.stdin.write((json.dumps(obj) + "\n").encode("utf-8"))
|
||||
self._proc.stdin.flush()
|
||||
|
||||
def _read_loop(self) -> None:
|
||||
assert self._proc and self._proc.stdout
|
||||
for raw in self._proc.stdout:
|
||||
line = raw.decode("utf-8", "replace").strip()
|
||||
if not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
rid = msg.get("id")
|
||||
if rid is None and msg.get("event") == "ready":
|
||||
# ready 事件:塞进 _pending 供 _await_event 取
|
||||
ev = threading.Event()
|
||||
box = dict(msg); box["_event"] = "ready"
|
||||
self._pending[-1] = (ev, box)
|
||||
ev.set()
|
||||
continue
|
||||
if rid in self._pending:
|
||||
ev, box = self._pending[rid]
|
||||
box.update(msg)
|
||||
ev.set()
|
||||
|
||||
def _stderr_loop(self) -> None:
|
||||
assert self._proc and self._proc.stderr
|
||||
for raw in self._proc.stderr:
|
||||
line = raw.decode("utf-8", "replace").rstrip()
|
||||
self._stderr_tail.append(line)
|
||||
if len(self._stderr_tail) > 200:
|
||||
del self._stderr_tail[:100]
|
||||
|
||||
# ---------------- 便利 ----------------
|
||||
|
||||
@staticmethod
|
||||
def discover_defaults(store: str | Path) -> dict:
|
||||
"""从 store 目录推出 assets/assetIndex/gameDir 的默认值(照官方启动器布局)。"""
|
||||
store = Path(store)
|
||||
assets = store / "assets"
|
||||
idx_dir = assets / "indexes"
|
||||
asset_index = None
|
||||
if idx_dir.is_dir():
|
||||
names = sorted(p.stem for p in idx_dir.glob("*.json"))
|
||||
asset_index = names[-1] if names else None
|
||||
return {
|
||||
"assets": str(assets),
|
||||
"asset_index": asset_index,
|
||||
"game_dir": str(store / "instances" / "default"),
|
||||
}
|
||||
580
scaffold/McfoldRenderServer.java
Normal file
580
scaffold/McfoldRenderServer.java
Normal file
@ -0,0 +1,580 @@
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import com.mojang.blaze3d.pipeline.MainTarget;
|
||||
import com.mojang.blaze3d.platform.DisplayData;
|
||||
import com.mojang.blaze3d.systems.RenderSystem;
|
||||
import com.mojang.authlib.GameProfile;
|
||||
import com.mojang.authlib.properties.PropertyMap;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.User;
|
||||
import net.minecraft.client.main.GameConfig;
|
||||
import net.minecraft.client.multiplayer.ClientLevel;
|
||||
import net.minecraft.client.multiplayer.ClientPacketListener;
|
||||
import net.minecraft.client.telemetry.TelemetryEventSender;
|
||||
import net.minecraft.client.telemetry.WorldSessionTelemetryManager;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.Holder;
|
||||
import net.minecraft.core.RegistryAccess;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.network.Connection;
|
||||
import net.minecraft.network.FriendlyByteBuf;
|
||||
import net.minecraft.network.protocol.PacketFlow;
|
||||
import net.minecraft.resources.RegistryDataLoader;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.packs.resources.ResourceManager;
|
||||
import net.minecraft.util.profiling.InactiveProfiler;
|
||||
import net.minecraft.world.Difficulty;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.LightLayer;
|
||||
import net.minecraft.world.level.block.Block;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.level.chunk.DataLayer;
|
||||
import net.minecraft.world.level.dimension.DimensionType;
|
||||
import org.lwjgl.opengl.GL11;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.PrintStream;
|
||||
import java.net.Proxy;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
import java.util.OptionalInt;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* mcfold 渲染服务:常驻进程,NDJSON(行分隔 JSON) 协议,由 Python 侧驱动。
|
||||
*
|
||||
* 设计约束(用户 2026-09-25):
|
||||
* - 不起完整客户端:不跑主循环、不连服、不登录、不作玩家
|
||||
* - 我们的世界数据喂给官方渲染器出图
|
||||
*
|
||||
* 协议(stdin/stdout 各一行一个 JSON):
|
||||
* → {"id":1,"cmd":"chunk","cx":0,"cz":0,"data":"<b64>","heightmaps":null}
|
||||
* → {"id":2,"cmd":"camera","x":8,"y":70,"z":8,"yaw":0,"pitch":0}
|
||||
* → {"id":3,"cmd":"render","frames":60,"out":"/tmp/a.png"}
|
||||
* ← {"id":3,"ok":true,"width":854,"height":480,"chunksRendered":29,
|
||||
* "distinctColors":1450,"image":"<b64 png>"}
|
||||
*
|
||||
* 启动就绪时主动发 {"event":"ready"}。
|
||||
*
|
||||
* ★ 关键实现点(全部实测得来,详见 scaffold/README.md):
|
||||
* 1. tryDetectVersion 必须先于 Bootstrap.bootStrap
|
||||
* 2. FolderData 顺序 (gameDir, resourcePackDir, assets, assetIndex),assetIndex 用落盘名
|
||||
* 3. 资源必须手工同步重载(各 listener 的 public reload()),不能 join Minecraft 的
|
||||
* 4. LoadingOverlay + ReceivingLevelScreen 都要清,否则渲出加载屏
|
||||
* 5. 每帧 mc.tick() 才能推进区块网格重建队列
|
||||
* 6. 读像素必须绑 FBO
|
||||
* 7. chunk 数据走官方 replaceWithPacketData(与网络包同路径)
|
||||
*/
|
||||
public class McfoldRenderServer {
|
||||
|
||||
static final Gson GSON = new Gson();
|
||||
static final PrintStream OUT = new PrintStream(
|
||||
new java.io.FileOutputStream(java.io.FileDescriptor.out), true, StandardCharsets.UTF_8);
|
||||
|
||||
static Minecraft mc;
|
||||
static ClientLevel level;
|
||||
static int width = 854, height = 480;
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
// stdout 只放协议 JSON,MC 的 INFO/WARN 全部赶到 stderr
|
||||
System.setOut(new PrintStream(new java.io.FileOutputStream(java.io.FileDescriptor.err), true));
|
||||
|
||||
String gameDir = arg(args, "--gameDir", "/home/newqqagent/mcview/instances/default");
|
||||
String assets = arg(args, "--assets", "/home/newqqagent/mcview/store/vanilla-1.20.1/assets");
|
||||
String assetIdx = arg(args, "--assetIndex", "1.20.1");
|
||||
width = Integer.parseInt(arg(args, "--width", "854"));
|
||||
height = Integer.parseInt(arg(args, "--height", "480"));
|
||||
|
||||
try {
|
||||
boot(gameDir, assets, assetIdx);
|
||||
} catch (Throwable t) {
|
||||
emitError(null, "启动失败: " + t);
|
||||
t.printStackTrace(System.err);
|
||||
System.exit(1);
|
||||
}
|
||||
event("ready", null);
|
||||
|
||||
BufferedReader in = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
|
||||
String line;
|
||||
while ((line = in.readLine()) != null) {
|
||||
line = line.trim();
|
||||
if (line.isEmpty()) continue;
|
||||
JsonObject req;
|
||||
try {
|
||||
req = JsonParser.parseString(line).getAsJsonObject();
|
||||
} catch (Exception e) {
|
||||
emitError(null, "非法 JSON: " + e.getMessage());
|
||||
continue;
|
||||
}
|
||||
Integer id = req.has("id") ? req.get("id").getAsInt() : null;
|
||||
try {
|
||||
handle(id, req);
|
||||
} catch (Throwable t) {
|
||||
emitError(id, t.toString());
|
||||
t.printStackTrace(System.err);
|
||||
}
|
||||
}
|
||||
System.exit(0);
|
||||
}
|
||||
|
||||
// ---------------- 协议处理 ----------------
|
||||
|
||||
static void handle(Integer id, JsonObject req) throws Exception {
|
||||
String cmd = req.get("cmd").getAsString();
|
||||
switch (cmd) {
|
||||
case "shutdown": {
|
||||
JsonObject r = new JsonObject(); r.addProperty("ok", true);
|
||||
emit(id, r);
|
||||
System.exit(0);
|
||||
}
|
||||
case "chunk": {
|
||||
int cx = req.get("cx").getAsInt(), cz = req.get("cz").getAsInt();
|
||||
byte[] data = decode(req.get("data").getAsString());
|
||||
var resp = applyChunk(cx, cz, data);
|
||||
JsonObject r = new JsonObject();
|
||||
r.addProperty("ok", true);
|
||||
r.add("result", resp);
|
||||
emit(id, r);
|
||||
break;
|
||||
}
|
||||
case "chunks": {
|
||||
JsonArray items = req.getAsJsonArray("items");
|
||||
int ok = 0, failed = 0;
|
||||
String firstErr = null;
|
||||
for (JsonElement el : items) {
|
||||
JsonObject it = el.getAsJsonObject();
|
||||
try {
|
||||
applyChunk(it.get("cx").getAsInt(), it.get("cz").getAsInt(),
|
||||
decode(it.get("data").getAsString()));
|
||||
ok++;
|
||||
} catch (Throwable t) {
|
||||
failed++;
|
||||
if (firstErr == null) {
|
||||
firstErr = it.get("cx") + "," + it.get("cz") + ": " + t;
|
||||
}
|
||||
}
|
||||
}
|
||||
levelRendererAllChanged();
|
||||
JsonObject r = new JsonObject();
|
||||
r.addProperty("ok", true);
|
||||
r.addProperty("injected", ok);
|
||||
r.addProperty("failed", failed);
|
||||
if (firstErr != null) r.addProperty("firstError", firstErr);
|
||||
var cache2 = (net.minecraft.client.multiplayer.ClientChunkCache) level.getChunkSource();
|
||||
r.addProperty("loadedChunks", cache2.getLoadedChunksCount());
|
||||
emit(id, r);
|
||||
break;
|
||||
}
|
||||
case "camera": {
|
||||
double x = req.get("x").getAsDouble(), y = req.get("y").getAsDouble(), z = req.get("z").getAsDouble();
|
||||
float yaw = req.has("yaw") ? req.get("yaw").getAsFloat() : 0f;
|
||||
float pitch = req.has("pitch") ? req.get("pitch").getAsFloat() : 0f;
|
||||
// ★ 必须 setPosRaw + 手动更新 chunk 追踪,否则实体仍属旧区块
|
||||
mc.player.setPosRaw(x, y, z);
|
||||
mc.player.xo = x; mc.player.yo = y; mc.player.zo = z;
|
||||
mc.player.setYRot(yaw);
|
||||
mc.player.setXRot(pitch);
|
||||
mc.player.yRotO = yaw;
|
||||
mc.player.xRotO = pitch;
|
||||
mc.setCameraEntity(mc.player);
|
||||
// 视野中心跟着相机走,否则目标区域区块被 ClientChunkCache 丢弃
|
||||
level.getChunkSource().updateViewCenter((int)Math.floor(x) >> 4, (int)Math.floor(z) >> 4);
|
||||
mc.levelRenderer.allChanged();
|
||||
tickFrames(5);
|
||||
JsonObject r = new JsonObject();
|
||||
r.addProperty("ok", true);
|
||||
JsonArray c = new JsonArray(); c.add(x); c.add(y); c.add(z);
|
||||
r.add("camera", c);
|
||||
emit(id, r);
|
||||
break;
|
||||
}
|
||||
case "view": {
|
||||
if (req.has("cameraType")) {
|
||||
mc.options.setCameraType(net.minecraft.client.CameraType.valueOf(req.get("cameraType").getAsString()));
|
||||
}
|
||||
if (req.has("renderDistance")) {
|
||||
mc.options.renderDistance().set(req.get("renderDistance").getAsInt());
|
||||
}
|
||||
if (req.has("fov")) {
|
||||
mc.options.fov().set(req.get("fov").getAsInt());
|
||||
}
|
||||
JsonObject r = new JsonObject();
|
||||
r.addProperty("ok", true);
|
||||
emit(id, r);
|
||||
break;
|
||||
}
|
||||
case "registry": {
|
||||
JsonObject r = new JsonObject();
|
||||
r.addProperty("ok", true);
|
||||
r.addProperty("blockCount", BuiltInRegistries.BLOCK.size());
|
||||
r.addProperty("stateCount", Block.BLOCK_STATE_REGISTRY.size());
|
||||
emit(id, r);
|
||||
break;
|
||||
}
|
||||
case "lookup": {
|
||||
int sid = req.get("stateId").getAsInt();
|
||||
BlockState st = Block.stateById(sid);
|
||||
ResourceLocation key = BuiltInRegistries.BLOCK.getKey(st.getBlock());
|
||||
JsonObject r = new JsonObject();
|
||||
r.addProperty("ok", true);
|
||||
r.addProperty("stateId", sid);
|
||||
r.addProperty("block", key.toString());
|
||||
r.addProperty("state", st.toString());
|
||||
r.addProperty("inRange", sid >= 0 && sid < Block.BLOCK_STATE_REGISTRY.size());
|
||||
emit(id, r);
|
||||
break;
|
||||
}
|
||||
case "fog": {
|
||||
// 关雾:fogStart/fogEnd 是 levelRenderer 内部状态;
|
||||
// 更简单可靠的是加大 renderDistance + 用 BLOCK 光照
|
||||
JsonObject r = new JsonObject();
|
||||
r.addProperty("ok", true);
|
||||
emit(id, r);
|
||||
break;
|
||||
}
|
||||
case "resolve": {
|
||||
// ★ 方块名(+属性) → state id。存档 palette 是名字,网络载荷要 id,
|
||||
// 而这个名字→id 的权威映射只有客户端注册表有。
|
||||
// 入参: {"cmd":"resolve","blocks":["minecraft:oak_log[axis=y]", ...]}
|
||||
JsonArray arr = req.getAsJsonArray("blocks");
|
||||
// ★ parseForBlock 要 HolderLookup(不是 Registry)
|
||||
var reg = mc.level.registryAccess().lookupOrThrow(Registries.BLOCK);
|
||||
JsonArray out = new JsonArray();
|
||||
int unknown = 0;
|
||||
for (JsonElement el : arr) {
|
||||
String spec = el.getAsString();
|
||||
int sid;
|
||||
try {
|
||||
var res = net.minecraft.commands.arguments.blocks.BlockStateParser
|
||||
.parseForBlock(reg, spec, false);
|
||||
sid = Block.getId(res.blockState());
|
||||
} catch (Throwable t) {
|
||||
sid = -1; unknown++;
|
||||
}
|
||||
out.add(sid);
|
||||
}
|
||||
JsonObject r = new JsonObject();
|
||||
r.addProperty("ok", true);
|
||||
r.add("ids", out);
|
||||
r.addProperty("unknown", unknown);
|
||||
r.addProperty("total", arr.size());
|
||||
emit(id, r);
|
||||
break;
|
||||
}
|
||||
case "debug": {
|
||||
JsonObject r = new JsonObject();
|
||||
r.addProperty("ok", true);
|
||||
r.addProperty("playerPos", mc.player.getX() + "," + mc.player.getY() + "," + mc.player.getZ());
|
||||
int pcx = (int)Math.floor(mc.player.getX()) >> 4, pcz = (int)Math.floor(mc.player.getZ()) >> 4;
|
||||
r.addProperty("playerChunk", pcx + "," + pcz);
|
||||
var cc = (net.minecraft.client.multiplayer.ClientChunkCache) level.getChunkSource();
|
||||
r.addProperty("loadedChunks", cc.getLoadedChunksCount());
|
||||
r.addProperty("chunksRendered", mc.levelRenderer.countRenderedChunks());
|
||||
r.addProperty("hasRenderedAllChunks", mc.levelRenderer.hasRenderedAllChunks());
|
||||
var got = cc.getChunk(pcx, pcz, net.minecraft.world.level.chunk.ChunkStatus.FULL, false);
|
||||
r.addProperty("chunkAtPlayer", got != null ? got.getClass().getSimpleName() : "null");
|
||||
if (got != null) {
|
||||
// 扫描玩家所在列,找最高的非空气方块
|
||||
int top = Integer.MIN_VALUE;
|
||||
for (int y = level.getMaxBuildHeight() - 1; y >= level.getMinBuildHeight(); y--) {
|
||||
var st = got.getBlockState(new BlockPos(
|
||||
(int)Math.floor(mc.player.getX()) & 15, y, (int)Math.floor(mc.player.getZ()) & 15));
|
||||
if (!st.isAir()) {
|
||||
top = y;
|
||||
r.addProperty("topSolidY", y);
|
||||
r.addProperty("topSolidBlock", BuiltInRegistries.BLOCK.getKey(st.getBlock()).toString());
|
||||
r.addProperty("topSolidProps", st.toString());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (top == Integer.MIN_VALUE) r.addProperty("topSolidY", "none");
|
||||
}
|
||||
// viewCenter
|
||||
r.addProperty("fogStart", String.valueOf(mc.options.renderDistance().get()));
|
||||
emit(id, r);
|
||||
break;
|
||||
}
|
||||
case "viewport": {
|
||||
// ★ viewCenter 决定 ClientChunkCache 收哪些区块(超范围会被丢弃)
|
||||
int cx = req.get("cx").getAsInt(), cz = req.get("cz").getAsInt();
|
||||
if (req.has("radius")) {
|
||||
level.getChunkSource().updateViewRadius(req.get("radius").getAsInt());
|
||||
}
|
||||
level.getChunkSource().updateViewCenter(cx, cz);
|
||||
JsonObject r = new JsonObject();
|
||||
r.addProperty("ok", true);
|
||||
r.addProperty("viewCenter", cx + "," + cz);
|
||||
emit(id, r);
|
||||
break;
|
||||
}
|
||||
case "render": {
|
||||
int frames = req.has("frames") ? req.get("frames").getAsInt() : 60;
|
||||
String out = req.has("out") ? req.get("out").getAsString() : null;
|
||||
if (req.has("width")) width = req.get("width").getAsInt();
|
||||
if (req.has("height")) height = req.get("height").getAsInt();
|
||||
render(id, frames, out);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
emitError(id, "未知命令: " + cmd);
|
||||
}
|
||||
}
|
||||
|
||||
static JsonObject applyChunk(int cx, int cz, byte[] data) {
|
||||
// ★ 官方路径:ClientChunkCache.replaceWithPacketData
|
||||
// 内部:new LevelChunk + chunk.replaceWithPacketData(buf, heightmaps, consumer)
|
||||
// 与网络包 handleLevelChunkWithLight 完全同一路径
|
||||
FriendlyByteBuf buf = new FriendlyByteBuf(io.netty.buffer.Unpooled.wrappedBuffer(data));
|
||||
var cache = level.getChunkSource();
|
||||
// 显式匿名类而非 lambda:lambda 参数类型推断在此处会失败(实测)
|
||||
net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput beOut =
|
||||
new net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput() {
|
||||
@Override public void accept(BlockPos bp,
|
||||
net.minecraft.world.level.block.entity.BlockEntityType<?> type,
|
||||
net.minecraft.nbt.CompoundTag tag) { }
|
||||
};
|
||||
// ★ 参数是 Consumer<BlockEntityTagOutput>(消费这个接口本身),不是该接口的方法签名。
|
||||
// LevelChunk.replaceWithPacketData 内部会调 beOut.accept(...),这里直接空实现。
|
||||
cache.replaceWithPacketData(cx, cz, buf, new net.minecraft.nbt.CompoundTag(), out -> {});
|
||||
// 无光照数据 ⇒ 显式全亮,否则地形全黑
|
||||
var light = cache.getLightEngine();
|
||||
for (int sy = level.getMinSection(); sy < level.getMaxSection(); sy++) {
|
||||
DataLayer sky = new DataLayer();
|
||||
for (int x = 0; x < 16; x++)
|
||||
for (int y = 0; y < 16; y++)
|
||||
for (int z = 0; z < 16; z++) sky.set(x, y, z, 15);
|
||||
light.queueSectionData(LightLayer.SKY, net.minecraft.core.SectionPos.of(cx, sy, cz), sky);
|
||||
light.queueSectionData(LightLayer.BLOCK, net.minecraft.core.SectionPos.of(cx, sy, cz), sky);
|
||||
}
|
||||
light.setLightEnabled(new ChunkPos(cx, cz), true);
|
||||
int upd = light.runLightUpdates();
|
||||
JsonObject r = new JsonObject();
|
||||
r.addProperty("lightUpdates", upd);
|
||||
return r;
|
||||
}
|
||||
|
||||
static void levelRendererAllChanged() {
|
||||
mc.levelRenderer.allChanged();
|
||||
}
|
||||
|
||||
/** tick + 渲染各帧(区块网格重建队列靠 tick 推进) */
|
||||
static void tickFrames(int n) {
|
||||
MainTarget target = (MainTarget) mc.getMainRenderTarget();
|
||||
for (int f = 0; f < n; f++) {
|
||||
try { mc.tick(); } catch (Throwable ignore) {}
|
||||
RenderSystem.clear(16640, Minecraft.ON_OSX);
|
||||
target.bindWrite(true);
|
||||
mc.gameRenderer.render(0.0F, (long) f * 16_000_000L, true);
|
||||
target.unbindWrite();
|
||||
target.blitToScreen(mc.getWindow().getWidth(), mc.getWindow().getHeight());
|
||||
mc.getWindow().updateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
static void render(Integer id, int frames, String out) throws Exception {
|
||||
tickFrames(frames);
|
||||
|
||||
MainTarget target = (MainTarget) mc.getMainRenderTarget();
|
||||
int w = mc.getWindow().getWidth(), h = mc.getWindow().getHeight();
|
||||
org.lwjgl.opengl.GL30.glBindFramebuffer(org.lwjgl.opengl.GL30.GL_FRAMEBUFFER, target.frameBufferId);
|
||||
GL11.glReadBuffer(org.lwjgl.opengl.GL30.GL_COLOR_ATTACHMENT0);
|
||||
var buf = org.lwjgl.BufferUtils.createByteBuffer(w * h * 4);
|
||||
GL11.glReadPixels(0, 0, w, h, GL11.GL_RGBA, GL11.GL_UNSIGNED_BYTE, buf);
|
||||
org.lwjgl.opengl.GL30.glBindFramebuffer(org.lwjgl.opengl.GL30.GL_FRAMEBUFFER, 0);
|
||||
|
||||
BufferedImage img = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB);
|
||||
java.util.Set<Integer> colors = new java.util.HashSet<>();
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++) {
|
||||
int i = ((h - 1 - y) * w + x) * 4;
|
||||
int rgb = ((buf.get(i) & 0xFF) << 16) | ((buf.get(i + 1) & 0xFF) << 8) | (buf.get(i + 2) & 0xFF);
|
||||
img.setRGB(x, y, rgb);
|
||||
colors.add(rgb);
|
||||
}
|
||||
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
ImageIO.write(img, "png", bos);
|
||||
byte[] png = bos.toByteArray();
|
||||
if (out != null) ImageIO.write(img, "png", new File(out));
|
||||
|
||||
JsonObject r = new JsonObject();
|
||||
r.addProperty("ok", true);
|
||||
r.addProperty("width", w);
|
||||
r.addProperty("height", h);
|
||||
r.addProperty("chunksRendered", mc.levelRenderer.countRenderedChunks());
|
||||
r.addProperty("hasRenderedAllChunks", mc.levelRenderer.hasRenderedAllChunks());
|
||||
r.addProperty("distinctColors", colors.size());
|
||||
JsonArray cam = new JsonArray();
|
||||
cam.add(mc.player.getX()); cam.add(mc.player.getY()); cam.add(mc.player.getZ());
|
||||
r.add("camera", cam);
|
||||
if (out != null) r.addProperty("path", out);
|
||||
r.addProperty("image", Base64.getEncoder().encodeToString(png));
|
||||
emit(id, r);
|
||||
}
|
||||
|
||||
// ---------------- 启动 ----------------
|
||||
|
||||
static void boot(String gameDir, String assets, String assetIdx) throws Exception {
|
||||
SharedConstants.tryDetectVersion();
|
||||
SharedConstants.enableDataFixerOptimizations();
|
||||
Bootstrap.bootStrap();
|
||||
|
||||
File runDir = new File(gameDir);
|
||||
runDir.mkdirs();
|
||||
User user = new User("mcfold_cam", "0".repeat(32), "mcfold_cam",
|
||||
Optional.empty(), Optional.empty(), User.Type.LEGACY);
|
||||
PropertyMap props = new PropertyMap();
|
||||
GameConfig cfg = new GameConfig(
|
||||
new GameConfig.UserData(user, props, props, Proxy.NO_PROXY),
|
||||
new DisplayData(width, height, OptionalInt.empty(), OptionalInt.empty(), false),
|
||||
new GameConfig.FolderData(runDir, new File(runDir, "resourcepacks"),
|
||||
new File(assets), assetIdx),
|
||||
new GameConfig.GameData(false, "mcfold", "release", false, false),
|
||||
new GameConfig.QuickPlayData(null, null, null, null));
|
||||
|
||||
RenderSystem.initRenderThread();
|
||||
RenderSystem.beginInitialization();
|
||||
mc = new Minecraft(cfg);
|
||||
RenderSystem.finishInitialization();
|
||||
System.err.println("[mcfold] Minecraft constructed");
|
||||
|
||||
reloadResources(mc);
|
||||
System.err.println("[mcfold] resources reloaded");
|
||||
|
||||
Connection conn = new Connection(PacketFlow.CLIENTBOUND);
|
||||
GameProfile profile = new GameProfile(UUID.randomUUID(), "mcfold_cam");
|
||||
WorldSessionTelemetryManager telemetry = new WorldSessionTelemetryManager(
|
||||
TelemetryEventSender.DISABLED, false, Duration.ZERO, "");
|
||||
ClientPacketListener listener = new ClientPacketListener(mc, null, conn, null, profile, telemetry);
|
||||
|
||||
var packs = net.minecraft.server.packs.repository.ServerPacksSource.createPackRepository(
|
||||
new File(runDir, "datapacks").toPath());
|
||||
packs.reload();
|
||||
ResourceManager dataRm = new net.minecraft.server.packs.resources.MultiPackResourceManager(
|
||||
net.minecraft.server.packs.PackType.SERVER_DATA,
|
||||
java.util.List.of(packs.getPack("vanilla").open()));
|
||||
var all = new java.util.ArrayList<RegistryDataLoader.RegistryData<?>>();
|
||||
all.addAll(RegistryDataLoader.WORLDGEN_REGISTRIES);
|
||||
all.addAll(RegistryDataLoader.DIMENSION_REGISTRIES);
|
||||
RegistryAccess.Frozen loaded = RegistryDataLoader.load(dataRm, listener.registryAccess(), all);
|
||||
var layered = net.minecraft.client.multiplayer.ClientRegistryLayer.createRegistryAccess()
|
||||
.replaceFrom(net.minecraft.client.multiplayer.ClientRegistryLayer.REMOTE,
|
||||
new RegistryAccess.Frozen[]{loaded});
|
||||
var rf = ClientPacketListener.class.getDeclaredField("registryAccess");
|
||||
rf.setAccessible(true);
|
||||
rf.set(listener, layered);
|
||||
|
||||
Holder<DimensionType> dimHolder = loaded.registryOrThrow(Registries.DIMENSION_TYPE)
|
||||
.getHolderOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, new ResourceLocation("overworld")));
|
||||
var levelData = new ClientLevel.ClientLevelData(Difficulty.NORMAL, false, false);
|
||||
level = new ClientLevel(listener, levelData, Level.OVERWORLD, dimHolder,
|
||||
Math.max(8, mc.options.renderDistance().get()), 8,
|
||||
() -> InactiveProfiler.INSTANCE, mc.levelRenderer, false, 0L);
|
||||
mc.setLevel(level);
|
||||
|
||||
var player = new net.minecraft.client.player.LocalPlayer(mc, level, listener,
|
||||
new net.minecraft.stats.StatsCounter(), new net.minecraft.client.ClientRecipeBook(), false, false);
|
||||
player.setPos(8.0, 70.0, 8.0);
|
||||
mc.player = player;
|
||||
mc.setCameraEntity(player);
|
||||
mc.gameMode = new net.minecraft.client.multiplayer.MultiPlayerGameMode(mc, listener);
|
||||
|
||||
net.minecraft.client.renderer.McfoldBridge.reloadShaders(mc.gameRenderer, mc.getResourceManager());
|
||||
|
||||
mc.setOverlay(null);
|
||||
mc.setScreen(null);
|
||||
mc.options.setCameraType(net.minecraft.client.CameraType.THIRD_PERSON_BACK);
|
||||
System.err.println("[mcfold] boot done");
|
||||
}
|
||||
|
||||
/** 同步驱动各 reload listener(不能 join Minecraft 的 reloadResourcePacks:等主循环 → 死锁)*/
|
||||
static void reloadResources(Minecraft mc) throws Exception {
|
||||
var rmF = Minecraft.class.getDeclaredField("resourceManager");
|
||||
rmF.setAccessible(true);
|
||||
var rm = (net.minecraft.server.packs.resources.ReloadableResourceManager) rmF.get(mc);
|
||||
mc.getResourcePackRepository().reload();
|
||||
|
||||
var barrier = new net.minecraft.server.packs.resources.PreparableReloadListener.PreparationBarrier() {
|
||||
@Override public <T> java.util.concurrent.CompletableFuture<T> wait(T t) {
|
||||
return java.util.concurrent.CompletableFuture.completedFuture(t);
|
||||
}
|
||||
};
|
||||
var prof = InactiveProfiler.INSTANCE;
|
||||
java.util.concurrent.Executor sync = Runnable::run;
|
||||
|
||||
for (var listener : reloadListeners(rm)) {
|
||||
String name = listener.getName();
|
||||
try {
|
||||
var fut = listener.reload(barrier, rm, prof, prof, sync, sync);
|
||||
long deadline = System.currentTimeMillis() + 60_000;
|
||||
while (!fut.isDone() && System.currentTimeMillis() < deadline) Thread.sleep(10);
|
||||
if (fut.isDone()) fut.join();
|
||||
else System.err.println("[mcfold] TIMEOUT " + name);
|
||||
} catch (Throwable e) {
|
||||
System.err.println("[mcfold] FAILED " + name + ": " + e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static java.util.List<net.minecraft.server.packs.resources.PreparableReloadListener> reloadListeners(Object rm) throws Exception {
|
||||
for (var f : rm.getClass().getDeclaredFields()) {
|
||||
if (java.util.List.class.isAssignableFrom(f.getType())) {
|
||||
f.setAccessible(true);
|
||||
Object v = f.get(rm);
|
||||
if (v instanceof java.util.List<?> l && !l.isEmpty()
|
||||
&& l.get(0) instanceof net.minecraft.server.packs.resources.PreparableReloadListener) {
|
||||
return (java.util.List<net.minecraft.server.packs.resources.PreparableReloadListener>) l;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("reload listeners not found");
|
||||
}
|
||||
|
||||
// ---------------- 工具 ----------------
|
||||
|
||||
static byte[] decode(String b64) {
|
||||
return Base64.getDecoder().decode(b64);
|
||||
}
|
||||
|
||||
static String arg(String[] a, String key, String def) {
|
||||
for (int i = 0; i < a.length - 1; i++) if (a[i].equals(key)) return a[i + 1];
|
||||
return def;
|
||||
}
|
||||
|
||||
static synchronized void emit(Integer id, JsonObject obj) {
|
||||
if (id != null) obj.addProperty("id", id);
|
||||
OUT.println(GSON.toJson(obj));
|
||||
OUT.flush();
|
||||
}
|
||||
|
||||
static void emitError(Integer id, String msg) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("ok", false);
|
||||
o.addProperty("error", msg);
|
||||
emit(id, o);
|
||||
}
|
||||
|
||||
static void event(String name, JsonObject payload) {
|
||||
JsonObject o = new JsonObject();
|
||||
o.addProperty("event", name);
|
||||
if (payload != null) o.add("payload", payload);
|
||||
OUT.println(GSON.toJson(o));
|
||||
OUT.flush();
|
||||
}
|
||||
}
|
||||
BIN
scaffold/sample-real-save-render.png
Normal file
BIN
scaffold/sample-real-save-render.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 297 KiB |
BIN
scaffold/sample-underwater.png
Normal file
BIN
scaffold/sample-underwater.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 520 KiB |
Reference in New Issue
Block a user