Files
agentmc/tests/test_render_bridge.py
JianFeeeee 16f2e04fff 架构重构:差异全部下移到 Java,Python 只做路由
用户口径:差异放在 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(分层原则 + 加新世代步骤 + 防回归清单)
2026-09-25 19:21:45 +08:00

229 lines
8.2 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.

"""渲染联测: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)