From 40c5739cae97b494896637b62f0651ee57950e91 Mon Sep 17 00:00:00 2001 From: pi Date: Fri, 25 Sep 2026 14:25:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcfold):=20P4=20=E5=8C=BA=E5=9D=97?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=EF=BC=88=E4=BD=8D=E6=89=93=E5=8C=85/?= =?UTF-8?q?=E4=B8=89=E7=A7=8D=E8=B0=83=E8=89=B2=E6=9D=BF=E5=88=86=E6=94=AF?= =?UTF-8?q?/=E6=B3=A8=E5=86=8C=E8=A1=A8=E6=98=BE=E5=BC=8F=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=EF=BC=89+=20prismarine=20=E7=9C=9F=E5=AE=9E=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E4=BA=A4=E5=8F=89=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 + mcfold/__pycache__/chunk.cpython-312.pyc | Bin 0 -> 13629 bytes mcfold/chunk.py | 265 +++++++++++++++++++++++ tests/support_testserver.js | 9 + tests/test_chunk.py | 155 +++++++++++++ tests/test_e2e_interop.py | 32 ++- 6 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 mcfold/__pycache__/chunk.cpython-312.pyc create mode 100644 mcfold/chunk.py create mode 100644 tests/support_testserver.js create mode 100644 tests/test_chunk.py diff --git a/README.md b/README.md index ceda917..5b1bed2 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,12 @@ | 协议**不需重写**,只需解释声明式 JSON | 111 个 play 包由同一份 `protocol.json` 驱动 | | Python 客户端能真连真服 | Node 侧日志 `SERVER_GOT_LOGIN username= PyProbe` | | 协议号由实例文件夹决定 | `1.20.1/version.json` → 763 | +| 区块格式**必须自己解**(协议层是声明式,但 chunkData 是不透明 buffer) | `protocol.json` 里 chunkData 只声明为 buffer | +| Python 解码器能解独立实现产出的区块 | prismarine-chunk 产样本 → 4/4 方块命中 | +| mod 方块在本机注册表里**显式 unknown**,而非静默错配 | `state 99999 -> unknown(99999)` | + +## 进度(续) + +- [x] **P4** 区块解析(`mcfold/chunk.py`,11 项回归全绿) + 位打包 / 单值·本地·全局调色板 / 生物群系 section / 世界边界 + **关键行为**:越界 state id ⇒ `unknown(id)`(对照 mineflayer 的静默错配) diff --git a/mcfold/__pycache__/chunk.cpython-312.pyc b/mcfold/__pycache__/chunk.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b7809821d1a1273cdb3e3b892dcfc1451f9f73ed GIT binary patch literal 13629 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 literal 0 HcmV?d00001 diff --git a/mcfold/chunk.py b/mcfold/chunk.py new file mode 100644 index 0000000..168f449 --- /dev/null +++ b/mcfold/chunk.py @@ -0,0 +1,265 @@ +"""区块解析:chunkData buffer → 方块(state id / 方块名)。 + +为什么这段要自己写(对照 P2 的协议层): + 协议层是**声明式**的(protodef 解释 JSON 即可), + 但 `packet_map_chunk` 里的 `chunkData` 是一个 **不透明 buffer** + —— protodef 只声明它是 buffer,内部结构不在 protocol.json 里。 + 所以区块格式必须按 MC 的固定布局自己解。 + +1.20 的区块格式(实测自 prismarine-chunk src/pc/1.18/ChunkColumn.js:253): + minY = -64, worldHeight = 384 ⇒ 24 个 section + 每个 section 交替出现: + [方块 section] [生物群系 section] + 方块 section: + solidBlockCount : i16 (大端) + bitsPerBlock : u8 + == 0 -> 单一值调色板: varint(stateId) + varint(0) ← 后一个是 longs 数 + > MAX_BITS(8) -> 全局调色板: 直接是 state id,16 位/值 + 否则 -> 本地调色板: varint(paletteLen) + palette[] + 位数组 + 位数组(1.20 有 size 前缀;1.21.5+ 才去掉): + longs : varint + data : longs 个 64 位值;每个 long 按 [高 u32, 低 u32] 存储 + +关键坑:**值不跨 long 边界**(valuesPerLong = floor(64/bitsPerValue)), +即一个 64 位 long 里整齐放若干个值。这与我最初以为的"紧密打包"不同。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from .protodef import ProtoError, Reader + +# ---- 1.20 区块常量(实测自 prismarine-chunk constants.js + 1.18/ChunkColumn.js)---- +MIN_Y = -64 +WORLD_HEIGHT = 384 +NUM_SECTIONS = WORLD_HEIGHT >> 4 # 24 +SECTION_VOLUME = 16 * 16 * 16 # 4096 +BIOME_SECTION_VOLUME = SECTION_VOLUME // (4 * 4 * 4) # 64 + +MIN_BITS_PER_BLOCK = 4 +MAX_BITS_PER_BLOCK = 8 # 超过它就转全局调色板 +GLOBAL_BITS_PER_BLOCK = 16 + +MIN_BITS_PER_BIOME = 1 +MAX_BITS_PER_BIOME = 3 +GLOBAL_BITS_PER_BIOME = 6 + + +class ChunkError(Exception): + pass + + +class BitArray: + """MC 的位打包数组(每个 long 内整齐放 floor(64/bits) 个值,不跨边界)。""" + + __slots__ = ("data", "capacity", "bits_per_value", "values_per_long", "value_mask") + + def __init__(self, data: list[int], capacity: int, bits_per_value: int): + self.data = data # 每个元素是一个 64 位 long + self.capacity = capacity + self.bits_per_value = bits_per_value + self.values_per_long = 64 // bits_per_value + self.value_mask = (1 << bits_per_value) - 1 + + @classmethod + def from_buffer(cls, r: Reader, capacity: int, bits_per_value: int, + size_prefix: bool = True) -> "BitArray": + """按 MC 的在线格式读位数组。""" + if bits_per_value <= 0 or bits_per_value > 32: + raise ChunkError(f"非法 bitsPerValue: {bits_per_value}") + vpl = 64 // bits_per_value + if size_prefix: + longs = r.varint() + else: + longs = (capacity + vpl - 1) // vpl + if longs < 0 or longs > 1_000_000: + raise ChunkError(f"位数组长度异常: {longs}") + data: list[int] = [] + for _ in range(longs): + # JS 侧:先读高 u32 再读低 u32 → long = (high<<32)|low + high = int.from_bytes(r.read(4), "big") + low = int.from_bytes(r.read(4), "big") + data.append((high << 32) | low) + return cls(data, capacity, bits_per_value) + + def get(self, index: int) -> int: + if index < 0 or index >= self.capacity: + return 0 + start = index // self.values_per_long + off = (index - start * self.values_per_long) * self.bits_per_value + if start >= len(self.data): + return 0 + # 值不跨 long 边界 ⇒ 单个 long 内移位即可 + return (self.data[start] >> off) & self.value_mask + + def to_list(self) -> list[int]: + return [self.get(i) for i in range(self.capacity)] + + +@dataclass +class Section: + """一个 16x16x16 的方块 section。""" + y_index: int # 0..23(对应世界 y = MIN_Y + 16*y_index) + solid_block_count: int = 0 + bits_per_block: int = 0 + palette: list[int] = field(default_factory=list) # 本地调色板(indirect 时用) + bits: BitArray | None = None + single_value: int | None = None # bits==0 时的单一 state id + + def state_id_at(self, x: int, y: int, z: int) -> int: + """section 内 (x,y,z) 的全局 state id。索引顺序 = (y<<8)|(z<<4)|x。""" + idx = (y << 8) | (z << 4) | x + if self.bits_per_block == 0: + return self.single_value or 0 + raw = self.bits.get(idx) if self.bits else 0 + if self.palette: + # 本地调色板:raw 是调色板下标 + return self.palette[raw] if 0 <= raw < len(self.palette) else 0 + return raw # 全局调色板:raw 就是 state id + + +@dataclass +class BiomeSection: + bits_per_biome: int = 0 + palette: list[int] = field(default_factory=list) + bits: BitArray | None = None + single_value: int | None = None + + +@dataclass +class Chunk: + """一个区块列(16 x 384 x 16)。""" + x: int + z: int + sections: list[Section] = field(default_factory=list) + biomes: list[BiomeSection] = field(default_factory=list) + + def state_id_at(self, x: int, y: int, z: int) -> int: + """世界坐标 → state id(y 是绝对高度)。""" + if y < MIN_Y or y >= MIN_Y + WORLD_HEIGHT: + return 0 + sec = (y - MIN_Y) >> 4 + if sec >= len(self.sections): + return 0 + return self.sections[sec].state_id_at(x & 15, y & 15, z & 15) + + +def _read_block_section(r: Reader) -> Section: + solid = int.from_bytes(r.read(2), "big", signed=True) + bits = r.read(1)[0] + if bits > 16: + raise ChunkError(f"bitsPerBlock 过大: {bits}") + + sec = Section(y_index=-1, solid_block_count=solid, bits_per_block=bits) + + if bits == 0: + # 单一值:varint(stateId) 后跟 varint(0)(longs 数) + sec.single_value = r.varint() + r.varint() # longs = 0 + return sec + + if bits > MAX_BITS_PER_BLOCK: + # 全局调色板:直接 state id + sec.bits = BitArray.from_buffer(r, SECTION_VOLUME, GLOBAL_BITS_PER_BLOCK, + size_prefix=True) + sec.bits_per_block = GLOBAL_BITS_PER_BLOCK + return sec + + # 本地调色板 + n = r.varint() + if n < 0 or n > 4096: + raise ChunkError(f"调色板长度异常: {n}") + sec.palette = [r.varint() for _ in range(n)] + sec.bits = BitArray.from_buffer(r, SECTION_VOLUME, bits, size_prefix=True) + return sec + + +def _read_biome_section(r: Reader) -> BiomeSection: + bits = r.read(1)[0] + if bits > 8: + raise ChunkError(f"bitsPerBiome 过大: {bits}") + sec = BiomeSection(bits_per_biome=bits) + + if bits == 0: + sec.single_value = r.varint() + r.varint() # longs = 0 + return sec + + if bits > MAX_BITS_PER_BIOME: + sec.bits = BitArray.from_buffer(r, BIOME_SECTION_VOLUME, GLOBAL_BITS_PER_BIOME, + size_prefix=True) + sec.bits_per_biome = GLOBAL_BITS_PER_BIOME + return sec + + n = r.varint() + if n < 0 or n > 1024: + raise ChunkError(f"生物群系调色板长度异常: {n}") + sec.palette = [r.varint() for _ in range(n)] + sec.bits = BitArray.from_buffer(r, BIOME_SECTION_VOLUME, bits, size_prefix=True) + return sec + + +def decode_chunk(x: int, z: int, chunk_data: bytes, + num_sections: int = NUM_SECTIONS) -> Chunk: + """把 packet_map_chunk 的 chunkData 解成区块列。 + + num_sections 由世界的 worldHeight 决定(1.20 = 24);传入以适配不同版本。 + """ + r = Reader(chunk_data) + chunk = Chunk(x=x, z=z) + + for i in range(num_sections): + if r.remaining() <= 0: + break + sec = _read_block_section(r) + sec.y_index = i + chunk.sections.append(sec) + # 每个方块 section 后紧跟一个生物群系 section + if r.remaining() > 0: + chunk.biomes.append(_read_biome_section(r)) + + return chunk + + +# ---------------- state id → 方块名(方块注册表) ---------------- + +class BlockRegistry: + """方块注册表:state id ↔ 方块名。 + + mod 支持的落点正在这里:原版表是内置的(minecraft-data 的 blocks.json), + 而 mod 方块需要另外的途径补进来(见文档附录 D/E)。找不到时必须**显式**报 + "unknown",而不是像 mineflayer 那样静默映射成别的方块。 + """ + + def __init__(self, blocks: list[dict[str, Any]]): + self.blocks = blocks + self.by_state: dict[int, str] = {} + self.by_name: dict[str, dict[str, Any]] = {} + for b in blocks: + name = b["name"] + self.by_name[name] = b + lo = b.get("minStateId", 0) + hi = b.get("maxStateId", lo) + for sid in range(lo, hi + 1): + self.by_state[sid] = name + self.max_state_id = max(self.by_state) if self.by_state else -1 + + @classmethod + def from_json_file(cls, path: str) -> "BlockRegistry": + import json + with open(path, "r", encoding="utf-8") as f: + return cls(json.load(f)) + + def name_of(self, state_id: int) -> str | None: + """state id → 方块名;越界返回 None(**不**静默回退)。""" + return self.by_state.get(state_id) + + def name_or_unknown(self, state_id: int) -> str: + n = self.name_of(state_id) + return n if n is not None else f"unknown({state_id})" + + def is_out_of_range(self, state_id: int) -> bool: + """越界 ⇒ 该 id 极可能来自 mod 方块(本机注册表没有它)。""" + return state_id > self.max_state_id diff --git a/tests/support_testserver.js b/tests/support_testserver.js new file mode 100644 index 0000000..9ca0ba9 --- /dev/null +++ b/tests/support_testserver.js @@ -0,0 +1,9 @@ +// 供测试用的 Node 服务端(独立实现)。 +// 用法: node support_testserver.js +const mc = require('minecraft-protocol') +const port = parseInt(process.argv[2] || '25599') +const server = mc.createServer({ 'online-mode': false, port, version: '1.20.1', motd: 'mcfold-test' }) +server.on('listening', () => console.log('SERVER_READY')) +server.on('login', (c) => console.log('SERVER_GOT_LOGIN username=', c.username)) +server.on('error', e => console.log('SERVER_ERR', e.message)) +setTimeout(() => process.exit(0), 120000) diff --git a/tests/test_chunk.py b/tests/test_chunk.py new file mode 100644 index 0000000..f266257 --- /dev/null +++ b/tests/test_chunk.py @@ -0,0 +1,155 @@ +"""chunk 解析回归。 + +核心验证方式:用 **prismarine-chunk(独立实现)产出的真实格式数据** +交给 Python 解 —— 两边独立,对得上才算格式对。 +样本由 /home/newqqagent/plugin_data/mc/runtime/chunkgen*.js 生成。 +""" +import sys, os, json, subprocess, tempfile +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from mcfold.chunk import (decode_chunk, BlockRegistry, MIN_Y, NUM_SECTIONS, + BitArray, Reader, ChunkError) + +RUNTIME = "/home/newqqagent/plugin_data/mc/runtime" +BLOCKS = f"{RUNTIME}/node_modules/minecraft-data/minecraft-data/data/pc/1.20/blocks.json" + +def gen(script): + """跑 Node 生成独立实现产出的样本。""" + r = subprocess.run(["node", f"{RUNTIME}/{script}"], capture_output=True, text=True, + cwd=RUNTIME, timeout=120) + if r.returncode != 0: + return None, r.stderr[:300] + return json.loads(r.stdout), None + +def reg(): + return BlockRegistry.from_json_file(BLOCKS) + +def test_bitarray(): + """位打包:值不跨 long 边界(这是最容易写错的地方)。""" + # 4 位/值,一个 long 放 16 个值 + ba = BitArray(data=[0x3210], capacity=16, bits_per_value=4) + assert [ba.get(i) for i in range(4)] == [0, 1, 2, 3], [ba.get(i) for i in range(4)] + print(" ✓ 位打包:值不跨 long 边界(4位/值 → 16 值/long)") + # 5 位/值 → floor(64/5)=12 值/long + ba2 = BitArray(data=[0x1], capacity=12, bits_per_value=5) + assert ba2.values_per_long == 12, ba2.values_per_long + assert ba2.get(0) == 1 + print(" ✓ 位打包:5位/值 → 12 值/long(余位丢弃)") + +def test_real_chunk_from_prismarine(): + """核心回归:解独立实现产出的区块。""" + d, err = gen("chunkgen.js") + if d is None: + print(f" - 跳过(Node 生成失败: {err})"); return + exp = d["expect"] + ch = decode_chunk(0, 0, bytes.fromhex(d["hex"])) + assert len(ch.sections) == NUM_SECTIONS, len(ch.sections) + r = reg() + checks = [((0,-60,0),"stone"), ((1,-59,2),"gold_block"), + ((5,-59,7),"diamond_block"), ((3,20,4),"diamond_block")] + for (x,y,z), want in checks: + sid = ch.state_id_at(x,y,z) + got = r.name_or_unknown(sid) + assert got == want, f"({x},{y},{z}) {sid} -> {got}, 期望 {want}" + nonempty = [s.y_index for s in ch.sections if s.solid_block_count > 0] + assert nonempty == [0,5], nonempty + print(f" ✓ 解 prismarine 真实区块: 4/4 方块命中, 非空 section={nonempty}") + +def test_all_encoding_branches(): + """覆盖单值/本地调色板/高 section 三种编码分支。""" + d, err = gen("chunkgen2.js") + if d is None: + print(f" - 跳过(Node 生成失败: {err})"); return + r = reg() + # A: 全空气 → 应无实心方块 + chA = decode_chunk(0,0,bytes.fromhex(d["A"]["hex"])) + assert all(s.solid_block_count == 0 for s in chA.sections), "全空气却有实心方块" + print(" ✓ 分支 A(全空气): solidBlockCount 全 0") + # B: 一行石头 + chB = decode_chunk(0,0,bytes.fromhex(d["B"]["hex"])) + names = {r.name_or_unknown(chB.state_id_at(i,0,0)) for i in range(16)} + assert names == {"stone"}, names + print(f" ✓ 分支 B(一行石头): y=0 的 16 个方块全是 stone") + # C: 四种混合 → 验证本地调色板 + chC = decode_chunk(0,0,bytes.fromhex(d["C"]["hex"])) + got = [r.name_or_unknown(chC.state_id_at(i,60,0)) for i in range(4)] + assert got == ["stone","dirt","gold_block","diamond_block"], got + print(f" ✓ 分支 C(本地调色板): {got}") + # D: 高 section + chD = decode_chunk(0,0,bytes.fromhex(d["D"]["hex"])) + assert r.name_or_unknown(chD.state_id_at(3,20,4)) == "diamond_block" + print(" ✓ 分支 D(高 section y=20)") + +def test_mod_block_explicit_unknown(): + """★ 关键行为:本机注册表没有的 state id 必须显式 unknown,**不可静默错配**。 + + 这正是 mineflayer 的缺陷(越界 id 静默变成 note_block),我们要把它变成可见降级。 + + 说明:格式正确性已由 test_real_chunk_from_prismarine(独立实现数据,4/4)证明; + 这里聚焦**注册表行为**,故用 Python 侧的 writer 构造样本(自洽即可)。 + """ + from mcfold.protodef import Writer + w = Writer() + # section 0: solidCount=1, bits=4, palette=[99999], longs=256, 全 0 位 + w.write((1).to_bytes(2, "big")) + w.write(bytes([4])) + w.varint(1); w.varint(99999) + w.varint(256) + w.write(b"\x00" * (256 * 8)) + # biome section(单值) + w.write(bytes([0])); w.varint(0); w.varint(0) + # 其余 23 个 section:单值空气 + biome + for _ in range(23): + w.write((0).to_bytes(2, "big")); w.write(bytes([0])) + w.varint(0); w.varint(0) + w.write(bytes([0])); w.varint(0); w.varint(0) + + ch = decode_chunk(0, 0, w.getvalue()) + # 末尾 section 属半写状态,取已完整解出的 section 0(y=-64..-49) + sid = ch.state_id_at(0, -64, 0) + assert sid == 99999, f"state id 解码错: {sid}" + + r = reg() + got = r.name_or_unknown(sid) + assert got.startswith("unknown("), f"越界 id 竟解析出了名字: {got}" + assert r.is_out_of_range(sid), "越界判定失效" + # 对照:mineflayer 的行为是静默错配 —— 我们明确不同 + assert r.name_of(sid) is None, "越界 id 不应能查到名字" + print(f" ✓ ★ mod 方块显式降级: state {sid} -> {got}(而非静默错配)") + +def test_registry_degradation_direct(): + """直接验证注册表降级语义(不依赖线格式)。""" + r = reg() + assert r.name_or_unknown(0) == "air" + assert r.name_or_unknown(24134) is not None # 原版上界内 + assert r.name_or_unknown(24135).startswith("unknown(") # 超出上界 + assert r.is_out_of_range(50000) + print(" ✓ 注册表降级语义:原版内可查、越界显式 unknown") + +def test_out_of_bounds_safe(): + """世界边界外的查询必须安全返回空气,而不是崩。""" + from mcfold.protodef import Writer + w = Writer() + for _ in range(NUM_SECTIONS): + w.write((0).to_bytes(2, "big")); w.write(bytes([0])) + w.varint(0); w.varint(0) # 方块 section(单值空气) + w.write(bytes([0])); w.varint(0); w.varint(0) # biome section + ch = decode_chunk(0, 0, w.getvalue()) + assert ch.state_id_at(0, -1000, 0) == 0 + assert ch.state_id_at(0, 1000, 0) == 0 + assert ch.state_id_at(0, MIN_Y, 0) == 0 + print(" ✓ 世界边界外查询安全返回空气") + +def test_bad_data_raises(): + """损坏数据必须报错,不能静默产出错数据。""" + try: + decode_chunk(0,0,b"\x00\x01\xff") # bitsPerBlock=255 + assert False, "应当报错" + except (ChunkError, Exception) as e: + print(f" ✓ 损坏数据显式报错: {str(e)[:45]}") + +if __name__ == "__main__": + for fn in [test_bitarray, test_real_chunk_from_prismarine, test_all_encoding_branches, + test_mod_block_explicit_unknown, test_registry_degradation_direct, + test_out_of_bounds_safe, test_bad_data_raises]: + fn() + print("\nchunk 解析:全部通过") diff --git a/tests/test_e2e_interop.py b/tests/test_e2e_interop.py index bade923..f06ae56 100644 --- a/tests/test_e2e_interop.py +++ b/tests/test_e2e_interop.py @@ -19,9 +19,39 @@ def reachable(): except OSError: return False +def _start_server(): + """自动起 Node 服务端(独立实现),让本测试**总能真跑**而不是跳过。""" + import subprocess, time, shutil + runtime = "/home/newqqagent/plugin_data/mc/runtime" + script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "support_testserver.js") + if not shutil.which("node"): + return None + shutil.copy(script, os.path.join(runtime, "support_testserver.js")) + p = subprocess.Popen(["node", "support_testserver.js", str(PORT)], cwd=runtime, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True) + for _ in range(50): + if reachable(): return p + if p.poll() is not None: return None + time.sleep(0.2) + return None + def test_interop(): + proc = None if not reachable(): - print(f" - 服务端 {HOST}:{PORT} 未运行,跳过(见本文件顶部用法)"); return + proc = _start_server() + if not reachable(): + print(f" - 服务端 {HOST}:{PORT} 不可用,跳过"); return + try: + _do_interop() + finally: + if proc: + proc.terminate() + try: proc.wait(timeout=5) + except Exception: proc.kill() + try: os.remove("/home/newqqagent/plugin_data/mc/runtime/support_testserver.js") + except OSError: pass + +def _do_interop(): c = Client(HOST, PORT, "PyProbe", "1.20.1") try: r = c.run_smoke()