用户定向:在 python 层写 switch,调用不同 java 后端(而非一个文件里 switch)。
新增:
mcfold/backends/{__init__,base,gen_1_20,gen_1_21}.py 后端体系与分派
scaffold/java/gen_1_20/ 对 1.20.x client jar 编译
scaffold/java/gen_1_21/ 对 1.21.1 client jar 编译(✅ 实测编译通过)
scaffold/java/VERSION-DIFFS.md 实测差异全表
判据:DeltaTracker 类是否存在(1.21 引入,正是 render 签名变化的根源)
⇒ 比硬编码版本号稳
实测差异共 11 处(21 个检查点里 13 个跨世代完全一致):
setLevel / GameRenderer.render / countRendered* / createPackRepository /
ResourceLocation 构造 / enableDataFixerOptimizations(移除) / User 构造 /
ChunkStatus 包路径 / ClientPacketListener 构造(参数个数语义都变) /
loadEffect(变 private)
为什么必须分后端:DeltaTracker 在 1.20.x jar 中不存在,
两代调用无法共存于一份源码(实测编译报 cannot find symbol)。
1.21.1 反混淆已验证(29MB named jar,DeltaTracker 3 处)
388 lines
15 KiB
Python
388 lines
15 KiB
Python
"""把世界数据渲染成画面 —— 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 = backend_class(client_jar=client_jar, generation=generation)
|
||
bridge = 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)
|