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:
JianFeeeee
2026-09-25 17:33:41 +08:00
parent 541fe232a8
commit 391d452c85
11 changed files with 1670 additions and 0 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

300
mcfold/anvil.py Normal file
View 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
View 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
View 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"),
}