用户口径:差异放在 Java 层,Python 相当于路由。 下移到 Java(新增 cmd=setblocks): Python 只发 RLE([[stateId,count],...]),Java 用官方 API 完成 调色板组装 / 位打包 / 官方序列化 / 入 cache / 渲染。 ★ 意外收益:走 ClientboundLevelChunkPacketData(chunk).getReadBuffer() + replaceWithPacketData(...) 这条官方路径,两代签名完全一致 ⇒ 原先的 Storage.chunks 反射注入**整段删掉**。 新增 cmd=capacity:世界尺寸(sectionsCount/minSection/defaultBiome) 由运行时回答,Python 不再硬编码 24/-4(1.16 时代是 16/0)。 Python 侧删除(防回归,见 test_import_surface): render_bridge.py(旧桥,无世代概念) render._pack/_long_array/_encode_paletted/_encode_chunk/_varint anvil.encode_block_section/encode_chunk_payload/save_chunk_to_payload 合计 454→374 行(render.py)、300→177 行(anvil.py) 保留在 Python(都是「不随版本变」的知识): RLE 压缩(rle_encode/rle_decode,具名可测) 存档读取(region/NBT) 方块名→id 的路由(查询在 Java) NDJSON 协议 测试:62 项单元 + 6 项渲染联测全绿 新测试反映新架构:RLE 边界、运行时容量、世代分派、新路径渲染 test_anvil 里原有 8 项协议位打包测试已删(那已下移到 Java, 由两代后端各自的编译验证覆盖) 新增 scaffold/ARCHITECTURE.md(分层原则 + 加新世代步骤 + 防回归清单)
375 lines
15 KiB
Python
375 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_VOLUME = 16 * 16 * 16 # 一个 section 的方块数
|
||
BIOME_VOLUME = 4 * 4 * 4 # 一个 biome section 的值数
|
||
|
||
# ── 世界尺寸:★ 不硬编码,运行时问 Java ──
|
||
# 这些量随版本/世界类型变(1.20 起 worldHeight 384、minSection -4;
|
||
# 更早的版本是 256/-0)。Python 只做路由与协议编解码,
|
||
# 版本相关的量由后端回答(cmd="capacity")。
|
||
# 下面三个仅在没有后端时的降级默认值。
|
||
FALLBACK_SECTION_COUNT = 24
|
||
FALLBACK_MIN_SECTION_Y = -4
|
||
FALLBACK_DEFAULT_BIOME = "minecraft:plains"
|
||
|
||
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] = {}
|
||
self._capacity: dict[str, Any] | None = None
|
||
self._biome_id: int | None = None
|
||
|
||
# ---------------- 构造 ----------------
|
||
|
||
@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()
|
||
wr.refresh_capacity()
|
||
return wr
|
||
|
||
def refresh_capacity(self) -> dict[str, Any]:
|
||
"""向 Java 后端询问世界尺寸(Python 不硬编码版本相关量)。"""
|
||
try:
|
||
self._capacity = self.bridge._request({"cmd": "capacity"})
|
||
except Exception:
|
||
self._capacity = None
|
||
return self._capacity or {}
|
||
|
||
@property
|
||
def sections_count(self) -> int:
|
||
if self._capacity and self._capacity.get("sectionsCount"):
|
||
return int(self._capacity["sectionsCount"])
|
||
return FALLBACK_SECTION_COUNT
|
||
|
||
@property
|
||
def min_section_y(self) -> int:
|
||
if self._capacity and "minSection" in self._capacity:
|
||
return int(self._capacity["minSection"])
|
||
return FALLBACK_MIN_SECTION_Y
|
||
|
||
@property
|
||
def _default_biome(self) -> int:
|
||
"""默认生物群系 id —— 运行时向 Java 查(不硬编码 1)。"""
|
||
if self._biome_id is None:
|
||
name = (self._capacity or {}).get("defaultBiome") or FALLBACK_DEFAULT_BIOME
|
||
self._biome_id = self._resolve_biome_id(name)
|
||
return self._biome_id
|
||
|
||
def _resolve_biome_id(self, name: str) -> int:
|
||
key = f"__biome__{name}"
|
||
if key in self._spec_cache:
|
||
return self._spec_cache[key]
|
||
try:
|
||
ids = self.bridge.resolve_blocks([name])
|
||
bid = ids[0] if ids else 1
|
||
except Exception:
|
||
bid = 1
|
||
self._spec_cache[key] = bid
|
||
return bid
|
||
|
||
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:
|
||
"""把存档区块交给渲染器。
|
||
|
||
★ Python 只做「方块名 → state id」的路由与 RLE 压缩;
|
||
调色板组装、位打包、协议序列化、入 cache 全在 Java 侧用官方 API 完成。
|
||
这样历代改过的协议格式不需要在 Python 里复制一份。
|
||
"""
|
||
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)
|
||
|
||
n_sections = self.sections_count
|
||
min_sy = self.min_section_y
|
||
injected = 0
|
||
for c in chunks:
|
||
sections: dict[int, list[int]] = {}
|
||
for idx in range(n_sections):
|
||
sec = c.sections.get(idx + min_sy)
|
||
if sec is None:
|
||
continue # 空 section 由 Java 侧留空
|
||
sections[idx] = [max(0, id_map.get(sp, -1)) for sp in sec["states"]]
|
||
if sections:
|
||
self.bridge.set_blocks(c.cx, c.cz, sections)
|
||
injected += 1
|
||
self.chunks[(c.cx, c.cz)] = c
|
||
|
||
self._loaded.update((c.cx, c.cz) for c in chunks)
|
||
out = {"injected": injected, "unknownBlocks": unknown,
|
||
"uniqueSpecs": len(id_map)}
|
||
if meta:
|
||
out.update(meta)
|
||
return 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)
|