feat(mcfold): 最小客户端 + 跨实现互操作测试(Python 客户端握手 Node 服务端成功)
This commit is contained in:
12
README.md
12
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 |
|
||||
|
||||
0
mcfold/__init__.py
Normal file
0
mcfold/__init__.py
Normal file
BIN
mcfold/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
mcfold/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
mcfold/__pycache__/client.cpython-312.pyc
Normal file
BIN
mcfold/__pycache__/client.cpython-312.pyc
Normal file
Binary file not shown.
189
mcfold/client.py
Normal file
189
mcfold/client.py
Normal 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
45
tests/test_e2e_interop.py
Normal 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端到端互操作:通过")
|
||||
Reference in New Issue
Block a user