diff --git a/logs/2026-09-25-2.log.gz b/logs/2026-09-25-2.log.gz new file mode 100644 index 0000000..8312f78 Binary files /dev/null and b/logs/2026-09-25-2.log.gz differ diff --git a/logs/2026-09-25-3.log.gz b/logs/2026-09-25-3.log.gz new file mode 100644 index 0000000..80b97fc Binary files /dev/null and b/logs/2026-09-25-3.log.gz differ diff --git a/logs/2026-09-25-4.log.gz b/logs/2026-09-25-4.log.gz new file mode 100644 index 0000000..a12cf7f Binary files /dev/null and b/logs/2026-09-25-4.log.gz differ diff --git a/logs/2026-09-25-5.log.gz b/logs/2026-09-25-5.log.gz new file mode 100644 index 0000000..629a55a Binary files /dev/null and b/logs/2026-09-25-5.log.gz differ diff --git a/logs/2026-09-25-6.log.gz b/logs/2026-09-25-6.log.gz new file mode 100644 index 0000000..3ea8980 Binary files /dev/null and b/logs/2026-09-25-6.log.gz differ diff --git a/logs/2026-09-25-7.log.gz b/logs/2026-09-25-7.log.gz new file mode 100644 index 0000000..e0d577a Binary files /dev/null and b/logs/2026-09-25-7.log.gz differ diff --git a/logs/latest.log b/logs/latest.log index 1d858f1..632b0f8 100644 --- a/logs/latest.log +++ b/logs/latest.log @@ -1,6 +1,6 @@ -[17:19:27] [Datafixer Bootstrap/INFO]: 188 Datafixer optimizations took 93 milliseconds -[17:19:28] [main/INFO]: Environment: authHost='https://authserver.mojang.com', accountsHost='https://api.mojang.com', sessionHost='https://sessionserver.mojang.com', servicesHost='https://api.minecraftservices.com', name='PROD' -[17:19:29] [main/ERROR]: Failed to verify authentication +[17:42:09] [Datafixer Bootstrap/INFO]: 188 Datafixer optimizations took 113 milliseconds +[17:42:10] [main/INFO]: Environment: authHost='https://authserver.mojang.com', accountsHost='https://api.mojang.com', sessionHost='https://sessionserver.mojang.com', servicesHost='https://api.minecraftservices.com', name='PROD' +[17:42:10] [main/ERROR]: Failed to verify authentication com.mojang.authlib.exceptions.InvalidCredentialsException: Status: 401 at com.mojang.authlib.exceptions.MinecraftClientHttpException.toAuthenticationException(MinecraftClientHttpException.java:56) ~[authlib-4.0.43.jar:?] at com.mojang.authlib.yggdrasil.YggdrasilUserApiService.fetchProperties(YggdrasilUserApiService.java:156) ~[authlib-4.0.43.jar:?] @@ -8,24 +8,24 @@ com.mojang.authlib.exceptions.InvalidCredentialsException: Status: 401 at com.mojang.authlib.yggdrasil.YggdrasilAuthenticationService.createUserApiService(YggdrasilAuthenticationService.java:124) ~[authlib-4.0.43.jar:?] at net.minecraft.client.Minecraft.createUserApiService(SourceFile:733) ~[client-named.jar:?] at net.minecraft.client.Minecraft.(SourceFile:442) ~[client-named.jar:?] - at McfoldRenderServer.boot(McfoldRenderServer.java:434) ~[classes/:?] + at McfoldRenderServer.boot(McfoldRenderServer.java:452) ~[classes/:?] at McfoldRenderServer.main(McfoldRenderServer.java:104) ~[classes/:?] Caused by: com.mojang.authlib.exceptions.MinecraftClientHttpException: Status: 401 at com.mojang.authlib.minecraft.client.MinecraftClient.readInputStream(MinecraftClient.java:85) ~[authlib-4.0.43.jar:?] at com.mojang.authlib.minecraft.client.MinecraftClient.get(MinecraftClient.java:48) ~[authlib-4.0.43.jar:?] at com.mojang.authlib.yggdrasil.YggdrasilUserApiService.fetchProperties(YggdrasilUserApiService.java:129) ~[authlib-4.0.43.jar:?] ... 6 more -[17:19:29] [main/INFO]: Setting user: mcfold_cam -[17:19:29] [main/INFO]: Backend library: LWJGL version 3.3.1 build 7 -[17:19:29] [main/INFO]: [STDERR]: [LWJGL] [ThreadLocalUtil] Unsupported JNI version detected, this may result in a crash. Please inform LWJGL developers. -[17:19:29] [main/WARN]: Attempted to end step for load_time_pre_window_ms before starting it -[17:19:29] [main/INFO]: Reloading ResourceManager: vanilla -[17:19:30] [Worker-Main-11/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading -[17:19:30] [main/INFO]: [STDERR]: [mcfold] Minecraft constructed -[17:20:30] [main/INFO]: [STDERR]: [mcfold] TIMEOUT TextureManager -[17:20:30] [main/WARN]: Missing sound for event: minecraft:item.goat_horn.play -[17:20:30] [main/WARN]: Missing sound for event: minecraft:entity.goat.screaming.horn_break -[17:20:30] [main/ERROR]: Error starting SoundSystem. Turning off sounds & music +[17:42:10] [main/INFO]: Setting user: mcfold_cam +[17:42:10] [main/INFO]: Backend library: LWJGL version 3.3.1 build 7 +[17:42:10] [main/INFO]: [STDERR]: [LWJGL] [ThreadLocalUtil] Unsupported JNI version detected, this may result in a crash. Please inform LWJGL developers. +[17:42:10] [main/WARN]: Attempted to end step for load_time_pre_window_ms before starting it +[17:42:11] [main/INFO]: Reloading ResourceManager: vanilla +[17:42:11] [Worker-Main-11/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading +[17:42:11] [main/INFO]: [STDERR]: [mcfold] Minecraft constructed +[17:43:11] [main/INFO]: [STDERR]: [mcfold] TIMEOUT TextureManager +[17:43:11] [main/WARN]: Missing sound for event: minecraft:item.goat_horn.play +[17:43:11] [main/WARN]: Missing sound for event: minecraft:entity.goat.screaming.horn_break +[17:43:11] [main/ERROR]: Error starting SoundSystem. Turning off sounds & music java.lang.IllegalStateException: Failed to open OpenAL device at com.mojang.blaze3d.audio.Library.openDeviceOrFallback(SourceFile:283) ~[client-named.jar:?] at com.mojang.blaze3d.audio.Library.init(SourceFile:153) ~[client-named.jar:?] @@ -40,33 +40,23 @@ java.lang.IllegalStateException: Failed to open OpenAL device at java.util.concurrent.CompletableFuture.uniAcceptStage(CompletableFuture.java:735) ~[?:?] at java.util.concurrent.CompletableFuture.thenAcceptAsync(CompletableFuture.java:2223) ~[?:?] at net.minecraft.server.packs.resources.SimplePreparableReloadListener.reload(SourceFile:13) ~[client-named.jar:?] - at McfoldRenderServer.reloadResources(McfoldRenderServer.java:505) ~[classes/:?] - at McfoldRenderServer.boot(McfoldRenderServer.java:438) ~[classes/:?] + at McfoldRenderServer.reloadResources(McfoldRenderServer.java:523) ~[classes/:?] + at McfoldRenderServer.boot(McfoldRenderServer.java:456) ~[classes/:?] at McfoldRenderServer.main(McfoldRenderServer.java:104) ~[classes/:?] -[17:20:30] [main/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading -[17:20:30] [main/INFO]: Created: 1024x512x4 minecraft:textures/atlas/blocks.png-atlas -[17:20:30] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/signs.png-atlas -[17:20:30] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/shield_patterns.png-atlas -[17:20:30] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/banner_patterns.png-atlas -[17:20:30] [main/INFO]: Created: 1024x1024x4 minecraft:textures/atlas/armor_trims.png-atlas -[17:20:30] [main/INFO]: Created: 128x64x4 minecraft:textures/atlas/decorated_pot.png-atlas -[17:20:30] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/chest.png-atlas -[17:20:30] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/beds.png-atlas -[17:20:30] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/shulker_boxes.png-atlas -[17:20:31] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program. -[17:20:31] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/particles.png-atlas -[17:20:31] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/paintings.png-atlas -[17:20:31] [main/INFO]: Created: 128x128x0 minecraft:textures/atlas/mob_effects.png-atlas -[17:20:31] [main/INFO]: [STDERR]: [mcfold] resources reloaded -[17:20:31] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program. -[17:20:31] [main/INFO]: [STDERR]: [mcfold] boot done -[17:20:32] [main/INFO]: [STDERR]: java.lang.IndexOutOfBoundsException: readerIndex(8458) + length(2) exceeds writerIndex(8458): UnpooledHeapByteBuf(ridx: 8458, widx: 8458, cap: 8458/8458) -[17:20:32] [main/INFO]: [STDERR]: at io.netty.buffer.AbstractByteBuf.checkReadableBytes0(AbstractByteBuf.java:1442) -[17:20:32] [main/INFO]: [STDERR]: at io.netty.buffer.AbstractByteBuf.readShort(AbstractByteBuf.java:749) -[17:20:32] [main/INFO]: [STDERR]: at net.minecraft.network.FriendlyByteBuf.readShort(SourceFile:1313) -[17:20:32] [main/INFO]: [STDERR]: at net.minecraft.world.level.chunk.LevelChunkSection.read(SourceFile:156) -[17:20:32] [main/INFO]: [STDERR]: at net.minecraft.world.level.chunk.LevelChunk.replaceWithPacketData(SourceFile:456) -[17:20:32] [main/INFO]: [STDERR]: at net.minecraft.client.multiplayer.ClientChunkCache.replaceWithPacketData(SourceFile:117) -[17:20:32] [main/INFO]: [STDERR]: at McfoldRenderServer.applyChunk(McfoldRenderServer.java:335) -[17:20:32] [main/INFO]: [STDERR]: at McfoldRenderServer.handle(McfoldRenderServer.java:160) -[17:20:32] [main/INFO]: [STDERR]: at McfoldRenderServer.main(McfoldRenderServer.java:126) +[17:43:11] [main/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading +[17:43:12] [main/INFO]: Created: 1024x512x4 minecraft:textures/atlas/blocks.png-atlas +[17:43:12] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/signs.png-atlas +[17:43:12] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/banner_patterns.png-atlas +[17:43:12] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/shield_patterns.png-atlas +[17:43:12] [main/INFO]: Created: 1024x1024x4 minecraft:textures/atlas/armor_trims.png-atlas +[17:43:12] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/chest.png-atlas +[17:43:12] [main/INFO]: Created: 128x64x4 minecraft:textures/atlas/decorated_pot.png-atlas +[17:43:12] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/beds.png-atlas +[17:43:12] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/shulker_boxes.png-atlas +[17:43:12] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program. +[17:43:12] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/particles.png-atlas +[17:43:12] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/paintings.png-atlas +[17:43:12] [main/INFO]: Created: 128x128x0 minecraft:textures/atlas/mob_effects.png-atlas +[17:43:12] [main/INFO]: [STDERR]: [mcfold] resources reloaded +[17:43:13] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program. +[17:43:13] [main/INFO]: [STDERR]: [mcfold] boot done diff --git a/mcfold/__pycache__/render.cpython-312.pyc b/mcfold/__pycache__/render.cpython-312.pyc index af4ced9..2679b18 100644 Binary files a/mcfold/__pycache__/render.cpython-312.pyc and b/mcfold/__pycache__/render.cpython-312.pyc differ diff --git a/mcfold/render.py b/mcfold/render.py index 2af5695..26df48e 100644 --- a/mcfold/render.py +++ b/mcfold/render.py @@ -251,12 +251,18 @@ class WorldRenderer: # ---------------- 装载 ---------------- def load_save(self, save_dir: str | Path, *, center: tuple[int, int] | None = None, - radius: int = 3, dim: str | None = None, + radius: int | None = None, dim: str | None = None, only_status: str = "minecraft:full") -> dict: """从存档装载区块(零网络、零登录)。 - center/radius 控制范围(区块坐标),避免一次喂太大。 + radius —— 加载半径(区块)。**缺省跟随视距**(这是关键: + 视距设了 12 但只加载 3 圈数据,画面看起来就像视距没生效)。 + 传 None 时用 renderDistance;显式传值则两者取大。 """ + if radius is None: + radius = max(3, self.render_distance) + else: + radius = max(radius, 1) save_dir = Path(save_dir) level = anvil.read_level_dat(save_dir) data = level.get("Data", {}) @@ -266,7 +272,7 @@ class WorldRenderer: regs: dict[tuple[int, int], anvil.RegionFile] = {} # ★ 先设视野中心:ClientChunkCache 只保留视野内的区块(实测否则注入被丢弃) - self.set_viewport(center[0], center[1], radius=max(radius + 2, 8)) + self.set_viewport(center[0], center[1], radius=radius + 2) parsed: list[SaveChunk] = [] skipped = 0 @@ -341,6 +347,17 @@ class WorldRenderer: # ---------------- 视角与截图 ---------------- + @property + def render_distance(self) -> int: + """当前视距(区块)。默认 8,可用 set_view(render_distance=) 改。""" + return getattr(self, "_render_distance", 8) + + def set_render_distance(self, rd: int) -> dict: + """设置视距(区块)。内部同时设 options + serverRenderDistance + + ClientChunkCache 的 storage 半径 —— 三者缺一则视距不生效(实测)。""" + self._render_distance = int(rd) + return self.bridge.set_view(render_distance=int(rd)) + def set_viewport(self, cx: int, cz: int, radius: int | None = None) -> dict: """设定视野中心 —— 必须在注入前设好,否则区块会被 ClientChunkCache 丢弃。""" req: dict[str, Any] = {"cmd": "viewport", "cx": cx, "cz": cz} diff --git a/run_tests.sh b/run_tests.sh index 219c4e2..dea1970 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -1,9 +1,25 @@ #!/usr/bin/env bash -# 统一测试入口。用法: ./run_tests.sh +# 统一测试入口。 +# +# ./run_tests.sh 纯 Python 单元测试(秒级) +# ./run_tests.sh --with-render 额外跑渲染桥联测(需 Java + Xvfb,约 4 分钟) +# +# 渲染联测需要: +# MCFOLD_RENDER_CLASSPATH 含 client-named.jar 与 McfoldRenderServer.class +# MCFOLD_NATIVES_DIR lwjgl natives(.so) +# DISPLAY 指向可用的 X(Xvfb 即可) +# 缺任一项会**跳过而非失败**(不误报)。 cd "$(dirname "$0")" +WITH_RENDER=0 +[ "${1:-}" = "--with-render" ] && WITH_RENDER=1 + total=0; fail=0 for t in tests/test_*.py; do name=$(basename "$t") + if [ "$name" = "test_render_bridge.py" ] && [ $WITH_RENDER -eq 0 ]; then + printf ' %-24s ⊘ 跳过(用 --with-render 启用)\n' "$name" + continue + fi out=$(python3 "$t" 2>&1) if echo "$out" | grep -qE "全部通过|通过$"; then n=$(echo "$out" | grep -c "^ ✓") @@ -12,7 +28,7 @@ for t in tests/test_*.py; do else fail=$((fail+1)) printf ' %-24s ✗ 失败\n' "$name" - echo "$out" | tail -5 | sed 's/^/ /' + echo "$out" | tail -6 | sed 's/^/ /' fi done echo diff --git a/scaffold/McfoldRenderServer.java b/scaffold/McfoldRenderServer.java index a1f4a0b..a10e57c 100644 --- a/scaffold/McfoldRenderServer.java +++ b/scaffold/McfoldRenderServer.java @@ -208,7 +208,16 @@ public class McfoldRenderServer { mc.options.setCameraType(net.minecraft.client.CameraType.valueOf(req.get("cameraType").getAsString())); } if (req.has("renderDistance")) { - mc.options.renderDistance().set(req.get("renderDistance").getAsInt()); + int rd = req.get("renderDistance").getAsInt(); + // ★ 两处都要设:renderDistance 是本地设置; + // serverRenderDistance 模拟服务端下发的视距上限 + // (getEffectiveRenderDistance = min(两者)) + mc.options.renderDistance().set(rd); + mc.options.setServerRenderDistance(rd); + // ★ 必须同步 ClientChunkCache 的 storage 半径, + // 否则区块被 inRange 丢弃(官方靠服务端包触发 updateViewRadius) + level.getChunkSource().updateViewRadius(rd); + mc.levelRenderer.allChanged(); } if (req.has("fov")) { mc.options.fov().set(req.get("fov").getAsInt()); diff --git a/scaffold/README.md b/scaffold/README.md index 3448d80..81c9e39 100644 --- a/scaffold/README.md +++ b/scaffold/README.md @@ -108,3 +108,32 @@ BuiltInRegistries.BLOCK.getKey(state.getBlock()) ```bash ./build.sh /path/to/store/vanilla-1.20.1 1.20.1 ``` + +## 视距(render distance) + +★ **三处都要设**,缺一则视距不生效(全部实测): + +```java +mc.options.renderDistance().set(rd); // 本地设置 +mc.options.setServerRenderDistance(rd); // 模拟服务端下发的上限 +level.getChunkSource().updateViewRadius(rd); // ClientChunkCache 的 storage 半径 +``` + +为什么第三处必需:官方只在**收到服务端半径包**时才调 `updateViewRadius` +(`ClientPacketListener.java:2316`)。我们不连服,所以必须自己调 —— +否则 `Storage.inRange` 用旧半径判定,超出的区块被**静默丢弃**。 + +★ **加载半径必须跟随视距**:视距设 12 但只喂 3 圈数据, +画面看起来就像视距没生效(这是最容易误判「设了没用」的坑)。 + +实测对比(同一相机、同一存档): + +| 视距 | 注入区块 | 渲染区块 | distinctColors | +|---|---|---|---| +| 4 | 81 | 55 | 15226 | +| 12 | 595 | **447** | 18477 | + +见 `sample-render-distance-4.png` / `sample-render-distance-12.png`。 + +雾(fog)也由同一处驱动:`GameRenderer.renderLevel` 每帧取 +`options.getEffectiveRenderDistance() * 16` 传给 `FogRenderer.setupFog`。 diff --git a/scaffold/sample-render-distance-12.png b/scaffold/sample-render-distance-12.png new file mode 100644 index 0000000..112de2d Binary files /dev/null and b/scaffold/sample-render-distance-12.png differ diff --git a/scaffold/sample-render-distance-4.png b/scaffold/sample-render-distance-4.png new file mode 100644 index 0000000..6bbed92 Binary files /dev/null and b/scaffold/sample-render-distance-4.png differ diff --git a/tests/test_anvil.py b/tests/test_anvil.py new file mode 100644 index 0000000..e7557f3 --- /dev/null +++ b/tests/test_anvil.py @@ -0,0 +1,240 @@ +"""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) diff --git a/tests/test_render_bridge.py b/tests/test_render_bridge.py new file mode 100644 index 0000000..eb69590 --- /dev/null +++ b/tests/test_render_bridge.py @@ -0,0 +1,184 @@ +"""渲染桥联测: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)