From fc5a1aae919df6c598451f55d02e2a24083d8b34 Mon Sep 17 00:00:00 2001 From: pi Date: Fri, 25 Sep 2026 14:10:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcfold):=20=E5=A3=B0=E6=98=8E=E5=BC=8F=20p?= =?UTF-8?q?rotodef=20=E8=A7=A3=E9=87=8A=E5=99=A8=EF=BC=88=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E7=B1=BB=E5=9E=8B+=E9=98=B6=E6=AE=B5=E4=BD=9C?= =?UTF-8?q?=E7=94=A8=E5=9F=9F+switch/mapper/bitfield=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E7=9C=9F=E5=AE=9E=E6=8F=A1=E6=89=8B=E5=AD=97=E8=8A=82=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mcfold/__pycache__/protodef.cpython-312.pyc | Bin 0 -> 14047 bytes mcfold/protodef.py | 297 ++++++++++++++++++++ tests/test_protodef.py | 104 +++++++ 3 files changed, 401 insertions(+) create mode 100644 mcfold/__pycache__/protodef.cpython-312.pyc create mode 100644 mcfold/protodef.py create mode 100644 tests/test_protodef.py diff --git a/mcfold/__pycache__/protodef.cpython-312.pyc b/mcfold/__pycache__/protodef.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3aa34c3dce8d25f159a679ae198ab1053d92bebe GIT binary patch literal 14047 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

