新增 mcfold/actions.py(16 个操作:look/move/move_and_look/look_at/ jump/sneak/sprint/select_slot/use_item/swing/dig/place/interact_block/ attack/use_entity/interact_entity_at + chat/command) 新增 mcfold/textcomp.py(1.20.3+ 的 NBT 文本组件渲染,查 language.json) ★★ 最关键发现:teleport_confirm 是必需前置包 1.19+ 的 position 带 teleportId,必须回 confirm,否则服务端**默默忽略所有方块操作**。 症状是「操作没反应、零报错」,与权限/物品/位置问题无法区分。 定位过程:先猜了 5 轮(包名/字段顺序/face 类型/sequence/位置与面)都不对; 最后拿 minecraft-protocol 对照,发现它也失败 ⇒ 排除我的包有问题; 再用 data get entity 排除权限与物品 ⇒ 补 confirm 后立刻成功。 其他真 bug: - packet_interact/swing/animation 在 1.21.1 都不存在(应是 use_entity/arm_animation) - dig 原写成 block_place + 多余 varint(应为独立 packet_block_dig)→ 断连 - use_entity(mouse=0) 也带 hand 字段,我漏了 → 少 1 字节 - 背包字段是 itemId(驼峰),我写 item_id → 永远判「空手」 - window0 的 slot 36..44 才是热键栏 - 聊天 NBT 组件三重漏(空串/键名/空键参数)→ 现在正确渲染人话 判据:126 项全通过;5 项变异全被抓。核心判据「16 个包能被官方 protodef 精确解回(0 余字节)」—— 唯一能离线证明「包写对了」的办法
342 lines
14 KiB
Python
342 lines
14 KiB
Python
"""M5 操作层判据:actions.py + textcomp.py。
|
||
|
||
## 这层判据的判别标准
|
||
|
||
操作层的缺陷几乎都是**静默**的:
|
||
- 包发错 ⇒ 服务端默默忽略(不报错、不断连)
|
||
- 少了某个前置包 ⇒ 所有操作集体失效
|
||
- 字段名写错 ⇒ 一切都「没反应」
|
||
|
||
所以判据分两类,缺一不可:
|
||
1. **字节级自洽**:构造的包必须能被**官方 protodef** 解回原值
|
||
(这是唯一能离线验证「包写对了」的办法);
|
||
2. **结构级**:预置条件、夹紧、不编造 —— 这些纯本地可判。
|
||
"""
|
||
import sys, os, json, struct
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
from mcfold.actions import Actions, _packed_position, _direction, _f32, _f64
|
||
from mcfold.textcomp import render_text, _humanize, flatten_with
|
||
from mcfold.protodef import Reader, Writer, for_phase
|
||
from mcfold.state import WorldState, item_name
|
||
from mcfold.client import Client
|
||
from mcfold.session import StateSession
|
||
from mcfold.events import EventLog
|
||
|
||
|
||
DATA_ROOT = ('/home/newqqagent/plugin_data/mc/runtime/node_modules/'
|
||
'minecraft-data/minecraft-data/data')
|
||
V = '1.21.1'
|
||
|
||
|
||
def _client():
|
||
c = Client(host='h', port=1, username='u', version=V)
|
||
c.load_protocol()
|
||
return c
|
||
|
||
|
||
def _bare():
|
||
"""一个不联网的 StateSession,能捕获发出的包。"""
|
||
from mcfold.state import load_vanilla_registry
|
||
s = StateSession.__new__(StateSession)
|
||
s.state = WorldState(registry=load_vanilla_registry(V, DATA_ROOT), num_sections=24)
|
||
s.events = EventLog()
|
||
s.sent = []
|
||
s.client = _client()
|
||
s.client.send = lambda b: s.sent.append(b)
|
||
s._listeners = []
|
||
s.teleports_confirmed = 0
|
||
return s
|
||
|
||
|
||
# ---------- 字节级:包必须能被官方 protodef 解回 ----------
|
||
|
||
|
||
def test_every_packet_decodes_with_official_protodef():
|
||
"""★ 用官方 protodef 解回我构造的每个包 —— 唯一能离线证明「包写对了」的办法。
|
||
|
||
这条判据抓过的真 bug:
|
||
- dig 原本写成 packet_block_place + 2 个多余 varint(应为独立 packet_block_dig)
|
||
- interact/swing 用了 1.21.1 **不存在**的包名(packet_interact / packet_swing)
|
||
"""
|
||
s = _bare()
|
||
s.state.player.entity_id = 42
|
||
s.state.player.held_slot = 0
|
||
# ★ 9 格都放上物品:前面的 select_slot 用例会切换 held_slot,
|
||
# 若只填 0 号格,后面 place 会因「手上是空槽」被前置检查拦下
|
||
# —— 那时测的是「被拦下」,而不是「包写对了」。
|
||
for _i in range(9):
|
||
s.state.hotbar[_i] = {"itemCount": 1, "itemId": 1}
|
||
# ★ dig 有「区块未加载就拒发」的前置检查 ⇒ 测试里要放一个假区块,
|
||
# 否则测的是「被拦下」而不是「包写对了」。
|
||
class _FakeChunk:
|
||
def state_id_at(self, x, y, z):
|
||
return 9
|
||
s.state.world.chunks[(0, 0)] = _FakeChunk()
|
||
s.state.world.chunks[(7 >> 4, 10 >> 4)] = _FakeChunk()
|
||
a = Actions(s)
|
||
pd = for_phase(s.client.proto, "play", "toServer")
|
||
|
||
cases = [
|
||
("look", lambda: a.look(90, 45)),
|
||
("move", lambda: a.move(1.5, -60.0, 2.5)),
|
||
("move_look", lambda: a.move_and_look(1, 2, 3, 10, 20)),
|
||
("jump", lambda: a.jump()),
|
||
("sneak", lambda: a.sneak(True)),
|
||
("sprint", lambda: a.sprint(True)),
|
||
("select_slot", lambda: a.select_slot(3)),
|
||
("use_item", lambda: a.use_item()),
|
||
("swing", lambda: a.swing()),
|
||
("dig", lambda: a.dig(7, -61, 10, face="up")),
|
||
("dig_finish", lambda: a.dig(7, -61, 10, face="up", finish=True)),
|
||
("place", lambda: a.place(7, -61, 10, face="up")),
|
||
("interact_blk",lambda: a.interact_block(7, -61, 10)),
|
||
("attack", lambda: a.attack(7)),
|
||
("use_entity", lambda: a.use_entity(7)),
|
||
("interact_at", lambda: a.interact_entity_at(7, 1, 2, 3)),
|
||
]
|
||
for label, fn in cases:
|
||
s.sent.clear()
|
||
fn()
|
||
assert len(s.sent) == 1, f"{label}: 期望发 1 个包,实发 {len(s.sent)}"
|
||
# ★ 关键:用**同一个 Reader** 解完并检查是否正好读完
|
||
# (另起一个 Reader 量 remaining 是我先前写错的地方:
|
||
# 那个 Reader 从未读过,pos 恒为 0,left 恒等于包长)
|
||
rr = Reader(s.sent[0])
|
||
dec = pd.read("packet", rr, {})
|
||
assert dec.get("name"), f"{label}: 解不出包名 → {dec}"
|
||
left = rr.remaining()
|
||
assert left == 0, f"{label}: 解完还剩 {left} 字节(多写字段或包体超长)"
|
||
print(f" ★ {len(cases)} 个操作的包都能被官方 protodef 精确解回(0 余字节)")
|
||
|
||
|
||
def test_packed_position_roundtrip():
|
||
"""★ 打包坐标必须与官方 position 解码**互逆**(含负数与极值)。"""
|
||
pd = for_phase(_client().proto, "play", "toServer")
|
||
for (x, y, z) in [(0, 0, 0), (6, -60, 10), (-2, -61, -5),
|
||
(1000, 320, -1000), (-2_000_000, 319, 2_000_000)]:
|
||
b = _packed_position(x, y, z)
|
||
assert len(b) == 8, len(b)
|
||
got = pd.read("position", Reader(b), {})
|
||
assert (got["x"], got["y"], got["z"]) == (x, y, z), \
|
||
f"({x},{y},{z}) 往返不一致 → {got}"
|
||
print(" ★ 打包坐标与官方解码互逆(含负数/极值)")
|
||
|
||
|
||
def test_direction_mapping():
|
||
"""面名 → 协议编号:0=down 1=up 2=north 3=south 4=west 5=east。"""
|
||
c = _client()
|
||
expect = {"down": 0, "up": 1, "north": 2, "south": 3, "west": 4, "east": 5}
|
||
for face, n in expect.items():
|
||
assert _direction(c, face) == n, f"{face} → {_direction(c, face)},期望 {n}"
|
||
# 别名
|
||
assert _direction(c, "top") == 1 and _direction(c, "bottom") == 0
|
||
print(f" ★ 六个面映射正确({expect})")
|
||
|
||
|
||
# ---------- 行为级:不编造、夹紧、预置条件 ----------
|
||
|
||
|
||
def test_pitch_is_clamped_and_reported():
|
||
"""★ 超界 pitch 必须夹住**并告知**(不是静默改)。"""
|
||
s = _bare()
|
||
a = Actions(s)
|
||
r = a.look(0, 200)
|
||
assert r["pitch"] == 90.0 and "clamped" in r, r
|
||
r2 = a.look(0, -200)
|
||
assert r2["pitch"] == -90.0 and "clamped" in r2, r2
|
||
r3 = a.look(0, 30)
|
||
assert "clamped" not in r3, "正常值不该报 clamped"
|
||
# yaw 取模
|
||
assert a.look(370, 0)["yaw"] == 10.0
|
||
print(" ★ pitch 夹紧并如实报告,yaw 正确取模")
|
||
|
||
|
||
def test_invalid_slot_rejected_before_sending():
|
||
"""★ 非法槽位**不发送**(预置条件检查在本地拦下)。"""
|
||
s = _bare()
|
||
a = Actions(s)
|
||
s.sent.clear()
|
||
r = a.select_slot(99)
|
||
assert r["ok"] is False and "0..8" in r["reason"], r
|
||
assert s.sent == [], "预置条件失败时不该发包"
|
||
a.select_slot(8)
|
||
assert len(s.sent) == 1, "合法槽位应发送"
|
||
print(" ★ 非法槽位本地拦下(不发包)")
|
||
|
||
|
||
def test_dig_refuses_on_unloaded_chunk():
|
||
"""★ 区块没加载就别发挖掘意图(发了也无效,且浪费往返)。"""
|
||
s = _bare()
|
||
a = Actions(s)
|
||
s.sent.clear()
|
||
r = a.dig(1000, 60, 1000) # 无区块
|
||
assert r["ok"] is False, r
|
||
assert "未加载" in r["reason"], r
|
||
assert s.sent == [], "不应发包"
|
||
print(" ★ 未加载区块的挖掘被本地拒绝")
|
||
|
||
|
||
def test_place_warns_when_held_unknown():
|
||
"""★ 手上是**未知**时必须 warning —— 因为症状与「服务端拒绝」无法区分。"""
|
||
s = _bare()
|
||
a = Actions(s)
|
||
s.sent.clear()
|
||
r = a.place(0, 0, 0)
|
||
assert r["ok"] is False and "set_slot" in r["reason"], r
|
||
# 已知为空槽
|
||
s.state.hotbar[0] = None
|
||
s.state.slots[36] = {"itemCount": 0, "itemId": 0}
|
||
r2 = a.place(0, 0, 0)
|
||
assert "warning" in r2, r2
|
||
# 已知有东西(★ 同时要把 held_slot 指回 0:上一例把它留在了空槽 1)
|
||
s.state.player.held_slot = 0
|
||
s.state.hotbar[0] = {"itemCount": 64, "itemId": 1}
|
||
r3 = a.place(0, 0, 0)
|
||
assert r3["ok"] and "warning" not in r3 and r3["held"], r3
|
||
print(" ★ 未知/空/有物 三种状态都如实报告")
|
||
|
||
|
||
def test_move_does_not_falsely_update_local_state():
|
||
"""★ move 只发意图,**不改**本地坐标。
|
||
|
||
改了就会与服务端不一致而撒谎(服务端会做碰撞校验并把玩家拉回)。
|
||
"""
|
||
s = _bare()
|
||
a = Actions(s)
|
||
before = (s.state.player.x, s.state.player.y, s.state.player.z)
|
||
r = a.move(100.0, -60.0, 100.0)
|
||
after = (s.state.player.x, s.state.player.y, s.state.player.z)
|
||
assert before == after, f"move 不该乐观改本地状态:{before} → {after}"
|
||
assert "position 为准" in r["note"], r
|
||
print(" ★ move 不乐观改本地状态(真值以服务端为准)")
|
||
|
||
|
||
# ---------- 背包与手持物 ----------
|
||
|
||
|
||
def test_item_name_handles_real_slot_shape():
|
||
"""★ 实测 1.21.1 字段是 **itemId**(驼峰);写错就永远判「空手」。"""
|
||
assert item_name({"itemCount": 31, "itemId": 1}) == "item#1"
|
||
assert item_name({"itemCount": 1, "item_id": "minecraft:stone"}) == "minecraft:stone"
|
||
assert item_name({"itemCount": 0, "itemId": 0}) is None, "空槽应是 None"
|
||
assert item_name(None) is None
|
||
assert item_name({}) is None, "无 itemId 时不编名字"
|
||
print(" ★ itemId / item_id / 空槽 / 缺失 四种都对")
|
||
|
||
|
||
def test_hotbar_index_mapping():
|
||
"""★ window0 的 slot 36..44 才是热键栏(0..8)。搞错就看错物品。"""
|
||
s = _bare()
|
||
s.state.set_held_slot_item(36, {"itemCount": 5, "itemId": 2})
|
||
s.state.set_held_slot_item(44, {"itemCount": 7, "itemId": 3})
|
||
s.state.player.held_slot = 0
|
||
assert s.state.held_item_name() == "item#2"
|
||
s.state.player.held_slot = 8
|
||
assert s.state.held_item_name() == "item#3"
|
||
assert s.state.hotbar_item_name(0) == "item#2"
|
||
assert s.state.hotbar_item_name(3) is None
|
||
print(" ★ 热键栏 36..44 ↔ 0..8 映射正确")
|
||
|
||
|
||
# ---------- 文本组件(1.20.3+ 聊天) ----------
|
||
|
||
|
||
def test_render_uses_real_server_shapes():
|
||
"""★ 用**线上真实抓到的** NBT 形状(不是我想象的)。
|
||
|
||
三个真实 bug 都出在这:
|
||
1. 只取 `text` ⇒ 拿到 ""(组件的 text 常为空串)
|
||
2. translate 原样吐 ⇒ agent 看到 commands.give.success.single 这种键名
|
||
3. with 里数字存成 {"": 1}(空键)没读 ⇒ “Gave Dirt to ”
|
||
"""
|
||
assert render_text({"color": "red", "extra": [{"translate":
|
||
"command.unknown.command"}], "text": ""}) \
|
||
== "Unknown or incomplete command, see below for error"
|
||
got = render_text({"translate": "commands.give.success.single", "with": [
|
||
{"": "1"}, {"translate": "block.minecraft.dirt"}, {"": "mcfold_act"}]})
|
||
assert got == "Gave 1 Dirt to mcfold_act", repr(got)
|
||
got2 = render_text({"translate": "commands.setblock.success", "with": [
|
||
{"": -2}, {"": -61}, {"": -5}]})
|
||
assert got2 == "Changed the block at -2, -61, -5", repr(got2)
|
||
print(f" ★ 真实形状渲染正确({got!r})")
|
||
|
||
|
||
def test_render_never_returns_empty_for_unknown():
|
||
"""★ 取不到就保留键名,**绝不**返回空串(空串=「什么也没发生」)。"""
|
||
assert render_text({"translate": "no.such.key.xyz"}) == "⟨no.such.key.xyz⟩"
|
||
assert render_text(None) == ""
|
||
assert render_text("bare") == "bare"
|
||
assert render_text({"hoverEvent": {"contents": {"id": "minecraft:stone"}}}) == "Stone"
|
||
print(" ★ 未知键保留原样(不静默变空)")
|
||
|
||
|
||
def test_render_survives_deep_nesting():
|
||
"""深嵌套不能栈溢出(服务端数据不可信)。"""
|
||
comp = {"text": "x"}
|
||
for _ in range(40):
|
||
comp = {"extra": [comp]}
|
||
out = render_text(comp)
|
||
assert isinstance(out, str) and ("⟨" in out or "x" in out)
|
||
print(" ✓ 深嵌套安全降级(不崩溃)")
|
||
|
||
|
||
def test_humanize():
|
||
assert _humanize("minecraft:stone") == "Stone"
|
||
assert _humanize("item.minecraft:diamond_sword") == "Diamond sword"
|
||
assert _humanize("block.minecraft:red_flower") == "Red flower"
|
||
print(" ✓ 物品 id 人性化")
|
||
|
||
|
||
def test_flatten_with():
|
||
args = [{"": 1}, {"translate": "block.minecraft.stone"}]
|
||
assert flatten_with(args) == ["1", "Stone"], flatten_with(args)
|
||
assert flatten_with(None) == []
|
||
print(" ✓ with 参数摊平(含空键数字与 translate)")
|
||
|
||
|
||
# ---------- 决定性:teleport_confirm(这层最隐蔽的必需前置包) ----------
|
||
|
||
|
||
def test_teleport_confirm_sent_on_position():
|
||
"""★★ 1.19+ 必须在 position 后回 teleport_confirm。
|
||
|
||
不回 ⇒ 服务端**默默忽略所有方块操作**(dig/place 全无效、不报错)。
|
||
症状与「权限不足」完全无法区分,是本层最隐蔽的必需前置包。
|
||
回归:手工补发 confirm 后 place 立刻成功(air → stone)。
|
||
"""
|
||
s = _bare()
|
||
s._apply("position", {"x": 1.0, "y": 2.0, "z": 3.0, "yaw": 0, "pitch": 0,
|
||
"teleportId": 7})
|
||
assert len(s.sent) == 1, s.sent
|
||
raw = s.sent[0]
|
||
assert raw[0] == s.client._serverbound_id("play", "packet_teleport_confirm")
|
||
assert raw[1] == 7, raw[:4].hex()
|
||
assert s.teleports_confirmed == 1
|
||
# ★ 反向对照:teleportId 缺失(老版本)时不得乱发
|
||
s.sent.clear()
|
||
s._apply("position", {"x": 1.0, "y": 2.0, "z": 3.0, "yaw": 0, "pitch": 0})
|
||
assert s.sent == [], "无 teleportId 时不该发 confirm"
|
||
print(" ★★ position 必回 teleport_confirm;无 id 时不乱发")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
for fn in [test_every_packet_decodes_with_official_protodef,
|
||
test_packed_position_roundtrip,
|
||
test_direction_mapping,
|
||
test_pitch_is_clamped_and_reported,
|
||
test_invalid_slot_rejected_before_sending,
|
||
test_dig_refuses_on_unloaded_chunk,
|
||
test_place_warns_when_held_unknown,
|
||
test_move_does_not_falsely_update_local_state,
|
||
test_item_name_handles_real_slot_shape,
|
||
test_hotbar_index_mapping,
|
||
test_render_uses_real_server_shapes,
|
||
test_render_never_returns_empty_for_unknown,
|
||
test_render_survives_deep_nesting,
|
||
test_humanize,
|
||
test_flatten_with,
|
||
test_teleport_confirm_sent_on_position]:
|
||
fn()
|
||
print("\n操作层:全部通过")
|