"""最小 MC 客户端(Python 净室实现)—— 跨实现互操作验证。 用它去连 Node 的 minecraft-protocol 服务端: 两边是**完全独立的实现**,能握手成功才证明协议层真的通了。 """ from __future__ import annotations import json import socket import struct from pathlib import Path from .protodef import Reader, Writer, ProtoError, for_phase class ProtocolError(Exception): pass class Client: """一个最小 MC 客户端:握手 → 登录 → 读包。""" def __init__( self, host: str, port: int, username: str, version: str = "1.20.1", protocol_path: str | Path | None = None, ): self.host = host self.port = port self.username = username self.version = version self.sock: socket.socket | None = None 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 # ---------- 协议数据 ---------- def load_protocol(self) -> None: """按版本号找 minecraft-data 的 protocol.json。 实测关键:1.20.1 的数据靠 majorVersion 继承 1.20(1.20.1/ 目录里只有 version.json)。所以要先读 version.json 拿 majorVersion 再取协议。 """ if self.proto is not None and self.proto: return root = self._data_root() vdir = root / "pc" / self.version if not vdir.is_dir(): # 版本目录不存在时,尝试按 majorVersion 找 raise ProtocolError(f"无该版本数据: {self.version}") vj = vdir / "version.json" meta = json.loads(vj.read_text()) if vj.is_file() else {} self.proto_num = meta.get("version", 0) major = meta.get("majorVersion", self.version) pj = root / "pc" / major / "protocol.json" if not pj.is_file(): pj = vdir / "protocol.json" self.proto = json.loads(pj.read_text()) self._protocol_file = pj def _data_root(self) -> Path: if self._path: # 传进来的可能是 data 根,也可能是 data/pc —— 都容忍 for c in (self._path, self._path / "pc", self._path.parent): if (c / "pc").is_dir(): return c if c.name == "pc" and c.is_dir(): return c.parent return self._path cands = [ Path("/home/newqqagent/plugin_data/mc/runtime/node_modules/" "minecraft-data/minecraft-data/data"), Path("/home/newqqagent/plugindev/mcfold/data"), ] for c in cands: if (c / "pc").is_dir(): return c raise ProtocolError("找不到 minecraft-data 数据目录") def _pd(self, phase: str, side: str): return for_phase(self.proto, phase, side) # ---------- 网络 ---------- def connect(self) -> None: self.sock = socket.create_connection((self.host, self.port), timeout=15) 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 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: """读一个包体(已去长度前缀;压缩未启用时即原始包)。""" assert self.sock length = self._read_varint_sock() body = self._recv_exact(length) if self.compression >= 0: r = Reader(body) data_len = r.varint() if data_len == 0: return body[r.pos:] # 真压缩(zlib)时这里需要解压;测试服通常不压缩登录前 import zlib return zlib.decompress(body[r.pos:]) return body def _recv_exact(self, n: int) -> bytes: assert self.sock buf = b"" while len(buf) < n: chunk = self.sock.recv(n - len(buf)) if not chunk: raise ProtocolError(f"连接被关闭(还需 {n - len(buf)} 字节)") buf += chunk return buf def _read_varint_sock(self) -> int: result = 0 shift = 0 while True: b = self._recv_exact(1)[0] result |= (b & 0x7F) << shift if not (b & 0x80): break shift += 7 return result # ---------- 流程 ---------- def handshake(self, state: int = 2) -> None: """handshaking: set_protocol → 宣告协议版本与目标状态。""" w = Writer() w.varint(0x00) w.varint(self.proto_num) w.pstring(self.host) w.write(struct.pack(">H", self.port)) w.varint(state) self.send(w.getvalue()) def login_start(self) -> None: """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) 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)) return pkt def run_smoke(self) -> dict: """握手 → 登录 → 读若干包,返回摘要。""" self.load_protocol() self.connect() self.handshake(state=2) self.login_start() got: list[str] = [] login_ok = False for _ in range(20): body = self.read_packet() try: pkt = self.decode("login", "toClient", body) except ProtoError: break name = pkt.get("name") got.append(name) if name == "compress": self.compression = pkt.get("params", {}).get("threshold", 256) elif name == "success": login_ok = True break elif name == "disconnect": raise ProtocolError(f"服务端拒绝: {pkt.get('params', {}).get('reason')}") return {"login_ok": login_ok, "packets": got, "protocol": self.proto_num} def close(self) -> None: if self.sock: try: self.sock.close() except OSError: pass self.sock = None