Files
agentmc/tests/test_render_bridge.py
JianFeeeee 330867c6df 版本隔离守卫(新套件立即抓出两处真实回流)
## 修掉渲染联测的嵌套实例目录

`test_render_bridge.py` 把实例写成 `<store>/instances/test-mcfold`。
而权威口径(mcview/lib/versions.cjs:59/61)是:

    contentRoot = BASE/store/<key>       共享只读(jar/libraries/assets)
    instanceDir = BASE/instances/<name>  实例独占(mods/config/options/saves)

⇒ 那是「一个版本的内容根里长出了实例态」。而版本隔离要防的崩因正是
「多实例共用 mods」—— 内层目录会把它重新引回来,而那条路径看起来完全合法。
改为 `BASE/instances/test-mcfold`。

## 新增 tests/test_version_isolation.py(4 项,默认套件里跑)

★ 它**不需要 JVM**(纯路径断言),所以必须落在默认测试里 ——
放进 `--with-render` 才跑的套件等于没守卫(大多数人不会加那个参数)。

  ✓ 三层同级(store/ · instances/ · runtime/ 是 BASE 下的兄弟)
  ✓ 内容根干净(无 instances/ 嵌套、无 mods/config/options.txt/saves)
  ✓ 测试实例归位(落在 instances/ 层,不在任何内容根下)
  ✓ 内容根无实例配置(查内容,不只查存在)

## ★ 守卫首次运行就抓出第二处(我此前漏掉的)

```
  ✗ 内容根被实例化:
    - store/vanilla-1.21.1/instances/(内容根里嵌套了实例层)
```

内容是 `instances/default/{options.txt,downloads/log.json}`(生成态,
正式 `instances/default` 已存在)⇒ 已删。两个内容根现已干净。

⇒ 这条说明:**「我检查过了」不等于「有一条会自己跑的检查」** ——
  我上一轮手工核实过 1.20.1 那处并修掉,却没发现 1.21.1 那处;
  守卫一运行,两处一次全暴露。

## 验证

  ./run_tests.sh               → 66 项
  ./run_tests.sh --with-render → **80 项全绿**
2026-09-26 07:23:54 +08:00

304 lines
11 KiB
Python
Raw Permalink 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")
# ★ 实例目录是 store/ 的**兄弟**(不是嵌套在里面)。
# 权威口径:mcview/lib/versions.cjs:59/61
# contentRoot = BASE/store/<key> (共享只读:jar/libraries/assets)
# instanceDir = BASE/instances/<name>(实例独占:mods/config/options/saves)
# store 里再开一层 instances/ 会让「一个版本的内容根」多出实例态,
# 正是版本隔离要防的那种回流。
INSTANCES = Path("/home/newqqagent/mcview/instances")
TEST_INSTANCE = INSTANCES / "test-mcfold"
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(
# ★ 用测试专用实例目录,不碰生产 default(版本隔离策略)
game_dir=str(TEST_INSTANCE),
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_get_block_info():
"""get_block_info:方块名/属性/未加载区分(Java 侧回答)。
★ 关键判据:**未加载的区块必须 known=false**,不能伪装成空气
(ClientLevel.hasChunk() 恒 true、ChunkSource.hasChunk() 也恒 true,
实测都不顶用;必须用 getChunk(..., requireChunk=false) 判 null)。
"""
br, why = _open_backend()
if br is None:
print(f" - 跳过({why})")
return
try:
cap = br._request({"cmd": "capacity"})
min_s = cap["minSection"]
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(cap["sectionsCount"]):
v = [0] * SECTION_VOLUME
if idx < 4:
v = [stone] * SECTION_VOLUME
elif idx == 4:
v = [grass] * SECTION_VOLUME
elif idx == 5:
v[7 + 7 * 16] = log # x=7,z=7
sections[idx] = v
for cx in range(-1, 2):
for cz in range(-1, 2):
br.set_blocks(cx, cz, sections)
y_grass = (min_s + 4) * 16
y_stone = (min_s + 2) * 16
y_log = (min_s + 5) * 16
g = br.get_block_info(8, y_grass, 8)
assert g["known"] and g["block"] == "minecraft:grass_block", g
assert g["isAir"] is False and g["isSolid"] is True
assert g["properties"].get("snowy") == "false", g
assert g["isMissingTexture"] is False, g
assert g["fromMod"] is False
st = br.get_block_info(8, y_stone, 8)
assert st["known"] and st["block"] == "minecraft:stone", st
lg = br.get_block_info(7, y_log, 7)
assert lg["known"] and lg["block"] == "minecraft:oak_log", lg
assert lg["properties"].get("axis") == "y", lg # 属性必须读出来
air = br.get_block_info(8, 200, 8)
assert air["known"] and air["isAir"] is True, air
# ★ 最重要的一条:未加载 ≠ 空气
far = br.get_block_info(9999, 64, 9999)
assert far["known"] is False, f"未加载区块必须 known=false,得到 {far}"
assert far.get("reason") == "chunk_not_loaded", far
print(f" ✓ get_block_info({g['block']} 属性={g['properties']}, "
f"原木 axis={lg['properties'].get('axis')}, 未加载已区分)")
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)