## 修掉渲染联测的嵌套实例目录
`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 项全绿**
304 lines
11 KiB
Python
304 lines
11 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")
|
||
# ★ 实例目录是 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)
|