用户口径:差异放在 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(分层原则 + 加新世代步骤 + 防回归清单)
229 lines
8.2 KiB
Python
229 lines
8.2 KiB
Python
"""渲染联测:Python ↔ Java 后端。
|
||
|
||
需要:
|
||
- 已构建的 client-named.jar + McfoldRenderServer.class
|
||
- 环境变量 MCFOLD_RENDER_CLASSPATH、MCFOLD_NATIVES_DIR
|
||
- Xvfb DISPLAY
|
||
缺任一项则跳过(不误报失败)。
|
||
|
||
★ 架构口径:Python 只发 RLE(set_blocks),协议格式由 Java 侧组装。
|
||
"""
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
|
||
from mcfold.backends import Gen_1_20, backend_class
|
||
from mcfold.backends.base import BackendError, rle_encode
|
||
from mcfold.render import SECTION_VOLUME, WorldRenderer, parse_save_chunk
|
||
|
||
SAVE = Path("/home/mc-test/mc-test")
|
||
STORE = Path("/home/newqqagent/mcview/store/vanilla-1.20.1")
|
||
|
||
|
||
def _available() -> tuple[bool, str]:
|
||
cp = os.environ.get("MCFOLD_RENDER_CLASSPATH")
|
||
if not cp:
|
||
return False, "未设 MCFOLD_RENDER_CLASSPATH"
|
||
paths = cp.split(os.pathsep)
|
||
if not any("client-named" in p for p in paths):
|
||
return False, "classpath 缺 client-named.jar"
|
||
if not any("McfoldRenderServer" in p or "classes" in p for p in paths):
|
||
return False, "classpath 缺编译产物"
|
||
if not os.environ.get("DISPLAY"):
|
||
return False, "无 DISPLAY"
|
||
return True, ""
|
||
|
||
|
||
def _open_backend():
|
||
ok, why = _available()
|
||
if not ok:
|
||
return None, why
|
||
br = Gen_1_20(
|
||
game_dir=str(STORE / "instances" / "default"),
|
||
assets=str(STORE / "assets"), asset_index="1.20.1",
|
||
)
|
||
try:
|
||
br.start()
|
||
except BackendError as e:
|
||
return None, f"启动失败:{str(e)[:100]}"
|
||
return br, ""
|
||
|
||
|
||
# ---------------------------------------------------------------- 世代分派
|
||
|
||
def test_generation_dispatch():
|
||
"""判据是 DeltaTracker 是否存在(不是硬编码版本号)。"""
|
||
jars = {
|
||
"1.20.1": "/tmp/client-1.20.1.jar",
|
||
"1.21.1": "/tmp/genb/client-1.21.1-named.jar",
|
||
}
|
||
present = {v: p for v, p in jars.items() if Path(p).is_file()}
|
||
if not present:
|
||
print(" - 无 named jar,跳过")
|
||
return
|
||
for ver, jar in present.items():
|
||
cls = backend_class(jar)
|
||
if ver == "1.20.1":
|
||
assert cls is Gen_1_20, f"{ver} 应选 Gen_1_20,得到 {cls.__name__}"
|
||
else:
|
||
# 1.21+ 必须有 DeltaTracker ⇒ 选 gen_1_21
|
||
from mcfold.backends import Gen_1_21
|
||
assert cls is Gen_1_21, f"{ver} 应选 Gen_1_21,得到 {cls.__name__}"
|
||
print(f" ✓ 世代分派 {ver} → {cls.__name__}")
|
||
|
||
|
||
def test_generation_metadata():
|
||
"""每个后端要能说清自己的 API 特征(加新世代时的对照依据)。"""
|
||
notes = Gen_1_20.api_notes()
|
||
assert "setLevel" in notes and "render" in notes
|
||
assert notes.get("deltaTracker") is False
|
||
from mcfold.backends import Gen_1_21
|
||
n21 = Gen_1_21.api_notes()
|
||
assert n21.get("deltaTracker") is True, "1.21+ 用 DeltaTracker"
|
||
assert "DeltaTracker" in n21["render"]
|
||
print(f" ✓ 世代元信息(1.20: {notes['render']})")
|
||
|
||
|
||
# ---------------------------------------------------------------- 协议层
|
||
|
||
def test_capacity_from_runtime():
|
||
"""★ 世界尺寸必须问后端,不是 Python 硬编码。"""
|
||
br, why = _open_backend()
|
||
if br is None:
|
||
print(f" - 跳过({why})")
|
||
return
|
||
try:
|
||
cap = br._request({"cmd": "capacity"})
|
||
assert cap["sectionsCount"] > 0, cap
|
||
assert "minSection" in cap and "maxSection" in cap
|
||
# 1.20.1 实测 24 / -4 / 20
|
||
assert cap["sectionsCount"] == 24, cap
|
||
assert cap["minSection"] == -4, cap
|
||
assert cap["maxSection"] == 20, cap
|
||
print(f" ✓ 运行时世界尺寸(sections={cap['sectionsCount']}, "
|
||
f"min={cap['minSection']}, biome={cap.get('defaultBiome')})")
|
||
finally:
|
||
br.stop()
|
||
|
||
|
||
def test_resolve_and_lookup():
|
||
"""方块名↔state id 是渲染器侧权威映射(Python 不复制这张表)。"""
|
||
br, why = _open_backend()
|
||
if br is None:
|
||
print(f" - 跳过({why})")
|
||
return
|
||
try:
|
||
reg = br.registry_info()
|
||
assert reg["blockCount"] > 500 and reg["stateCount"] > 1000
|
||
assert br.lookup_state(1)["block"] == "minecraft:stone"
|
||
ids = br.resolve_blocks([
|
||
"minecraft:stone", "minecraft:oak_log[axis=y]",
|
||
"minecraft:grass_block[snowy=false]", "minecraft:no_such_block",
|
||
])
|
||
assert ids[0] > 0 and ids[1] > 0 and ids[2] > 0
|
||
assert ids[3] == -1, "未知方块应返回 -1"
|
||
print(f" ✓ 名字↔id(blocks={reg['blockCount']}, states={reg['stateCount']})")
|
||
finally:
|
||
br.stop()
|
||
|
||
|
||
# ---------------------------------------------------------------- 渲染(新路径)
|
||
|
||
def test_set_blocks_render():
|
||
"""新路径:Python 只发 RLE,Java 组装协议 + 官方序列化 + 入 cache。"""
|
||
br, why = _open_backend()
|
||
if br is None:
|
||
print(f" - 跳过({why})")
|
||
return
|
||
try:
|
||
cap = br._request({"cmd": "capacity"})
|
||
n_sec = cap["sectionsCount"]
|
||
stone, grass, log = br.resolve_blocks([
|
||
"minecraft:stone", "minecraft:grass_block[snowy=false]",
|
||
"minecraft:oak_log[axis=y]",
|
||
])
|
||
assert stone > 0 and grass > 0 and log > 0
|
||
|
||
br.set_viewport(0, 0, radius=12)
|
||
sections = {}
|
||
for idx in range(n_sec):
|
||
v = [0] * SECTION_VOLUME
|
||
if idx < 4:
|
||
v = [stone] * SECTION_VOLUME # 地基
|
||
elif idx == 4:
|
||
v = [grass] * SECTION_VOLUME # 地表
|
||
elif idx == 5:
|
||
for x in range(7, 9): # 索引 = x + z*16 + y*256
|
||
for z in range(7, 9):
|
||
v[x + z * 16] = log
|
||
sections[idx] = v
|
||
|
||
total_placed = 0
|
||
for cx in range(-3, 4):
|
||
for cz in range(-3, 4):
|
||
r = br.set_blocks(cx, cz, sections)
|
||
assert r["ok"], r
|
||
total_placed += r["placed"]
|
||
assert total_placed > 0, "应写入方块"
|
||
|
||
wr = WorldRenderer(br)
|
||
wr._capacity = cap
|
||
dbg = br.debug()
|
||
assert dbg["loadedChunks"] > 0, f"仓库为空: {dbg}"
|
||
|
||
br.set_camera(8.0, 72.0, 8.0, yaw=45.0, pitch=30.0)
|
||
res = br.render(frames=80)
|
||
assert res.chunks_rendered > 0, f"没有区块被渲染: {res.raw}"
|
||
assert res.distinct_colors > 400, f"画面过单调: {res.distinct_colors}"
|
||
assert res.has_world_content
|
||
print(f" ✓ 新路径渲染(placed={total_placed}, chunks={res.chunks_rendered}, "
|
||
f"colors={res.distinct_colors})")
|
||
finally:
|
||
br.stop()
|
||
|
||
|
||
def test_real_save_render():
|
||
"""真实存档经新路径(RLE 路由)渲染 —— 最终验收。"""
|
||
ok, why = _available()
|
||
if not ok:
|
||
print(f" - 跳过({why})")
|
||
return
|
||
if not (SAVE / "level.dat").is_file():
|
||
print(" - 跳过(无测试存档)")
|
||
return
|
||
wr = WorldRenderer.open(store=str(STORE), java="java")
|
||
try:
|
||
info = wr.load_save(SAVE, center=(8, -7), radius=3)
|
||
assert info["injected"] > 0, info
|
||
assert info["unknownBlocks"] == 0, f"有未解析方块: {info}"
|
||
wr.camera(x=130.0, y=95.0, z=-110.0, yaw=135.0, pitch=35.0)
|
||
d = wr.debug()
|
||
assert d["loadedChunks"] > 0, d
|
||
res = wr.screenshot(frames=90)
|
||
assert res.chunks_rendered > 0, res.raw
|
||
assert res.distinct_colors > 2000, f"真实地形应色彩丰富: {res.distinct_colors}"
|
||
assert res.has_world_content
|
||
print(f" ✓ 真实存档渲染(injected={info['injected']}, "
|
||
f"specs={info['uniqueSpecs']}, chunks={res.chunks_rendered}, "
|
||
f"colors={res.distinct_colors})")
|
||
finally:
|
||
wr.close()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
|
||
failed = 0
|
||
for t in tests:
|
||
try:
|
||
t()
|
||
except Exception as e:
|
||
failed += 1
|
||
import traceback
|
||
print(f" ✗ {t.__name__}: {e}")
|
||
traceback.print_exc()
|
||
print(f"\n渲染联测: "
|
||
+ ("全部通过" if failed == 0 else f"{len(tests)-failed}/{len(tests)} 通过"))
|
||
sys.exit(1 if failed else 0)
|