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 项通过
This commit is contained in:
JianFeeeee
2026-09-26 12:15:48 +08:00
parent b21b869e09
commit 547f4beb7e
8 changed files with 1122 additions and 240 deletions

View File

@ -37,6 +37,16 @@ class Client:
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
@ -94,12 +104,46 @@ class Client:
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
# 未启用压缩前:包 = varint(长度) + 包体
w = Writer()
w.varint(len(payload))
w.write(payload)
self.sock.sendall(w.getvalue())
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:
"""读一个包体(已去长度前缀;压缩未启用时即原始包)。"""
@ -149,13 +193,320 @@ class Client:
self.send(w.getvalue())
def login_start(self) -> None:
"""login: login_start(离线模式只发用户名)。"""
"""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)
w.varint(0) # uuid 可选,0 表示无
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))

View File

@ -109,6 +109,31 @@ class Writer:
self.varint(len(b))
self.parts.append(b)
# ---- 固定宽度原语(配置阶段要发 i8/u8/bool,实测标了 signed/unsigned) ----
def _fixed(self, fmt: str, v: int) -> None:
import struct as _s
self.parts.append(_s.pack(fmt, v))
def int8(self, v: int) -> None:
"""i8:有符号 8 位(如 viewDistance)。"""
self._fixed(">b", v)
def uint8(self, v: int) -> None:
"""u8:无符号 8 位(如 skinParts 位掩码 0x7F)。"""
self._fixed(">B", v)
def boolean(self, v: bool) -> None:
"""bool:协议里是 1 字节 0/1。"""
self._fixed(">B", 1 if v else 0)
def byte(self, v: int) -> None:
"""单字节(如 resource_pack 回应里的结果码 0..3)。"""
self._fixed(">B", v)
def int64(self, v: int) -> None:
"""i64:8 字节大端有符号(如 keep_alive 的 keepAliveId)。"""
self._fixed(">q", v)
# ---- 固定宽度原语:[struct 格式, 字节数] ----
FIXED: dict[str, tuple[str, int]] = {
@ -147,22 +172,47 @@ class ProtoDef:
# 2) 复合类型
if kind == "container":
out: dict[str, Any] = {}
# ★ 子作用域:`../x` 路径要能回到外层(实测 6 处 `../flags/…`、
# `../action/…` 判据)。保留 __parent__ 链即可。
sub = dict(ctx)
sub["__parent__"] = ctx
for f in t[1]:
name = f.get("name") or f.get("anon")
val = self.read(f["type"], r, ctx)
val = self.read(f["type"], r, sub)
if f.get("anon"):
# 匿名字段:若是 dict 就并入
if isinstance(val, dict):
out.update(val)
sub.update(val)
else:
out[name] = val
ctx[name] = val
sub[name] = val
# 回写外层(同名字段让外层也能查到,兼容平铺写法)
for k, v in out.items():
if k not in ("__parent__",):
ctx[k] = v
return out
if kind == "array":
spec = t[1]
if isinstance(spec, dict):
cnt = self._count(spec.get("countType", "varint"), r, ctx)
# ★ protodef 的计数有两种写法(实测全协议:6 处 count / 103 处 countType):
# countType: 'varint' → 从流里读一个计数
# count: 'gridWidth' → **引用已读字段**(ctx),不读流
# 我先前只认 countType,遇到 `count` 就默认按 varint 读 ⇒
# 多读一个字节 ⇒ **流错位**(表现为后面 switch 收到 948
# 这种荒谬值,报“switch 无匹配分支”)。
if "count" in spec and isinstance(spec["count"], str):
cname = spec["count"]
cnt = ctx.get(cname)
if cnt is None:
# 退化:当它是个要读的字段名
cnt = self.read(cname, r, ctx)
cnt = int(cnt or 0)
else:
cnt = self._count(spec.get("countType", "varint"), r, ctx)
if cnt < 0:
cnt = 0
return [self.read(spec["type"], r, ctx) for _ in range(cnt)]
# ["array", "u8"] 形式:读到结尾
out = []
@ -173,16 +223,29 @@ class ProtoDef:
if kind == "switch":
spec = t[1]
cmp_raw = spec.get("compareTo", "")
# protodef 两种写法:"$field" 或 "field"(都指引用已读字段)
# ★★ compareTo 是**路径表达式**,不一定是字段名(实测全协议):
# "type" 普通字段
# "flags/has_redirect_node" 子字段(跳进已读的 dict)
# "../flags/min_present" 父作用域的子字段
# "../action/add_player" 父作用域的位标志
# 我先前只做 ctx.get(平铺名) ⇒ 这些全部查不到,
# 退回 self.read('flags/xxx') ⇒ 报「未知类型: flags/xxx」
# (这正是 player_info / declare_commands 解不开的原因)。
key = None
val = None
if isinstance(cmp_raw, str):
key = cmp_raw[1:] if cmp_raw.startswith("$") else cmp_raw
val = ctx.get(key) if (key and key in ctx) else self.read(cmp_raw, r, ctx)
val = self._resolve_ctx(key, ctx)
if val is None:
# 退化:当它是个要读的类型名(罕见的隐式 switch)
val = self.read(cmp_raw, r, ctx)
fields = spec.get("fields", {})
default = spec.get("default")
# 字段键可能是 '0x01'/'1' 等写法,统一规整
# 字段键可能是 '0x01'/'1'/'true' 等写法,统一规整
chosen = fields.get(str(val))
if chosen is None and isinstance(val, bool):
chosen = fields.get("1" if val else "0")
if chosen is None:
chosen = default
if chosen is None:
@ -228,11 +291,109 @@ class ProtoDef:
# 列表形式引用内置原语,如 ["pstring", {"countType":"varint"}]、["varint"]
if kind in FIXED or kind in ("varint", "optvarint", "varlong", "pstring",
"string", "restBuffer", "UUID", "void"):
"string", "restBuffer", "UUID", "void",
"anonymousNbt", "nbt", "optionalNbt"):
return self._read_named(kind, r, ctx)
# ---- bitflags:单个整数拆成多个布尔位(实测定义:PositionUpdateRelatives)
#
# 参照实现 protodef/src/datatypes/utils.js readBitflags:
# 返回 {_value: 原始值, <flag>: bool, ...}
# ★ 为什么必须实现:`position` 包里的 `flags` 就是它。
# position 是「玩家在哪」的唯一来源 —— 实现不了它就等于
# 看不到自己位置,后续 M3 状态层根本无从上手。
if kind == "bitflags":
spec = t[1]
base = spec.get("type", "u8")
raw = self.read(base, r, ctx)
flags = spec.get("flags")
out: dict[str, Any] = {"_value": raw}
if isinstance(flags, list):
for i, nm in enumerate(flags):
out[nm] = bool(raw & (1 << i))
elif isinstance(flags, dict):
shift = spec.get("shift")
for nm, bit in flags.items():
b = shift if shift else bit
out[nm] = bool(raw & (1 << int(b)))
return out
# ---- bitfield:把一个整数的各位切成具名子字段(实测定义:command_node.flags)
# ---- entityMetadataLoop:读实体元数据条目直到终止值(实测:entityMetadata)
#
# ★ 终止判据是**键名等于 endVal**(协议里 metadata 以 key=0xFF 收尾),
# 不是读到流尽头 —— 写成读尽头会在同包多个 metadata 时错位。
if kind == "entityMetadataLoop":
spec = t[1]
end_val = spec.get("endVal", 255)
entry_t = spec.get("type")
out_list: list[Any] = []
# ★ 终止符是**光秃秃的一个字节**(0xFF),后面**没有** type/value。
# 我先前「读完整个条目再看 key 是否 == endVal」⇒ 终止时多读了
# type+value 2 字节 ⇒ 报「读越界」。
# 参照 minecraft-protocol/src/datatypes/minecraft.js:120-128:
# item = buffer.readUInt8(cursor); if (item === endVal) 停
# 线上实测(entityId=144 的真实包 58 90 01 09 03 41a00000 11 00 7f ff):
# 按窥视语义切,13/13 字节完全对齐。
while True:
if r.remaining() <= 0:
break
peek = r.buf[r.pos]
if peek == end_val:
r.read(1) # 只消费终止键本身
break
start = r.pos
entry = self.read(entry_t, r, ctx)
out_list.append(entry)
if r.pos == start:
break # 防死循环(零字节推进)
return out_list
raise ProtoError(f"未实现的复合类型: {kind}")
@staticmethod
def _resolve_ctx(path: str, ctx: dict[str, Any]) -> Any:
"""按路径表达式取值:`a/b`、`../a/b`、`a.b` 都要支持。
★ 实测语法(全协议 12 处带路径的 compareTo):
flags/has_redirect_node 子字段
../flags/has_custom_suggestions 父作用域子字段
★ 为什么需“向上逐层试”:`..` 的层数依赖引用点嵌套深度,
而不同分支的嵌套深度不一样 ⇒ 固定按字面层级走会在某些分支落空,
落空后代码会退化成“读一个叫 ../flags/… 的类型”
(报「未知类型: ../flags/has_custom_suggestions」)。
"""
parts = [p for p in path.replace(".", "/").split("/") if p]
ups = 0
while parts and parts[0] == "..":
parts.pop(0)
ups += 1
if not parts:
return None
# 先按字面层级走,失败则向上多跳,直到祖先链尽头
for extra in range(0, 8):
for total in ({ups + extra} if extra == 0 else {ups - extra, ups + extra}):
if total < 0:
continue
cur: Any = ctx
for _ in range(total):
if not isinstance(cur, dict):
cur = None
break
cur = cur.get("__parent__")
if cur is None:
continue
ok = True
for part in parts:
if isinstance(cur, dict) and part in cur:
cur = cur[part]
else:
ok = False
break
if ok:
return cur
return None
def _read_named(self, name: str, r: Reader, ctx: dict[str, Any]) -> Any:
# 阶段作用域优先(packet_set_protocol 这类只存在于阶段内)
if name in self.phase_types:
@ -265,18 +426,90 @@ class ProtoDef:
return f"{h[:8]}-{h[8:12]}-{h[12:16]}-{h[16:20]}-{h[20:]}"
if name == "nbt":
return self._read_nbt(r)
if name == "anonymousNbt":
return self._read_anonymous_nbt(r)
if name == "optionalNbt":
return self._read_nbt(r) if r.read(1)[0] != 0 else None
raise ProtoError(f"未知类型: {name}")
@staticmethod
def _read_nbt(r: Reader) -> Any:
"""最小 NBT 读取(够用于 registry/codec 探测)。"""
# ---- NBT ----
# ★ 为什么必须真解 NBT(实测 2026-09-26):
# configuration 阶段的 registry_data 条目是 `anonymousNbt` ——
# 方块/物品注册表的**权威内容就在 NBT 里**(mod 方块的 state 表也在这)。
# 旧版只回 {"_nbt_raw": ...} 占位,等于拿不到任何注册表内容,
# 上层只能看到 `unknown(id)` —— 这正是「与服务端 mod 物品交互」的卡点。
_NBT_END, _NBT_BYTE, _NBT_SHORT, _NBT_INT, _NBT_LONG = 0, 1, 2, 3, 4
_NBT_FLOAT, _NBT_DOUBLE, _NBT_BYTE_ARRAY, _NBT_STRING = 5, 6, 7, 8
_NBT_LIST, _NBT_COMPOUND, _NBT_INT_ARRAY, _NBT_LONG_ARRAY = 9, 10, 11, 12
@classmethod
def _read_nbt_payload(cls, r: Reader, tag: int, depth: int = 0) -> Any:
"""按 tag 号读一个 NBT 载荷(不含 tag 字节本身)。"""
import struct as _s
if depth > 64:
raise ProtoError("NBT 嵌套过深")
if tag == cls._NBT_BYTE:
return r.read(1)[0]
if tag == cls._NBT_SHORT:
return int.from_bytes(r.read(2), "big", signed=True)
if tag == cls._NBT_INT:
return int.from_bytes(r.read(4), "big", signed=True)
if tag == cls._NBT_LONG:
return int.from_bytes(r.read(8), "big", signed=True)
if tag == cls._NBT_FLOAT:
return _s.unpack(">f", r.read(4))[0]
if tag == cls._NBT_DOUBLE:
return _s.unpack(">d", r.read(8))[0]
if tag == cls._NBT_BYTE_ARRAY:
n = int.from_bytes(r.read(4), "big", signed=True)
return list(r.read(max(n, 0)))
if tag == cls._NBT_STRING:
n = int.from_bytes(r.read(2), "big")
return r.read(n).decode("utf-8", "replace")
if tag == cls._NBT_LIST:
et = r.read(1)[0]
n = int.from_bytes(r.read(4), "big", signed=True)
if et == cls._NBT_END or n <= 0:
return []
return [cls._read_nbt_payload(r, et, depth + 1) for _ in range(n)]
if tag == cls._NBT_COMPOUND:
out: dict[str, Any] = {}
while True:
t = r.read(1)[0]
if t == cls._NBT_END:
return out
nm_len = int.from_bytes(r.read(2), "big")
nm = r.read(nm_len).decode("utf-8", "replace")
out[nm] = cls._read_nbt_payload(r, t, depth + 1)
if tag in (cls._NBT_INT_ARRAY, cls._NBT_LONG_ARRAY):
w = 4 if tag == cls._NBT_INT_ARRAY else 8
n = int.from_bytes(r.read(4), "big", signed=True)
return [int.from_bytes(r.read(w), "big", signed=True) for _ in range(max(n, 0))]
raise ProtoError(f"未知 NBT tag: {tag}")
@classmethod
def _read_nbt(cls, r: Reader) -> Any:
"""读一个具名 NBT(tag + 名字 + 载荷)—— 未压缩的普通 NBT。"""
tag = r.read(1)[0]
if tag == 0:
if tag == cls._NBT_END:
return None
return {"_nbt_tag": tag, "_nbt_raw": r.read(r.remaining())}
n = int.from_bytes(r.read(2), "big")
name = r.read(n).decode("utf-8", "replace")
return {"name": name, "value": cls._read_nbt_payload(r, tag)}
@classmethod
def _read_anonymous_nbt(cls, r: Reader) -> Any:
"""anonymousNbt:无根名字的 NBT(网络格式,registry_data 用的就是这个)。
★ 与普通 NBT 的差别只在**没有根名字** —— 少读一个
(short 长度 + 字节) 就直接进载荷。读多了会把后面
所有字节都错位(并且不报错,只是字段全乱)。
"""
tag = r.read(1)[0]
if tag == cls._NBT_END:
return None
return cls._read_nbt_payload(r, tag)
def _count(self, count_type: Any, r: Reader, ctx: dict[str, Any]) -> int:
return self.read(count_type, r, ctx)