Files
agentmc/mcfold/render.py
JianFeeeee 253146cffe gen_1_21 实测启动成功 + provision 工具
决定性验证:gen_1_21 后端在 1.21.1 上收到 {"event":"ready"}
  ⇒ 世代分派架构成立:同一套 Python 层,两个独立编译的 Java 后端

运行时发现第 12 处世代差异(编译期看不出):
  1.20.x  registryAccess 是可变 LayeredRegistryAccess → 事后反射替换
  1.21.x  registryAccess 是 final RegistryAccess$Frozen → 反射抛
          "Can not set final field"
  处理:gen_1_21 改为先载注册表 → 经 CommonListenerCookie.receivedRegistries
        传入 → 再构造 listener。比 1.20 的反射做法更干净。

新增 mcfold/provision.py(从官方 manifest 装任意版本,版本无关):
  - 跳过音频资源:1.21.1 实测 3911 条里跳过 3744 条
  - natives 两种形态都认(1.21 起是独立库条目,非 classifiers)
  - 实测 1.21.1:274 文件 / 200MB / 32s / 0 失败
2026-09-25 18:35:28 +08:00

390 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""把世界数据渲染成画面 —— 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 .backends import backend_class, detect_generation
from .backends.base import BackendError, RenderBackend, 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: RenderBackend, 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", client_jar: str | None = None,
generation: str | None = None) -> "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")
# ★ Python 层 switch:按 client jar 的世代选后端
# (1.21+ 的 DeltaTracker 在 1.20.x jar 中不存在 ⇒ 必须独立编译的后端)
# ★ 变量名不能叫 cls —— 会覆盖 classmethod 的 cls,
# 导致下面 cls(bridge, instance) 构造出后端而非 WorldRenderer(实测踩过)
backend_cls = backend_class(client_jar=client_jar, generation=generation)
bridge = backend_cls(
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 | None = None, dim: str | None = None,
only_status: str = "minecraft:full") -> dict:
"""从存档装载区块(零网络、零登录)。
radius —— 加载半径(区块)。**缺省跟随视距**(这是关键:
视距设了 12 但只加载 3 圈数据,画面看起来就像视距没生效)。
传 None 时用 renderDistance;显式传值则两者取大。
"""
if radius is None:
radius = max(3, self.render_distance)
else:
radius = max(radius, 1)
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=radius + 2)
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)
# ---------------- 视角与截图 ----------------
@property
def render_distance(self) -> int:
"""当前视距(区块)。默认 8,可用 set_view(render_distance=) 改。"""
return getattr(self, "_render_distance", 8)
def set_render_distance(self, rd: int) -> dict:
"""设置视距(区块)。内部同时设 options + serverRenderDistance +
ClientChunkCache 的 storage 半径 —— 三者缺一则视距不生效(实测)。"""
self._render_distance = int(rd)
return self.bridge.set_view(render_distance=int(rd))
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)