From a4914b932e7f40ad83b036c95fbb44a8e6754dbb Mon Sep 17 00:00:00 2001 From: pi Date: Fri, 25 Sep 2026 14:12:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcfold):=20=E6=9C=80=E5=B0=8F=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=20+=20=E8=B7=A8=E5=AE=9E=E7=8E=B0=E4=BA=92?= =?UTF-8?q?=E6=93=8D=E4=BD=9C=E6=B5=8B=E8=AF=95=EF=BC=88Python=20=E5=AE=A2?= =?UTF-8?q?=E6=88=B7=E7=AB=AF=E6=8F=A1=E6=89=8B=20Node=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E7=AB=AF=E6=88=90=E5=8A=9F=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 ++ mcfold/__init__.py | 0 mcfold/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 149 bytes mcfold/__pycache__/client.cpython-312.pyc | Bin 0 -> 9794 bytes mcfold/client.py | 189 ++++++++++++++++++++ tests/test_e2e_interop.py | 45 +++++ 6 files changed, 246 insertions(+) create mode 100644 mcfold/__init__.py create mode 100644 mcfold/__pycache__/__init__.cpython-312.pyc create mode 100644 mcfold/__pycache__/client.cpython-312.pyc create mode 100644 mcfold/client.py create mode 100644 tests/test_e2e_interop.py diff --git a/README.md b/README.md index c42e9d2..ceda917 100644 --- a/README.md +++ b/README.md @@ -29,3 +29,15 @@ ## 进度 - [x] **P1** 按文件夹解析版本(`mcfold/version.py`,6 项回归全绿) +- [x] **P2** 声明式 protodef 解释器(`mcfold/protodef.py`) + 真实握手字节解码通过(proto=763 / login success / play 包路由) +- [x] **P3** 最小客户端 + 跨实现互操作(`mcfold/client.py`) + **Python 客户端 ←→ Node 服务端握手成功**(两边独立实现,最有说服力) + +## 已证实的结论 + +| 结论 | 证据 | +|---|---| +| 协议**不需重写**,只需解释声明式 JSON | 111 个 play 包由同一份 `protocol.json` 驱动 | +| Python 客户端能真连真服 | Node 侧日志 `SERVER_GOT_LOGIN username= PyProbe` | +| 协议号由实例文件夹决定 | `1.20.1/version.json` → 763 | diff --git a/mcfold/__init__.py b/mcfold/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcfold/__pycache__/__init__.cpython-312.pyc b/mcfold/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ce7512fd2c4238ba7227de6350230cd8b2a09c96 GIT binary patch literal 149 zcmX@j%ge<81YH8#vOx4>5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!GSSb-&rQ|OOD!)f zOiWMBE732=DNWDJOGz!$&rMFt&q;w%@$s2?nI-Y@dIgoYIBatBQ%ZAE?TT1|hA;wg QF^KVznURsPh#ANN0C>(Lg#Z8m literal 0 HcmV?d00001 diff --git a/mcfold/__pycache__/client.cpython-312.pyc b/mcfold/__pycache__/client.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e93faf93828c97bb464d2295d963957666b3dbb7 GIT binary patch literal 9794 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 Z8k*L{9=}OJ@;^+Qf?o6^g)!OD{{h?N=hgrK literal 0 HcmV?d00001 diff --git a/mcfold/client.py b/mcfold/client.py new file mode 100644 index 0000000..2fc195e --- /dev/null +++ b/mcfold/client.py @@ -0,0 +1,189 @@ +"""最小 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 + 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: + 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: + assert self.sock + # 未启用压缩前:包 = varint(长度) + 包体 + w = Writer() + w.varint(len(payload)) + w.write(payload) + self.sock.sendall(w.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(离线模式只发用户名)。""" + w = Writer() + w.varint(0x00) + w.pstring(self.username) + w.varint(0) # uuid 可选,0 表示无 + self.send(w.getvalue()) + + 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 diff --git a/tests/test_e2e_interop.py b/tests/test_e2e_interop.py new file mode 100644 index 0000000..bade923 --- /dev/null +++ b/tests/test_e2e_interop.py @@ -0,0 +1,45 @@ +"""端到端互操作:Python 客户端 ←→ Node 服务端(跨实现)。 + +价值:这是**唯一能证明协议层真通**的测试 —— +两边是完全独立的实现,若我的 protodef 有偏差,握手不可能成功。 + +用法: + 1) cd /home/newqqagent/plugin_data/mc/runtime && node testserver.js & + 2) python3 tests/test_e2e_interop.py +""" +import sys, os, socket +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from mcfold.client import Client + +HOST, PORT = "127.0.0.1", 25599 + +def reachable(): + try: + socket.create_connection((HOST, PORT), timeout=2).close(); return True + except OSError: + return False + +def test_interop(): + if not reachable(): + print(f" - 服务端 {HOST}:{PORT} 未运行,跳过(见本文件顶部用法)"); return + c = Client(HOST, PORT, "PyProbe", "1.20.1") + try: + r = c.run_smoke() + assert r["protocol"] == 763, r + assert r["login_ok"], r + assert "success" in r["packets"], r + print(f" ✓ 跨实现互操作: 协议={r['protocol']} 包={r['packets']} 登录成功") + finally: + c.close() + +def test_protocol_version_from_folder(): + """协议版本必须由「实例文件夹」决定,而不是硬编码。""" + c = Client(HOST, PORT, "x", "1.20.1") + c.load_protocol() + assert c.proto_num == 763, c.proto_num + print(f" ✓ 版本来自 version.json: 1.20.1 -> 协议 {c.proto_num}") + +if __name__ == "__main__": + test_protocol_version_from_folder() + test_interop() + print("\n端到端互操作:通过")