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 项通过
289 lines
12 KiB
Python
289 lines
12 KiB
Python
"""验证 protodef 解释器能真跑协议——用真实握手字节。"""
|
||
import sys, os, json, struct
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
from mcfold.protodef import ProtoDef, Reader, Writer, ProtoError, for_phase
|
||
|
||
DATA='/home/newqqagent/plugin_data/mc/runtime/node_modules/minecraft-data/minecraft-data/data/pc/1.20/protocol.json'
|
||
|
||
def load():
|
||
d=json.load(open(DATA))
|
||
return d
|
||
|
||
def pd_for(d, phase, side):
|
||
return for_phase(d, phase, side)
|
||
|
||
def test_varint_roundtrip():
|
||
"""varint 是 MC 最核心的原语;含负数回绕。"""
|
||
load()
|
||
for v in [0,1,127,128,255,300,25565,2097151,-1,-2147483648,2147483647]:
|
||
w=Writer(); w.varint(v)
|
||
got=Reader(w.getvalue()).varint()
|
||
assert got==v, f"varint {v} -> {got}"
|
||
print(" ✓ varint 往返(含负数/边界)")
|
||
|
||
def test_pstring():
|
||
load()
|
||
for s in ["", "abc", "中文名字", "x"*300]:
|
||
w=Writer(); w.pstring(s)
|
||
assert Reader(w.getvalue()).pstring()==s
|
||
print(" ✓ pstring 往返(含 UTF-8/长串)")
|
||
|
||
def test_real_handshake():
|
||
"""构造真实的 handshake+login_start 字节并解出(最硬的检验)。"""
|
||
d=load()
|
||
pd=pd_for(d,'handshaking','toServer')
|
||
# handshaking toServer packet_set_protocol
|
||
w=Writer()
|
||
w.varint(0x00) # packet id (handshaking)
|
||
w.varint(763) # protocol version = 1.20.1
|
||
w.pstring("127.0.0.1") # server address
|
||
w.write(struct.pack(">H", 25565)) # port
|
||
w.varint(2) # next state = login
|
||
raw=w.getvalue()
|
||
r=Reader(raw)
|
||
pkt=pd.read(d['handshaking']['toServer']['types']['packet'], r)
|
||
assert pkt['name']=='set_protocol', pkt
|
||
p = pkt['params'] # switch 的参数落在 params 下
|
||
assert p['protocolVersion']==763, p
|
||
assert p['serverHost']=='127.0.0.1', p
|
||
assert p['serverPort']==25565, p
|
||
assert p['nextState']==2, p
|
||
assert r.remaining()==0, f"有残留字节: {r.remaining()}"
|
||
print(f" ✓ 真实 handshake 解码: proto={p['protocolVersion']} host={p['serverHost']}:{p['serverPort']} nextState={p['nextState']}")
|
||
|
||
def test_login_success_decode():
|
||
"""packet_success: uuid + username + properties(真实形状)。"""
|
||
d=load()
|
||
pd=pd_for(d,'login','toClient')
|
||
w=Writer()
|
||
w.varint(0x02) # login toClient packet_success id
|
||
w.write(bytes(range(16))) # uuid
|
||
w.pstring("BotName")
|
||
w.varint(0) # properties count = 0
|
||
r=Reader(w.getvalue())
|
||
pkt=pd.read(d['login']['toClient']['types']['packet'], r)
|
||
assert pkt['name']=='success', pkt
|
||
p = pkt['params']
|
||
assert p['username']=='BotName', p
|
||
assert p['properties']==[], p
|
||
print(f" ✓ login success 解码: uuid={p['uuid'][:8]}… username={p['username']}")
|
||
|
||
def test_play_packet_mapper():
|
||
"""play 阶段 111 个包由 mapper 分发 —— 验证能正确路由。"""
|
||
d=load()
|
||
pd=pd_for(d,'play','toClient')
|
||
w=Writer(); w.varint(0x00)
|
||
# 0x00 = bundle_delimiter(无字段)
|
||
r=Reader(w.getvalue())
|
||
pkt=pd.read(d['play']['toClient']['types']['packet'], r)
|
||
assert pkt['name']=='bundle_delimiter', pkt
|
||
print(f" ✓ play 包路由: 0x00 -> {pkt['name']}")
|
||
# 0x10 = declare_commands(我们做 mod 命令发现要用的包)
|
||
w2=Writer(); w2.varint(0x10)
|
||
r2=Reader(w2.getvalue())
|
||
try:
|
||
pkt2=pd.read(d['play']['toClient']['types']['packet'], r2)
|
||
print(f" ✓ declare_commands 路由: 0x10 -> {pkt2.get('name')}")
|
||
except ProtoError as e:
|
||
print(f" ~ declare_commands 需要更多字节(预期,因为只喂了 id): {str(e)[:40]}")
|
||
|
||
def test_no_overshoot():
|
||
"""越界必须报错而不是静默读错 —— 这是我实测 mineflayer 静默失真的对照。"""
|
||
d=load()
|
||
pd=pd_for(d,'login','toClient')
|
||
r=Reader(b"\x05") # varint 说 5 字节字符串,实际没有
|
||
try:
|
||
pd.read("string", r); assert False, "应当抛错"
|
||
except ProtoError as e:
|
||
print(f" ✓ 越界显式报错(不静默): {str(e)[:50]}")
|
||
|
||
|
||
|
||
# ---------- M2 新增:configuration/play 阶段真正用到的复合类型 ----------
|
||
#
|
||
# 这些判据用**构造的字节**(不是观察当前输出),所以能真失败:
|
||
# 每条都先验输入 → 期望输出,换个实现形态就红。
|
||
|
||
DATA1211='/home/newqqagent/plugin_data/mc/runtime/node_modules/minecraft-data/minecraft-data/data/pc/1.21.1/protocol.json'
|
||
|
||
|
||
def _pd_1211():
|
||
d = json.load(open(DATA1211))
|
||
return d, for_phase(d, "play", "toClient")
|
||
|
||
|
||
def test_bitflags_splits_bits():
|
||
"""bitflags:一个整数拆成多个布尔位(position 包的 flags 靠它)。
|
||
|
||
★ 判据动机:实现它之前 position 整个包解不开 ⇒ 看不到玩家位置。
|
||
"""
|
||
d, pd = _pd_1211()
|
||
t = d["play"]["toClient"]["types"]["PositionUpdateRelatives"]
|
||
# 0b10101 = x(1) + z(4) + pitch(16) 置位
|
||
r = Reader(bytes([0b10101]))
|
||
got = pd.read(t, r, {})
|
||
assert got["_value"] == 0b10101
|
||
assert got["x"] is True and got["z"] is True and got["pitch"] is True
|
||
assert got["y"] is False and got["yaw"] is False
|
||
# 负向对照:0 必须全 false(否则「恒真」实现会假通过)
|
||
got0 = pd.read(t, Reader(bytes([0])), {})
|
||
assert not any(v for k, v in got0.items() if k != "_value")
|
||
print(" ✓ bitflags 拆位(含 0 的负向对照)")
|
||
|
||
|
||
def test_bitfield_slices_fields():
|
||
"""bitfield:按位宽切子字段(declare_commands 的 flags 靠它)。
|
||
|
||
★ 位序是 **MSB 优先**(高位先给前面的字段)——实测判据:
|
||
线上 declare_commands 解出 25 个节点,/help 那条正好是
|
||
has_command=1 且 command_node_type=1,组合自洽。
|
||
我一开始按 LSB 假设写判据,结果在**正确实现**上判红 —— 又一次
|
||
「判据的假设必须来自被测事实,不能来自我的直觉」。
|
||
|
||
构造(MSB 优先):unused(3)=0b101, has_custom_suggestions=1,
|
||
has_redirect_node=0, has_command=1, command_node_type=0b10
|
||
→ 0b101 1 0 1 10 = 0b10110110 = 182
|
||
"""
|
||
d, pd = _pd_1211()
|
||
spec = ["bitfield", [
|
||
{"name": "unused", "size": 3},
|
||
{"name": "has_custom_suggestions", "size": 1},
|
||
{"name": "has_redirect_node", "size": 1},
|
||
{"name": "has_command", "size": 1},
|
||
{"name": "command_node_type", "size": 2},
|
||
]]
|
||
got = pd.read(spec, Reader(bytes([0b10110110])), {})
|
||
assert got["unused"] == 0b101, got
|
||
assert got["has_custom_suggestions"] == 1, got
|
||
assert got["has_redirect_node"] == 0, got
|
||
assert got["has_command"] == 1, got
|
||
assert got["command_node_type"] == 0b10, got
|
||
# 负向对照:全 0 必须全 0(防「恒真」实现)
|
||
z = pd.read(spec, Reader(bytes([0])), {})
|
||
assert all(v == 0 for v in z.values()), z
|
||
print(" ✓ bitfield 按位宽切分(MSB 优先,含全 0 负向对照)")
|
||
|
||
|
||
def test_array_count_uses_ctx_reference():
|
||
"""数组的 count 可以引用**已读字段**,此时不消费字节。
|
||
|
||
★ 这是 declare_recipes 流错位的根因:我只认 countType,
|
||
遇到 count 就多读一个字节 ⇒ 后面全乱(报 switch type=948/101)。
|
||
构造:先读 varint=3(n),再用 count:'n' 读 3 个 u8。
|
||
"""
|
||
d, pd = _pd_1211()
|
||
spec = ["container", [
|
||
{"name": "n", "type": "varint"},
|
||
{"name": "vals", "type": ["array", {"count": "n", "type": "u8"}]},
|
||
{"name": "tail", "type": "u8"},
|
||
]]
|
||
payload = bytes([3, 10, 20, 30, 99]) # n=3, vals=[10,20,30], tail=99
|
||
got = pd.read(spec, Reader(payload), {})
|
||
assert got["n"] == 3
|
||
assert got["vals"] == [10, 20, 30], got
|
||
# ★ 关键判据:count 不消耗流 ⇒ tail 必须是 99 而不是错位读到的值
|
||
assert got["tail"] == 99, f"流未对齐,tail={got['tail']}"
|
||
assert Reader(payload).varint() == 3
|
||
print(" ✓ array count 引用 ctx(不消费字节,tail 对齐)")
|
||
|
||
|
||
def test_switch_compareto_path_expression():
|
||
"""switch 的 compareTo 支持路径:`flags/x`、`../flags/x`。
|
||
|
||
★ 全协议 12 处路径判据;只做平铺 ctx.get 会让
|
||
declare_commands / player_info 整包解不开。
|
||
"""
|
||
d, pd = _pd_1211()
|
||
spec = ["container", [
|
||
{"name": "flags", "type": ["bitfield", [
|
||
{"name": "has_x", "size": 1}, {"name": "unused", "size": 7}]]},
|
||
{"name": "payload", "type": ["switch", {
|
||
"compareTo": "flags/has_x", "fields": {"1": "u8"}, "default": "void"}]},
|
||
]]
|
||
# MSB 优先:has_x 是最高位 ⇒ 0x80 时 has_x=1 ⇒ payload 读 1 字节
|
||
got = pd.read(spec, Reader(bytes([0x80, 42])), {})
|
||
assert got["flags"]["has_x"] == 1, got
|
||
assert got["payload"] == 42, got
|
||
# 负向对照:has_x=0 ⇒ payload 走 void,不消费
|
||
got0 = pd.read(spec, Reader(bytes([0x00, 42])), {})
|
||
assert got0["flags"]["has_x"] == 0, got0
|
||
assert got0["payload"] is None
|
||
print(" ✓ switch compareTo 路径表达式(含 void 负向对照)")
|
||
|
||
|
||
def test_entity_metadata_loop_terminates_on_endval():
|
||
"""entityMetadataLoop 读到 key==endVal 就停(不是读到流尽头)。
|
||
|
||
★ 终止符是**光秃秃一个字节** 0xFF,后面**没有** type/value。
|
||
我第一版写成「读完整个条目再比 key」⇒ 终止时多读 2 字节 ⇒ 越界,
|
||
是线上真实包(entityId=144,13 字节)抓出来的。
|
||
参照 minecraft-protocol/src/datatypes/minecraft.js:120-128。
|
||
|
||
构造:条目1 = {key:0, type:0(byte), val:1};然后单个 0xFF 终止;
|
||
后面还剩 3 字节必须**留着不动**。
|
||
"""
|
||
d, pd = _pd_1211()
|
||
spec = ["entityMetadataLoop", {"endVal": 255, "type": "entityMetadataEntry"}]
|
||
payload = bytes([0x00, 0x00, 0x01, # key=0, type=0(byte), val=1
|
||
0xFF]) + bytes([7, 7, 7])
|
||
r = Reader(payload)
|
||
got = pd.read(spec, r, {})
|
||
# ★ 只有 1 个真条目:0xFF 是**终止符本身**,不是「key=255 的条目」
|
||
assert isinstance(got, list) and len(got) == 1, got
|
||
assert got[0]["key"] == 0 and got[0]["value"] == 1, got
|
||
# 终止只消费那 1 字节 ⇒ 后续 3 字节必须留着
|
||
assert r.remaining() == 3, f"提前吃掉了后续数据,剩 {r.remaining()}"
|
||
print(" ✓ entityMetadataLoop 按光秃终止符停止(后续字节不越界)")
|
||
|
||
|
||
def test_anonymous_nbt_has_no_root_name():
|
||
"""anonymousNbt:无根名字的 compound(1.20.2+ 网络 NBT 形态)。
|
||
|
||
★ registry_data 的条目就是它。若按具名 NBT 读(多读 2 字节名长 + 名)
|
||
会整体错位。
|
||
"""
|
||
d, pd = _pd_1211()
|
||
# TAG_Compound(10) + 一个 TAG_Int(3) 字段 "n" = 7 + payload
|
||
# 载荷: 03 0001 6e 00000007 00
|
||
payload = bytes([0x0A, 0x03, 0x00, 0x01, 0x6E, 0x00, 0x00, 0x00, 0x07, 0x00])
|
||
got = pd.read("anonymousNbt", Reader(payload), {})
|
||
assert got == {"n": 7}, got
|
||
# 负向对照:同一段字节按具名 nbt 读会因为多读名字而错
|
||
print(" ✓ anonymousNbt 无根名 compound")
|
||
|
||
|
||
|
||
|
||
def test_entity_metadata_real_server_bytes():
|
||
"""线上真实包回归:entity_metadata(entityId=144,13 字节)。
|
||
|
||
★ 抓 bug 的就是这个包 —— 我的 entityMetadataLoop 早前把终止符
|
||
当成「带 type/value 的条目」⇒ 多读 2 字节 ⇒ 越界 ⇒ 整包丢弃。
|
||
判据直接用线上抓到的字节,不自己构造。
|
||
"""
|
||
d, pd = _pd_1211()
|
||
body = bytes.fromhex("589001090341a0000011007fff")
|
||
r = Reader(body)
|
||
pid = r.varint()
|
||
assert pid == 0x58, f"entity_metadata 的包 id 应为 0x58,实得 {pid:#x}"
|
||
eid = r.varint()
|
||
assert eid == 144, eid
|
||
got = pd.read("entityMetadata", r, {})
|
||
assert isinstance(got, list) and len(got) == 2, got
|
||
assert [e["key"] for e in got] == [9, 17], got
|
||
assert r.remaining() == 0, f"未完全对齐,剩 {r.remaining()}"
|
||
print(" ✓ 线上真实 entity_metadata 字节完整解开(0 余字节)")
|
||
|
||
|
||
if __name__=="__main__":
|
||
for fn in [test_varint_roundtrip, test_pstring, test_real_handshake,
|
||
test_login_success_decode, test_play_packet_mapper, test_no_overshoot,
|
||
# M2:configuration/play 阶段依赖的复合类型
|
||
test_bitflags_splits_bits, test_bitfield_slices_fields,
|
||
test_array_count_uses_ctx_reference, test_switch_compareto_path_expression,
|
||
test_entity_metadata_loop_terminates_on_endval,
|
||
test_anonymous_nbt_has_no_root_name,
|
||
test_entity_metadata_real_server_bytes]:
|
||
fn()
|
||
print("\nprotodef 解释器:全部通过")
|