视距(用户报「无法调整视距?」)——根因是三处不同步:
1. options.renderDistance 本地设置(原先只设了这个)
2. options.setServerRenderDistance 服务端下发上限(getEffectiveRenderDistance = min(两者))
3. ClientChunkCache.updateViewRadius storage 半径
第三处最关键:官方只在收到服务端半径包时才调它(ClientPacketListener:2316),
我们不连服 ⇒ Storage.inRange 用旧半径判定,超出区块被**静默丢弃**。
另一个前置:加载半径必须跟随视距。视距 12 只喂 3 圈数据,
画面看起来就像没生效(最容易误判的坑)。
实测(同相机同存档):
视距 4 → 注入 81 渲染 55 色彩 15226
视距 12 → 注入 595 渲染 447 色彩 18477
画面差异显著:RD=12 能看到延伸到地平线的森林与完整海岸线。
(sample-render-distance-{4,12}.png)
测试:新增 tests/test_anvil.py(13 项,存档/NBT/编码)
tests/test_render_bridge.py(3 项,桥协议+合成渲染+真实存档渲染)
run_tests.sh 改为默认跑单元测试、--with-render 跑联测
合计 61 项全绿(原 48 + 13)
185 lines
6.9 KiB
Python
185 lines
6.9 KiB
Python
"""渲染桥联测:Python ↔ Java 渲染器。
|
||
|
||
需要:
|
||
- 已构建的 client-named.jar + McfoldRenderServer.class
|
||
- 环境变量 MCFOLD_RENDER_CLASSPATH、MCFOLD_NATIVES_DIR
|
||
- Xvfb DISPLAY
|
||
缺任一项则跳过(不误报失败)。
|
||
"""
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
|
||
from mcfold.render import (
|
||
SECTION_VOLUME, _encode_paletted, _varint, parse_save_chunk,
|
||
)
|
||
from mcfold.render_bridge import RenderBridge, RenderBridgeError
|
||
|
||
SAVE = Path("/home/mc-test/mc-test")
|
||
STORE = Path("/home/newqqagent/mcview/store/vanilla-1.20.1")
|
||
|
||
|
||
def _bridge_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 缺 McfoldRenderServer.class"
|
||
if not os.environ.get("DISPLAY"):
|
||
return False, "无 DISPLAY"
|
||
return True, ""
|
||
|
||
|
||
def test_bridge_protocol():
|
||
"""桥的协议层:registry / lookup / resolve —— 不涉及渲染。"""
|
||
ok, why = _bridge_available()
|
||
if not ok:
|
||
print(f" - 跳过({why})")
|
||
return
|
||
br = RenderBridge(
|
||
game_dir=str(STORE / "instances" / "default"),
|
||
assets=str(STORE / "assets"), asset_index="1.20.1",
|
||
)
|
||
try:
|
||
br.start()
|
||
except RenderBridgeError as e:
|
||
print(f" - 跳过(启动失败:{str(e)[:80]})")
|
||
return
|
||
try:
|
||
reg = br.registry_info()
|
||
assert reg["ok"] and reg["blockCount"] > 500, reg
|
||
assert reg["stateCount"] > 1000, reg
|
||
|
||
# state id → 方块名(含属性)
|
||
st = br.lookup_state(1)
|
||
assert st["block"] == "minecraft:stone", st
|
||
assert st["inRange"] is True
|
||
|
||
# 方块名(+属性) → state id
|
||
r = br._request({"cmd": "resolve", "blocks": [
|
||
"minecraft:stone",
|
||
"minecraft:oak_log[axis=y]",
|
||
"minecraft:grass_block[snowy=false]",
|
||
"minecraft:this_block_does_not_exist",
|
||
]})
|
||
ids = r["ids"]
|
||
assert ids[0] > 0, "stone 应有有效 id"
|
||
assert ids[1] > 0, "oak_log[axis=y] 应可解析"
|
||
assert ids[2] > 0, "grass_block 应可解析"
|
||
assert ids[3] == -1, "不存在的方块应返回 -1"
|
||
assert r["unknown"] == 1
|
||
print(f" ✓ 桥协议(blocks={reg['blockCount']}, states={reg['stateCount']}, "
|
||
f"resolve 3 中 / 1 未知)")
|
||
finally:
|
||
br.stop()
|
||
|
||
|
||
def test_bridge_render_synthetic():
|
||
"""合成数据渲染:验证「注入 → 网格重建 → 出图」全链路。"""
|
||
ok, why = _bridge_available()
|
||
if not ok:
|
||
print(f" - 跳过({why})")
|
||
return
|
||
br = RenderBridge(
|
||
game_dir=str(STORE / "instances" / "default"),
|
||
assets=str(STORE / "assets"), asset_index="1.20.1",
|
||
)
|
||
try:
|
||
br.start()
|
||
except RenderBridgeError as e:
|
||
print(f" - 跳过(启动失败:{str(e)[:80]})")
|
||
return
|
||
try:
|
||
# 先设视野中心(否则区块被丢弃)
|
||
br._request({"cmd": "viewport", "cx": 0, "cz": 0, "radius": 12})
|
||
|
||
# 造:地板 stone(索引 4 = y=0 = 世界 y=64)+ 中间一根原木
|
||
stone = br._request({"cmd": "resolve", "blocks": ["minecraft:stone"]})["ids"][0]
|
||
log = br._request({"cmd": "resolve", "blocks": ["minecraft:oak_log[axis=y]"]})["ids"][0]
|
||
assert stone > 0 and log > 0
|
||
|
||
payload = bytearray()
|
||
for sy in range(24):
|
||
ids = [0] * SECTION_VOLUME
|
||
if sy == 4:
|
||
ids = [stone] * SECTION_VOLUME # 地板(世界 y=64)
|
||
elif sy in (5, 6, 7):
|
||
for x in range(7, 9): # 中间 2x2 柱子
|
||
for z in range(7, 9):
|
||
ids[x + z * 16] = log
|
||
non_empty = sum(1 for v in ids if v != 0)
|
||
payload += (non_empty & 0xFFFF).to_bytes(2, "big")
|
||
payload += _encode_paletted(ids, SECTION_VOLUME)
|
||
payload += bytes([0]) + _varint(1) + _varint(0)
|
||
|
||
items = [(cx, cz, bytes(payload)) for cx in range(-3, 4) for cz in range(-3, 4)]
|
||
fed = br._request({"cmd": "chunks", "items": [
|
||
{"cx": cx, "cz": cz, "data": __import__("base64").b64encode(d).decode()}
|
||
for cx, cz, d in items
|
||
]}, timeout=180)
|
||
assert fed["failed"] == 0, f"注入失败: {fed.get('firstError')}"
|
||
assert fed["loadedChunks"] > 0, f"仓库为空: {fed}"
|
||
|
||
br.set_camera(8.0, 72.0, 8.0, yaw=45.0, pitch=25.0)
|
||
res = br.render(frames=80)
|
||
assert res.width > 0 and res.height > 0
|
||
assert res.chunks_rendered > 0, f"没有区块被渲染: {res.raw}"
|
||
assert res.distinct_colors > 400, f"画面过单调: {res.distinct_colors}"
|
||
assert res.has_world_content is True
|
||
print(f" ✓ 合成数据渲染(chunks={res.chunks_rendered}, "
|
||
f"colors={res.distinct_colors}, {len(res.image)}B PNG)")
|
||
finally:
|
||
br.stop()
|
||
|
||
|
||
def test_bridge_render_real_save():
|
||
"""真实存档渲染:最终验收判据。"""
|
||
ok, why = _bridge_available()
|
||
if not ok:
|
||
print(f" - 跳过({why})")
|
||
return
|
||
if not (SAVE / "level.dat").is_file():
|
||
print(" - 跳过(无测试存档)")
|
||
return
|
||
from mcfold.render import WorldRenderer
|
||
wr = WorldRenderer.open(store=str(STORE), java="java")
|
||
try:
|
||
info = wr.load_save(SAVE, center=(8, -7), radius=4)
|
||
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)
|
||
dbg = wr.debug()
|
||
assert dbg["loadedChunks"] > 0, f"仓库为空: {dbg}"
|
||
assert dbg["playerChunk"] == "8,-7", f"相机区块不对: {dbg}"
|
||
|
||
res = wr.screenshot(frames=100)
|
||
assert res.chunks_rendered > 0, res.raw
|
||
assert res.distinct_colors > 2000, f"真实地形应色彩丰富: {res.distinct_colors}"
|
||
assert res.has_world_content is True
|
||
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)
|