From 547f4beb7e35551eb781e9da167b5daa57e0efcf Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 26 Sep 2026 12:15:48 +0800 Subject: [PATCH] =?UTF-8?q?M1+M2=EF=BC=9A1.21.1=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E8=B7=91=E9=80=9A=20+=20=E5=8D=8F=E8=AE=AE=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E8=BF=9B=20play=20=E9=98=B6=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M1(构建): - build.sh 编译 CP 加入 $OUT/fabric/*.jar,解决 net.fabricmc.* 找不到 - gen_1_21 内联 Sodium 通知改为委托共用 RenderCompat,补 reportRenderCompat - 1.21.1 全量七步构建产物齐备(classes-intermediary.jar / names*.json / srg / inheritance.map) M2(协议): - login_start 按协议数据发 UUID(1.21 必填;1.20 可选) - login_acknowledged / serverbound 包 id 改为查表,不再写死 0x00 - send() 实现压缩帧格式(varint(dataLen)+数据,小包不压) - configuration 双向协商:发 settings、回 select_known_packs、拒 resource_pack - protodef 补 bitflags / entityMetadataLoop / count(ctx引用) / compareTo 路径表达式 - NBT 读取器由占位符改为真实现(anonymousNbt 支撑 registry_data) - 坏包容错:长度前缀是帧层权威,解不开整包丢弃并布告,不断流 实测:login→configuration(11 注册表)→play,位置 (5.5,-60.0,-0.5),map_chunk×9 判据:protodef 6→13 项(含线上真实 entity_metadata 字节回归);全量 77 项通过 --- mcfold/client.py | 365 +++++++++++++++++- mcfold/protodef.py | 257 +++++++++++- plan.md | 38 +- scaffold/build.sh | 17 +- scaffold/java/common/mcfold/RenderCompat.java | 214 ++++++++++ .../java/gen_1_20/McfoldRenderServer.java | 227 ++--------- .../java/gen_1_21/McfoldRenderServer.java | 58 +-- tests/test_protodef.py | 186 ++++++++- 8 files changed, 1122 insertions(+), 240 deletions(-) create mode 100644 scaffold/java/common/mcfold/RenderCompat.java diff --git a/mcfold/client.py b/mcfold/client.py index 5ba78a2..9562cea 100644 --- a/mcfold/client.py +++ b/mcfold/client.py @@ -37,6 +37,16 @@ class Client: self.proto: dict = {} self.proto_num: int = 0 self.compression: int = -1 + # 当前所处协议阶段:login → configuration → play + # ★ 1.20.2 起才有 configuration 阶段(1.20.1 跳过它) + self.phase: str = "login" + # ★ configuration 阶段拿到的**服务端权威注册表**(mod 方块/物品在这里) + self.registries: dict[str, Any] = {} + self._pkt_names: dict[tuple[str, str], dict[int, str]] = {} + self._pid_cache: dict[tuple[str, str, str], int] = {} + # ★ 解不开的包(上游数据缺口)记在这里,不断流。 + # 实测动机:declare_recipes 在官方数据上同样解不开。 + self.decode_failures: list[dict[str, Any]] = [] # 允许直接指定协议数据文件(优先),否则按 data_root 找 self._path = Path(protocol_path) if protocol_path else None @@ -94,12 +104,46 @@ class Client: self.sock.settimeout(15) def send(self, payload: bytes) -> None: + """发一个包(自动适配压缩帧格式)。 + + ★★ 压缩启用后的包体格式(这正是 M2 卡住我的地方): + 包 = varint(总长) + varint(dataLength) + 数据 + 其中 dataLength: + == 0 → 数据**未压缩**,原样跟在后面(小包) + > 0 → 数据是 zlib,解压后长度为 dataLength + ★ 服务端的判据是「未压缩长度 >= threshold 才压缩」: + 包小于 threshold ⇒ dataLength 必须为 0(原样发) + 我先前没加这个前缀,直接发裸包体 ⇒ 服务端把包体第 1 字节 + 当成 dataLength 读 ⇒ `Badly compressed packet - size of 3 + is below server threshold of 256` ⇒ 断连。 + 反过来:把小于 threshold 的包也 zlib 压了同样会被拒。 + """ assert self.sock - # 未启用压缩前:包 = varint(长度) + 包体 - w = Writer() - w.varint(len(payload)) - w.write(payload) - self.sock.sendall(w.getvalue()) + if self.compression >= 0: + fw = Writer() + if self.compression > 0 and len(payload) >= self.compression: + import zlib + comp = zlib.compress(payload) + # 压缩后反而变大(极小包)时依然走未压缩分支 + if len(comp) < len(payload): + fw.varint(len(payload)) + fw.write(comp) + body = fw.getvalue() + else: + fw2 = Writer() + fw2.varint(0) + fw2.write(payload) + body = fw2.getvalue() + else: + fw.varint(0) + fw.write(payload) + body = fw.getvalue() + else: + body = payload + out = Writer() + out.varint(len(body)) + out.write(body) + self.sock.sendall(out.getvalue()) def read_packet(self) -> bytes: """读一个包体(已去长度前缀;压缩未启用时即原始包)。""" @@ -149,13 +193,320 @@ class Client: self.send(w.getvalue()) def login_start(self) -> None: - """login: login_start(离线模式只发用户名)。""" + """login: login_start(离线模式只发用户名)。 + + ★ **世代差异**(实测 2026-09-26,两种都撞过): + 1.20.x playerUUID: ['option', 'UUID'] ⇒ 可选,发 varint(0) 表示「无」 + 1.21.1 playerUUID: UUID ⇒ **必填 16 字节** + 之前我无条件发 `varint(0)`,在 1.21.1 上服务端直接回 + Failed to decode packet 'serverbound/minecraft:hello' + ⇒ 这类差异必须**从协议数据里读**,不能凭印象写死。 + """ w = Writer() w.varint(0x00) w.pstring(self.username) - w.varint(0) # uuid 可选,0 表示无 + fields = self._login_start_fields() + if fields is not None and fields.get("playerUUID") == "UUID": + # 1.21.1+:必填 16 字节。离线模式下用**由用户名派生的确定性 UUID** + # (与服务端算法一致,见 _offline_uuid),避免每次连都换身份。 + w.write(self._offline_uuid(self.username)) + else: + # 1.20.x:可选,varint(0) = 无 + w.varint(0) self.send(w.getvalue()) + def _login_start_fields(self) -> dict[str, str] | None: + """从 protocol.json 读 login_start 的字段类型(不凭印象)。""" + try: + types = self.proto["login"]["toServer"]["types"] + for name in ("packet_login_start", "packet_hello"): + c = types.get(name) + if c and isinstance(c, list) and len(c) > 1: + return {f["name"]: f["type"] for f in c[1]} + except Exception: + return None + return None + + @staticmethod + def _offline_uuid(username: str) -> bytes: + """离线模式的 UUID = UUID.nameUUIDFromBytes("OfflinePlayer:" + name)。 + + ★ 与官方一致:服务端对 offline-mode 也用这个算玩家身份 + (GameProfile / ServerPlayer 里的同名算法)⇒ 我们发一样的值, + 服务端认这个玩家,不是新建一个。 + """ + import hashlib + h = bytearray(hashlib.md5(f"OfflinePlayer:{username}".encode()).digest()) + h[6] = (h[6] & 0x0F) | 0x30 # version 3 + h[8] = (h[8] & 0x3F) | 0x80 # IETF variant + return bytes(h) + + def login_acknowledged(self) -> None: + """login: acknowledged —— **1.21+ 才有**(1.20.x 没有这个包)。 + + ★ 这是从 login 切到 configuration 的信号(实测)。 + ★★ 包 id **必须查表**,不能写死 `0x00`: + 1.21.1 的 login/login_acknowledged 是 **0x03** + (0x00=login_start, 0x01=encryption_begin, + 0x02=login_plugin_response, 0x03=login_acknowledged) + 我第一版写死 0x00 ⇒ 服务端收到一个新 login_start, + 停在 login 阶段等正确信号 ⇒ configuration 阶段永远收不到包 + (时间超时,日志上看起来像"挂住")。 + """ + try: + types = self.proto["login"]["toServer"]["types"] + except Exception: + types = {} + if "packet_login_acknowledged" not in types: + return + w = Writer() + w.varint(self._serverbound_id("login", "packet_login_acknowledged")) + self.send(w.getvalue()) + + def _recv_packet(self, phase: str) -> dict: + """读一包并解成 {name, params, _id, _len}。 + + ★★ 关键韧性设计(实测下来这是必需的):**用一个包坏掉不能断流**。 + + 长度前缀是**帧层权威**,所以无论一个包的载荷能不能解, + 下一个包的边界都精确可知 ⇒ 解不开就整包丢弃并标记 + `_undecoded`,而不是抛异常让整条连接作废。 + + 实测依据(2026-09-26): + `declare_recipes`(108KB,1290 条配方)在 + **minecraft-protocol + 官方 minecraft-data 3.117 上同样报零** + (PartialReadError: found 108271, expected 114153) + ⇒ 这是**上游数据定义的缺口**,不是我们的解析器 bug。 + 若因它抛异常,play 阶段的 position/map_chunk 就永远收不到 —— + agent 会完全看不到世界,因为一条配方列表解不开。 + + ⇒ 此路:坏包容错 + 布告(`undecoded` 计数),连接继续。 + """ + body = self.read_packet() + try: + pkt = self.decode(phase, "toClient", body) + except Exception as e: + pid = -1 + try: + pid = Reader(body).varint() + except Exception: + pass + name = self._name_by_id(phase, "toClient", pid) + self.decode_failures.append({"phase": phase, "name": name, + "len": len(body), "error": str(e)[:160]}) + return {"name": name, "params": {}, "_raw": None, + "_len": len(body), "_undecoded": True, "_error": str(e)[:160]} + name = pkt.get("name") + if name in (None, "unknown"): + name = self._name_by_id(phase, "toClient", pkt.get("_pid", -1)) + return {"name": name, "params": pkt.get("params", {}), + "_raw": pkt, "_len": len(body)} + + def _name_by_id(self, phase: str, side: str, pid: int) -> str: + return self._packet_names(phase, side).get(pid, f"unknown_0x{pid:02x}") + + def _decode_any(self, phase: str, side: str, body: bytes) -> dict: + """解一个包并回包名(保留 params 形状,供流程控制)。""" + pd = self._pd(phase, side) + pkt = pd.read(self.proto[phase][side]["types"]["packet"], Reader(body)) + if not isinstance(pkt, dict): + return {"name": None, "params": {}, "_raw": pkt} + pid = 0 + try: + pid = Reader(body).varint() + except Exception: + pass + pkt["_id"] = pid + return pkt + + def _packet_names(self, phase: str, side: str) -> dict[int, str]: + """包 id → 包名(从 protocol.json 的 mapper 读;缓存)。 + + ★ mapper 的实际形状(实测): + ['mapper', {'type': 'varint', 'mappings': {'0x00': 'disconnect', ...}}, 'string'] + 映射在 [1]['mappings'],**不是** [1] 本身 —— 我第一版把 [1] 当映射表, + 结果所有包名都是 unknown。 + """ + key = (phase, side) + if key in self._pkt_names: + return self._pkt_names[key] + out: dict[int, str] = {} + try: + tm = self.proto[phase][side]["types"].get("packet", {}) + for f in tm[1]: + if f["name"] != "name": + continue + spec = f["type"] # ['mapper', {...}, 'string'] + d = spec[1] if isinstance(spec, list) and len(spec) > 1 else {} + m = d.get("mappings", d) if isinstance(d, dict) else {} + for k, v in m.items(): + if k == "type": + continue + out[int(k, 0) if isinstance(k, str) else int(k)] = v + break + except Exception: + pass + self._pkt_names[key] = out + return out + + def _recv(self, phase: str) -> dict: + return self._recv_packet(phase) + + def configuration_handshake(self) -> dict: + """走完 configuration 阶段,拿到服务端的注册表(含 mod 方块)。 + + ## 为什么这一步是 M2 的核心 + + 1.20.2 起服务端在 login 后插入一个 **configuration 阶段**, + 里面发 `registry_data`。它是**方块/物品注册表的权威来源** —— + 而客户端本机 jar 里的注册表只包含自己装的东西: + **mod 方块的 state id 只有服务端知道**。 + + 这就是「与服务端 mod 物品交互」的技术核心: + 不读这个阶段,我们看到 mod 方块只会是 `unknown(id)`。 + + ## 流程(实测 1.21.1) + + login: success + → 发 login_acknowledged (1.21+ 才有) + configuration: + 收到 feature_flags / registry_data×N / tags / ... + keep_alive → 必须回 pong,否则超时断 + → 发 finish_configuration + configuration: finish_configuration(服务端回) + → 进 play 阶段 + """ + self.login_acknowledged() + self.phase = "configuration" + # ★ 先报本地设置(vanilla 行为,否则服务端等不到 finish_configuration) + self.send_settings() + raw: dict[str, Any] = {} + keepalive = 0 + seen: list[str] = [] + while True: + pkt = self._recv("configuration") + nm = pkt.get("name") + pr = pkt.get("params", {}) or {} + seen.append(str(nm)) + if nm == "keep_alive": + keepalive += 1 + kid = pr.get("keepAliveId", pr.get("keepAliveId", 0)) + w = Writer() + w.varint(self._serverbound_id("configuration", "packet_keep_alive")) + w.int64(int(kid)) + self.send(w.getvalue()) + continue + if nm == "select_known_packs": + # ★ 必须回应,否则服务端不再发 registry_data(实测) + self.select_known_packs() + continue + if nm == "add_resource_pack": + # 资源包:同意/拒绝二选一。我们不装,**拒绝** + # (约定值 3 = 已成功加载的包集合里没有它,即 declined) + w = Writer() + w.varint(self._serverbound_id("configuration", "packet_resource_pack_receive")) + w.byte(3) + w.varint(0) # 已加载包数 = 0 + self.send(w.getvalue()) + continue + if nm == "registry_data": + rid = pr.get("id") + ents = pr.get("entries") + n = len(ents) if isinstance(ents, (list, tuple)) else 0 + raw.setdefault("registries", {})[str(rid)] = n + # ★ 保留实际条目供上层建表(这是**权威**注册表) + self.registries[str(rid)] = ents + continue + if nm in ("finish_configuration", "disconnect"): + raw["finished"] = (nm == "finish_configuration") + if nm == "disconnect": + raw["reason"] = pr.get("reason") + break + if raw.get("finished"): + w = Writer() + w.varint(self._serverbound_id("configuration", "packet_finish_configuration")) + self.send(w.getvalue()) + self.phase = "play" + raw["keepAlive"] = keepalive + raw["registryCount"] = len(self.registries) + raw["seenKinds"] = sorted(set(seen)) + return raw + + def send_settings(self) -> None: + """configuration: settings(Client Information)—— **vanilla 客户端会发**。 + + ★ 必需的原因(实测):configuration 阶段是**双向协商**, + 服务端等客户端报上本地化/视距/渲染偏好后才收尾发 + finish_configuration;不发就一直等(表现为"挂住")。 + 参照实现 minecraft-protocol/src/client/play.js:63-72 同样在 + 进 configuration 状态时立即写一份 settings。 + + ★ 字段定义不在 configuration.toServer.types 里,而在**共享顶层** + `types.packet_common_settings` —— 我先前在阶段内找 `packet_settings` + 得到 null,差点当成「不需要发」。 + ★ 配置值取 vanilla 默认(en_us / 视距 10 / 全部皮肤层 / 主手右手)。 + """ + w = Writer() + w.varint(self._serverbound_id("configuration", "packet_settings")) + w.pstring("en_us") # locale + w.int8(8) # viewDistance(i8) + w.varint(0) # chatFlags + w.boolean(True) # chatColors + w.uint8(0x7F) # skinParts 127 = 全部 + w.varint(1) # mainHand 1 = 右手 + w.boolean(False) # enableTextFiltering + w.boolean(True) # enableServerListing + self.send(w.getvalue()) + + def select_known_packs(self) -> None: + """configuration: select_known_packs → 回一份**空列表**(我们不认它的包)。 + + ★ 实测(2026-09-26,服务端发完这个就不发 registry_data 了): + configuration 阶段 = 双向协商,服务端会**等回应**才继续: + select_known_packs → 必须回(同理 cookie_request / resource_pack) + settings → 必须发 + registry_data ×N → 只需要收 + finish_configuration → 必须回 + 只收不回,registry_data 永远不来(实测卡在这)。 + ★ 字段定义在 protocol.json 里是 null(数据缺口),按参照实现 + minecraft-protocol/src/client/play.js:78-80 用 `packs: []`。 + """ + w = Writer() + w.varint(self._serverbound_id("configuration", "packet_select_known_packs")) + w.varint(0) # packs 数量 = 0(一个都不认) + self.send(w.getvalue()) + + def _serverbound_id(self, phase: str, pname: str) -> int: + """包名 → 包 id(serverbound)。拿不到就报错(不猜)。 + + ★ 包名有两种写法(实测): + mapper 里是**短名** `login_acknowledged` + 类型定义里是**长名** `packet_login_acknowledged` + 两者都要能吃 —— 写死一种会在另一处失配 + (我第一版只认长名,`packet_login_acknowledged` 查不到)。 + """ + key = ("toServer", phase, pname) + if key in self._pid_cache: + return self._pid_cache[key] + short = pname[7:] if pname.startswith("packet_") else pname + tm = self.proto[phase]["toServer"]["types"].get("packet", {}) + found = None + for f in tm[1]: + if f["name"] != "name": + continue + spec = f["type"] + d = spec[1] if isinstance(spec, list) and len(spec) > 1 else {} + m = d.get("mappings", d) if isinstance(d, dict) else {} + for k, v in m.items(): + if v in (pname, short): + found = int(k, 0) if isinstance(k, str) else int(k) + break + break + if found is None: + raise ProtocolError(f"{phase} 阶段无此 serverbound 包: {pname}") + self._pid_cache[key] = found + return found + def decode(self, phase: str, side: str, body: bytes) -> dict: pd = self._pd(phase, side) pkt = pd.read(self.proto[phase][side]["types"]["packet"], Reader(body)) diff --git a/mcfold/protodef.py b/mcfold/protodef.py index 30ffb5e..9646460 100644 --- a/mcfold/protodef.py +++ b/mcfold/protodef.py @@ -109,6 +109,31 @@ class Writer: self.varint(len(b)) self.parts.append(b) + # ---- 固定宽度原语(配置阶段要发 i8/u8/bool,实测标了 signed/unsigned) ---- + def _fixed(self, fmt: str, v: int) -> None: + import struct as _s + self.parts.append(_s.pack(fmt, v)) + + def int8(self, v: int) -> None: + """i8:有符号 8 位(如 viewDistance)。""" + self._fixed(">b", v) + + def uint8(self, v: int) -> None: + """u8:无符号 8 位(如 skinParts 位掩码 0x7F)。""" + self._fixed(">B", v) + + def boolean(self, v: bool) -> None: + """bool:协议里是 1 字节 0/1。""" + self._fixed(">B", 1 if v else 0) + + def byte(self, v: int) -> None: + """单字节(如 resource_pack 回应里的结果码 0..3)。""" + self._fixed(">B", v) + + def int64(self, v: int) -> None: + """i64:8 字节大端有符号(如 keep_alive 的 keepAliveId)。""" + self._fixed(">q", v) + # ---- 固定宽度原语:[struct 格式, 字节数] ---- FIXED: dict[str, tuple[str, int]] = { @@ -147,22 +172,47 @@ class ProtoDef: # 2) 复合类型 if kind == "container": out: dict[str, Any] = {} + # ★ 子作用域:`../x` 路径要能回到外层(实测 6 处 `../flags/…`、 + # `../action/…` 判据)。保留 __parent__ 链即可。 + sub = dict(ctx) + sub["__parent__"] = ctx for f in t[1]: name = f.get("name") or f.get("anon") - val = self.read(f["type"], r, ctx) + val = self.read(f["type"], r, sub) if f.get("anon"): # 匿名字段:若是 dict 就并入 if isinstance(val, dict): out.update(val) + sub.update(val) else: out[name] = val - ctx[name] = val + sub[name] = val + # 回写外层(同名字段让外层也能查到,兼容平铺写法) + for k, v in out.items(): + if k not in ("__parent__",): + ctx[k] = v return out if kind == "array": spec = t[1] if isinstance(spec, dict): - cnt = self._count(spec.get("countType", "varint"), r, ctx) + # ★ protodef 的计数有两种写法(实测全协议:6 处 count / 103 处 countType): + # countType: 'varint' → 从流里读一个计数 + # count: 'gridWidth' → **引用已读字段**(ctx),不读流 + # 我先前只认 countType,遇到 `count` 就默认按 varint 读 ⇒ + # 多读一个字节 ⇒ **流错位**(表现为后面 switch 收到 948 + # 这种荒谬值,报“switch 无匹配分支”)。 + if "count" in spec and isinstance(spec["count"], str): + cname = spec["count"] + cnt = ctx.get(cname) + if cnt is None: + # 退化:当它是个要读的字段名 + cnt = self.read(cname, r, ctx) + cnt = int(cnt or 0) + else: + cnt = self._count(spec.get("countType", "varint"), r, ctx) + if cnt < 0: + cnt = 0 return [self.read(spec["type"], r, ctx) for _ in range(cnt)] # ["array", "u8"] 形式:读到结尾 out = [] @@ -173,16 +223,29 @@ class ProtoDef: if kind == "switch": spec = t[1] cmp_raw = spec.get("compareTo", "") - # protodef 两种写法:"$field" 或 "field"(都指引用已读字段) + # ★★ compareTo 是**路径表达式**,不一定是字段名(实测全协议): + # "type" 普通字段 + # "flags/has_redirect_node" 子字段(跳进已读的 dict) + # "../flags/min_present" 父作用域的子字段 + # "../action/add_player" 父作用域的位标志 + # 我先前只做 ctx.get(平铺名) ⇒ 这些全部查不到, + # 退回 self.read('flags/xxx') ⇒ 报「未知类型: flags/xxx」 + # (这正是 player_info / declare_commands 解不开的原因)。 key = None + val = 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) + val = self._resolve_ctx(key, ctx) + if val is None: + # 退化:当它是个要读的类型名(罕见的隐式 switch) + val = self.read(cmp_raw, r, ctx) fields = spec.get("fields", {}) default = spec.get("default") - # 字段键可能是 '0x01'/'1' 等写法,统一规整 + # 字段键可能是 '0x01'/'1'/'true' 等写法,统一规整 chosen = fields.get(str(val)) + if chosen is None and isinstance(val, bool): + chosen = fields.get("1" if val else "0") if chosen is None: chosen = default if chosen is None: @@ -228,11 +291,109 @@ class ProtoDef: # 列表形式引用内置原语,如 ["pstring", {"countType":"varint"}]、["varint"] if kind in FIXED or kind in ("varint", "optvarint", "varlong", "pstring", - "string", "restBuffer", "UUID", "void"): + "string", "restBuffer", "UUID", "void", + "anonymousNbt", "nbt", "optionalNbt"): return self._read_named(kind, r, ctx) + # ---- bitflags:单个整数拆成多个布尔位(实测定义:PositionUpdateRelatives) + # + # 参照实现 protodef/src/datatypes/utils.js readBitflags: + # 返回 {_value: 原始值, : bool, ...} + # ★ 为什么必须实现:`position` 包里的 `flags` 就是它。 + # position 是「玩家在哪」的唯一来源 —— 实现不了它就等于 + # 看不到自己位置,后续 M3 状态层根本无从上手。 + if kind == "bitflags": + spec = t[1] + base = spec.get("type", "u8") + raw = self.read(base, r, ctx) + flags = spec.get("flags") + out: dict[str, Any] = {"_value": raw} + if isinstance(flags, list): + for i, nm in enumerate(flags): + out[nm] = bool(raw & (1 << i)) + elif isinstance(flags, dict): + shift = spec.get("shift") + for nm, bit in flags.items(): + b = shift if shift else bit + out[nm] = bool(raw & (1 << int(b))) + return out + + # ---- bitfield:把一个整数的各位切成具名子字段(实测定义:command_node.flags) + # ---- entityMetadataLoop:读实体元数据条目直到终止值(实测:entityMetadata) + # + # ★ 终止判据是**键名等于 endVal**(协议里 metadata 以 key=0xFF 收尾), + # 不是读到流尽头 —— 写成读尽头会在同包多个 metadata 时错位。 + if kind == "entityMetadataLoop": + spec = t[1] + end_val = spec.get("endVal", 255) + entry_t = spec.get("type") + out_list: list[Any] = [] + # ★ 终止符是**光秃秃的一个字节**(0xFF),后面**没有** type/value。 + # 我先前「读完整个条目再看 key 是否 == endVal」⇒ 终止时多读了 + # type+value 2 字节 ⇒ 报「读越界」。 + # 参照 minecraft-protocol/src/datatypes/minecraft.js:120-128: + # item = buffer.readUInt8(cursor); if (item === endVal) 停 + # 线上实测(entityId=144 的真实包 58 90 01 09 03 41a00000 11 00 7f ff): + # 按窥视语义切,13/13 字节完全对齐。 + while True: + if r.remaining() <= 0: + break + peek = r.buf[r.pos] + if peek == end_val: + r.read(1) # 只消费终止键本身 + break + start = r.pos + entry = self.read(entry_t, r, ctx) + out_list.append(entry) + if r.pos == start: + break # 防死循环(零字节推进) + return out_list + raise ProtoError(f"未实现的复合类型: {kind}") + @staticmethod + def _resolve_ctx(path: str, ctx: dict[str, Any]) -> Any: + """按路径表达式取值:`a/b`、`../a/b`、`a.b` 都要支持。 + + ★ 实测语法(全协议 12 处带路径的 compareTo): + flags/has_redirect_node 子字段 + ../flags/has_custom_suggestions 父作用域子字段 + ★ 为什么需“向上逐层试”:`..` 的层数依赖引用点嵌套深度, + 而不同分支的嵌套深度不一样 ⇒ 固定按字面层级走会在某些分支落空, + 落空后代码会退化成“读一个叫 ../flags/… 的类型” + (报「未知类型: ../flags/has_custom_suggestions」)。 + """ + parts = [p for p in path.replace(".", "/").split("/") if p] + ups = 0 + while parts and parts[0] == "..": + parts.pop(0) + ups += 1 + if not parts: + return None + # 先按字面层级走,失败则向上多跳,直到祖先链尽头 + for extra in range(0, 8): + for total in ({ups + extra} if extra == 0 else {ups - extra, ups + extra}): + if total < 0: + continue + cur: Any = ctx + for _ in range(total): + if not isinstance(cur, dict): + cur = None + break + cur = cur.get("__parent__") + if cur is None: + continue + ok = True + for part in parts: + if isinstance(cur, dict) and part in cur: + cur = cur[part] + else: + ok = False + break + if ok: + return cur + return None + def _read_named(self, name: str, r: Reader, ctx: dict[str, Any]) -> Any: # 阶段作用域优先(packet_set_protocol 这类只存在于阶段内) if name in self.phase_types: @@ -265,18 +426,90 @@ class ProtoDef: 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 == "anonymousNbt": + return self._read_anonymous_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 探测)。""" + # ---- NBT ---- + # ★ 为什么必须真解 NBT(实测 2026-09-26): + # configuration 阶段的 registry_data 条目是 `anonymousNbt` —— + # 方块/物品注册表的**权威内容就在 NBT 里**(mod 方块的 state 表也在这)。 + # 旧版只回 {"_nbt_raw": ...} 占位,等于拿不到任何注册表内容, + # 上层只能看到 `unknown(id)` —— 这正是「与服务端 mod 物品交互」的卡点。 + _NBT_END, _NBT_BYTE, _NBT_SHORT, _NBT_INT, _NBT_LONG = 0, 1, 2, 3, 4 + _NBT_FLOAT, _NBT_DOUBLE, _NBT_BYTE_ARRAY, _NBT_STRING = 5, 6, 7, 8 + _NBT_LIST, _NBT_COMPOUND, _NBT_INT_ARRAY, _NBT_LONG_ARRAY = 9, 10, 11, 12 + + @classmethod + def _read_nbt_payload(cls, r: Reader, tag: int, depth: int = 0) -> Any: + """按 tag 号读一个 NBT 载荷(不含 tag 字节本身)。""" + import struct as _s + if depth > 64: + raise ProtoError("NBT 嵌套过深") + if tag == cls._NBT_BYTE: + return r.read(1)[0] + if tag == cls._NBT_SHORT: + return int.from_bytes(r.read(2), "big", signed=True) + if tag == cls._NBT_INT: + return int.from_bytes(r.read(4), "big", signed=True) + if tag == cls._NBT_LONG: + return int.from_bytes(r.read(8), "big", signed=True) + if tag == cls._NBT_FLOAT: + return _s.unpack(">f", r.read(4))[0] + if tag == cls._NBT_DOUBLE: + return _s.unpack(">d", r.read(8))[0] + if tag == cls._NBT_BYTE_ARRAY: + n = int.from_bytes(r.read(4), "big", signed=True) + return list(r.read(max(n, 0))) + if tag == cls._NBT_STRING: + n = int.from_bytes(r.read(2), "big") + return r.read(n).decode("utf-8", "replace") + if tag == cls._NBT_LIST: + et = r.read(1)[0] + n = int.from_bytes(r.read(4), "big", signed=True) + if et == cls._NBT_END or n <= 0: + return [] + return [cls._read_nbt_payload(r, et, depth + 1) for _ in range(n)] + if tag == cls._NBT_COMPOUND: + out: dict[str, Any] = {} + while True: + t = r.read(1)[0] + if t == cls._NBT_END: + return out + nm_len = int.from_bytes(r.read(2), "big") + nm = r.read(nm_len).decode("utf-8", "replace") + out[nm] = cls._read_nbt_payload(r, t, depth + 1) + if tag in (cls._NBT_INT_ARRAY, cls._NBT_LONG_ARRAY): + w = 4 if tag == cls._NBT_INT_ARRAY else 8 + n = int.from_bytes(r.read(4), "big", signed=True) + return [int.from_bytes(r.read(w), "big", signed=True) for _ in range(max(n, 0))] + raise ProtoError(f"未知 NBT tag: {tag}") + + @classmethod + def _read_nbt(cls, r: Reader) -> Any: + """读一个具名 NBT(tag + 名字 + 载荷)—— 未压缩的普通 NBT。""" tag = r.read(1)[0] - if tag == 0: + if tag == cls._NBT_END: return None - return {"_nbt_tag": tag, "_nbt_raw": r.read(r.remaining())} + n = int.from_bytes(r.read(2), "big") + name = r.read(n).decode("utf-8", "replace") + return {"name": name, "value": cls._read_nbt_payload(r, tag)} + + @classmethod + def _read_anonymous_nbt(cls, r: Reader) -> Any: + """anonymousNbt:无根名字的 NBT(网络格式,registry_data 用的就是这个)。 + + ★ 与普通 NBT 的差别只在**没有根名字** —— 少读一个 + (short 长度 + 字节) 就直接进载荷。读多了会把后面 + 所有字节都错位(并且不报错,只是字段全乱)。 + """ + tag = r.read(1)[0] + if tag == cls._NBT_END: + return None + return cls._read_nbt_payload(r, tag) def _count(self, count_type: Any, r: Reader, ctx: dict[str, Any]) -> int: return self.read(count_type, r, ctx) diff --git a/plan.md b/plan.md index 6bfa004..9407170 100644 --- a/plan.md +++ b/plan.md @@ -560,8 +560,8 @@ shot.rough → ASCII + 线索(给 agent 看它为什么选了这个角度) | # | 内容 | 依赖 | 状态 | |---|---|---|---| | **M0** | 起**空白测试服**(§1c),版本隔离三件套 | — | ✅ | -| **M1** | 修 build.sh 编译 CP(加 Fabric 库)⇒ 1.21.1 构建跑通 | — | 🔄 | -| **M2** | **协议客户端**:连测试服 → configuration → registry_data → 进 play | M0 | ⬜ | +| **M1** | 修 build.sh 编译 CP(加 Fabric 库)⇒ 1.21.1 构建跑通 | — | ✅ | +| **M2** | **协议客户端**:连测试服 → configuration → registry_data → 进 play | M0 | ✅ | | | (这是最大缺口:现有只到 `login_start` 冒烟级) | | | | **M3** | **状态层**:World / entities / inventory 从**服务端真状态**来 | M2 | ⬜ | | **M4** | **事件层**:粗粒度 + 细分层(照 mineflayer,省 token) | M2 | ⬜ | @@ -571,6 +571,40 @@ shot.rough → ASCII + 线索(给 agent 看它为什么选了这个角度) | **M8** | **细渲染**(单次调用):出图,带光影 | M2 | ⬜ | | **M9** | 第一个用例:`capture_photo()`(粗搜机位 → 细出图) | M6, M8 | ⬜ | +### M2 实测结论(2026-09-26) + +**已达成的可复现事实**(连 M0 测试服 127.0.0.1:25566): + +``` +login phase=login → success(服务端回 UUID 与离线算法一致) +configuration phase=play 注册表=11(biome/damage_type/enchantment/…) +play 28 种包 map_chunk×9、position、declare_commands、entity_metadata + position (5.5, -60.0, -0.5) +``` + +**踩过的 7 个真实坑**(都是「世代差异」或协议数据理解错误): + +| 症状 | 根因 | +|---|---| +| `Failed to decode packet 'hello'` | 1.21.1 `login_start.playerUUID` 是**必填 UUID**;1.20 是 `['option','UUID']` | +| configuration 收不到包 | `login_acknowledged` 包 id 写死 `0x00`,实际 **`0x03`** | +| `Badly compressed packet` | 写侧没实现压缩帧:`varint(dataLen)+数据`,小包 `dataLen=0` | +| 服务端发完 `select_known_packs` 不动 | configuration 是**双向协商**,必须回 `select_known_packs`/发 `settings` | +| `registry_data` 解不开 | 缺 `anonymousNbt`;旧 NBT 只是占位符 | +| `declare_recipes` 流错位 | 数组计数有 `countType`(103处) 与 **`count`(引用 ctx,6处)** 两种 | +| `player_info`/`declare_commands` 解不开 | `compareTo` 可用**路径**:`flags/x`、`../flags/x`(12 处) | +| `entity_metadata` 越界 | 终止符是**光秃 0xFF 一个字节**,不含 type/value | + +**关键架构发现(修正项目前提)**:vanilla 1.21.1 只在线同步 **11 个动态注册表**, +**不含 `minecraft:block` / `minecraft:item`**。静态注册表不走线同步 —— +客户端用自己 jar 里的。⇒ Java 侧读 client jar 的路线对 vanilla 正确; +但 **mod 新增方块 vanilla 不会告诉客户端**,那条要靠加载器自身的同步机制。 + +**已知上游数据缺口(非本仓 bug,已容错)**:`declare_recipes`(108KB) +在**官方 minecraft-data 3.117 + minecraft-protocol** 上同样解不开 +(PartialReadError: found 108271 / expected 114153)。 +处理:长度前缀是帧层权威 ⇒ **坏包整包丢弃不断流**(`decode_failures` 布告)。 + **建议顺序:M0 → M1 → M2**。 - M0/M1 是解阻断(M1 是我已经查到根因的那个 classpath bug) - **M2 是真正的门槛**:从「能渲染」到「能玩」,缺的不是渲染,是**完整协议层**。 diff --git a/scaffold/build.sh b/scaffold/build.sh index 8b69a47..6c84999 100755 --- a/scaffold/build.sh +++ b/scaffold/build.sh @@ -101,11 +101,20 @@ echo "[4/7] 库 $(wc -l < "$OUT/classpath.txt") 个, natives $(find "$OUT/native # ── 5. 编译渲染服务 ─────────────────────────────────────────── echo "[5/7] 编译 $JAVA_SUB ..." +# ★ 编译 CP 必须含 **Fabric 库**:loader_fabric/LoaderBoot 引用 +# net.fabricmc.api / net.fabricmc.loader.impl.launch.knot, +# 缺了就是 "package net.fabricmc.api does not exist"(1.21.1 实测报过)。 +# 1.20.1 那次能过是因为**手工拼的运行 CP** 里恰好带了它, +# 而 build.sh 自己的编译 CP 一直没加 —— 侥幸而已。 CP="$(paste -sd: "$OUT/classpath.txt"):$NAMED:$OUT/classes" -find "$HERE/$JAVA_SUB" "$HERE/common" -name "*.java" > "$WORK/srcs.txt" +if [ -d "$OUT/fabric" ]; then +FAB_CP=$(find "$OUT/fabric" -name '*.jar' | paste -sd:) +[ -n "$FAB_CP" ] && CP="$CP:$FAB_CP" +fi +find "$HERE/$JAVA_SUB" "$HERE/java/common" -name "*.java" > "$WORK/srcs.txt" # ★ LoaderBoot(加载器启动器)也要编进来:它是 launcher="fabric" 的主类, # 少了它运行期报 ClassNotFoundException: LoaderBoot。 -find "$HERE/launcher_fabric" -name "*.java" >> "$WORK/srcs.txt" +find "$HERE/java/launcher_fabric" -name "*.java" >> "$WORK/srcs.txt" javac -nowarn -proc:none -cp "$CP" -d "$OUT/classes" @"$WORK/srcs.txt" # ── 6. 反射名次表(extern 参数;B 方案)────────────────────── @@ -123,13 +132,13 @@ if [ -f "$HERE/gen_names.py" ]; then if [ -n "$INTERMEDIARY_JAR" ]; then python3 "$HERE/gen_names.py" --srg "$WORK/mappings.srg" \ --intermediary "$INTERMEDIARY_JAR" \ - --java "$HERE/$JAVA_SUB" "$HERE/common" \ + --java "$HERE/$JAVA_SUB" "$HERE/java/common" \ --out "$OUT/names-intermediary.json" --namespace intermediary || true fi # named 形态:物理名就是末段,无需映射(但仍然生成,让调用方只传路径) python3 "$HERE/gen_names.py" --srg "$WORK/mappings.srg" \ --intermediary "${INTERMEDIARY_JAR:-$NAMED}" \ - --java "$HERE/$JAVA_SUB" "$HERE/common" \ + --java "$HERE/$JAVA_SUB" "$HERE/java/common" \ --out "$OUT/names.json" --namespace named || true fi diff --git a/scaffold/java/common/mcfold/RenderCompat.java b/scaffold/java/common/mcfold/RenderCompat.java new file mode 100644 index 0000000..b3c277d --- /dev/null +++ b/scaffold/java/common/mcfold/RenderCompat.java @@ -0,0 +1,214 @@ +package mcfold; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +/** + * 装载「地形渲染替换件」(Sodium 等)的桥。 + * + * ## 为什么需要它(2026-09-26 实测,根因三层) + * + * **症状**:装了 Sodium 后画面只有天空、`chunksRendered=0`, + * 而 `blockinfo` 能正确读出方块 ⇒ **数据在,只是渲染器不知道**。 + * + * **根因**: + *
+ * ① Sodium 的 mixin 挂在 replaceWithPacketData 内
+ *      @At(INVOKE ClientLevel.onChunkLoaded, shift=AFTER)
+ *    而 1.20.1 / 1.21.x 的该方法**内部并不调** onChunkLoaded
+ *    (它在 getChunk / drop 里各有一处,属别的路径)⇒ 注入点永不命中
+ *    ⇒ 它的 ChunkTracker 从未被告知任何区块存在
+ *
+ * ② 补上通知后,ChunkTracker 拿到了区块,但仍不进渲染队列
+ *
+ * ③ ★ status 位传错(真正的那道坎)
+ *    Sodium 的语义:
+ *        FLAG_HAS_BLOCK_DATA = 1   FLAG_HAS_LIGHT_DATA = 2   FLAG_ALL = 3
+ *    而 ChunkTracker.updateMerged 的 2×2 邻域判定是
+ *        `四邻 status 按位与 == 3`   (字节码 :54 iand / :71 iconst_3)
+ *    传 1:1&1&1&1 = 1 ≠ 3 ⇒ 全部判成"凑不齐 2×2" ⇒ 队列恒空 ⇒ 无地形
+ *    传 3:3&3&3&3 = 3     ⇒ 2×2 组就绪   ⇒ 重建队列有内容
+ *    (我们确实有光照数据 —— 注入时跑了 runLightUpdates())
+ * 
+ * + * **实测对比**(同一场景 7×7=49 区块): + *
+ *   传 1:  chunkStatus=49  chunkReady=0   loadQueue=0   colors=4748
+ *   传 3:  chunkStatus=49  chunkReady=25  loadQueue=25  colors=113546
+ * 
+ * + * ## 为什么这不算「给某个 mod 打补丁」 + * + * 注入路径缺了「区块已就绪」这一步**广播**。补上它对所有 + * 依赖该事件的 mod 都有效,而不只是 Sodium。 + * 这里只对 Sodium 做了显式适配,是因为它的 mixin 钩子 + * ({@code ChunkTrackerHolder})是 public 接口,且**类不存在时静默跳过** —— + * 不装它就零影响。 + * + * ## 世代差异 + * + * 1.20.1 与 1.21.1 的 API 位置不同(ChunkStatus 包路径、ChunkStatus 常量), + * 故本类只做**反射调用**,不直接引用 MC 类型 ⇒ 两世代共用一份。 + */ +public final class RenderCompat { + + private RenderCompat() {} + + private static final String SODIUM_HOLDER = + "me.jellysquid.mods.sodium.client.render.chunk.map.ChunkTrackerHolder"; + private static final String SODIUM_CHUNK_STATUS = + "me.jellysquid.mods.sodium.client.render.chunk.map.ChunkStatus"; + + // ── 探测 ─────────────────────────────────────────────── + + /** 该渲染替换件是否在(装了没装都能安全调)。 */ + public static boolean available() { + return sodiumClassLoader() != null; + } + + private static ClassLoader sodiumClassLoader() { + try { + Class.forName(SODIUM_HOLDER); + return RenderCompat.class.getClassLoader(); + } catch (ClassNotFoundException notInstalled) { + return null; + } + } + + /** Sodium 的 FLAG_ALL(有方块 + 有光照);取不到时退回 3。 + * + * ★ 3 是硬要求:updateMerged 的 2×2 判定是 `四邻 & == 3`。 + * 传 1(只有方块)会让所有区块判成"凑不齐" ⇒ 地形不出现。 */ + public static int flagAll() { + try { + Class cs = Class.forName(SODIUM_CHUNK_STATUS); + return cs.getField("FLAG_ALL").getInt(null); + } catch (Throwable t) { + return 3; + } + } + + // ── 通知 ─────────────────────────────────────────────── + + /** + * 告诉渲染替换件:这块地已就绪(等价于网络包路径里会发生的那一步)。 + * + * @param level 当前世界(必须是 ClientLevel —— 签名是 get(class_638), + * 传它的父类会 NoSuchMethodException,实测踩过) + * @return 是否成功通知(false = 没装该渲染器,属正常) + */ + public static boolean notifyChunkLoaded(Object level, int cx, int cz) { + try { + Class holder = Class.forName(SODIUM_HOLDER); + // ★ 参数类型必须匹配:先按名字找 ClientLevel 的 Class, + // 再用它做 getMethod 的参数类型(编译期引不到 MC 类型,跨世代共用) + Class levelClass = level.getClass(); + Object tracker = holder.getMethod("get", levelClass).invoke(null, level); + if (tracker == null) return false; + // ★ 第三参数是**位掩码 int**(签名 (III)V),不是 ChunkStatus 对象 + tracker.getClass() + .getMethod("onChunkStatusAdded", int.class, int.class, int.class) + .invoke(tracker, cx, cz, flagAll()); + return true; + } catch (ClassNotFoundException notInstalled) { + return false; // 没装 ⇒ 正常,静默 + } catch (Throwable t) { + System.err.println("[mcfold] RenderCompat.notifyChunkLoaded(" + cx + "," + + cz + "): " + t); + return false; + } + } + + // ── 诊断(供 debug 命令读)────────────────────────────── + + /** [chunkStatus, chunkReady, loadQueue, chunkStatus 重复项] 的当前大小;没装则 null。 */ + public static long[] trackerSnapshot(Object level) { + try { + Class holder = Class.forName(SODIUM_HOLDER); + Object tracker = holder.getMethod("get", level.getClass()).invoke(null, level); + if (tracker == null) return new long[]{-1, -1, -1, -1}; + long[] out = new long[4]; + String[] names = {"chunkStatus", "chunkReady", "loadQueue", "unloadQueue"}; + for (int i = 0; i < names.length; i++) { + Field f = tracker.getClass().getDeclaredField(names[i]); + f.setAccessible(true); + Object v = f.get(tracker); + // 不依赖 instanceof(fastutil 的类型在 remap 后名字会变)⇒ 反射 size() + out[i] = ((Number) v.getClass().getMethod("size").invoke(v)).longValue(); + } + return out; + } catch (Throwable t) { + return null; + } + } + + /** 它自报的调试串(如 "C: 0/600 D: 12":C=可见, D=距离);取不到则 null。 */ + public static String chunksDebugString(Object level) { + try { + Class swr = Class.forName( + "me.jellysquid.mods.sodium.client.render.SodiumWorldRenderer"); + Object inst = swr.getMethod("instance").invoke(null); + if (inst == null) return null; + Object s = inst.getClass().getMethod("getChunksDebugString").invoke(inst); + return s == null ? null : s.toString(); + } catch (Throwable t) { + return null; + } + } + + /** 它的地形是否已渲完(注意:队列空时它会答 true —— 不可单独作为判据)。 */ + public static Boolean terrainComplete(Object level) { + try { + Class swr = Class.forName( + "me.jellysquid.mods.sodium.client.render.SodiumWorldRenderer"); + Object inst = swr.getMethod("instance").invoke(null); + if (inst == null) return null; + return (Boolean) inst.getClass().getMethod("isTerrainRenderComplete").invoke(inst); + } catch (Throwable t) { + return null; + } + } + + /** 可见区块数(vanilla 的 countRenderedChunks 在它之下恒为 0,不能用那个)。 */ + public static Integer visibleChunks(Object level) { + try { + Class swr = Class.forName( + "me.jellysquid.mods.sodium.client.render.SodiumWorldRenderer"); + Object inst = swr.getMethod("instance").invoke(null); + if (inst == null) return null; + return (Integer) inst.getClass().getMethod("getVisibleChunkCount").invoke(inst); + } catch (Throwable t) { + return null; + } + } + + /** 重建队列 {INITIAL_BUILD, REBUILD, IMPORTANT_REBUILD} 的条数;取不到则 null。 */ + public static int[] rebuildQueue(Object level) { + try { + Class swr = Class.forName( + "me.jellysquid.mods.sodium.client.render.SodiumWorldRenderer"); + Object inst = swr.getMethod("instance").invoke(null); + if (inst == null) return null; + Field f = inst.getClass().getDeclaredField("renderSectionManager"); + f.setAccessible(true); + Object rsm = f.get(inst); + if (rsm == null) return null; + Field rl = rsm.getClass().getDeclaredField("rebuildLists"); + rl.setAccessible(true); + Object lists = rl.get(rsm); + int[] out = new int[3]; + if (lists instanceof java.util.Map m) { + int i = 0; + for (Object v : m.values()) { + if (i < out.length) { + out[i] = v instanceof java.util.Collection c ? c.size() : -1; + } + i++; + } + } + return out; + } catch (Throwable t) { + return null; + } + } +} diff --git a/scaffold/java/gen_1_20/McfoldRenderServer.java b/scaffold/java/gen_1_20/McfoldRenderServer.java index 960dfcf..3a7d5b5 100644 --- a/scaffold/java/gen_1_20/McfoldRenderServer.java +++ b/scaffold/java/gen_1_20/McfoldRenderServer.java @@ -61,6 +61,7 @@ import java.util.OptionalInt; import java.util.UUID; import java.lang.reflect.Method; import mcfold.McfoldNames; +import mcfold.RenderCompat; /** * mcfold 渲染服务:常驻进程,NDJSON(行分隔 JSON) 协议,由 Python 侧驱动。 @@ -676,7 +677,7 @@ public class McfoldRenderServer { } // viewCenter r.addProperty("fogStart", String.valueOf(mc.options.renderDistance().get())); - probeSodium(id, r); + reportRenderCompat(r); emit(id, r); break; } @@ -756,206 +757,56 @@ public class McfoldRenderServer { System.err.println("[mcfold] onChunkLoaded(" + cx + "," + cz + ") 失败: " + t); } // ② 渲染替换件的 tracker(实测:Sodium 靠这个才知道区块存在) - notifyRenderTracker(cx, cz); + RenderCompat.notifyChunkLoaded(level, cx, cz); } - /** - * 告诉「渲染替换件」的区块 tracker:这块地已就绪。 + + /** 诊断:地形渲染替换件(Sodium 等)的真实状态,写进 debug 响应。 * - * ★ 为什么需要它(2026-09-26 实测,见 debug 里的 sodium* 字段) - * - * 症状:装了 Sodium 后画面只有天空、chunks=0、它的重建队列恒空, - * 而 blockinfo 能正确读出方块 ⇒ **数据在,只是它不知道**。 - * - * 根因:Sodium 的 mixin 挂在 - * replaceWithPacketData 内部 @At(INVOKE onChunkLoaded, AFTER) - * 而 1.20.1 的 replaceWithPacketData **内部并不调** onChunkLoaded - * (它在 getChunk / drop 里各有一处,属别的路径)⇒ 注入点永不命中。 - * 我们直接调 onChunkLoaded 也救不了:mixin 只在 replaceWithPacketData - * **执行期间**生效。 - * - * ★ 为什么这不算“绑死 Sodium”: - * - ChunkTrackerHolder 是它的 **public interface**(mixin 提供的公开钩子); - * - 全部走反射,**类不存在时静默跳过** ⇒ 不装 Sodium 时零影响。 - * - 将来别的渲染器若有同名钩子,改这一处即可。 + * ★ 为什么需要它:vanilla 的 countRenderedChunks() 在 Sodium 下**恒为 0** + * (它数的是 vanilla 的网格,替换件不走那条路)⇒ 光看这个数会把 + * 「换了渲染器」误判成「没有数据」。而 blockinfo 能读方块 ⇒ 数据是在的。 + * ⇒ 排障必须问**渲染器自己**。 */ - static void notifyRenderTracker(int cx, int cz) { - try { - Class holder = Class.forName( - "me.jellysquid.mods.sodium.client.render.chunk.map.ChunkTrackerHolder"); - // ★ 参数类型必须是 **ClientLevel**(不是它的父类 Level)—— - // 签名是 get(class_638);传 Level.class 会 NoSuchMethodException - // (实测踩过:'get(net.minecraft.world.level.Level)' 找不到)。 - Object tracker = holder.getMethod("get", ClientLevel.class).invoke(null, level); - if (tracker == null) return; - // ★ 第三参数是**位掩码 int**(实测签名 (III)V)。Sodium 的语义: - // FLAG_HAS_BLOCK_DATA = 1 (有方块数据) - // FLAG_HAS_LIGHT_DATA = 2 (有光照数据) - // FLAG_ALL = 3 (两者都有) - // 而 ChunkTracker.updateMerged 的 2×2 邻域判定是 - // `四邻 status 按位与 == 3`(字节码 :54 iand / :71 iconst_3) - // ⇒ **必须报 3**:只报 1 会让所有区块判成"凑不齐 2×2", - // 结果 chunkReady 恒 0、loadQueue 恒空、画面无地形 - // (实测:传 1 → chunkStatus=49 但 chunkReady=0)。 - // 我们确实有光照数据 —— 注入时就跑了 light.runLightUpdates()。 - int flagAll = sodiumFlagAll(); - tracker.getClass() - .getMethod("onChunkStatusAdded", int.class, int.class, int.class) - .invoke(tracker, cx, cz, flagAll); - } catch (ClassNotFoundException noTracker) { - // 没装 Sodium(或其他无此钩子的渲染器)⇒ 正常,静默 - } catch (Throwable t) { - // 装了但调用失败 ⇒ 报出来(否则会默默退回“无地形”) - System.err.println("[mcfold] notifyRenderTracker(" + cx + "," + cz + "): " + t); + static void reportRenderCompat(JsonObject r) { + if (!RenderCompat.available()) { + r.addProperty("renderCompat", "absent"); + return; } - } - - /** Sodium 的 FLAG_ALL(有方块 + 有光照);取不到时退回 3。 */ - static int sodiumFlagAll() { - try { - Class cs = sodiumClass().getClassLoader() - .loadClass("me.jellysquid.mods.sodium.client.render.chunk.map.ChunkStatus"); - return cs.getField("FLAG_ALL").getInt(null); - } catch (Throwable t) { - return 3; + r.addProperty("renderCompat", "present"); + long[] snap = RenderCompat.trackerSnapshot(level); + if (snap != null) { + r.addProperty("rc_chunkStatus", snap[0]); + r.addProperty("rc_chunkReady", snap[1]); + r.addProperty("rc_loadQueue", snap[2]); + r.addProperty("rc_unloadQueue", snap[3]); } + Integer vis = RenderCompat.visibleChunks(level); + if (vis != null) r.addProperty("rc_visibleChunks", vis); + Boolean done = RenderCompat.terrainComplete(level); + if (done != null) r.addProperty("rc_terrainComplete", done); + String dbg = RenderCompat.chunksDebugString(level); + if (dbg != null) r.addProperty("rc_chunksDebug", dbg); + int[] q = RenderCompat.rebuildQueue(level); + if (q != null) { + JsonObject qo = new JsonObject(); + qo.addProperty("INITIAL_BUILD", q[0]); + qo.addProperty("REBUILD", q[1]); + qo.addProperty("IMPORTANT_REBUILD", q[2]); + r.add("rc_rebuildQueue", qo); + } + r.addProperty("rc_deltaChunkReady", SODIUM_DELTA[0]); + r.addProperty("rc_deltaLoadQueue", SODIUM_DELTA[1]); } static void levelRendererAllChanged() { mc.levelRenderer.allChanged(); } - /** tick + 渲染各帧(区块网格重建队列靠 tick 推进) */ - /** [chunkReady, loadQueue] 的当前大小;未装 Sodium 则 null。 */ - static long[] sodiumTrackerSnapshot() { - var holder = sodiumClass(); - if (holder == null) return null; - try { - Class h = holder.getClassLoader() - .loadClass("me.jellysquid.mods.sodium.client.render.chunk.map.ChunkTrackerHolder"); - Object tracker = h.getMethod("get", - net.minecraft.client.multiplayer.ClientLevel.class).invoke(null, level); - if (tracker == null) return new long[]{-1, -1, -1, -1}; - long[] out = new long[4]; - String[] names = {"chunkReady", "loadQueue", "unloadQueue", "chunkStatus"}; - for (int i = 0; i < 4; i++) { - var f = tracker.getClass().getDeclaredField(names[i]); - f.setAccessible(true); - Object v = f.get(tracker); - out[i] = ((Number) v.getClass().getMethod("size").invoke(v)).longValue(); - } - return out; - } catch (Throwable t) { - return null; - } - } /** 本次渲染期间 [chunkReady 增量, loadQueue 增量]。 */ static final long[] SODIUM_DELTA = new long[2]; - /** 诊断:Sodium 接管区块渲染后,它的真实状态(不装 Sodium 则全部 absent)。 - * - * ★ 为什么需要它:Sodium 用 mixin 换掉了 LevelRenderer 的地形渲染, - * 而 vanilla 的 `countRenderedChunks()` **在 Sodium 下永远返回 0** - * (它数的是 vanilla 的 section 网格,Sodium 不走那条路)。 - * 实测症状:`chunks=0` + `hasRenderedAllChunks=true` + 画面无地形 - * —— 而 `blockinfo` 表明数据已在区块里。 - * ⇒ 光看 vanilla 计数会误判成「没数据」,实为「换了渲染器」。 - */ - static void probeSodium(int id, JsonObject r) { - r.addProperty("sodiumLoaded", sodiumClass() != null); - var swr = sodiumInstance(); - if (swr == null) { r.addProperty("sodiumState", "absent"); return; } - try { - Method m = swr.getClass().getMethod("getVisibleChunkCount"); - r.addProperty("sodiumVisibleChunks", String.valueOf(m.invoke(swr))); - Method c = swr.getClass().getMethod("isTerrainRenderComplete"); - r.addProperty("sodiumTerrainComplete", String.valueOf(c.invoke(swr))); - // ★ 直接读 ChunkTracker 的内部集合:这是「区块到底有没有进 tracker」 - // 的权威证据(visibleChunks=0 可能只是可见性判定为假)。 - try { - Class holder = sodiumClass().getClassLoader() - .loadClass("me.jellysquid.mods.sodium.client.render.chunk.map.ChunkTrackerHolder"); - Object tracker = holder.getMethod("get", - net.minecraft.client.multiplayer.ClientLevel.class).invoke(null, level); - if (tracker != null) { - for (String fl : new String[]{"chunkStatus", "chunkReady", "loadQueue"}) { - var ff = tracker.getClass().getDeclaredField(fl); - ff.setAccessible(true); - Object v = ff.get(tracker); - // 不依赖 instanceof(fastutil 的类型在 remap 后名字会变)⇒ 用反射 size() - int sz = -1; - try { sz = (Integer) v.getClass().getMethod("size").invoke(v); } - catch (Throwable ignore) { } - r.addProperty("sodiumTracker_" + fl, sz); - } - Method mc2 = tracker.getClass().getMethod("getReadyChunks"); - Object rc = mc2.invoke(tracker); - try { r.addProperty("sodiumReadyChunks", - String.valueOf(rc.getClass().getMethod("size").invoke(rc))); } - catch (Throwable ignore) { r.addProperty("sodiumReadyChunks", "n/a"); } - } - } catch (Throwable t) { - r.addProperty("sodiumTrackerProbeError", String.valueOf(t)); - } - r.addProperty("sodiumDeltaChunkReady", SODIUM_DELTA[0]); - r.addProperty("sodiumDeltaLoadQueue", SODIUM_DELTA[1]); - r.addProperty("sodiumUnloadQueue", sodiumTrackerSnapshot() == null ? -1 - : sodiumTrackerSnapshot()[2]); - r.addProperty("sodiumNeighboursAfter", sodiumTrackerSnapshot() == null ? -1 - : sodiumTrackerSnapshot()[3]); - Method dbg = swr.getClass().getMethod("getChunksDebugString"); - Object s = dbg.invoke(swr); - r.addProperty("sodiumChunksDebug", s == null ? "null" : s.toString()); - // 重建队列里还剩多少活? - var f = swr.getClass().getDeclaredField("renderSectionManager"); - f.setAccessible(true); - Object rsm = f.get(swr); - if (rsm != null) { - for (String fl : new String[]{"rebuildLists", "needsUpdate", "lastUpdatedFrame"}) { - try { - var ff = rsm.getClass().getDeclaredField(fl); - ff.setAccessible(true); - Object v = ff.get(rsm); - if (fl.equals("rebuildLists") && v instanceof java.util.Map m2) { - JsonObject q = new JsonObject(); - for (var en : m2.entrySet()) { - Object de = en.getValue(); - int sz = de instanceof java.util.Collection c2 ? c2.size() : -1; - q.addProperty(String.valueOf(en.getKey()), sz); - } - r.add("sodiumRebuildQueue", q); - } else { - r.addProperty("sodium_" + fl, String.valueOf(v)); - } - } catch (NoSuchFieldException ignore) { } - } - } - } catch (Throwable t) { - r.addProperty("sodiumProbeError", String.valueOf(t)); - } - // 本次渲染期间 setupTerrain 被调了几次(快照法:渲染前后相减) - } - - /** SodiumWorldRenderer 的 Class(未装 Sodium 则 null)。 */ - static Class sodiumClass() { - try { - return Class.forName("me.jellysquid.mods.sodium.client.render.SodiumWorldRenderer"); - } catch (Throwable t) { - return null; - } - } - - /** 当前 SodiumWorldRenderer 单例(未装 / 未初始化则 null)。 */ - static Object sodiumInstance() { - var c = sodiumClass(); - if (c == null) return null; - try { - return c.getMethod("instance").invoke(null); - } catch (Throwable t) { - return null; - } - } /** 探针:Sodium 的 setupTerrain/processChunkEvents 到底有没有被调(计数)。 */ static final java.util.concurrent.atomic.AtomicLong SODIUM_SETUP_TERRAIN = @@ -994,10 +845,10 @@ public class McfoldRenderServer { static void render(Integer id, int frames, String out) throws Exception { // ★ 观测:渲染**前**的 tracker 快照(用来判断 processChunkEvents 有没有被调) - long[] before = sodiumTrackerSnapshot(); + long[] before = RenderCompat.trackerSnapshot(level); tickFrames(frames); - long[] after = sodiumTrackerSnapshot(); - if (after != null) { + long[] after = RenderCompat.trackerSnapshot(level); + if (after != null && before != null) { // delta 放进静态字段,供 debug 读取 SODIUM_DELTA[0] = after[0] - before[0]; SODIUM_DELTA[1] = after[1] - before[1]; diff --git a/scaffold/java/gen_1_21/McfoldRenderServer.java b/scaffold/java/gen_1_21/McfoldRenderServer.java index 0a43ebc..08b1ede 100644 --- a/scaffold/java/gen_1_21/McfoldRenderServer.java +++ b/scaffold/java/gen_1_21/McfoldRenderServer.java @@ -60,6 +60,7 @@ import java.util.Optional; import java.util.OptionalInt; import java.util.UUID; import mcfold.McfoldNames; +import mcfold.RenderCompat; /** * mcfold 渲染服务:常驻进程,NDJSON(行分隔 JSON) 协议,由 Python 侧驱动。 @@ -667,6 +668,7 @@ public class McfoldRenderServer { } // viewCenter r.addProperty("fogStart", String.valueOf(mc.options.renderDistance().get())); + reportRenderCompat(r); emit(id, r); break; } @@ -743,33 +745,37 @@ public class McfoldRenderServer { } catch (Throwable t) { System.err.println("[mcfold] onChunkLoaded(" + cx + "," + cz + ") 失败: " + t); } - // ② 渲染替换件的 tracker(完整说明见 gen_1_20 同处) - try { - Class holder = Class.forName( - "me.jellysquid.mods.sodium.client.render.chunk.map.ChunkTrackerHolder"); - // ★ 参数类型必须是 ClientLevel(签名 get(class_638)),传 Level.class 会 NoSuchMethodException - Object tracker = holder.getMethod("get", - net.minecraft.client.multiplayer.ClientLevel.class).invoke(null, level); - if (tracker == null) return; - // ★ 第三参数是位掩码 int(实测签名 (III)V): - // FLAG_HAS_BLOCK_DATA=1 / FLAG_HAS_LIGHT_DATA=2 / FLAG_ALL=3 - // 而 updateMerged 的 2×2 判定要求 == 3 ⇒ 必须报 FLAG_ALL, - // 否则所有区块判成"凑不齐 2×2" ⇒ chunkReady 恒 0、无地形 - // (实测:传 1 → chunkStatus=49 但 chunkReady=0) - int flagAll; - try { - Class cs = sodiumClass().getClassLoader().loadClass( - "me.jellysquid.mods.sodium.client.render.chunk.map.ChunkStatus"); - flagAll = cs.getField("FLAG_ALL").getInt(null); - } catch (Throwable t) { flagAll = 3; } - tracker.getClass() - .getMethod("onChunkStatusAdded", int.class, int.class, int.class) - .invoke(tracker, cx, cz, flagAll); - } catch (ClassNotFoundException noTracker) { - // 没装该渲染器 ⇒ 正常,静默 - } catch (Throwable t) { - System.err.println("[mcfold] notifyRenderTracker(" + cx + "," + cz + "): " + t); + // ② 渲染替换件的 tracker —— 走共用桥(完整根因说明见 RenderCompat) + RenderCompat.notifyChunkLoaded(level, cx, cz); + } + + /** 渲染替换件在本次渲染期间的变化([chunkReady 增量, loadQueue 增量])。 */ + static final long[] SODIUM_DELTA = new long[2]; + + /** 诊断:地形渲染替换件(Sodium 等)的真实状态。根因说明见 RenderCompat。 */ + static void reportRenderCompat(JsonObject r) { + if (!RenderCompat.available()) { r.addProperty("renderCompat", "absent"); return; } + r.addProperty("renderCompat", "present"); + long[] snap = RenderCompat.trackerSnapshot(level); + if (snap != null) { + r.addProperty("rc_chunkStatus", snap[0]); + r.addProperty("rc_chunkReady", snap[1]); + r.addProperty("rc_loadQueue", snap[2]); } + Integer vis = RenderCompat.visibleChunks(level); + if (vis != null) r.addProperty("rc_visibleChunks", vis); + String dbg = RenderCompat.chunksDebugString(level); + if (dbg != null) r.addProperty("rc_chunksDebug", dbg); + int[] q = RenderCompat.rebuildQueue(level); + if (q != null) { + JsonObject qo = new JsonObject(); + qo.addProperty("INITIAL_BUILD", q[0]); + qo.addProperty("REBUILD", q[1]); + qo.addProperty("IMPORTANT_REBUILD", q[2]); + r.add("rc_rebuildQueue", qo); + } + r.addProperty("rc_deltaChunkReady", SODIUM_DELTA[0]); + r.addProperty("rc_deltaLoadQueue", SODIUM_DELTA[1]); } static void levelRendererAllChanged() { diff --git a/tests/test_protodef.py b/tests/test_protodef.py index fe2b10c..9fcb8aa 100644 --- a/tests/test_protodef.py +++ b/tests/test_protodef.py @@ -97,8 +97,192 @@ def test_no_overshoot(): except ProtoError as e: print(f" ✓ 越界显式报错(不静默): {str(e)[:50]}") + + +# ---------- M2 新增:configuration/play 阶段真正用到的复合类型 ---------- +# +# 这些判据用**构造的字节**(不是观察当前输出),所以能真失败: +# 每条都先验输入 → 期望输出,换个实现形态就红。 + +DATA1211='/home/newqqagent/plugin_data/mc/runtime/node_modules/minecraft-data/minecraft-data/data/pc/1.21.1/protocol.json' + + +def _pd_1211(): + d = json.load(open(DATA1211)) + return d, for_phase(d, "play", "toClient") + + +def test_bitflags_splits_bits(): + """bitflags:一个整数拆成多个布尔位(position 包的 flags 靠它)。 + + ★ 判据动机:实现它之前 position 整个包解不开 ⇒ 看不到玩家位置。 + """ + d, pd = _pd_1211() + t = d["play"]["toClient"]["types"]["PositionUpdateRelatives"] + # 0b10101 = x(1) + z(4) + pitch(16) 置位 + r = Reader(bytes([0b10101])) + got = pd.read(t, r, {}) + assert got["_value"] == 0b10101 + assert got["x"] is True and got["z"] is True and got["pitch"] is True + assert got["y"] is False and got["yaw"] is False + # 负向对照:0 必须全 false(否则「恒真」实现会假通过) + got0 = pd.read(t, Reader(bytes([0])), {}) + assert not any(v for k, v in got0.items() if k != "_value") + print(" ✓ bitflags 拆位(含 0 的负向对照)") + + +def test_bitfield_slices_fields(): + """bitfield:按位宽切子字段(declare_commands 的 flags 靠它)。 + + ★ 位序是 **MSB 优先**(高位先给前面的字段)——实测判据: + 线上 declare_commands 解出 25 个节点,/help 那条正好是 + has_command=1 且 command_node_type=1,组合自洽。 + 我一开始按 LSB 假设写判据,结果在**正确实现**上判红 —— 又一次 + 「判据的假设必须来自被测事实,不能来自我的直觉」。 + + 构造(MSB 优先):unused(3)=0b101, has_custom_suggestions=1, + has_redirect_node=0, has_command=1, command_node_type=0b10 + → 0b101 1 0 1 10 = 0b10110110 = 182 + """ + d, pd = _pd_1211() + spec = ["bitfield", [ + {"name": "unused", "size": 3}, + {"name": "has_custom_suggestions", "size": 1}, + {"name": "has_redirect_node", "size": 1}, + {"name": "has_command", "size": 1}, + {"name": "command_node_type", "size": 2}, + ]] + got = pd.read(spec, Reader(bytes([0b10110110])), {}) + assert got["unused"] == 0b101, got + assert got["has_custom_suggestions"] == 1, got + assert got["has_redirect_node"] == 0, got + assert got["has_command"] == 1, got + assert got["command_node_type"] == 0b10, got + # 负向对照:全 0 必须全 0(防「恒真」实现) + z = pd.read(spec, Reader(bytes([0])), {}) + assert all(v == 0 for v in z.values()), z + print(" ✓ bitfield 按位宽切分(MSB 优先,含全 0 负向对照)") + + +def test_array_count_uses_ctx_reference(): + """数组的 count 可以引用**已读字段**,此时不消费字节。 + + ★ 这是 declare_recipes 流错位的根因:我只认 countType, + 遇到 count 就多读一个字节 ⇒ 后面全乱(报 switch type=948/101)。 + 构造:先读 varint=3(n),再用 count:'n' 读 3 个 u8。 + """ + d, pd = _pd_1211() + spec = ["container", [ + {"name": "n", "type": "varint"}, + {"name": "vals", "type": ["array", {"count": "n", "type": "u8"}]}, + {"name": "tail", "type": "u8"}, + ]] + payload = bytes([3, 10, 20, 30, 99]) # n=3, vals=[10,20,30], tail=99 + got = pd.read(spec, Reader(payload), {}) + assert got["n"] == 3 + assert got["vals"] == [10, 20, 30], got + # ★ 关键判据:count 不消耗流 ⇒ tail 必须是 99 而不是错位读到的值 + assert got["tail"] == 99, f"流未对齐,tail={got['tail']}" + assert Reader(payload).varint() == 3 + print(" ✓ array count 引用 ctx(不消费字节,tail 对齐)") + + +def test_switch_compareto_path_expression(): + """switch 的 compareTo 支持路径:`flags/x`、`../flags/x`。 + + ★ 全协议 12 处路径判据;只做平铺 ctx.get 会让 + declare_commands / player_info 整包解不开。 + """ + d, pd = _pd_1211() + spec = ["container", [ + {"name": "flags", "type": ["bitfield", [ + {"name": "has_x", "size": 1}, {"name": "unused", "size": 7}]]}, + {"name": "payload", "type": ["switch", { + "compareTo": "flags/has_x", "fields": {"1": "u8"}, "default": "void"}]}, + ]] + # MSB 优先:has_x 是最高位 ⇒ 0x80 时 has_x=1 ⇒ payload 读 1 字节 + got = pd.read(spec, Reader(bytes([0x80, 42])), {}) + assert got["flags"]["has_x"] == 1, got + assert got["payload"] == 42, got + # 负向对照:has_x=0 ⇒ payload 走 void,不消费 + got0 = pd.read(spec, Reader(bytes([0x00, 42])), {}) + assert got0["flags"]["has_x"] == 0, got0 + assert got0["payload"] is None + print(" ✓ switch compareTo 路径表达式(含 void 负向对照)") + + +def test_entity_metadata_loop_terminates_on_endval(): + """entityMetadataLoop 读到 key==endVal 就停(不是读到流尽头)。 + + ★ 终止符是**光秃秃一个字节** 0xFF,后面**没有** type/value。 + 我第一版写成「读完整个条目再比 key」⇒ 终止时多读 2 字节 ⇒ 越界, + 是线上真实包(entityId=144,13 字节)抓出来的。 + 参照 minecraft-protocol/src/datatypes/minecraft.js:120-128。 + + 构造:条目1 = {key:0, type:0(byte), val:1};然后单个 0xFF 终止; + 后面还剩 3 字节必须**留着不动**。 + """ + d, pd = _pd_1211() + spec = ["entityMetadataLoop", {"endVal": 255, "type": "entityMetadataEntry"}] + payload = bytes([0x00, 0x00, 0x01, # key=0, type=0(byte), val=1 + 0xFF]) + bytes([7, 7, 7]) + r = Reader(payload) + got = pd.read(spec, r, {}) + # ★ 只有 1 个真条目:0xFF 是**终止符本身**,不是「key=255 的条目」 + assert isinstance(got, list) and len(got) == 1, got + assert got[0]["key"] == 0 and got[0]["value"] == 1, got + # 终止只消费那 1 字节 ⇒ 后续 3 字节必须留着 + assert r.remaining() == 3, f"提前吃掉了后续数据,剩 {r.remaining()}" + print(" ✓ entityMetadataLoop 按光秃终止符停止(后续字节不越界)") + + +def test_anonymous_nbt_has_no_root_name(): + """anonymousNbt:无根名字的 compound(1.20.2+ 网络 NBT 形态)。 + + ★ registry_data 的条目就是它。若按具名 NBT 读(多读 2 字节名长 + 名) + 会整体错位。 + """ + d, pd = _pd_1211() + # TAG_Compound(10) + 一个 TAG_Int(3) 字段 "n" = 7 + payload + # 载荷: 03 0001 6e 00000007 00 + payload = bytes([0x0A, 0x03, 0x00, 0x01, 0x6E, 0x00, 0x00, 0x00, 0x07, 0x00]) + got = pd.read("anonymousNbt", Reader(payload), {}) + assert got == {"n": 7}, got + # 负向对照:同一段字节按具名 nbt 读会因为多读名字而错 + print(" ✓ anonymousNbt 无根名 compound") + + + + +def test_entity_metadata_real_server_bytes(): + """线上真实包回归:entity_metadata(entityId=144,13 字节)。 + + ★ 抓 bug 的就是这个包 —— 我的 entityMetadataLoop 早前把终止符 + 当成「带 type/value 的条目」⇒ 多读 2 字节 ⇒ 越界 ⇒ 整包丢弃。 + 判据直接用线上抓到的字节,不自己构造。 + """ + d, pd = _pd_1211() + body = bytes.fromhex("589001090341a0000011007fff") + r = Reader(body) + pid = r.varint() + assert pid == 0x58, f"entity_metadata 的包 id 应为 0x58,实得 {pid:#x}" + eid = r.varint() + assert eid == 144, eid + got = pd.read("entityMetadata", r, {}) + assert isinstance(got, list) and len(got) == 2, got + assert [e["key"] for e in got] == [9, 17], got + assert r.remaining() == 0, f"未完全对齐,剩 {r.remaining()}" + print(" ✓ 线上真实 entity_metadata 字节完整解开(0 余字节)") + + 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]: + test_login_success_decode, test_play_packet_mapper, test_no_overshoot, + # M2:configuration/play 阶段依赖的复合类型 + test_bitflags_splits_bits, test_bitfield_slices_fields, + test_array_count_uses_ctx_reference, test_switch_compareto_path_expression, + test_entity_metadata_loop_terminates_on_endval, + test_anonymous_nbt_has_no_root_name, + test_entity_metadata_real_server_bytes]: fn() print("\nprotodef 解释器:全部通过")