Files
agentmc/mcfold/session.py
JianFeeeee 1a6f197572 M5:操作层(真能改世界)+ 16 项判据
新增 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 余字节)」—— 唯一能离线证明「包写对了」的办法
2026-09-26 12:44:08 +08:00

446 lines
19 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.

"""把 M2 的包流灌进 M3 的状态层(M3 的「线」)。
## 为什么单独一个模块
M2(协议)与 M3(状态)关心的事不同:
- 协议层:**这串字节是什么包**
- 状态层:**世界现在是什么样**
把「收到包 ⇒ 更新状态」单独放一处,好处:
- 状态层的每个字段都能追到它来自哪个包(可核查)
- 新增状态字段时不必碰协议解析
- 线上的坏包(解不开的)在此处统一记账,不污染状态
## 刻意的克制
只做「落状态」,**不做**:
- 预测(不打提前量、不猜实体下一步)
- 决策(不去找目标、不规划路径)
- 猜测(认不出的 state id 照实记 unknown,不映射成别的方块)
"""
from __future__ import annotations
from typing import Any, Callable
from .client import Client
from .state import Entity, WorldState, ingest_configuration
from .protodef import Writer
from .events import EventLog
from .actions import Actions
def _nbt_text(nbt: Any) -> Any:
"""把 NBT 文本组件渲染成可读文本(1.20.3+ 聊天走组件树,不是纯字符串)。
★ 之前的三个错(都是“看着像服务端没反应”的静默失败):
1. 只取 `text` 字段 ⇒ 拿到 `""`(组件的 text 常常是空串)
2. 就算取到 `translate` 也只当字符串吐 ⇒ agent 读到的是
`commands.give.success.single` 这种键名,不是人话
3. 就算翻译了,`with` 参数里数字存成 `{"": 1}`(空键)没读
⇒ “Gave Dirt to ”(参数全空)
现在统一走 textcomp.render_text(查 language.json + 填 %s)。
★ 失败时**保留原文/键名**而不是空串:空串会让 agent 以为「什么也没发生」。
"""
try:
from .textcomp import render_text
out = render_text(nbt)
return out if out else nbt
except Exception:
return nbt
class StateSession:
"""一条「客户端 + 状态」的可运行会话。
## 用法
```python
with StateSession("127.0.0.1", 25566, "mcfold_test", "1.21.1") as s:
s.connect() # handshake → login → configuration → play
s.pump(2.0) # 收 2 秒,把区块/位置/实体落进状态
print(s.state.summary())
```
"""
def __init__(
self,
host: str,
port: int,
username: str,
version: str,
registry: Any = None,
data_root: str = "/home/newqqagent/plugin_data/mc/runtime/node_modules/"
"minecraft-data/minecraft-data/data",
):
self.client = Client(host=host, port=port, username=username, version=version)
self.state = WorldState(registry=registry, num_sections=24)
if registry is None:
from .state import load_vanilla_registry
self.state.world.registry = load_vanilla_registry(version, data_root)
# ★ M4 事件层:给 LLM 用的**可按需排空**的事件日志(非回调推送)
self.events = EventLog()
# ★ M5 操作层:让 agent 能改变世界(chat/look/move/dig/place…)
self.actions = Actions(self)
# ★ 服务端传送确认计数(不 confirm 就会导致方块操作被静默忽略)
self.teleports_confirmed = 0
# 兼容旧接口:回调式监听(给非 LLM 的宿主用),事件仍会同时入 EventLog
self._listeners: list[tuple[str, Callable[[str, Any], None]]] = []
# ---------- 事件(M4 的落点,本轮只留机制) ----------
def on(self, event: str, fn: Callable[[str, Any], None]) -> None:
self._listeners.append((event, fn))
def _emit(self, event: str, payload: Any) -> None:
"""同时进事件日志(LLM 路径)与回调(宿主路径)。
★ 两路并存的原因:LLM 是回合制的,需要 `drain()` 式拉取;
而某些宿主(如测试/工具)想要即时回调。
只做其中一路都会让另一类使用者难用。
"""
if isinstance(payload, dict):
self.events.emit(event, payload)
for name, fn in self._listeners:
if name == event:
fn(event, payload)
# ---------- 连接 ----------
def connect(self, timeout: float = 15.0) -> dict[str, Any]:
"""走完 login → configuration → play,返回各阶段摘要。"""
import time as _t
c = self.client
c.load_protocol()
c.connect()
c.sock.settimeout(timeout)
c.handshake(state=2)
c.login_start()
# login 阶段:读到 success(途中处理压缩阈值)
login_seen: list[str] = []
deadline = _t.time() + timeout
while _t.time() < deadline:
p = c._recv("login")
nm = p["name"]
login_seen.append(nm)
if nm == "compress":
c.compression = p["params"].get("threshold", 256)
continue
if nm == "success":
break
if nm == "disconnect":
raise RuntimeError(f"服务端拒绝: {p['params'].get('reason')}")
cfg = c.configuration_handshake()
ingest_configuration(self.state, cfg, c.registries)
self._emit("connected", {"cfg": cfg})
return {
"login_packets": login_seen,
"registries": cfg.get("registryCount"),
"phase": c.phase,
"geometry": self.state.geometry.describe(),
}
# ---------- 收包并落状态 ----------
def pump(self, seconds: float = 1.0) -> dict[str, int]:
"""收 N 秒包,把能落地的状态落地,返回各包计数。"""
import time as _t
c = self.client
c.sock.settimeout(0.5)
deadline = _t.time() + seconds
counts: dict[str, int] = {}
while _t.time() < deadline:
try:
p = c._recv(c.phase)
except TimeoutError:
continue
except Exception as e:
counts["_error"] = counts.get("_error", 0) + 1
self.state.note_packet(f"_error:{type(e).__name__}")
continue
nm = p["name"]
counts[nm] = counts.get(nm, 0) + 1
self.state.note_packet(nm)
if p.get("_undecoded"):
# 上游数据缺口(当前只有 declare_recipes):不落状态,但记账
continue
try:
self._apply(nm, p["params"])
except Exception as e:
counts["_apply_error"] = counts.get("_apply_error", 0) + 1
self._emit("state_error", {"packet": nm, "error": str(e)[:120]})
return counts
def _apply(self, name: str, params: dict[str, Any]) -> None:
st = self.state
c = self.client
if name == "position":
st.update_from_position(params)
# ★★ M5 的关键一环(实测 2026-09-26,卡了很久):
# 1.19+ 的 position 包带 `teleportId`,客户端**必须**回
# teleport_confirm,**否则服务端会忽略后续的方块操作**
# (dig/place/use 全部默默无效,且不给任何错误)。
# 症状:“操作没反应”,与“权限不足/空手”完全无法区分。
# 实测:补发 confirm 后 place 立刻成功(air → stone)。
tid = params.get("teleportId")
if isinstance(tid, int) and tid >= 0:
w = Writer()
w.varint(c._serverbound_id("play", "packet_teleport_confirm"))
w.varint(tid)
c.send(w.getvalue())
self.teleports_confirmed += 1
self._emit("player_moved", st.player.as_dict())
elif name == "login":
st.update_from_login(params)
self._emit("joined", st.player.as_dict())
elif name == "map_chunk":
ch = st.load_chunk(params["x"], params["z"], params["chunkData"])
self._emit("chunk_loaded", {"x": params["x"], "z": params["z"],
"sections": len(ch.sections)})
elif name == "unload_chunk":
# ★ 协议字段顺序是 chunkZ **在前**、chunkX 在后
# (1.21.1 packet_unload_chunk: [chunkZ:i32, chunkX:i32])
# 我先前写成 params.get("chunkZ", …) 塞给 cx ⇒ x/z 互换,
# 卸载永远不命中 ⇒ 区块越积越多(内存缓慢泄漏 + 状态撒谎)。
cx = params.get("chunkX")
cz = params.get("chunkZ")
if isinstance(cx, int) and isinstance(cz, int):
st.unload_chunk(cx, cz)
self._emit("chunk_unloaded", {"x": cx, "z": cz})
elif name == "keep_alive":
# ★ 必须回,否则服务端 15s 后踢我们(状态层的第一责任)
w = Writer()
w.varint(c._serverbound_id("play", "packet_keep_alive"))
w.int64(int(params.get("keepAliveId", 0)))
c.send(w.getvalue())
elif name == "game_state_change":
reason = params.get("reason")
# 0=开始游戏 1=结束游戏 3=改变gamemode 5=改变难度 6=改变生命
if reason == 3:
st.player.gamemode = str(params.get("gameMode"))
self._emit("game_state", params)
elif name == "update_health":
v = params.get("health")
if isinstance(v, (int, float)):
st.player.health = float(v)
f = params.get("food")
if isinstance(f, (int, float)):
st.player.food = int(f)
self._emit("health_changed", params)
elif name == "entity_position":
eid = params.get("entityId")
if eid in st.entities:
e = st.entities[eid]
if params.get("deltaX") is not None:
e.x += float(params["deltaX"])
e.y += float(params["deltaY"])
e.z += float(params["deltaZ"])
self._emit("entity_moved", e.as_dict())
elif name == "remove_entities":
ids = params.get("entityIds") or params.get("entities") or []
if isinstance(ids, (list, tuple)):
for eid in ids:
st.remove_entity(int(eid))
self._emit("entities_removed", ids)
elif name == "add_entity":
eid = params.get("entityId")
if isinstance(eid, int):
st.upsert_entity(Entity(
entity_id=eid,
kind=str(params.get("type", "unknown")),
x=float(params.get("x", 0.0)),
y=float(params.get("y", 0.0)),
z=float(params.get("z", 0.0)),
))
self._emit("entity_spawned", eid)
elif name == "update_view_position":
self._emit("view_moved", params)
# ---------- M4:世界变化类事件 ----------
elif name == "block_change":
# 1.21.1 的 position 是 **bitfield 打包坐标**(x 26 / z 26 / y 12,有符号)
loc = params.get("location") or {}
bx, by, bz = int(loc.get("x", 0)), int(loc.get("y", 0)), int(loc.get("z", 0))
before = st.block_at(bx, by, bz)
# ★ from 要能区分「之前是空气」与「之前我没见过」:
# unloaded → 写「未加载」,而不是 None(None 看着像“没有值”)。
# agent 若把 unloaded 读成 air,会以为脚下是空气而做出错误动作。
from_name = before.get("name")
if from_name is None:
from_name = {"unloaded": "未加载(无原值)",
"out-of-world": "界外"}.get(
before.get("source"), f"未知({before.get('source')})")
st.on_block_change(bx, by, bz, params.get("type"))
# ★ 把 state_id 解成**方块名**再进事件:
# agent 读「grass_block → stone」比读「9 → 1」有用得多;
# 而且我先前写的事件键是 to_state_id、渲染器读 to ⇒ 显示 None
# (「变了个不知道是什么的东西」,比不报还糟)。
now = st.block_at(bx, by, bz)
self._emit("block_changed", {
"x": bx, "y": by, "z": bz,
"from": from_name,
"to": now.get("name"),
"to_state_id": params.get("type"),
})
self.events.emit_fine(f"block_changed:{bx}:{by}:{bz}",
{"to": now.get("name"),
"to_state_id": params.get("type")})
elif name == "multi_block_change":
sec = params.get("chunkCoordinates") or {}
recs = params.get("records") or []
st.on_multi_block_change(int(sec.get("x", 0)), int(sec.get("z", 0)), recs)
self._emit("block_changed", {
"chunk": [int(sec.get("x", 0)), int(sec.get("z", 0))],
"count": len(recs), "bulk": True,
})
elif name in ("system_chat", "profileless_chat"):
self._emit("chat", {
"text": _nbt_text(params.get("content")),
"action_bar": bool(params.get("isActionBar")),
"system": True,
})
elif name == "player_chat":
self._emit("chat", {
"from": params.get("senderName") or str(params.get("senderUuid")),
"text": params.get("plainMessage"),
"system": False,
})
elif name == "damage_event":
eid = params.get("entityId")
if eid == st.player.entity_id:
amt = params.get("damage")
if amt is not None and st.player.health is not None:
st.player.health = max(0.0, st.player.health - float(amt))
self._emit("damage", {"amount": amt, "health": st.player.health})
elif name == "entity_destroy":
for eid in (params.get("entityIds") or []):
e = st.entities.pop(int(eid), None)
if e is not None:
self._emit("entity_removed", e.as_dict())
elif name == "spawn_entity":
eid = params.get("entityId")
if isinstance(eid, int):
st.upsert_entity(Entity(
entity_id=eid,
kind=str(params.get("type", "unknown")),
x=float(params.get("x", 0.0)),
y=float(params.get("y", 0.0)),
z=float(params.get("z", 0.0)),
))
self._emit("entity_spawned", st.entities[eid].as_dict())
elif name in ("rel_entity_move", "entity_move_look"):
eid = params.get("entityId")
e = st.entities.get(eid) if isinstance(eid, int) else None
if e is not None:
# ★ 增量单位是 1/4096 方块(协议定义),不是 1/1 —— 写错会得到
# 看似合理但完全错误的坐标(实体“瞬移”到远处)。
e.x += float(params.get("deltaX", 0)) / 4096.0
e.y += float(params.get("deltaY", 0)) / 4096.0
e.z += float(params.get("deltaZ", 0)) / 4096.0
p = st.player
dist = ((e.x - p.x) ** 2 + (e.y - p.y) ** 2 + (e.z - p.z) ** 2) ** 0.5
self._emit("entity_moved",
e.as_dict() | {"distance": round(dist, 2)})
elif name == "command_suggestions":
self._emit("chat", {"text": params, "system": True, "suggestions": True})
elif name == "set_slot":
# ★ 背包变化:没有它就不知道手上是什么 ⇒ place 会“发了但没反应”
# 而你完全看不出原因(症状与“服务端拒绝”一模一样)。
wid = params.get("windowId")
if wid == 0 or wid is None:
slot = params.get("slot")
if isinstance(slot, int):
st.set_held_slot_item(slot, params.get("item"))
self._emit("held_item", {"slot": slot,
"item": st.held_item_name()})
elif name == "held_item_slot":
self._emit("held_item", {"slot": params.get("slot")})
elif name == "declare_recipes":
# 上游数据缺口解不开,但**包名已知**;这里不做任何事
self._emit("recipes_declared", {"decoded": False})
# ---------- 发(M5 的第一块,先把 chat/command 铺上) ----------
def send(self, payload: bytes) -> None:
self.client.send(payload)
def chat(self, text: str) -> None:
"""发聊天(M5)。结构随版本变化,**从协议数据取**而非写死。"""
fields = self._serverbound_fields("play", "packet_chat_message")
w = Writer()
w.varint(self.client._serverbound_id("play", "packet_chat_message"))
w.pstring(text)
# 1.19.1+ 有 timestamp / salt / signature / etc
for name, ftype in fields:
if name == "message":
continue
if name in ("timestamp", "salt"):
w.int64(0)
elif isinstance(ftype, list) and ftype and ftype[0] == "option":
w.boolean(False) # signature / unsignedChatContent:无
elif isinstance(ftype, list) and ftype and ftype[0] in ("varint", "i8", "u8", "bool"):
w.varint(0)
else:
w.pstring("") # 未知可选字段:保守发空
self.send(w.getvalue())
def command(self, cmd: str) -> None:
"""发命令(**不带**前导 /)。1.19+ 走 chat_command 独立包。"""
cmd = cmd.lstrip("/")
pid = self.client._serverbound_id("play", "packet_chat_command")
w = Writer()
w.varint(pid)
w.pstring(cmd)
self.send(w.getvalue())
@staticmethod
def _serverbound_fields(phase: str, pname: str) -> list[tuple[str, Any]]:
"""从 protocol.json 读 serverbound 包的字段(顺序敏感)。"""
import json as _json
try:
types = self.client.proto[phase]["toServer"]["types"]
c = types.get(pname)
if c and isinstance(c, list) and len(c) > 1:
return [(f["name"], f["type"]) for f in c[1]]
except Exception:
pass
return []
# ---------- 便捷 ----------
def close(self) -> None:
try:
self.client.close()
except Exception:
pass
def __enter__(self) -> "StateSession":
return self
def __exit__(self, *exc: Any) -> None:
self.close()