diff --git a/logs/2026-09-25-1.log.gz b/logs/2026-09-25-1.log.gz index 8312f78..3ea8980 100644 Binary files a/logs/2026-09-25-1.log.gz and b/logs/2026-09-25-1.log.gz differ diff --git a/logs/2026-09-25-2.log.gz b/logs/2026-09-25-2.log.gz index 80b97fc..e0d577a 100644 Binary files a/logs/2026-09-25-2.log.gz 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 index a12cf7f..048ec35 100644 Binary files a/logs/2026-09-25-3.log.gz 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 index 629a55a..90305b7 100644 Binary files a/logs/2026-09-25-4.log.gz 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 index 3ea8980..cb7d06a 100644 Binary files a/logs/2026-09-25-5.log.gz 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 index e0d577a..9b2b65b 100644 Binary files a/logs/2026-09-25-6.log.gz 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 index 048ec35..d8ad16b 100644 Binary files a/logs/2026-09-25-7.log.gz and b/logs/2026-09-25-7.log.gz differ diff --git a/logs/latest.log b/logs/latest.log index 1bc1c54..3b6ae25 100644 --- a/logs/latest.log +++ b/logs/latest.log @@ -1,6 +1,6 @@ -[17:51:20] [Datafixer Bootstrap/INFO]: 188 Datafixer optimizations took 108 milliseconds -[17:51:21] [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:51:22] [main/ERROR]: Failed to verify authentication +[19:20:23] [Datafixer Bootstrap/INFO]: 188 Datafixer optimizations took 129 milliseconds +[19:20:24] [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' +[19:20:25] [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:517) ~[classes/:?] - at McfoldRenderServer.main(McfoldRenderServer.java:104) ~[classes/:?] + at McfoldRenderServer.boot(McfoldRenderServer.java:605) ~[classes/:?] + at McfoldRenderServer.main(McfoldRenderServer.java:106) ~[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:51:22] [main/INFO]: Setting user: mcfold_cam -[17:51:22] [main/INFO]: Backend library: LWJGL version 3.3.1 build 7 -[17:51:22] [main/INFO]: [STDERR]: [LWJGL] [ThreadLocalUtil] Unsupported JNI version detected, this may result in a crash. Please inform LWJGL developers. -[17:51:22] [main/WARN]: Attempted to end step for load_time_pre_window_ms before starting it -[17:51:22] [main/INFO]: Reloading ResourceManager: vanilla -[17:51:22] [Worker-Main-4/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading -[17:51:22] [main/INFO]: [STDERR]: [mcfold] Minecraft constructed -[17:52:22] [main/INFO]: [STDERR]: [mcfold] TIMEOUT TextureManager -[17:52:22] [main/WARN]: Missing sound for event: minecraft:item.goat_horn.play -[17:52:22] [main/WARN]: Missing sound for event: minecraft:entity.goat.screaming.horn_break -[17:52:22] [main/ERROR]: Error starting SoundSystem. Turning off sounds & music +[19:20:25] [main/INFO]: Setting user: mcfold_cam +[19:20:25] [main/INFO]: Backend library: LWJGL version 3.3.1 build 7 +[19:20:25] [main/INFO]: [STDERR]: [LWJGL] [ThreadLocalUtil] Unsupported JNI version detected, this may result in a crash. Please inform LWJGL developers. +[19:20:25] [main/WARN]: Attempted to end step for load_time_pre_window_ms before starting it +[19:20:26] [main/INFO]: Reloading ResourceManager: vanilla +[19:20:26] [Worker-Main-10/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading +[19:20:26] [main/INFO]: [STDERR]: [mcfold] Minecraft constructed +[19:21:26] [main/INFO]: [STDERR]: [mcfold] TIMEOUT TextureManager +[19:21:26] [main/WARN]: Missing sound for event: minecraft:item.goat_horn.play +[19:21:26] [main/WARN]: Missing sound for event: minecraft:entity.goat.screaming.horn_break +[19:21:26] [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,24 +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:588) ~[classes/:?] - at McfoldRenderServer.boot(McfoldRenderServer.java:521) ~[classes/:?] - at McfoldRenderServer.main(McfoldRenderServer.java:104) ~[classes/:?] -[17:52:22] [main/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading -[17:52:23] [main/INFO]: Created: 1024x512x4 minecraft:textures/atlas/blocks.png-atlas -[17:52:23] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/signs.png-atlas -[17:52:23] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/shield_patterns.png-atlas -[17:52:23] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/banner_patterns.png-atlas -[17:52:23] [main/INFO]: Created: 1024x1024x4 minecraft:textures/atlas/armor_trims.png-atlas -[17:52:23] [main/INFO]: Created: 128x64x4 minecraft:textures/atlas/decorated_pot.png-atlas -[17:52:23] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/chest.png-atlas -[17:52:23] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/shulker_boxes.png-atlas -[17:52:23] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/beds.png-atlas -[17:52:24] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program. -[17:52:24] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/particles.png-atlas -[17:52:24] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/paintings.png-atlas -[17:52:24] [main/INFO]: Created: 128x128x0 minecraft:textures/atlas/mob_effects.png-atlas -[17:52:24] [main/INFO]: [STDERR]: [mcfold] resources reloaded -[17:52:24] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program. -[17:52:24] [main/INFO]: [STDERR]: [mcfold] boot done -[17:52:35] [main/WARN]: Shader invert could not find uniform named InSize in the specified shader program. + at McfoldRenderServer.reloadResources(McfoldRenderServer.java:676) ~[classes/:?] + at McfoldRenderServer.boot(McfoldRenderServer.java:609) ~[classes/:?] + at McfoldRenderServer.main(McfoldRenderServer.java:106) ~[classes/:?] +[19:21:26] [main/INFO]: Found unifont_all_no_pua-15.0.06.hex, loading +[19:21:26] [main/INFO]: Created: 1024x512x4 minecraft:textures/atlas/blocks.png-atlas +[19:21:26] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/signs.png-atlas +[19:21:26] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/shield_patterns.png-atlas +[19:21:26] [main/INFO]: Created: 512x512x4 minecraft:textures/atlas/banner_patterns.png-atlas +[19:21:26] [main/INFO]: Created: 1024x1024x4 minecraft:textures/atlas/armor_trims.png-atlas +[19:21:26] [main/INFO]: Created: 128x64x4 minecraft:textures/atlas/decorated_pot.png-atlas +[19:21:26] [main/INFO]: Created: 256x256x4 minecraft:textures/atlas/chest.png-atlas +[19:21:26] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/shulker_boxes.png-atlas +[19:21:27] [main/INFO]: Created: 512x256x4 minecraft:textures/atlas/beds.png-atlas +[19:21:27] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program. +[19:21:27] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/particles.png-atlas +[19:21:27] [main/INFO]: Created: 256x256x0 minecraft:textures/atlas/paintings.png-atlas +[19:21:27] [main/INFO]: Created: 128x128x0 minecraft:textures/atlas/mob_effects.png-atlas +[19:21:27] [main/INFO]: [STDERR]: [mcfold] resources reloaded +[19:21:27] [main/WARN]: Shader rendertype_entity_translucent_emissive could not find sampler named Sampler2 in the specified shader program. +[19:21:27] [main/INFO]: [STDERR]: [mcfold] boot done diff --git a/mcfold/__pycache__/anvil.cpython-312.pyc b/mcfold/__pycache__/anvil.cpython-312.pyc index 93f1a30..dc2df99 100644 Binary files a/mcfold/__pycache__/anvil.cpython-312.pyc and b/mcfold/__pycache__/anvil.cpython-312.pyc differ diff --git a/mcfold/__pycache__/render.cpython-312.pyc b/mcfold/__pycache__/render.cpython-312.pyc index cd49b91..ea09eaf 100644 Binary files a/mcfold/__pycache__/render.cpython-312.pyc and b/mcfold/__pycache__/render.cpython-312.pyc differ diff --git a/mcfold/anvil.py b/mcfold/anvil.py index 1196039..1a7f9f8 100644 --- a/mcfold/anvil.py +++ b/mcfold/anvil.py @@ -1,16 +1,16 @@ -"""从 MC 存档(anvil region)读真实区块数据,交给渲染桥出图。 +"""从 MC 存档(anvil region)读真实区块数据。 为什么走存档而不走服务端: 用户口径是「只调用渲染能力,不参与游戏」——存档是**零网络、零登录、 零服务端**的真实世界数据源,且与官方客户端认同一套格式。 -数据流: - region/*.mca → 解压区块 NBT → NBT 里的 block_states/Heightmaps - → 重编码为 packet_map_chunk 载荷 → Java 渲染器 +★ 本模块只做**读取**(Python 的职责): + region/*.mca → 解压区块 NBT → 取出 Sections[].block_states(palette + data) -为什么要重编码:存档用 NBT(Sections[].block_states.palette/data), -网络包用扁平二进制(nonEmpty + bitsPerValue + palette + long[])。 -两者信息等价,格式不同。渲染器吃的是网络包格式(官方同一路径)。 + 编码/协议组装**不在这里** —— 那属于 Java 侧(用官方 API 完成), + 因为历代改过协议格式(位宽、palette 分支、LongArray 编码), + 在 Python 里复制一份就等于把版本差异搬回了 Python。 + 下游 render.py 只把读取结果压成 RLE,交给后端。 """ from __future__ import annotations @@ -175,126 +175,3 @@ def region_path(save_dir: Path, dim: str | None, rx: int, rz: int) -> Path: def list_region_files(save_dir: Path, dim: str | None = None) -> list[Path]: base = save_dir / (f"{dim}/region" if dim else "region") return sorted(base.glob("r.*.*.mca")) if base.is_dir() else [] - - -# ---------------------------------------------------------------- 存档区块 → 网络载荷 - -def _pack_long_bits(values: list[int], bits: int) -> list[int]: - """把 state id 列表按 MC 的位打包规则塞进 long(值不跨 long 边界)。""" - if bits == 0: - return [] - vpl = 64 // bits - mask = (1 << bits) - 1 - longs: list[int] = [] - cur = 0 - slot = 0 - for v in values: - cur |= (v & mask) << (slot * bits) - slot += 1 - if slot == vpl: - longs.append(cur); cur = 0; slot = 0 - if slot: - longs.append(cur) - return longs - - -def _varint(v: int) -> bytes: - out = bytearray() - while True: - b = v & 0x7F; v >>= 7 - if v: out.append(b | 0x80) - else: out.append(b); break - return bytes(out) - - -def _write_long_array(longs: list[int]) -> bytes: - """MC 的 LongArray 编码:varint 长度 + 大端 i64 ×N""" - out = _varint(len(longs)) - for l in longs: - # 视为无符号 64 位 - out += struct.pack(">q", l - (1 << 64) if l >= (1 << 63) else l) - return out - - -def encode_block_section(non_empty: int, state_ids: list[int] | None, - palette: list[int] | None) -> bytes: - """按网络包格式编码一个 block section。 - - state_ids=None 表示单值(palette 只有一个元素)。 - """ - out = struct.pack(">h", non_empty) - if state_ids is None: - out += bytes([0]) + _varint(palette[0]) + _varint(0) - return out - - uniq = sorted(set(state_ids)) - if len(uniq) == 1: - out += bytes([0]) + _varint(uniq[0]) + _varint(0) - return out - - # 本地调色板(indirect)。bits = ceil(log2(uniq)),至少 4 - bits = max(4, (len(uniq) - 1).bit_length()) - if bits > 8: - # 全局调色板:bits 直接放 state id - gbits = 15 - out += bytes([gbits]) + _write_long_array(_pack_long_bits(state_ids, gbits)) - return out - - index = {v: i for i, v in enumerate(uniq)} - out += bytes([bits]) - out += _varint(len(uniq)) - for v in uniq: - out += _varint(v) - out += _write_long_array(_pack_long_bits([index[v] for v in state_ids], bits)) - return out - - -def encode_chunk_payload(elevated_sections: dict[int, dict]) -> bytes: - """把存档区块的 sections 编成 packet_map_chunk 的 chunkData 部分。 - - elevated_sections: {section_y_index: {"states": [4096 int], "biomes": [64 int]}} - ★ 必须覆盖整个 worldHeight 的 section 数(1.20.1 = 24),缺失的补空气。 - """ - num_sections = 24 - out = bytearray() - for sy in range(num_sections): - sec = elevated_sections.get(sy) - out += encode_block_section(0, None, [0]) if sec is None else \ - encode_block_section(sec["nonEmpty"], sec["states"], None) - # biome section:64 个值(4x4x4) - biomes = sec["biomes"] if sec else [1] * 64 - uniq = sorted(set(biomes)) - if len(uniq) == 1: - out += bytes([0]) + _varint(uniq[0]) + _varint(0) - else: - bits = max(1, (len(uniq) - 1).bit_length()) - if bits > 3: - out += bytes([6]) + _write_long_array(_pack_long_bits(biomes, 6)) - else: - idx = {v: i for i, v in enumerate(uniq)} - out += bytes([bits]) + _varint(len(uniq)) - for v in uniq: - out += _varint(v) - out += _write_long_array(_pack_long_bits([idx[b] for b in biomes], bits)) - return bytes(out) - - -def save_chunk_to_payload(chunk_nbt: dict) -> bytes | None: - """存档区块 NBT → 网络载荷。""" - sections = chunk_nbt.get("sections") - if not isinstance(sections, list): - return None - by_y: dict[int, dict] = {} - for s in sections: - sy = s.get("Y") - if sy is None: - continue - bs = s.get("block_states") - if not isinstance(bs, dict): - continue - palette = bs.get("palette") or [] - # palette 元素是 {"Name": "minecraft:stone", "Properties": {...}} - # 需要 state id —— 这要靠客户端注册表(渲染器侧提供 lookup) - data = bs.get("data") - by_y[sy] = {"palette": palette, "data": data} - return by_y # 返回中间结构;state id 解析交给调用方(需注册表) diff --git a/mcfold/backends/__pycache__/base.cpython-312.pyc b/mcfold/backends/__pycache__/base.cpython-312.pyc index 4be0a1e..416311c 100644 Binary files a/mcfold/backends/__pycache__/base.cpython-312.pyc and b/mcfold/backends/__pycache__/base.cpython-312.pyc differ diff --git a/mcfold/backends/base.py b/mcfold/backends/base.py index c0d95c2..6030522 100644 --- a/mcfold/backends/base.py +++ b/mcfold/backends/base.py @@ -23,6 +23,33 @@ from pathlib import Path from typing import Any +def rle_encode(values) -> list[list[int]]: + """行程编码:[[值, 重复次数], ...]。 + + ★ 这是**格式无关**的压缩 —— 只是把连续相同的值合并成一段, + 不含任何 MC 协议知识(调色板/位宽/字节序都不在这里)。 + 真正的协议组装由 Java 侧用官方 API 完成。 + """ + out: list[list[int]] = [] + for v in values: + iv = int(v) + if out and out[-1][0] == iv: + out[-1][1] += 1 + else: + out.append([iv, 1]) + return out + + +def rle_decode(rle, expected: int | None = None) -> list[int]: + """rle_encode 的逆(测试与诊断用)。""" + out: list[int] = [] + for v, n in rle: + out.extend([int(v)] * int(n)) + if expected is not None and len(out) != expected: + raise ValueError(f"RLE 长度 {len(out)} != 期望 {expected}") + return out + + class BackendError(RuntimeError): """后端不可用或渲染失败。""" @@ -183,6 +210,21 @@ class RenderBackend: "items": [{"cx": cx, "cz": cz, "data": self._b64(d)} for cx, cz, d in chunks], }, timeout=300.0) + def set_blocks(self, cx: int, cz: int, sections: dict[int, list[int]]) -> dict: + """直接写方块(Python 只做路由,不碰协议格式)。 + + sections: {sectionIndex: [4096 个 state id]},索引顺序 (y,z,x) 线性化 + (与 MC 的 PalettedContainer 索引一致:x 最快、y 最慢)。 + + 为什么走这条路(用户 2026-09-25 定向): + 方块写入、调色板组装、位打包、入 cache 全在 Java 侧用官方 API 完成。 + Python 不复制历代变过的协议格式 —— 版本差异留在 Java。 + """ + items = [{"index": int(idx), "rle": rle_encode(ids)} + for idx, ids in sorted(sections.items())] + return self._request({"cmd": "setblocks", "cx": cx, "cz": cz, + "sections": items}, timeout=300.0) + 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/mcfold/render.py b/mcfold/render.py index 6aad5dc..1e8b852 100644 --- a/mcfold/render.py +++ b/mcfold/render.py @@ -28,67 +28,18 @@ from . import anvil from .backends import backend_class, detect_generation from .backends.base import BackendError, RenderBackend, RenderResult -# 存档区块在网络载荷里的 section 数(1.20.1 worldHeight 384 / 16 = 24) -SECTION_COUNT = 24 -SECTION_VOLUME = 16 * 16 * 16 -BIOME_VOLUME = 4 * 4 * 4 -# ★ 最小 section 的 Y:1.20 起 worldHeight 384、minBuildHeight -64 ⇒ minSection = -4。 -# 网络载荷里的 section 索引是「从下往上数」(0..23),而存档 NBT 用的是绝对 Y(-4..19)。 -MIN_SECTION_Y = -4 - -# 位打包:把 palette 索引列表压进 long(值不跨 long 边界) -def _pack(values: list[int], bits: int) -> list[int]: - if bits <= 0: - return [] - vpl = 64 // bits - mask = (1 << bits) - 1 - out, cur, slot = [], 0, 0 - for v in values: - cur |= (v & mask) << (slot * bits) - slot += 1 - if slot == vpl: - out.append(cur); cur, slot = 0, 0 - if slot: - out.append(cur) - return out - - -def _varint(v: int) -> bytes: - out = bytearray() - while True: - b = v & 0x7F; v >>= 7 - if v: out.append(b | 0x80) - else: out.append(b); break - return bytes(out) - - -def _long_array(longs: list[int]) -> bytes: - out = _varint(len(longs)) - for l in longs: - out += (l & 0xFFFFFFFFFFFFFFFF).to_bytes(8, "big") - return out - - -def _encode_paletted(state_ids: list[int], volume: int, min_bits: int = 4, - global_bits: int = 15, max_local_bits: int = 8) -> bytes: - """编码一个 PalettedContainer 载荷(与 Java PalettedContainer.read 对齐)。""" - uniq = sorted(set(state_ids)) - if len(uniq) == 1: - return bytes([0]) + _varint(uniq[0]) + _varint(0) - - need = (len(uniq) - 1).bit_length() - if need > max_local_bits: - # 全局调色板:位宽直接放 state id - return bytes([global_bits]) + _long_array(_pack(state_ids, global_bits)) - - bits = max(min_bits, need) - idx = {v: i for i, v in enumerate(uniq)} - out = bytes([bits]) + _varint(len(uniq)) - for v in uniq: - out += _varint(v) - out += _long_array(_pack([idx[v] for v in state_ids], bits)) - return out +# ── 协议层常量(与版本无关,协议本身定义)── +SECTION_VOLUME = 16 * 16 * 16 # 一个 section 的方块数 +BIOME_VOLUME = 4 * 4 * 4 # 一个 biome section 的值数 +# ── 世界尺寸:★ 不硬编码,运行时问 Java ── +# 这些量随版本/世界类型变(1.20 起 worldHeight 384、minSection -4; +# 更早的版本是 256/-0)。Python 只做路由与协议编解码, +# 版本相关的量由后端回答(cmd="capacity")。 +# 下面三个仅在没有后端时的降级默认值。 +FALLBACK_SECTION_COUNT = 24 +FALLBACK_MIN_SECTION_Y = -4 +FALLBACK_DEFAULT_BIOME = "minecraft:plains" def _decode_storage(longs: Iterable[int], volume: int, bits: int) -> list[int]: """int[] 位数组 → 索引列表(不跨 long)。""" @@ -189,6 +140,8 @@ class WorldRenderer: self._spec_cache: dict[str, int] = {} self._loaded: set[tuple[int, int]] = set() self.chunks: dict[tuple[int, int], SaveChunk] = {} + self._capacity: dict[str, Any] | None = None + self._biome_id: int | None = None # ---------------- 构造 ---------------- @@ -232,8 +185,49 @@ class WorldRenderer: wr = cls(bridge, instance) if start: bridge.start() + wr.refresh_capacity() return wr + def refresh_capacity(self) -> dict[str, Any]: + """向 Java 后端询问世界尺寸(Python 不硬编码版本相关量)。""" + try: + self._capacity = self.bridge._request({"cmd": "capacity"}) + except Exception: + self._capacity = None + return self._capacity or {} + + @property + def sections_count(self) -> int: + if self._capacity and self._capacity.get("sectionsCount"): + return int(self._capacity["sectionsCount"]) + return FALLBACK_SECTION_COUNT + + @property + def min_section_y(self) -> int: + if self._capacity and "minSection" in self._capacity: + return int(self._capacity["minSection"]) + return FALLBACK_MIN_SECTION_Y + + @property + def _default_biome(self) -> int: + """默认生物群系 id —— 运行时向 Java 查(不硬编码 1)。""" + if self._biome_id is None: + name = (self._capacity or {}).get("defaultBiome") or FALLBACK_DEFAULT_BIOME + self._biome_id = self._resolve_biome_id(name) + return self._biome_id + + def _resolve_biome_id(self, name: str) -> int: + key = f"__biome__{name}" + if key in self._spec_cache: + return self._spec_cache[key] + try: + ids = self.bridge.resolve_blocks([name]) + bid = ids[0] if ids else 1 + except Exception: + bid = 1 + self._spec_cache[key] = bid + return bid + def close(self) -> None: self.bridge.stop() @@ -309,49 +303,40 @@ class WorldRenderer: }) def load_chunks(self, chunks: list[SaveChunk], meta: dict | None = None) -> dict: - """把已解析的区块编码并注入渲染器。""" + """把存档区块交给渲染器。 + + ★ Python 只做「方块名 → state id」的路由与 RLE 压缩; + 调色板组装、位打包、协议序列化、入 cache 全在 Java 侧用官方 API 完成。 + 这样历代改过的协议格式不需要在 Python 里复制一份。 + """ all_specs: list[str] = [] for c in chunks: all_specs.extend(c.palette_specs) id_map = self.resolve_specs(all_specs) unknown = sum(1 for v in id_map.values() if v < 0) - items: list[tuple[int, int, bytes]] = [] + n_sections = self.sections_count + min_sy = self.min_section_y + injected = 0 for c in chunks: - payload = self._encode_chunk(c, id_map) - items.append((c.cx, c.cz, payload)) + sections: dict[int, list[int]] = {} + for idx in range(n_sections): + sec = c.sections.get(idx + min_sy) + if sec is None: + continue # 空 section 由 Java 侧留空 + sections[idx] = [max(0, id_map.get(sp, -1)) for sp in sec["states"]] + if sections: + self.bridge.set_blocks(c.cx, c.cz, sections) + injected += 1 self.chunks[(c.cx, c.cz)] = c - resp = self.bridge.feed_chunks(items) if items else {"injected": 0} self._loaded.update((c.cx, c.cz) for c in chunks) - out = {"injected": len(items), "unknownBlocks": unknown, + out = {"injected": injected, "unknownBlocks": unknown, "uniqueSpecs": len(id_map)} if meta: out.update(meta) return out - def _encode_chunk(self, c: SaveChunk, id_map: dict[str, int]) -> bytes: - """SaveChunk → packet_map_chunk 的 chunkData 载荷。""" - out = bytearray() - for sy in range(SECTION_COUNT): - # ★ 索引 0..23 ↔ 绝对 Y (-4..19):存档用绝对 Y,载荷用相对索引 - sec = c.sections.get(sy + MIN_SECTION_Y) - if sec is None: - # 空 section:空气 - out += _encode_paletted([0] * SECTION_VOLUME, SECTION_VOLUME) - else: - ids = [max(0, id_map.get(s, -1)) for s in sec["states"]] - non_empty = sum(1 for v in ids if v != 0) - out += (non_empty & 0xFFFF).to_bytes(2, "big") - out += _encode_paletted(ids, SECTION_VOLUME) - # biome section - bios = sec["biomes"] if sec else ["minecraft:plains"] * BIOME_VOLUME - bio_pal = sorted(set(bios)) - # 生物群系用全局调色板 id 需要 registry —— 简化:统一用 plains 的 id 1 - # (生物群系只影响草/树叶着色,不影响「能不能渲染出世界」) - out += bytes([0]) + _varint(1) + _varint(0) - return bytes(out) - # ---------------- 视角与截图 ---------------- @property diff --git a/mcfold/render_bridge.py b/mcfold/render_bridge.py deleted file mode 100644 index 3b4009d..0000000 --- a/mcfold/render_bridge.py +++ /dev/null @@ -1,386 +0,0 @@ -"""渲染桥:把 Python 侧的世界数据交给 Java 客户端渲染器出图。 - -设计要点 --------- -两侧格式**同源**,不需要转换: - - Python client.run_smoke()/read_packet() 收到的 packet_map_chunk 载荷, - 其 chunkData 部分与 Java 侧 LevelChunkSection.read(FriendlyByteBuf) - 期待的是同一套字节(nonEmptyBlockCount + PalettedContainer×N + Biomes)。 - -所以桥的协议是「把 chunk 原始字节交给 Java」,而不是「把方块逐个传过去」—— -既避免重建世界的语义损失(mod 方块、NBT、biome、block entity), -又天然支持任意版本(格式由同一个 protocol.json 描述)。 - -通信 ----- -子进程 + 行分隔 JSON(NDJSON): - Java 侧 stdout 每行一个 JSON 对象;Python 写 stdin 用同样格式。 -选 NDJSON 而非 stdin 二进制:可读、可日志、便于调试; -chunk 字节量不大(单区块约 10-100KB),base64 开销可接受。 -""" -from __future__ import annotations - -import base64 -import json -import os -import shutil -import subprocess -import threading -import time -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - - -class RenderBridgeError(RuntimeError): - """桥不可用或渲染失败。""" - - -@dataclass -class RenderResult: - """一次渲染的结果。""" - - image: bytes | None = None # PNG 字节 - path: str | None = None - width: int = 0 - height: int = 0 - chunks_rendered: int = 0 # countRenderedChunks - distinct_colors: int = 0 # 判据:区分「世界」与「加载屏/空屏」 - camera: tuple[float, float, float] = (0.0, 0.0, 0.0) - warnings: list[str] = field(default_factory=list) - raw: dict[str, Any] = field(default_factory=dict) - - @property - def has_world_content(self) -> bool: - """distinct_colors 是实测有效的判据: - 空屏/纯色 1-2 色,加载屏 ~199(logo 抗锯齿),真实世界 >1000。""" - return self.distinct_colors > 400 and self.chunks_rendered > 0 - - -class RenderBridge: - """驱动 Java 渲染器子进程。 - - 生命周期:start() → feed_chunk()×N → set_camera() → render() → stop()。 - 渲染器是**常驻进程**:起一次约 3-5s(GL + 资源重载), - 之后每次渲染只需几十到几百毫秒。 - """ - - def __init__( - self, - java: str = "java", - classpath: str | list[str] | None = None, - main_class: str = "McfoldRenderServer", - game_dir: str | None = None, - assets: str | None = None, - asset_index: str | None = None, - java_args: list[str] | None = None, - startup_timeout: float = 180.0, - cwd: str | None = None, - ): - self.java = java - self.main_class = main_class - self.game_dir = game_dir - self.assets = assets - self.asset_index = asset_index - self.startup_timeout = startup_timeout - self.cwd = cwd - - if classpath is None: - classpath = os.environ.get("MCFOLD_RENDER_CLASSPATH") - if isinstance(classpath, (list, tuple)): - classpath = os.pathsep.join(classpath) - self.classpath = classpath - - self.java_args = java_args or [ - "-Xmx1200M", "-Xms512M", - # headless:不要 AWT 窗口;GL 仍走 GLFW + Xvfb - "-Djava.awt.headless=true", - ] - - self._proc: subprocess.Popen | None = None - self._next_id = 1 - self._pending: dict[int, tuple[threading.Event, dict]] = {} - self._lock = threading.Lock() - self._reader: threading.Thread | None = None - self._stderr_tail: list[str] = [] - - # ---------------- 生命周期 ---------------- - - @property - def running(self) -> bool: - return self._proc is not None and self._proc.poll() is None - - def start(self) -> "RenderBridge": - if self.running: - return self - if not self.classpath: - raise RenderBridgeError( - "未提供 classpath。设置 MCFOLD_RENDER_CLASSPATH 或传 classpath=。" - "构建:mcfold/scaffold/build.sh " - ) - cmd = [self.java, *self.java_args, "-cp", self.classpath, self.main_class] - if self.game_dir: - cmd += ["--gameDir", self.game_dir] - if self.assets: - cmd += ["--assets", self.assets] - if self.asset_index: - cmd += ["--assetIndex", self.asset_index] - - env = dict(os.environ) - # natives 目录(lwjgl .so):构建脚本解到 MCFOLD_NATIVES_DIR - nat = env.get("MCFOLD_NATIVES_DIR") - if nat: - env["LD_LIBRARY_PATH"] = nat + os.pathsep + env.get("LD_LIBRARY_PATH", "") - - try: - self._proc = subprocess.Popen( - cmd, cwd=self.cwd, env=env, - stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, - text=False, bufsize=0, - ) - except FileNotFoundError as e: - raise RenderBridgeError(f"无法启动 java: {e}") from e - - self._reader = threading.Thread(target=self._read_loop, daemon=True) - self._reader.start() - threading.Thread(target=self._stderr_loop, daemon=True).start() - - ready = self._await_event("ready", self.startup_timeout) - if not ready.pop("_ok", False): - self.stop() - raise RenderBridgeError( - "渲染器启动失败:" + (ready.get("error") or "超时") - + ("\n" + "".join(self._stderr_tail[-5:]) if self._stderr_tail else "") - ) - return self - - def stop(self, timeout: float = 10.0) -> None: - p = self._proc - if p is None: - return - try: - if p.poll() is None: - self._send_raw({"cmd": "shutdown"}) - try: - p.wait(timeout=timeout) - except subprocess.TimeoutExpired: - p.terminate() - try: - p.wait(timeout=5) - except subprocess.TimeoutExpired: - p.kill() - finally: - self._proc = None - - def __enter__(self) -> "RenderBridge": - return self.start() - - def __exit__(self, *exc) -> None: - self.stop() - - # ---------------- 数据注入 ---------------- - - def feed_chunk(self, cx: int, cz: int, chunk_data: bytes, - heightmaps_nbt: bytes | None = None) -> dict: - """注入一个区块。 - - chunk_data —— packet_map_chunk 载荷里「长度前缀之后」的那段, - 与 Java LevelChunkSection.read() 直接对应。 - 调 client 侧:decode 后的原始 payload 切片。 - heightmaps_nbt —— 可选 NBT(Java 侧允许空)。 - """ - return self._request({ - "cmd": "chunk", - "cx": cx, "cz": cz, - "data": base64.b64encode(chunk_data).decode("ascii"), - "heightmaps": base64.b64encode(heightmaps_nbt).decode("ascii") if heightmaps_nbt else None, - }) - - def feed_chunks(self, chunks: list[tuple[int, int, bytes]]) -> dict: - """批量注入(减少往返)。""" - return self._request({ - "cmd": "chunks", - "items": [ - {"cx": cx, "cz": cz, "data": base64.b64encode(d).decode("ascii")} - for cx, cz, d in chunks - ], - }) - - def set_camera(self, x: float, y: float, z: float, - yaw: float = 0.0, pitch: float = 0.0) -> dict: - """设置观察者位置与朝向(相机 = 我们想看哪里,不一定是玩家)。""" - return self._request({"cmd": "camera", "x": x, "y": y, "z": z, - "yaw": yaw, "pitch": pitch}) - - def set_view(self, *, camera_type: str | None = "THIRD_PERSON_BACK", - render_distance: int | None = None, - fov: float | None = None, - graphics_mode: str | None = None, - ambient_occlusion: bool | None = None, - mipmap_levels: int | None = None, - gamma: float | None = None, - entity_shadows: bool | None = None, - clouds: str | None = None, - particles: str | None = None, - post_effect: str | None = None) -> dict: - """视觉设置。 - - 光影能力边界(实测,见 scaffold/README.md): - - 原版画质选项(graphics_mode/AO/mipmap/gamma/云/实体阴影): 支持 - - 原版后处理链(post_effect,如 minecraft:shaders/post/invert.json): 支持 - - 第三方光影包(SEUS/BSL 等): 不支持(需 Iris/OptiFine 加载器改渲染管线) - """ - req: dict[str, Any] = {"cmd": "view"} - if camera_type: - req["cameraType"] = camera_type - if render_distance is not None: - req["renderDistance"] = render_distance - if fov is not None: - req["fov"] = fov - if graphics_mode is not None: - req["graphicsMode"] = graphics_mode - if ambient_occlusion is not None: - req["ambientOcclusion"] = ambient_occlusion - if mipmap_levels is not None: - req["mipmapLevels"] = mipmap_levels - if gamma is not None: - req["gamma"] = gamma - if entity_shadows is not None: - req["entityShadows"] = entity_shadows - if clouds is not None: - req["clouds"] = clouds - if particles is not None: - req["particles"] = particles - if post_effect is not None: - req["postEffect"] = post_effect - return self._request(req) - - # ---------------- 渲染 ---------------- - - def render(self, out: str | None = None, frames: int = 60, - width: int | None = None, height: int | None = None) -> RenderResult: - """渲若干帧并取回 PNG。 - - frames 不能太小:区块网格重建队列靠 tick 推进, - 实测 20 帧时 countRenderedChunks 已到位,60 帧更稳。 - """ - req: dict[str, Any] = {"cmd": "render", "frames": frames} - if out: - req["out"] = out - if width: - req["width"] = width - if height: - req["height"] = height - resp = self._request(req, timeout=300.0) - - img_b64 = resp.pop("image", None) - res = RenderResult( - image=base64.b64decode(img_b64) if img_b64 else None, - path=resp.get("path"), - width=resp.get("width", 0), - height=resp.get("height", 0), - chunks_rendered=resp.get("chunksRendered", 0), - distinct_colors=resp.get("distinctColors", 0), - camera=tuple(resp.get("camera", (0.0, 0.0, 0.0))), - warnings=resp.get("warnings", []), - raw=resp, - ) - return res - - def registry_info(self) -> dict: - """客户端方块注册表:state id ↔ 方块名(含 mod 方块的完整状态)。""" - return self._request({"cmd": "registry"}) - - def lookup_state(self, state_id: int) -> dict: - """查单个 state id(返回方块名 + 完整属性,如 instrument/note/powered)。""" - return self._request({"cmd": "lookup", "stateId": state_id}) - - # ---------------- 内部:NDJSON 通道 ---------------- - - def _request(self, req: dict, timeout: float = 120.0) -> dict: - if not self.running: - raise RenderBridgeError("渲染器未运行(先 start())") - with self._lock: - rid = self._next_id - self._next_id += 1 - req["id"] = rid - ev = threading.Event() - self._pending[rid] = (ev, {}) - self._send_raw(req) - if not ev.wait(timeout): - self._pending.pop(rid, None) - raise RenderBridgeError(f"渲染器无响应({timeout}s):cmd={req.get('cmd')}") - _, resp = self._pending.pop(rid) - if not resp.get("_ok", True): - raise RenderBridgeError(resp.get("error", "未知错误")) - return resp - - def _await_event(self, kind: str, timeout: float) -> dict: - """等待一个主动事件(如 ready)。""" - deadline = time.time() + timeout - while time.time() < deadline: - if not self.running: - return {"_ok": False, "error": f"进程退出(code={self._proc.poll() if self._proc else '?'})"} - for rid, (ev, box) in list(self._pending.items()): - if box.get("_event") == kind and ev.is_set(): - self._pending.pop(rid, None) - box["_ok"] = True - return box - time.sleep(0.05) - return {"_ok": False, "error": f"等待 {kind} 超时"} - - def _send_raw(self, obj: dict) -> None: - if not self._proc or self._proc.stdin is None: - raise RenderBridgeError("stdin 不可用") - self._proc.stdin.write((json.dumps(obj) + "\n").encode("utf-8")) - self._proc.stdin.flush() - - def _read_loop(self) -> None: - assert self._proc and self._proc.stdout - for raw in self._proc.stdout: - line = raw.decode("utf-8", "replace").strip() - if not line.startswith("{"): - continue - try: - msg = json.loads(line) - except json.JSONDecodeError: - continue - rid = msg.get("id") - if rid is None and msg.get("event") == "ready": - # ready 事件:塞进 _pending 供 _await_event 取 - ev = threading.Event() - box = dict(msg); box["_event"] = "ready" - self._pending[-1] = (ev, box) - ev.set() - continue - if rid in self._pending: - ev, box = self._pending[rid] - box.update(msg) - ev.set() - - def _stderr_loop(self) -> None: - assert self._proc and self._proc.stderr - for raw in self._proc.stderr: - line = raw.decode("utf-8", "replace").rstrip() - self._stderr_tail.append(line) - if len(self._stderr_tail) > 200: - del self._stderr_tail[:100] - - # ---------------- 便利 ---------------- - - @staticmethod - def discover_defaults(store: str | Path) -> dict: - """从 store 目录推出 assets/assetIndex/gameDir 的默认值(照官方启动器布局)。""" - store = Path(store) - assets = store / "assets" - idx_dir = assets / "indexes" - asset_index = None - if idx_dir.is_dir(): - names = sorted(p.stem for p in idx_dir.glob("*.json")) - asset_index = names[-1] if names else None - return { - "assets": str(assets), - "asset_index": asset_index, - "game_dir": str(store / "instances" / "default"), - } diff --git a/scaffold/ARCHITECTURE.md b/scaffold/ARCHITECTURE.md new file mode 100644 index 0000000..ad4ac4d --- /dev/null +++ b/scaffold/ARCHITECTURE.md @@ -0,0 +1,81 @@ +# 架构分层:谁负责什么 + +用户口径(2026-09-25):**差异放在 Java 层,Python 相当于路由。** + +## 三层 + +``` +Python(mcfold/) + ├── backends/__init__.py 路由:按 client jar 判世代 → 选后端 ← 唯一的 switch + ├── backends/base.py 路由:NDJSON 协议、命令集、RLE 压缩 + ├── anvil.py 读取:存档 region/NBT → 方块(名字规格) + └── render.py 读取 + 编排:WorldRenderer + │ + │ NDJSON(chunk 数据 / RLE 方块 / 相机 / 渲染指令) + ▼ +Java(scaffold/java/gen_1_20/ , gen_1_21/) + ├── McfoldRenderServer.java 协议格式、调色板组装、位打包、入 cache、渲染 + └── McfoldBridge.java 包私有 API 桥(reloadShaders / loadEffect) + │ + ▼ +官方客户端 jar(各自版本,重映射为可读名) +``` + +## 分界原则 + +| 层 | 负责 | 为什么 | +|---|---|---| +| **Python** | 路由、存档读取、方块名→id 查询、RLE 压缩、编排 | 这些**不随 MC 版本变** | +| **Java** | 协议格式、调色板、位打包、官方序列化、渲染调用 | 这些**历代改过**,必须贴着版本走 | + +### 判据:这个知识「随版本变吗」 + +| 知识 | 随版本变? | 归属 | +|---|---|---| +| 方块名 → state id | 变(mod/版本) | Java(问运行时注册表) | +| 世界尺寸(sectionsCount / minSection) | 变(1.16 是 16/0,1.20 是 24/-4) | Java(`cmd="capacity"`) | +| PalettedContainer 位打包 | 变过 | Java(官方 API) | +| LongArray 编码 | 变过 | Java(官方 API) | +| 区块入 cache 的路径 | 签名变过 | Java(官方 `replaceWithPacketData`) | +| **RLE 压缩** | **不变** | Python(只是压重复值) | +| **存档 NBT 结构** | 基本不变 | Python(读取) | +| **NDJSON 协议** | 不变(我们自己定的) | Python(路由层) | + +## 数据流:渲染一个存档 + +``` +1. Python: 读 level.dat + region/*.mca +2. Python: NBT → Sections[].block_states(palette + data) +3. Python: palette 名字(+属性) → "minecraft:oak_log[axis=y]" 规格 +4. Python: 向后端批量查询规格 → state id [Java: BuiltInRegistries.BLOCK.asLookup()] +5. Python: state id 列表 → RLE [[id,count],...] ← 唯一被压缩的东西 +6. Python: setblocks(cx, cz, sections) ← NDJSON +7. Java: LevelChunk + LevelChunkSection.setBlockState(...) +8. Java: 光照(queueSectionData 全亮) +9. Java: ClientboundLevelChunkPacketData(chunk) ← 官方序列化 +10. Java: replaceWithPacketData(官方入口入 cache) +11. Java: gameRenderer.render(...)(世代差异在此) +12. Java: 读 FBO → PNG → base64 回传 +``` + +**第 4/7/8/9/10/11 步都在 Java 侧** —— 版本相关知识全部集中在那里。 + +## 加新世代(用户 2026-09-25 定向的收益) + +1. `scaffold/java/gen_/` 建目录,拷上一代源码 +2. 对着 **javap 输出**改差异点(实测共 11 处,见 VERSION-DIFFS.md) +3. `mcfold/backends/gen_.py` 声明 `markers`(判据类) +4. `mcfold/backends/__init__.py` 的 `GENERATIONS` 加一条 +5. 各自对**对应版本**的 named jar 编译 + +**Python 侧只动第 3、4 步**(各一处),协议与渲染逻辑无需碰。 + +## 已删除(防回归) + +| 已删 | 原因 | +|---|---| +| `mcfold/render_bridge.py` | 被 `backends/` 取代(旧版无世代概念) | +| `render._pack/_long_array/_encode_paletted/_encode_chunk/_varint` | 协议编码下移到 Java | +| `anvil.encode_* / save_chunk_to_payload` | 同上 | + +回归判据见 `tests/test_anvil.py::test_import_surface` —— 这些名字**不应再出现**在 Python 里。 diff --git a/scaffold/java/gen_1_20/McfoldRenderServer.java b/scaffold/java/gen_1_20/McfoldRenderServer.java index e7927f9..36f29da 100644 --- a/scaffold/java/gen_1_20/McfoldRenderServer.java +++ b/scaffold/java/gen_1_20/McfoldRenderServer.java @@ -37,6 +37,8 @@ import net.minecraft.world.level.LightLayer; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.DataLayer; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.LevelChunkSection; import net.minecraft.world.level.dimension.DimensionType; import org.lwjgl.opengl.GL11; @@ -180,6 +182,66 @@ public class McfoldRenderServer { emit(id, r); break; } + case "setblocks": { + // ★ 世代无关:Python 只做路由,方块写入、调色板组装、入 cache + // 全部由 Java 用官方 API 完成 —— 版本差异不落回 Python。 + // 载荷:sections[].rle = [[stateId,count],...],按 (y,z,x) 线性序覆盖该 section 4096 格。 + // 优势:协议位打包(历代改过)不再由 Python 复制;入 cache 走官方 + // ClientboundLevelChunkPacketData(LevelChunk) → getReadBuffer(), + // 签名两代一致,**无需反射**。 + int cx = req.get("cx").getAsInt(), cz = req.get("cz").getAsInt(); + LevelChunk chunk = new LevelChunk(level, new ChunkPos(cx, cz)); + LevelChunkSection[] sections = chunk.getSections(); + int placed = 0; + long cells = 0; + for (JsonElement se : req.getAsJsonArray("sections")) { + JsonObject so = se.getAsJsonObject(); + int si = so.get("index").getAsInt(); + if (si < 0 || si >= sections.length) continue; + LevelChunkSection sec = sections[si]; + int x = 0, y = 0, z = 0; + for (JsonElement re : so.getAsJsonArray("rle")) { + JsonArray pair = re.getAsJsonArray(); + int stateId = pair.get(0).getAsInt(); + int count = pair.get(1).getAsInt(); + BlockState st = stateId == 0 ? null : Block.stateById(stateId); + for (int i = 0; i < count; i++) { + if (st != null) { sec.setBlockState(x, y, z, st); placed++; } + cells++; + if (++x == 16) { x = 0; if (++z == 16) { z = 0; y++; } } + } + } + } + // 光照(无光照数据则全亮,否则地形全黑) + var light = level.getChunkSource().getLightEngine(); + for (int sy = level.getMinSection(); sy < level.getMaxSection(); sy++) { + DataLayer sky = new DataLayer(); + for (int lx = 0; lx < 16; lx++) + for (int ly = 0; ly < 16; ly++) + for (int lz = 0; lz < 16; lz++) sky.set(lx, ly, lz, 15); + light.queueSectionData(LightLayer.SKY, net.minecraft.core.SectionPos.of(cx, sy, cz), sky); + light.queueSectionData(LightLayer.BLOCK, net.minecraft.core.SectionPos.of(cx, sy, cz), sky); + } + // ★ 官方序列化 → 官方入口入 cache(两代签名一致) + var pkt = new net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData(chunk); + level.getChunkSource().replaceWithPacketData(cx, cz, + pkt.getReadBuffer(), pkt.getHeightmaps(), + new java.util.function.Consumer< + net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput>() { + @Override public void accept( + net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput t) { } + }); + light.setLightEnabled(new ChunkPos(cx, cz), true); + light.runLightUpdates(); + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + r.addProperty("placed", placed); + r.addProperty("cells", cells); + r.addProperty("sections", sections.length); + r.addProperty("loadedChunks", level.getChunkSource().getLoadedChunksCount()); + emit(id, r); + break; + } case "camera": { double x = req.get("x").getAsDouble(), y = req.get("y").getAsDouble(), z = req.get("z").getAsDouble(); float yaw = req.has("yaw") ? req.get("yaw").getAsFloat() : 0f; @@ -283,6 +345,28 @@ public class McfoldRenderServer { emit(id, r); break; } + case "capacity": { + // ★ 世界尺寸由运行时提供 —— Python 不硬编码版本相关常量。 + // 这些方法都在 LevelHeightAccessor 上(Level 实现),两代 API 一致。 + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + r.addProperty("sectionsCount", level.getSectionsCount()); + r.addProperty("minSection", level.getMinSection()); + r.addProperty("maxSection", level.getMaxSection()); + r.addProperty("minBuildHeight", level.getMinBuildHeight()); + r.addProperty("maxBuildHeight", level.getMaxBuildHeight()); + r.addProperty("height", level.getHeight()); + // 生物群系默认 id(不硬编码 1) + try { + var bio = mc.level.registryAccess().registryOrThrow(Registries.BIOME); + var key = bio.getKey(bio.byId(1)); + r.addProperty("defaultBiome", key != null ? key.toString() : "minecraft:plains"); + } catch (Throwable t) { + r.addProperty("defaultBiome", "minecraft:plains"); + } + emit(id, r); + break; + } case "registry": { JsonObject r = new JsonObject(); r.addProperty("ok", true); @@ -317,8 +401,12 @@ public class McfoldRenderServer { // 而这个名字→id 的权威映射只有客户端注册表有。 // 入参: {"cmd":"resolve","blocks":["minecraft:oak_log[axis=y]", ...]} JsonArray arr = req.getAsJsonArray("blocks"); - // ★ parseForBlock 要 HolderLookup(不是 Registry) - var reg = mc.level.registryAccess().lookupOrThrow(Registries.BLOCK); + // ★ parseForBlock 要 HolderLookup(不是 Registry)。 + // 用**静态注册表**的 asLookup():它是方块名→id 的权威来源 + // (mod 方块也注册在这里),且两代 API 一致。 + // 不用 mc.level.registryAccess() —— 1.21 上它来自 cookie, + // 只含 worldgen 那批注册表,查 block 会报 "Registry not found"(实测)。 + var reg = BuiltInRegistries.BLOCK.asLookup(); JsonArray out = new JsonArray(); int unknown = 0; for (JsonElement el : arr) { diff --git a/scaffold/java/gen_1_21/McfoldRenderServer.java b/scaffold/java/gen_1_21/McfoldRenderServer.java index 1e01211..04e6763 100644 --- a/scaffold/java/gen_1_21/McfoldRenderServer.java +++ b/scaffold/java/gen_1_21/McfoldRenderServer.java @@ -37,6 +37,8 @@ import net.minecraft.world.level.LightLayer; import net.minecraft.world.level.block.Block; import net.minecraft.world.level.block.state.BlockState; import net.minecraft.world.level.chunk.DataLayer; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.LevelChunkSection; import net.minecraft.world.level.dimension.DimensionType; import org.lwjgl.opengl.GL11; @@ -180,6 +182,66 @@ public class McfoldRenderServer { emit(id, r); break; } + case "setblocks": { + // ★ 世代无关:Python 只做路由,方块写入、调色板组装、入 cache + // 全部由 Java 用官方 API 完成 —— 版本差异不落回 Python。 + // 载荷:sections[].rle = [[stateId,count],...],按 (y,z,x) 线性序覆盖该 section 4096 格。 + // 优势:协议位打包(历代改过)不再由 Python 复制;入 cache 走官方 + // ClientboundLevelChunkPacketData(LevelChunk) → getReadBuffer(), + // 签名两代一致,**无需反射**。 + int cx = req.get("cx").getAsInt(), cz = req.get("cz").getAsInt(); + LevelChunk chunk = new LevelChunk(level, new ChunkPos(cx, cz)); + LevelChunkSection[] sections = chunk.getSections(); + int placed = 0; + long cells = 0; + for (JsonElement se : req.getAsJsonArray("sections")) { + JsonObject so = se.getAsJsonObject(); + int si = so.get("index").getAsInt(); + if (si < 0 || si >= sections.length) continue; + LevelChunkSection sec = sections[si]; + int x = 0, y = 0, z = 0; + for (JsonElement re : so.getAsJsonArray("rle")) { + JsonArray pair = re.getAsJsonArray(); + int stateId = pair.get(0).getAsInt(); + int count = pair.get(1).getAsInt(); + BlockState st = stateId == 0 ? null : Block.stateById(stateId); + for (int i = 0; i < count; i++) { + if (st != null) { sec.setBlockState(x, y, z, st); placed++; } + cells++; + if (++x == 16) { x = 0; if (++z == 16) { z = 0; y++; } } + } + } + } + // 光照(无光照数据则全亮,否则地形全黑) + var light = level.getChunkSource().getLightEngine(); + for (int sy = level.getMinSection(); sy < level.getMaxSection(); sy++) { + DataLayer sky = new DataLayer(); + for (int lx = 0; lx < 16; lx++) + for (int ly = 0; ly < 16; ly++) + for (int lz = 0; lz < 16; lz++) sky.set(lx, ly, lz, 15); + light.queueSectionData(LightLayer.SKY, net.minecraft.core.SectionPos.of(cx, sy, cz), sky); + light.queueSectionData(LightLayer.BLOCK, net.minecraft.core.SectionPos.of(cx, sy, cz), sky); + } + // ★ 官方序列化 → 官方入口入 cache(两代签名一致) + var pkt = new net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData(chunk); + level.getChunkSource().replaceWithPacketData(cx, cz, + pkt.getReadBuffer(), pkt.getHeightmaps(), + new java.util.function.Consumer< + net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput>() { + @Override public void accept( + net.minecraft.network.protocol.game.ClientboundLevelChunkPacketData.BlockEntityTagOutput t) { } + }); + light.setLightEnabled(new ChunkPos(cx, cz), true); + light.runLightUpdates(); + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + r.addProperty("placed", placed); + r.addProperty("cells", cells); + r.addProperty("sections", sections.length); + r.addProperty("loadedChunks", level.getChunkSource().getLoadedChunksCount()); + emit(id, r); + break; + } case "camera": { double x = req.get("x").getAsDouble(), y = req.get("y").getAsDouble(), z = req.get("z").getAsDouble(); float yaw = req.has("yaw") ? req.get("yaw").getAsFloat() : 0f; @@ -283,6 +345,28 @@ public class McfoldRenderServer { emit(id, r); break; } + case "capacity": { + // ★ 世界尺寸由运行时提供 —— Python 不硬编码版本相关常量。 + // 这些方法都在 LevelHeightAccessor 上(Level 实现),两代 API 一致。 + JsonObject r = new JsonObject(); + r.addProperty("ok", true); + r.addProperty("sectionsCount", level.getSectionsCount()); + r.addProperty("minSection", level.getMinSection()); + r.addProperty("maxSection", level.getMaxSection()); + r.addProperty("minBuildHeight", level.getMinBuildHeight()); + r.addProperty("maxBuildHeight", level.getMaxBuildHeight()); + r.addProperty("height", level.getHeight()); + // 生物群系默认 id(不硬编码 1) + try { + var bio = mc.level.registryAccess().registryOrThrow(Registries.BIOME); + var key = bio.getKey(bio.byId(1)); + r.addProperty("defaultBiome", key != null ? key.toString() : "minecraft:plains"); + } catch (Throwable t) { + r.addProperty("defaultBiome", "minecraft:plains"); + } + emit(id, r); + break; + } case "registry": { JsonObject r = new JsonObject(); r.addProperty("ok", true); @@ -317,8 +401,12 @@ public class McfoldRenderServer { // 而这个名字→id 的权威映射只有客户端注册表有。 // 入参: {"cmd":"resolve","blocks":["minecraft:oak_log[axis=y]", ...]} JsonArray arr = req.getAsJsonArray("blocks"); - // ★ parseForBlock 要 HolderLookup(不是 Registry) - var reg = mc.level.registryAccess().lookupOrThrow(Registries.BLOCK); + // ★ parseForBlock 要 HolderLookup(不是 Registry)。 + // 用**静态注册表**的 asLookup():它是方块名→id 的权威来源 + // (mod 方块也注册在这里),且两代 API 一致。 + // 不用 mc.level.registryAccess() —— 1.21 上它来自 cookie, + // 只含 worldgen 那批注册表,查 block 会报 "Registry not found"(实测)。 + var reg = BuiltInRegistries.BLOCK.asLookup(); JsonArray out = new JsonArray(); int unknown = 0; for (JsonElement el : arr) { @@ -453,6 +541,10 @@ public class McfoldRenderServer { } static void render(Integer id, int frames, String out) throws Exception { + // ★ 1.21 实测:setLevel 会挂 ReceivingLevelScreen; + // 若渲染前它有残留,画面就是加载屏(distinctColors 停在 ~199)。 + mc.setScreen(null); + mc.setOverlay(null); tickFrames(frames); MainTarget target = (MainTarget) mc.getMainRenderTarget(); diff --git a/tests/test_anvil.py b/tests/test_anvil.py index e7557f3..ffc2786 100644 --- a/tests/test_anvil.py +++ b/tests/test_anvil.py @@ -1,6 +1,11 @@ -"""anvil.py —— 存档读取与区块编码的单元测试。 +"""Python 侧职责的单元测试。 -不依赖 Java 渲染器;渲染链路的联测在 test_render_bridge.py。 +★ 架构口径(用户 2026-09-25 定向) + Python 负责:路由、方块名→id 查询、RLE 压缩、存档读取 + Java 负责: 协议格式、调色板组装、位打包、入 cache、渲染 + +所以这里**不再**测协议位打包(那已下移到 Java,由两代后端各自的编译验证覆盖); +改测 Python 真正负责的部分,尤其是 RLE 的边界与存档读取的真实数据。 """ import struct import sys @@ -8,90 +13,81 @@ 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.anvil import ( + NbtReader, RegionFile, list_region_files, nbt_read, read_level_dat, region_path, +) +from mcfold.backends.base import rle_decode, rle_encode from mcfold.render import ( - _pack, _varint, _long_array, _decode_storage, _encode_paletted, - parse_save_chunk, SECTION_VOLUME, MIN_SECTION_Y, + BIOME_VOLUME, SECTION_VOLUME, SaveChunk, _props_to_spec, parse_save_chunk, ) 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 +# ---------------------------------------------------------------- RLE(Python 的职责) - 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_rle_basic(): + assert rle_encode([]) == [] + assert rle_encode([5]) == [[5, 1]] + assert rle_encode([5, 5, 5]) == [[5, 3]] + assert rle_encode([1, 2, 1]) == [[1, 1], [2, 1], [1, 1]] + print(" ✓ RLE 基本情形") -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_rle_roundtrip(): + cases = [ + [0] * 4096, + [1] * 4096, + [0] * 4090 + [9] * 6, + list(range(256)) * 16, + [7 if i % 3 else 0 for i in range(4096)], + ] + for v in cases: + assert rle_decode(rle_encode(v)) == v + print(" ✓ RLE 往返一致(含全同/交替/随机模式)") -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_rle_is_lossless_on_long_runs(): + """长游程必须无损 —— 一个 section 常有整片空气/石头。""" + v = [0] * 4000 + [1] * 96 + enc = rle_encode(v) + assert len(enc) == 2, f"应压成 2 段,得到 {len(enc)}" + assert rle_decode(enc, expected=SECTION_VOLUME) == v + print(" ✓ 长游程无损压缩(4096 格 → 2 段)") -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_rle_decode_length_check(): + """长度不符要报错,而不是静默给出错长度的列表(防下游越界)。""" + try: + rle_decode([[1, 3]], expected=4096) + except ValueError as e: + assert "4096" in str(e) + print(" ✓ RLE 长度校验") + return + raise AssertionError("长度不符时应抛 ValueError") -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_rle_handles_bool_like_and_negative(): + """palette 索引可能是任意 int;不假设范围。""" + v = [-1, -1, 0, 0, 0] + assert rle_decode(rle_encode(v)) == v + print(" ✓ RLE 接受任意 int(含负值)") +# ---------------------------------------------------------------- NBT / 存档读取 + 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([10]) + struct.pack(">H", 1) + b"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 + body += bytes([0]) + assert nbt_read(bytes(body)) == {"a": 7, "b": "hi"} print(" ✓ NBT 基本类型(int/string)") def test_nbt_nested_list(): - """嵌套 List/Compound(存档 sections 就是这个形状)。""" + """嵌套 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) @@ -99,44 +95,49 @@ def test_nbt_nested_list(): 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 + assert nbt_read(bytes(body)) == {"list": [{"Y": 0}, {"Y": 1}]} print(" ✓ NBT 嵌套 List[Compound]") +def test_nbt_long_array(): + """区块高度图与 block_states.data 都是 LongArray。""" + body = bytearray() + body += bytes([10]) + struct.pack(">H", 0) + body += bytes([12]) + struct.pack(">H", 1) + b"d" + struct.pack(">i", 2) + body += struct.pack(">qq", -1, 1234567890123) + body += bytes([0]) + d = nbt_read(bytes(body)) + assert d == {"d": [-1, 1234567890123]}, d + print(" ✓ NBT LongArray(含负值)") + + 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]" - # 多属性要排序(保证缓存命中稳定) + # 属性顺序必须稳定(否则 id 缓存命中率下降) 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 + assert a == b == "minecraft:oak_stairs[facing=north,half=top]" 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 + assert "LevelName" in data and isinstance(data.get("DataVersion"), int) 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 + names = set() for rf in regs[:4]: for cx, cz in [(-1, -1), (0, 0), (8, -7)]: try: @@ -144,25 +145,21 @@ def test_real_region_chunk(): except Exception: continue if raw: - found = nbt_read(raw) - break - if found: + n = nbt_read(raw) + for s in n.get("sections", []): + for p in (s.get("block_states") or {}).get("palette") or []: + names.add(p.get("Name")) + if names: 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}" + assert len(names) > 1, f"完整区块应含多种方块,得到 {names}" print(f" ✓ 真实区块 NBT({len(names)} 种方块)") -def test_parse_save_chunk_shapes(): - """parse_save_chunk 输出的结构:states 长度、Y 范围、规格去重。""" +def test_parse_save_chunk_shape(): + """parse_save_chunk 的产物必须可直接喂 RLE(长度=4096,值是名字规格)。""" 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)]: @@ -173,7 +170,7 @@ def test_parse_save_chunk_shapes(): if raw: n = nbt_read(raw) if n.get("Status") == "minecraft:full": - rf, nbt = r, n + nbt = n break if nbt: break @@ -184,47 +181,50 @@ def test_parse_save_chunk_shapes(): 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 all(isinstance(s, str) and s.startswith("minecraft:") for s in sec["states"][:8]) assert ch.palette_specs == sorted(ch.palette_specs), "规格应去重且排序" - print(f" ✓ parse_save_chunk({len(ch.sections)} sections, {len(ch.palette_specs)} 规格)") + print(f" ✓ parse_save_chunk 产物可直接喂 RLE({len(ch.sections)} sections, " + f"{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 +def test_section_y_mapping_needs_runtime_value(): + """★ 回归:section 索引 ↔ 绝对 Y 的偏移必须是运行时值,不是硬编码。 - 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)") + 1.20.x 是 minSection=-4;更早的版本不是。Python 只从后端取。 + """ + from mcfold.render import FALLBACK_MIN_SECTION_Y, WorldRenderer + assert FALLBACK_MIN_SECTION_Y == -4, "降级默认值(真实值应来自后端 capacity)" + # WorldRenderer 的 min_section_y 优先用后端值 + class FakeCap: + _capacity = {"minSection": -4, "sectionsCount": 24} + _biome_id = None + @property + def min_section_y(self): + return WorldRenderer.min_section_y.fget(self) + @property + def sections_count(self): + return WorldRenderer.sections_count.fget(self) + f = FakeCap() + assert f.min_section_y == -4 + assert f.sections_count == 24 + # 后端给不同值时跟随 + class FakeCap2(FakeCap): + _capacity = {"minSection": 0, "sectionsCount": 16} + f2 = FakeCap2() + assert f2.min_section_y == 0, "应跟随后端值(1.16 时代 minSection=0)" + assert f2.sections_count == 16 + print(" ✓ section 偏移取自运行时(不硬编码版本)") -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 编码为空气") +def test_import_surface(): + """已下移到 Java 的协议编码函数不应再存在于 Python(防回归)。""" + import mcfold.render as R + for gone in ("_pack", "_long_array", "_encode_paletted", "_encode_chunk", "_varint"): + assert not hasattr(R, gone), f"{gone} 应已下移到 Java 侧" + # 读路径必须保留 + assert hasattr(R, "_decode_storage") + assert hasattr(R, "parse_save_chunk") + print(" ✓ 模块面:写路径已下移,读路径保留") if __name__ == "__main__": @@ -235,6 +235,9 @@ if __name__ == "__main__": t() except Exception as e: failed += 1 + import traceback print(f" ✗ {t.__name__}: {e}") - print(f"\nanvil/render 单元测试: " + ("全部通过" if failed == 0 else f"{len(tests)-failed}/{len(tests)} 通过")) + traceback.print_exc() + 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 index eb69590..7dc0a05 100644 --- a/tests/test_render_bridge.py +++ b/tests/test_render_bridge.py @@ -1,10 +1,12 @@ -"""渲染桥联测:Python ↔ Java 渲染器。 +"""渲染联测:Python ↔ Java 后端。 需要: - 已构建的 client-named.jar + McfoldRenderServer.class - 环境变量 MCFOLD_RENDER_CLASSPATH、MCFOLD_NATIVES_DIR - Xvfb DISPLAY 缺任一项则跳过(不误报失败)。 + +★ 架构口径:Python 只发 RLE(set_blocks),协议格式由 Java 侧组装。 """ import os import sys @@ -12,16 +14,15 @@ 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 +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") -def _bridge_available() -> tuple[bool, str]: +def _available() -> tuple[bool, str]: cp = os.environ.get("MCFOLD_RENDER_CLASSPATH") if not cp: return False, "未设 MCFOLD_RENDER_CLASSPATH" @@ -29,139 +30,181 @@ def _bridge_available() -> tuple[bool, str]: 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" + return False, "classpath 缺编译产物" if not os.environ.get("DISPLAY"): return False, "无 DISPLAY" return True, "" -def test_bridge_protocol(): - """桥的协议层:registry / lookup / resolve —— 不涉及渲染。""" - ok, why = _bridge_available() +def _open_backend(): + ok, why = _available() if not ok: - print(f" - 跳过({why})") - return - br = RenderBridge( + return None, why + br = Gen_1_20( 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]})") + 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["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 未知)") + 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_bridge_render_synthetic(): - """合成数据渲染:验证「注入 → 网格重建 → 出图」全链路。""" - ok, why = _bridge_available() - if not ok: +# ---------------------------------------------------------------- 渲染(新路径) + +def test_set_blocks_render(): + """新路径:Python 只发 RLE,Java 组装协议 + 官方序列化 + 入 cache。""" + br, why = _open_backend() + if br is None: 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}) + 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 - # 造:地板 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 柱子 + 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): - 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) + v[x + z * 16] = log + sections[idx] = v - 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}" + 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, "应写入方块" - br.set_camera(8.0, 72.0, 8.0, yaw=45.0, pitch=25.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.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)") + assert res.has_world_content + print(f" ✓ 新路径渲染(placed={total_placed}, chunks={res.chunks_rendered}, " + f"colors={res.distinct_colors})") finally: br.stop() -def test_bridge_render_real_save(): - """真实存档渲染:最终验收判据。""" - ok, why = _bridge_available() +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 - from mcfold.render import WorldRenderer wr = WorldRenderer.open(store=str(STORE), java="java") try: - info = wr.load_save(SAVE, center=(8, -7), radius=4) + 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) - dbg = wr.debug() - assert dbg["loadedChunks"] > 0, f"仓库为空: {dbg}" - assert dbg["playerChunk"] == "8,-7", f"相机区块不对: {dbg}" - - res = wr.screenshot(frames=100) + 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 is True + assert res.has_world_content print(f" ✓ 真实存档渲染(injected={info['injected']}, " f"specs={info['uniqueSpecs']}, chunks={res.chunks_rendered}, " f"colors={res.distinct_colors})") @@ -180,5 +223,6 @@ if __name__ == "__main__": import traceback print(f" ✗ {t.__name__}: {e}") traceback.print_exc() - print(f"\n渲染桥联测: " + ("全部通过" if failed == 0 else f"{len(tests)-failed}/{len(tests)} 通过")) + print(f"\n渲染联测: " + + ("全部通过" if failed == 0 else f"{len(tests)-failed}/{len(tests)} 通过")) sys.exit(1 if failed else 0)