Files
agentmc/tests/test_anvil.py
JianFeeeee 34ed70ab53 修复视距不生效 + 接入统一测试
视距(用户报「无法调整视距?」)——根因是三处不同步:
  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)
2026-09-25 17:47:58 +08:00

241 lines
9.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.

"""anvil.py —— 存档读取与区块编码的单元测试。
不依赖 Java 渲染器;渲染链路的联测在 test_render_bridge.py。
"""
import struct
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from mcfold.anvil import NbtReader, RegionFile, nbt_read, region_path, list_region_files
from mcfold.render import (
_pack, _varint, _long_array, _decode_storage, _encode_paletted,
parse_save_chunk, SECTION_VOLUME, MIN_SECTION_Y,
)
SAVE = Path("/home/mc-test/mc-test")
def test_varint_roundtrip():
def read_varint(b: bytes) -> tuple[int, int]:
v = 0; shift = 0; i = 0
while True:
byte = b[i]; i += 1
v |= (byte & 0x7F) << shift
if not (byte & 0x80):
break
shift += 7
return v, i
for n in [0, 1, 127, 128, 255, 16384, 2_097_152, 24135]:
enc = _varint(n)
dec, used = read_varint(enc)
assert dec == n, f"{n} -> {dec}"
assert used == len(enc)
print(" ✓ varint 编解码")
def test_pack_unpack_roundtrip():
"""位打包必须能被 _decode_storage 解回来(值不跨 long 边界)。"""
for bits in [4, 5, 8, 15]:
vals = [(i * 7) % (1 << min(bits, 8)) for i in range(64 * 4)]
longs = _pack(vals, bits)
back = _decode_storage(longs, len(vals), bits)
assert back == vals, f"bits={bits} 不一致: {back[:8]} vs {vals[:8]}"
print(" ✓ 位打包往返一致(4/5/8/15 位)")
def test_long_array_encoding():
"""LongArray = varint(长度) + 大端 i64 ×N。"""
enc = _long_array([1, -1])
assert enc[0] == 2 # 长度 2
assert enc[1:9] == (1).to_bytes(8, "big")
assert enc[9:17] == (0xFFFFFFFFFFFFFFFF).to_bytes(8, "big")
assert _long_array([])[0] == 0
print(" ✓ LongArray 编码(含负数/空)")
def test_paletted_single_value():
"""单值分支:byte(0) + varint(state) + varint(0 longs) —— 与 Java 对齐。"""
data = _encode_paletted([5] * SECTION_VOLUME, SECTION_VOLUME)
assert data[0] == 0, "bitsPerValue 应为 0"
assert data[1] == 5, "state id 应跟在 bits 后"
assert data[2] == 0, "longs 数应为 0"
print(" ✓ 单值调色板编码(3 字节)")
def test_paletted_indirect():
"""多值走本地调色板:bits + n + palette + longs。"""
vals = [0] * (SECTION_VOLUME - 1) + [3]
data = _encode_paletted(vals, SECTION_VOLUME)
bits = data[0]
assert bits >= 4, f"本地调色板 bits 至少 4,得到 {bits}"
n = data[1]
assert n == 2, f"调色板应有 2 项,得到 {n}"
print(f" ✓ 间接调色板编码(bits={bits}, palette={n})")
def test_nbt_reader_primitives():
"""NBT 读取器的基本类型。"""
# Compound{ "a": Int(7), "b": String("hi") }
body = bytearray()
body += bytes([10]) # root: Compound
body += struct.pack(">H", 1) + b"R" # 名字 "R"
body += bytes([3]) + struct.pack(">H", 1) + b"a" + struct.pack(">i", 7)
body += bytes([8]) + struct.pack(">H", 1) + b"b" + struct.pack(">H", 2) + b"hi"
body += bytes([0]) # end
d = nbt_read(bytes(body))
assert d == {"a": 7, "b": "hi"}, d
print(" ✓ NBT 基本类型(int/string)")
def test_nbt_nested_list():
"""嵌套 List/Compound(存档 sections 就是这个形状)。"""
body = bytearray()
body += bytes([10]) + struct.pack(">H", 0)
body += bytes([9]) + struct.pack(">H", 4) + b"list" + bytes([10]) + struct.pack(">i", 2)
for y in (0, 1):
body += bytes([3]) + struct.pack(">H", 1) + b"Y" + struct.pack(">i", y)
body += bytes([0])
body += bytes([0])
d = nbt_read(bytes(body))
assert d == {"list": [{"Y": 0}, {"Y": 1}]}, d
print(" ✓ NBT 嵌套 List[Compound]")
def test_props_to_spec():
from mcfold.render import _props_to_spec
assert _props_to_spec("minecraft:stone", None) == "minecraft:stone"
assert _props_to_spec("minecraft:oak_log", {"axis": "y"}) == "minecraft:oak_log[axis=y]"
# 多属性要排序(保证缓存命中稳定)
a = _props_to_spec("minecraft:oak_stairs", {"facing": "north", "half": "top"})
b = _props_to_spec("minecraft:oak_stairs", {"half": "top", "facing": "north"})
assert a == b == "minecraft:oak_stairs[facing=north,half=top]", a
print(" ✓ 方块名规格生成(属性排序稳定)")
def test_real_save_level_dat():
"""真实 level.dat:本机存档是该格式(直接 gzip,无 8 字节头)。"""
if not (SAVE / "level.dat").is_file():
print(" - 无测试存档,跳过")
return
from mcfold.anvil import read_level_dat
lv = read_level_dat(SAVE)
data = lv["Data"]
assert "LevelName" in data
assert isinstance(data.get("DataVersion"), int)
assert "Version" in data
print(f" ✓ 真实 level.dat({data['LevelName']}, DataVersion={data['DataVersion']})")
def test_real_region_chunk():
"""真实 region:读出区块 NBT,且有完整地形(不是全空气)。"""
if not (SAVE / "region").is_dir():
print(" - 无测试存档,跳过")
return
regs = list_region_files(SAVE)
assert regs, "应有 region 文件"
found = None
for rf in regs[:4]:
for cx, cz in [(-1, -1), (0, 0), (8, -7)]:
try:
raw = RegionFile(rf).chunk_nbt(cx, cz)
except Exception:
continue
if raw:
found = nbt_read(raw)
break
if found:
break
assert found is not None, "应至少读到一个区块"
names = set()
for s in found.get("sections", []):
for p in (s.get("block_states") or {}).get("palette") or []:
names.add(p.get("Name"))
assert len(names) > 1, f"完整区块应有多种方块,得到 {names}"
print(f" ✓ 真实区块 NBT({len(names)} 种方块)")
def test_parse_save_chunk_shapes():
"""parse_save_chunk 输出的结构:states 长度、Y 范围、规格去重。"""
if not (SAVE / "region").is_dir():
print(" - 无测试存档,跳过")
return
rf = None
nbt = None
for r in list_region_files(SAVE):
for cx, cz in [(-1, -1), (0, 0)]:
try:
raw = RegionFile(r).chunk_nbt(cx, cz)
except Exception:
continue
if raw:
n = nbt_read(raw)
if n.get("Status") == "minecraft:full":
rf, nbt = r, n
break
if nbt:
break
if not nbt:
print(" - 无 full 区块,跳过")
return
ch = parse_save_chunk(-1, -1, nbt)
assert ch.sections, "应有 sections"
for sy, sec in ch.sections.items():
assert len(sec["states"]) == SECTION_VOLUME, f"Y={sy} states 长度 {len(sec['states'])}"
assert len(sec["biomes"]) == 64
assert sec["states"][0].startswith("minecraft:")
assert ch.palette_specs == sorted(ch.palette_specs), "规格应去重且排序"
print(f" ✓ parse_save_chunk({len(ch.sections)} sections, {len(ch.palette_specs)} 规格)")
def test_section_y_mapping():
"""★ 关键回归:存档绝对 Y(-4..19) ↔ 载荷索引(0..23)。"""
assert MIN_SECTION_Y == -4, "1.20 的 minSection 是 -4"
# 载荷索引 0 应取存档 Y=-4;索引 23 应取存档 Y=19
save_y = {i - 4: {"states": ["minecraft:air"] * SECTION_VOLUME,
"biomes": ["minecraft:plains"] * 64, "palette": []}
for i in range(24)}
# 用真实编码器验证:只放 Y=-4 有 stone,则载荷 section 0 应是 stone
save_y[-4]["states"] = ["minecraft:stone"] * SECTION_VOLUME
save_y[-4]["palette"] = ["minecraft:stone"]
from mcfold.render import SaveChunk
class FakeWR:
def _encode_chunk(self, c, id_map):
return bytearray()
# 直接调编码核心:验证第 0 个 section 用的是 Y=-4
from mcfold.render import _encode_paletted
out = bytearray()
for sy in range(24):
sec = save_y.get(sy - 4)
ids = [1 if s == "minecraft:stone" else 0 for s in sec["states"]]
out += (sum(1 for v in ids if v) & 0xFFFF).to_bytes(2, "big")
out += _encode_paletted(ids, SECTION_VOLUME)
out += bytes([0]) + _varint(1) + _varint(0)
# 第一个 section 的 nonEmpty 应为 4096(不是 0)
assert struct.unpack_from(">h", out, 0)[0] == 4096, "索引 0 应映射到存档 Y=-4"
print(" ✓ section 索引 ↔ 绝对 Y 映射(-4..19 ↔ 0..23)")
def test_encode_empty_section_is_air():
"""缺失的 section 编成空气(不是跳过)—— 否则 Java 侧读越界。"""
data = _encode_paletted([0] * SECTION_VOLUME, SECTION_VOLUME)
assert data == bytes([0, 0, 0]), f"空气 section 应是 3 字节,得到 {len(data)}"
print(" ✓ 空 section 编码为空气")
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
print(f" ✗ {t.__name__}: {e}")
print(f"\nanvil/render 单元测试: " + ("全部通过" if failed == 0 else f"{len(tests)-failed}/{len(tests)} 通过"))
sys.exit(1 if failed else 0)