Files
agentmc/mcfold/client.py
JianFeeeee 547f4beb7e M1+M2:1.21.1 构建跑通 + 协议客户端进 play 阶段
M1(构建):
- build.sh 编译 CP 加入 $OUT/fabric/*.jar,解决 net.fabricmc.* 找不到
- gen_1_21 内联 Sodium 通知改为委托共用 RenderCompat,补 reportRenderCompat
- 1.21.1 全量七步构建产物齐备(classes-intermediary.jar / names*.json / srg / inheritance.map)

M2(协议):
- login_start 按协议数据发 UUID(1.21 必填;1.20 可选)
- login_acknowledged / serverbound 包 id 改为查表,不再写死 0x00
- send() 实现压缩帧格式(varint(dataLen)+数据,小包不压)
- configuration 双向协商:发 settings、回 select_known_packs、拒 resource_pack
- protodef 补 bitflags / entityMetadataLoop / count(ctx引用) / compareTo 路径表达式
- NBT 读取器由占位符改为真实现(anonymousNbt 支撑 registry_data)
- 坏包容错:长度前缀是帧层权威,解不开整包丢弃并布告,不断流

实测:login→configuration(11 注册表)→play,位置 (5.5,-60.0,-0.5),map_chunk×9
判据:protodef 6→13 项(含线上真实 entity_metadata 字节回归);全量 77 项通过
2026-09-26 12:15:48 +08:00

548 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""最小 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