_|u7xe!pts_0MpP=WQR%*bz+p=6Uu#!&+Z^ zZEG{!3i1t6qmgtKJ|dtGBR+iR-sO+L?{Ku?%YqK&`w;TMocTi{bS~+13%-C4DH0#| zjE}r5&pSSH<=*G-jsNi?eI0V?lJZ#uK8P5<9+BH3YCC@Yjl|XKlP`W^Cl&1W)qC;B zCc!5r1OkSa$4k6$MxJWo(!2OXA`u*hlwp7}W`GEJ9xG=g;Lu2VWenVsq7W>(#{{>e z>S&tHh?1e9U94wK#1Zr_Ar`Dd0fNQM8T0xV_AU&$!-wJq`)$LLQNt3P)~n)%H9foT znDRr94DKJ;A1R8K#S1E;;?aZ_zflaw*(n@7exBPAaSgXxh{`eaD; zK`M&60nWY|BFS_FnTZa#R4^g&Pay;H%a(JMxZd?FJf=^>fk;<8PN|>f;3(7_keQa< z_TVKwyX^t*9DOU$j{8fve}(UQ;4o`<;wqD*>A>e6%w?4>m!)iao!Tey3#?{2VQx9Y z+#N%9PzcpVmVaqj8Fk*7xj^`LHD_FP+pzMcVdb}8ibMAou2Fxa-IN8P^9deBCLn%K zB_@78O_i8(jo4!N>#-BO12;IRZ)2 zZY6yz1`-|ES_C VzTqgJ-lfgr>-Yx+Dqg-;_OC_>?Cbyl literal 0 HcmV?d00001 diff --git a/mcfold/protodef.py b/mcfold/protodef.py new file mode 100644 index 0000000..30ffb5e --- /dev/null +++ b/mcfold/protodef.py @@ -0,0 +1,297 @@ +"""protodef 解释器:读声明式 protocol.json,通用地编解码任意包。 + +为什么这不是"重写协议"(关键判断,2026-09-25 实测): + protocol.json 是**声明式**的 —— `varint`/`pstring`/`mapper`/`switch` + 全是数据描述而非代码。所以 111 个 play 包由同一份 JSON 驱动, + 我们只需实现 ~12 个原语 + 6 个复合类型,**不需要为任何单个包写代码**。 + +对照 mineflayer 侧:这对应 protodef(2045 行 JS)。Python 版可显著更短。 +""" + +from __future__ import annotations + +import json +import struct +from pathlib import Path +from typing import Any + + +class ProtoError(Exception): + pass + + +class Reader: + """按协议原语从 buffer 顺序读取。""" + + __slots__ = ("buf", "pos") + + def __init__(self, buf: bytes, pos: int = 0): + self.buf = buf + self.pos = pos + + def remaining(self) -> int: + return len(self.buf) - self.pos + + def read(self, n: int) -> bytes: + if self.pos + n > len(self.buf): + raise ProtoError(f"读越界: 需 {n} 字节, 剩 {self.remaining()}") + b = self.buf[self.pos:self.pos + n] + self.pos += n + return b + + def varint(self) -> int: + """LEB128 有符号变长整数(MC 的 varint)。""" + result = 0 + shift = 0 + while True: + b = self.read(1)[0] + result |= (b & 0x7F) << shift + if not (b & 0x80): + break + shift += 7 + if shift > 35: + raise ProtoError("varint 过长") + # 32 位有符号回绕 + if result & (1 << 31): + result -= 1 << 32 + return result + + def varlong(self) -> int: + result = 0 + shift = 0 + while True: + b = self.read(1)[0] + result |= (b & 0x7F) << shift + if not (b & 0x80): + break + shift += 7 + if shift > 70: + raise ProtoError("varlong 过长") + if result & (1 << 63): + result -= 1 << 64 + return result + + def pstring(self) -> str: + n = self.varint() + if n < 0: + raise ProtoError(f"pstring 长度为负: {n}") + return self.read(n).decode("utf-8", errors="replace") + + +class Writer: + """按协议原语向 buffer 顺序写入。""" + + __slots__ = ("parts",) + + def __init__(self): + self.parts: list[bytes] = [] + + def getvalue(self) -> bytes: + return b"".join(self.parts) + + def write(self, b: bytes) -> None: + self.parts.append(b) + + def varint(self, v: int) -> None: + if v < 0: + v += 1 << 32 + while True: + b = v & 0x7F + v >>= 7 + if v: + self.parts.append(bytes([b | 0x80])) + else: + self.parts.append(bytes([b])) + return + + def pstring(self, s: str) -> None: + b = s.encode("utf-8") + self.varint(len(b)) + self.parts.append(b) + + +# ---- 固定宽度原语:[struct 格式, 字节数] ---- +FIXED: dict[str, tuple[str, int]] = { + "u8": (">B", 1), "i8": (">b", 1), + "u16": (">H", 2), "i16": (">h", 2), + "u32": (">I", 4), "i32": (">i", 4), + "u64": (">Q", 8), "i64": (">q", 8), + "f32": (">f", 4), "f64": (">d", 8), + "bool": (">B", 1), +} + + +class ProtoDef: + """按 protocol.json 的 types 表通用编解码。""" + + def __init__(self, types: dict[str, Any], phase_types: dict[str, Any] | None = None): + # types: 全局 types;phase_types: 当前阶段作用域(如 play.toClient.types) + # protodef 的具名类型会在阶段作用域里覆盖全局,必须两者都能查到。 + self.global_types = types + self.phase_types = phase_types or {} + self.types = {**types, **self.phase_types} + + # ---------- 读 ---------- + def read(self, t: Any, r: Reader, ctx: dict[str, Any] | None = None) -> Any: + ctx = ctx if ctx is not None else {} + + # 1) 具名类型(含自定义类型展开) + if isinstance(t, str): + return self._read_named(t, r, ctx) + + if not isinstance(t, list) or not t: + raise ProtoError(f"无法识别的类型声明: {t!r}") + + kind = t[0] if isinstance(t[0], str) else None + + # 2) 复合类型 + if kind == "container": + out: dict[str, Any] = {} + for f in t[1]: + name = f.get("name") or f.get("anon") + val = self.read(f["type"], r, ctx) + if f.get("anon"): + # 匿名字段:若是 dict 就并入 + if isinstance(val, dict): + out.update(val) + else: + out[name] = val + ctx[name] = val + return out + + if kind == "array": + spec = t[1] + if isinstance(spec, dict): + cnt = self._count(spec.get("countType", "varint"), r, ctx) + return [self.read(spec["type"], r, ctx) for _ in range(cnt)] + # ["array", "u8"] 形式:读到结尾 + out = [] + while r.remaining() > 0: + out.append(self.read(spec, r, ctx)) + return out + + if kind == "switch": + spec = t[1] + cmp_raw = spec.get("compareTo", "") + # protodef 两种写法:"$field" 或 "field"(都指引用已读字段) + key = None + if isinstance(cmp_raw, str): + key = cmp_raw[1:] if cmp_raw.startswith("$") else cmp_raw + val = ctx.get(key) if (key and key in ctx) else self.read(cmp_raw, r, ctx) + + fields = spec.get("fields", {}) + default = spec.get("default") + # 字段键可能是 '0x01'/'1' 等写法,统一规整 + chosen = fields.get(str(val)) + if chosen is None: + chosen = default + if chosen is None: + # 无匹配且无 default:按 void(不消费) + if spec.get("default") == "void": + return None + raise ProtoError(f"switch 无匹配分支: {key}={val!r}") + return self.read(chosen, r, ctx) + + if kind == "option": + present = self.read("bool", r, ctx) + return self.read(t[1], r, ctx) if present else None + + if kind == "buffer": + spec = t[1] if len(t) > 1 else {} + cnt = self._count(spec.get("countType", "varint"), r, ctx) if spec else r.remaining() + return r.read(cnt) + + if kind == "bitfield": + total = sum(f["size"] for f in t[1]) + nbytes = (total + 7) // 8 + raw = int.from_bytes(r.read(nbytes), "big") + out = {} + shift = total + for f in t[1]: + shift -= f["size"] + v = (raw >> shift) & ((1 << f["size"]) - 1) + if f.get("signed") and v >= (1 << (f["size"] - 1)): + v -= 1 << f["size"] + out[f["name"]] = v + return out + + if kind == "mapper": + spec = t[1] + raw = self.read(spec["type"], r, ctx) + for k, v in spec.get("mappings", {}).items(): + if int(k, 0) == raw: + return v + return raw + + if kind == "void": + return None + + # 列表形式引用内置原语,如 ["pstring", {"countType":"varint"}]、["varint"] + if kind in FIXED or kind in ("varint", "optvarint", "varlong", "pstring", + "string", "restBuffer", "UUID", "void"): + return self._read_named(kind, r, ctx) + + raise ProtoError(f"未实现的复合类型: {kind}") + + def _read_named(self, name: str, r: Reader, ctx: dict[str, Any]) -> Any: + # 阶段作用域优先(packet_set_protocol 这类只存在于阶段内) + if name in self.phase_types: + return self.read(self.phase_types[name], r, ctx) + # protocol.json 里 "native" 表示「此类型由实现直接提供」, + # 不是要展开的定义 —— 必须按内置原语处理(实测踩过这个坑)。 + if name in self.global_types and self.global_types[name] != "native": + return self.read(self.global_types[name], r, ctx) + + if name in FIXED: + fmt, n = FIXED[name] + v = struct.unpack(fmt, r.read(n))[0] + return bool(v) if name == "bool" else v + + if name == "varint": + return r.varint() + if name == "optvarint": + return r.varint() + if name == "varlong": + return r.varlong() + if name in ("string", "pstring"): + return r.pstring() + if name == "restBuffer": + return r.read(r.remaining()) + if name == "void": + return None + if name == "UUID": + b = r.read(16) + h = b.hex() + return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:]}" + if name == "nbt": + return self._read_nbt(r) + if name == "optionalNbt": + return self._read_nbt(r) if r.read(1)[0] != 0 else None + + raise ProtoError(f"未知类型: {name}") + + @staticmethod + def _read_nbt(r: Reader) -> Any: + """最小 NBT 读取(够用于 registry/codec 探测)。""" + tag = r.read(1)[0] + if tag == 0: + return None + return {"_nbt_tag": tag, "_nbt_raw": r.read(r.remaining())} + + def _count(self, count_type: Any, r: Reader, ctx: dict[str, Any]) -> int: + return self.read(count_type, r, ctx) + + +def for_phase(protocol: dict[str, Any], phase: str, side: str) -> ProtoDef: + """按「阶段+方向」构造解释器(如 play/toClient)。 + + 这是正确用法:protodef 的具名类型分全局与阶段两级作用域, + 只传全局表会导致 packet_set_protocol 这类阶段内类型查不到。 + """ + ph = protocol.get(phase, {}).get(side, {}) + return ProtoDef(protocol.get("types", {}), ph.get("types", {})) + + +def load_protocol(path: str | Path) -> dict[str, Any]: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) diff --git a/tests/test_protodef.py b/tests/test_protodef.py new file mode 100644 index 0000000..fe2b10c --- /dev/null +++ b/tests/test_protodef.py @@ -0,0 +1,104 @@ +"""验证 protodef 解释器能真跑协议——用真实握手字节。""" +import sys, os, json, struct +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from mcfold.protodef import ProtoDef, Reader, Writer, ProtoError, for_phase + +DATA='/home/newqqagent/plugin_data/mc/runtime/node_modules/minecraft-data/minecraft-data/data/pc/1.20/protocol.json' + +def load(): + d=json.load(open(DATA)) + return d + +def pd_for(d, phase, side): + return for_phase(d, phase, side) + +def test_varint_roundtrip(): + """varint 是 MC 最核心的原语;含负数回绕。""" + load() + for v in [0,1,127,128,255,300,25565,2097151,-1,-2147483648,2147483647]: + w=Writer(); w.varint(v) + got=Reader(w.getvalue()).varint() + assert got==v, f"varint {v} -> {got}" + print(" ✓ varint 往返(含负数/边界)") + +def test_pstring(): + load() + for s in ["", "abc", "中文名字", "x"*300]: + w=Writer(); w.pstring(s) + assert Reader(w.getvalue()).pstring()==s + print(" ✓ pstring 往返(含 UTF-8/长串)") + +def test_real_handshake(): + """构造真实的 handshake+login_start 字节并解出(最硬的检验)。""" + d=load() + pd=pd_for(d,'handshaking','toServer') + # handshaking toServer packet_set_protocol + w=Writer() + w.varint(0x00) # packet id (handshaking) + w.varint(763) # protocol version = 1.20.1 + w.pstring("127.0.0.1") # server address + w.write(struct.pack(">H", 25565)) # port + w.varint(2) # next state = login + raw=w.getvalue() + r=Reader(raw) + pkt=pd.read(d['handshaking']['toServer']['types']['packet'], r) + assert pkt['name']=='set_protocol', pkt + p = pkt['params'] # switch 的参数落在 params 下 + assert p['protocolVersion']==763, p + assert p['serverHost']=='127.0.0.1', p + assert p['serverPort']==25565, p + assert p['nextState']==2, p + assert r.remaining()==0, f"有残留字节: {r.remaining()}" + print(f" ✓ 真实 handshake 解码: proto={p['protocolVersion']} host={p['serverHost']}:{p['serverPort']} nextState={p['nextState']}") + +def test_login_success_decode(): + """packet_success: uuid + username + properties(真实形状)。""" + d=load() + pd=pd_for(d,'login','toClient') + w=Writer() + w.varint(0x02) # login toClient packet_success id + w.write(bytes(range(16))) # uuid + w.pstring("BotName") + w.varint(0) # properties count = 0 + r=Reader(w.getvalue()) + pkt=pd.read(d['login']['toClient']['types']['packet'], r) + assert pkt['name']=='success', pkt + p = pkt['params'] + assert p['username']=='BotName', p + assert p['properties']==[], p + print(f" ✓ login success 解码: uuid={p['uuid'][:8]}… username={p['username']}") + +def test_play_packet_mapper(): + """play 阶段 111 个包由 mapper 分发 —— 验证能正确路由。""" + d=load() + pd=pd_for(d,'play','toClient') + w=Writer(); w.varint(0x00) + # 0x00 = bundle_delimiter(无字段) + r=Reader(w.getvalue()) + pkt=pd.read(d['play']['toClient']['types']['packet'], r) + assert pkt['name']=='bundle_delimiter', pkt + print(f" ✓ play 包路由: 0x00 -> {pkt['name']}") + # 0x10 = declare_commands(我们做 mod 命令发现要用的包) + w2=Writer(); w2.varint(0x10) + r2=Reader(w2.getvalue()) + try: + pkt2=pd.read(d['play']['toClient']['types']['packet'], r2) + print(f" ✓ declare_commands 路由: 0x10 -> {pkt2.get('name')}") + except ProtoError as e: + print(f" ~ declare_commands 需要更多字节(预期,因为只喂了 id): {str(e)[:40]}") + +def test_no_overshoot(): + """越界必须报错而不是静默读错 —— 这是我实测 mineflayer 静默失真的对照。""" + d=load() + pd=pd_for(d,'login','toClient') + r=Reader(b"\x05") # varint 说 5 字节字符串,实际没有 + try: + pd.read("string", r); assert False, "应当抛错" + except ProtoError as e: + print(f" ✓ 越界显式报错(不静默): {str(e)[:50]}") + +if __name__=="__main__": + for fn in [test_varint_roundtrip, test_pstring, test_real_handshake, + test_login_success_decode, test_play_packet_mapper, test_no_overshoot]: + fn() + print("\nprotodef 解释器:全部通过")