feat(mcfold): 最小客户端 + 跨实现互操作测试(Python 客户端握手 Node 服务端成功)

This commit is contained in:
pi
2026-09-25 14:12:06 +08:00
parent fc5a1aae91
commit a4914b932e
6 changed files with 246 additions and 0 deletions

View File

@ -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 |

0
mcfold/__init__.py Normal file
View File

Binary file not shown.

Binary file not shown.

189
mcfold/client.py Normal file
View File

@ -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

45
tests/test_e2e_interop.py Normal file
View File

@ -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端到端互操作:通过")