视距(用户报「无法调整视距?」)——根因是三处不同步:
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)
241 lines
9.2 KiB
Python
241 lines
9.2 KiB
Python
"""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)
|