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 0000000..ce7512f Binary files /dev/null and b/mcfold/__pycache__/__init__.cpython-312.pyc differ diff --git a/mcfold/__pycache__/client.cpython-312.pyc b/mcfold/__pycache__/client.cpython-312.pyc new file mode 100644 index 0000000..e93faf9 Binary files /dev/null and b/mcfold/__pycache__/client.cpython-312.pyc differ 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端到端互操作:通过")