Files
agentmc/plan.md
JianFeeeee 6a9a955472 M10:启动生命周期(多实例/重复启动/残留清理)+ 10 项判据
用户定位:框架本身是给 agent 用的 MC 启动器 ⇒ 多实例、多版本隔离、
单个实例多次启动是天然要求。盘点发现缺的是**运行时管理**那一层。

新增 mcfold/launch.py:
- claim():启动前的**闸门**(重复启动必须拦在启动之前 ——
  不拦的话两个 JVM 抢同一个 run/ 与 X display,报错指向渲染异常,
  完全指不到「重复启动」)
- register():JVM 起来后登记 pid/启动时间/世代/版本/加载器
- status():四态分清 free / stale(有记录但进程已死=崩溃残留)/
  running / foreign(pid 存活但不属于本实例=pid 被复用)
- stop():先 SIGTERM 超时再 SIGKILL,**两次都复核身份**
- cleanup():只清记录不杀进程(状态 foreign 时杀就是误杀)

防误杀:pid 会被系统回收复用 ⇒ 终止前必须校验 cmdline 含本实例路径。

判据 10 项,用**真 java 进程**(mock 测不出 pid 复用/残留这类缺陷)。
★ 「模拟进程的形状」必须与真实启动一致:sleep 3600 带路径会拒绝参数退出;
  bash -c 会 exec 掉自己、cmdline 变成裸 sleep ⇒ 两种都测不到真实情形。
  改用 java -cp … Hold <实例路径>(形状同真实 --gameDir)。

4 项变异全被抓。
★ 又一次**空变异**:把身份校验的 return 改成恒真,看似改了,其实上面还有
  「是不是 java」早退 ⇒ 判据假绿。结论重申:**必须先验证变异真的落地**
  (打印改动后的行为),再信它的结果。

210 项全通过。
2026-09-26 14:08:06 +08:00

49 KiB
Raw Blame History

mcfold 工程计划

最后更新:2026-09-26 状态标记:✅ 已完成 · 🔄 进行中 · ⬜ 未开始 · ⚠️ 有已知缺口


0. 目的(最顶层,2026-09-26 用户定调)

因为我们的这个插件目的就是为了让 agent 可以借助我们提供的工具包去玩我的世界。

★ 这是最顶层的目的,下面所有东西都是为它服务的手段。

我们提供:一副「能在 MC 世界里感知 + 行动」的**工具包**
agent 提供:决策(该干什么、去哪、为什么)—— 我们**不替它决定**

推论(三条,改变工程重心):

  1. 工具形状要照 LLM 的用法设计,不是照程序员的用法。 mineflayer 是 create minecraft bots with a stable, high level API —— 它是给写 bot 的人用的库;我们面向的是 LLM 直接调用。 差别很实:参数要自解释、返回要可读、错误要说清"为什么不行", 而不是抛个异常让调用者去读栈。
  2. 「能感知 + 能行动」两者缺一不可。 只有感知(渲染)⇒ agent 是个观众; 只有行动(操作)⇒ agent 是瞎子。 所以 §1b 的「状态 / 事件 / 操作」三层是工具包的正式骨架, 不是"以后再补"。
  3. 渲染是工具之一,不是全部。 细渲染(出图)是工具;粗渲染(常驻感知)是工具; move / dig / place / chat 也是工具。 之前把它当主线,是重心错了。

0b. 目标(用户定向,按时间顺序)

  1. 照相机式渲染:给一个世界(存档数据 / 服务端),渲染出图。
    • 用户原话(2026-09-25):「不起完整客户端」——抽出 MC 的渲染能力, 喂我们自己的数据。
    • 用户纠正:「不写 mod,而不是不用 mod 加载器」⇒ 用加载器( 让真实 mod / Iris 光影能工作),但不写自己的 mod。
  2. 架构分层(用户 2026-09-26 明确定调):

    jar 部分封装为统一的接口,非通用数据通过 extern 参数传入。 jar 端接受操作意图,输出包,python 再将包发送到服务端。 python 相当于只是包装,让 agent 可以通过统一的接口调用。 同时不要忘了版本隔离。同一个版本应当可以存在多个实例(因为要装不同的 mod)。

  3. 范围(用户 2026-09-26 明确限定):

    我们只是为了渲染以及与服务端的 mod 物品交互。 ⇒ 不做完整客户端、不做玩法、不做移动/战斗。

  4. HUD 不显示(用户 2026-09-26):截图只关心画面,HUD 是噪声, 而且它给 distinctColors 掺固定像素(污染判据)。 ⇒ 走官方选项 options.hideGui(= 游戏里 F1)。

1. 用户设想的场景(2026-09-26,本计划的新方向)

★ 这是抽象模型的一个用例,不是全部(用户 2026-09-26: 「机位这个只是一个具体应用场景,而不是只有这一个场景」)。 它用来验证 §1b 那套「状态 + 事件 + 操作」是否立得住 —— 若拍照能跑通,agent 的日常感知/交互也就能跑通。

我设想的场景是玩家可以在聊天框给 agent 发消息,然后比如让 agent 给他拍照, 如果是他和某个景物的合照,agent 可以先通过 ass 粗渲染,找好角度 再去渲染实图。

拆成可执行的三段:

① 玩家在聊天框发消息        → agent 通过统一接口收到
② agent 要"给玩家拍合照"     → 需要:玩家位置/朝向 + 景物位置 + 相机可摆放范围
③ 选角度                    → 用 **ass 粗渲染**(低成本、快)试若干机位,
                              挑一个好的,再用**细渲染**出图

★ 注意:这只是粗渲染的一个用例。它的本职是 agent 的常驻感知层 (日常游玩),见 §4。

★ 粗渲染与细渲染的分工(用户 2026-09-26 明确,关键)

粗渲染是 agent 日常游玩用的;细渲染是出图和 agent 判断精细状态用的; 细渲染是单次调用的而非常驻的。

粗渲染(ass) 细渲染
用途 agent 日常游玩 出图 + 判断精细状态
生命周期 常驻(随 agent 会话) 单次调用(起 → 渲染 → 销毁)
频率 高(agent 每一步都可能问) 低(要图 / 需要精细判断时)
延迟预算 毫秒级 20-40s 可接受
依赖 纯 Python(无 JVM) JVM + 完整 MC 栈 + 加载器/光影
数据源 轻量世界快照 完整渲染管线

★ 这条分工决定了架构:

  • 粗渲染不是细渲染的降级,而是 agent 的常驻感知层 —— 它要回答 agent 平时的所有感知问题(周围有什么、挡不挡视线、 前面走得通吗、那边是什么),这些都必须廉价。
  • 细渲染按需起、用完走(JVM 启停成本不敏感)⇒ 慢一点没关系。
  • ⇒ 所以**「粗渲染↔细渲染一致性」不是刚需**:两者服务于不同问题, 不要求粗渲染预测细渲染的像素。真正要测的是 各自对各自用途的正确性(见 4.5)。

1b. 抽象模型:参考 mineflayer 的「状态 + 事件 + 操作」三层

至于事件更新通报与操作抽象,我觉得可以参考 mineflayer, 使用它的抽象模型调用我们的后端。(用户 2026-09-26)

★ 注意:机位/拍照只是这个抽象的一个用例,不是目的。 下面这套模型要覆盖 agent 的日常:感知、移动、交互、观察、问答。

1b.1 mineflayer 的实际形态(已读源码核实)

① 状态(挂在 bot 上的对象,agent 直接读)
   world      方块/生物群系/光照     entities  {id: Entity}
   players    {username: Player}     time / game / controlState
   inventory / experience / tablist / teamMap / spawnPoint / settings

② 事件(bot.emit(name, ...),细分层用 ':')
   消息类  message / messagestr / chat / chat:<pattern> / actionBar
   世界类  blockUpdate / chunkColumnLoad / weatherUpdate
   实体类  entitySpawn / entityMoved / playerJoined / playerLeft
   玩家类  moved / heldItemChanged / lookingAt / physicsTick
   流程类  login / spawn / health / death / kicked / error

③ 操作(bot.<verb>(...))
   查询    blockAt / findBlocks / raycast / nearestEntity
   移动    move / look / setControlState / simpleClick
   交互    dig / placeBlock / activateBlock / attackEntity / equip
   社交    chat / whisper / tell
   观察    saveScreenshot
   容器    openContainer / clickWindow / closeWindow / …

★ 三个值得直接照搬的设计:

  1. 状态是「对象」而不是「方法调用」 —— agent 读 bot.entities / bot.world 就拿到快照,不必每次查询。 ⇒ 我们的粗渲染(常驻)就该是这个角色。

  2. 事件分「粗粒度 + 细分层」(message 与 chat:<pattern> 并存) —— agent 可以只订阅粗粒度(省 token),需要时再挂细分。 ⇒ 避免「每个方块变化都推给 agent」把上下文淹掉。

  3. 操作是动词,参数是结构化对象 —— bot.findBlocks({ matching, maxDistance, count }) ⇒ 不是「一条命令 + 位置参数」,而是「意图 + 可选约束」。

1b.2 映射到我们的后端

mineflayer 概念          我们的实现                        备注
─────────────────────────────────────────────────────────────────
bot.world                mcfold 的 World(已有 block_at)  ★ 粗渲染的底座
bot.entities             待加:EntityStore                 从 chunk/实体数据来
bot.on(...)              事件总线(待加)                   粗/细分层同 mineflayer
bot.findBlocks({...})    rough.find_blocks(matching=…, …)   纯 Python,毫秒级
bot.raycast              rough.cast_ray(…)                 DDA
bot.move / bot.look      待定:见 1b.4(我们不做移动)
bot.chat                 走 agentmail / 服务端聊天          已有通路
bot.saveScreenshot       WorldRenderer.screenshot()        细渲染(单次调用)

★ 最重要的一条:bot 那些方法最终落到 bot._client(协议层), 而我们落到 jar 端(用户定调:「jar 端接受操作意图,输出包」)。 所以抽象形状照抄 mineflayer,落地换到 jar 端 —— 这正是用户那句话的含义。

1b.3 我们的三层(与 mineflayer 同形)

状态层  常驻、无 JVM、毫秒级
        World / EntityStore / 玩家与相机状态 / 时间天气
        ★ 这层就是「粗渲染」

操作层  意图式动词
        find_blocks / cast_ray / look / say / place / dig …
        多数落粗渲染(快);需精细视觉的转细渲染(慢,单次)

事件层  粗粒度 + 细分层
        moved / block_changed / entity_appeared / message / chat:<pattern>
        ★ 分层是为省 token,不是为性能

1b.4 一个必须先定的边界

⚠️ 我们不做移动/战斗(用户:范围是「渲染 + 与服务端 mod 物品交互」)。 所以 mineflayer 的 bot.move / dig / attackEntity 不照抄 —— 它们要么不需要,要么该由「服务端交互」那条线以别的方式提供。

⇒ 待定:bot.move 这类「改变世界状态」的操作,在我们的定位里 究竟不做、还是转交服务端(agent 在真实世界里动,我们在旁边看/记)。 这决定 操作层 的边界。


1c. 空白测试服(用户 2026-09-26 定向)

我觉得可以起一个空白新服用来测试功能,而不是用现在的服。

理由(不只是"干净"):

  • 现有服 /home/minecraft:world 15G、mods 64 个、neoforge 1.21.1 —— 任何协议层改动都可能污染它,且测不出边界(模组太多、状态太杂)。
  • 我们要验的是协议与行为,需要可预期的环境:已知方块集、已知出生点、 可重置的世界。

形态(待定,需你定):

项 选项
版本 vanilla 1.21.1(最简,协议最干净) / neoforge 1.21.1(与现有一致)
模组 先 0 个(验协议)→ 再逐个加(验 mod 差异)
端口 25565(避开现役) / 25566+
目录 与 /home/minecraft 同级(版本隔离:内容根 / 实例 / 运行期)
世界 全新生成,固定种子(可预期)

★ 磁盘:/home 尚有 136G ⇒ 放得下(现役 world 已 15G,空白服初期只占几十 M)。

它同时是「版本隔离」策略的第二个实例: store/vanilla-1.21.1/server/ + runtime/test-server/ —— 与我们给 agent 建的实例同一套口径。

✅ M0 已完成(2026-09-26,提交 480fe8e)

形态   vanilla 1.21.1 / 0 mods / 超平坦 / 固定种子 mcfold-test-2026
       creative · peaceful · 无怪物 · 无结构 · spawn-protection=0
位置   store/vanilla-1.21.1/server/  (内容根)
       runtime/test-server/          (运行期:log + pid)
端口   25566(避开现役 25565)
接口   python3 -m mcfold.server {start,stop,status,reset}
探活   协议级 Server List Ping ⇒ 1.21.1 / protocol 767 / players 0/4
判据   tests/test_server.py(4 项,默认套件)+ 变异验证过(能红)

★ 两个踩过的坑(已写进判据守护):

  1. properties 行内注释会被解析进值里(k=v # c)⇒ 生成器不写行内注释。
  2. 端口要看清楚 —— 我第一版以为 25566,写完启动日志才发现跑在 25565 (配置被自己写坏后回落到默认)。端口占用要 ss 实测,不能凭配置推断。

1d. 面向 LLM 的 API 形状(§0 推论 1 的落点)

mineflayer 的 API 面向程序员;我们面向 LLM。具体差别与对策:

维度 程序员友好的做法 LLM 需要的 我们的对策
参数 位置参数 + 选项对象 全命名、少量必填 find_blocks(matching=..., max_distance=...)
返回 原始对象/布尔 可读摘要 + 关键字段 附 describe()(BlockHit 已有)
失败 抛异常 说清"为什么不行 + 怎么办" source="unloaded" / "unknown-state" 那种自述
规模 返回全部 限量 + 计数(省 token) 默认 count=10,另给 total
发现 读文档 可自省 bot.describe() 列出可用操作与必需参数

★ 已有可借鉴的样本:BlockHit.describe() —— 明确区分 「未加载」/「超出世界高度」/「unknown(id),本机注册表无此 state id, 可能来自 mod 方块」/ 正常。这正是 LLM 需要的说法: 不是报错,而是告诉它「这个方块你暂时看不到,为什么」。

⚠️ unknown(id) 这一点尤其关键(§0 推论):mod 方块的 state id 只有服务端知道, 客户端本机注册表里没有 ⇒ 粗渲染遇到它必须如实说"我不认识",不能瞎猜一个。 这与「与服务端 mod 物品交互」那条线直接相关。


2. 已完成(可复用,有提交)

2.1 渲染桥(P8/P9)

  • pg2srg.py:ProGuard → SRG 映射转换
  • client-named.jar:官方 jar 反混淆产物
  • 官方初始化序列(反编译确认):tryDetectVersion → enableDataFixerOptimizations → Bootstrap.bootStrap() → initRenderThread → beginInitialization → new Minecraft(cfg) → finishInitialization(不跑主循环)
  • 单帧渲染:clear → bindWrite → gameRenderer.render → unbindWrite → blitToScreen → updateDisplay

2.2 架构(用户定调的落地)

  • Python 只做路由:0 处 net.minecraft 引用(实测)
  • 差异全在 Java:cmd=setblocks(Python 发 RLE,Java 组装协议)、cmd=capacity(问运行时)
  • 世代分派:DeltaTracker 类存在 ⇔ 1.21+;每代独立编译的 Java 后端
  • 加载器 × 世代正交:动态子类组合,不写 N 个类

2.3 加载器路线 + 光影 ✅(2026-09-26 打通)

关键突破:Knot.init() + loadIntoTarget() —— 拿到 mod 生态,但不起官方主循环。

三个必需条件(缺一失败,全部实测):

  1. classpath 放 client-named.jar(不是官方 obf jar)
  2. -Dfabric.gameMappingNamespace=named
  3. asm 只留一份(1.20.1 自带 9.2+9.6 两版 ⇒ Knot.<clinit> 抛 duplicate ASM classes)

B 方案(用户选定):统一接口 + 非通用数据走 extern

  • McfoldNames:源码写语义路径(net/minecraft/client/Minecraft/resourceManager), 运行时按 names.json 查物理名
  • 19 处字符串反射 → 0(remap 改不了字符串字面量,这是唯一两态都通的形态)
  • gen_names.py:从两份映射复合生成表(11/11 解析成功)

remap 链(build.sh 第 7 步,含两个实测坑):

  • 坑①:SRG 的 desc 两侧都是 official ⇒ 左必须转 named、右转 intermediary (只改类名不改 desc ⇒ 类对了、方法没改 ⇒ NoSuchMethodError)
  • 坑②:SpecialSource 按 owner 精确匹配、不查继承链 ⇒ 必须 -h inheritance.map (不带漏 ~60 处)

结果:Iris 光影 + 地形同时渲染 ✅

before: colors=4748   (只有天空)
after:  colors=113546 (地形 + 光影 + 阴影)

2.4 Sodium tracker 通知(notifyRenderTracker)✅

症状:装 Sodium 后只有天空、chunks=0,而 blockinfo 能正确读方块 ⇒ 数据在,渲染器不知道。

三层根因(逐层实测):

  1. Sodium 的 mixin 挂 replaceWithPacketData 内 @At(INVOKE onChunkLoaded), 而 1.20.1 该方法内部不调 onChunkLoaded ⇒ 注入点永不命中
  2. 反射签名:参数是 ClientLevel 不是 Level;第三参是位掩码 int
  3. ★ status 值:FLAG_HAS_BLOCK_DATA=1 / FLAG_HAS_LIGHT_DATA=2 / FLAG_ALL=3, 而 updateMerged 的 2×2 判定要 四邻 & == 3 ⇒ 传 1:1&1&1&1=1 ≠ 3 ⇒ 全部判成"凑不齐" ⇒ 队列恒空
传 1:  chunkStatus=49  chunkReady=0   loadQueue=0   colors=4748
传 3:  chunkStatus=49  chunkReady=25  loadQueue=25  colors=113546

2.5 版本隔离 + 实例层 ✅

  • 三层(权威口径 mcview/lib/versions.cjs:59/61):
    BASE/store/<loader>-<version>/   内容根   共享只读:jar/libraries/assets/.mcfold 产物
    BASE/instances/<name>/           实例     独占:mods/shaderpacks/config/saves
    BASE/runtime/<name>/             运行期   独占:X display/screenshots/logs
    
  • mcfold/instances.py(Python 接口,用户定调):
    create_instance(name, version=, loader=, mods=[...], force=False)  # → Path
    list_instances()                                                    # → 摘要
    find_content_root(version, loader)
    
    • 实例内部:instance.json + mods/ shaderpacks/ resourcepacks/ datapacks/ (★ agent 的接口面,放进去即生效)+ config/saves/logs
    • 不在实例里建 versions/libraries/assets(属内容根,只读共享)
    • 实例名白名单 ^[A-Za-z0-9._-]{1,64}$;已存在则报错而非默默复用
  • 版本隔离守卫(tests/test_version_isolation.py,默认套件里跑)
    • 首次运行就抓出两处真实回流(store/vanilla-1.20.1/instances/ 与 store/vanilla-1.21.1/instances/)
    • ★ 教训:「我检查过了」≠「有一条会自己跑的检查」

2.6 HUD 开关 ✅

hide_gui(两世代后端 + Python set_view(hide_gui=...)),走官方 options.hideGui。

2.7 工程卫生 ✅

  • .gitignore(运行期产物 / 实例配置 / 构建缓存)
  • git rm --cached 28 个误入库的文件
  • Sodium 往进程 cwd 写 config/ 的漏洞已修(cwd=self.cwd or self.game_dir)

测试:./run_tests.sh 66 项 · --with-render 80 项(实测全绿)


3. 当前缺口(按依赖顺序)

⚠️ G1. 1.21.1 的构建没跑通(阻断服务端那条线)

build.sh 的路径 bug 已修($HERE/common → $HERE/java/common), 但 1.21.1 编译仍有错:

LoaderBoot.java:1: error: package net.fabricmc.api does not exist
LoaderBoot.java:2: error: package net.fabricmc.loader.impl.launch.knot does not exist
LoaderBoot.java:53: error: package net.fabricmc.loader.api does not exist
gen_1_21/McfoldRenderServer.java:761: error: cannot find symbol

根因:build.sh 第 5 步的编译 classpath(CP)没有包含 Fabric 库, 而 LoaderBoot 需要 net.fabricmc.*。1.20.1 那次是侥幸(Fabric 库在 hand-composed classpath 里),build.sh 本身没把它加进编译 CP。

修法:第 5 步编译时若有 $OUT/fabric/,把它加进 CP。

⬜ G2. 服务端交互没实现(目标里明写的那半)

现状:client.py 只有 handshake / login_start / run_smoke(冒烟级)。

缺:

  • configuration 阶段(1.20.2+,登录后、play 前):这里有 registry_data
  • 服务端注册表:方块 id / 物品 id 的权威来源
  • 物品 ↔ 方块映射

★ 技术核心:mod 方块的 state id 只有服务端知道 (客户端只知道自己 jar 里那套)。这正是「与服务端 mod 物品交互」的难点。

已实测确认:ServerboundChatPacket / ClientboundSystemChatPacket 的 protodef 描述不随版本变 ⇒ ProtocolVersion 只影响少数结构。

⬜ G3. 统一接口门面(用户口径:agent 调 Python)

已有 WorldRenderer / Instance / create_instance,缺一个把 「渲染」+「服务端交互」串起来的门面。

⬜ G4. 粗渲染(ass):agent 的常驻感知层(§1、§4)

见 §4 设计(常驻,与细渲染的单次调用互补)。

⬜ G5. 聊天框 → agent → 拍照 的行为链(§1,新方向)

见 §5 设计。


4. 粗渲染(ass)设计(⬜ 未开始)

4.1 定位:agent 的常驻感知层

按 §1 的分工,粗渲染服务于日常游玩,所以它不是一个"拍照辅助", 而是一个常驻的、无 JVM 的世界感知接口。

细渲染:  JVM + 完整 MC 栈 + Iris      单次调用(起→渲→销)  ~20-40s
粗渲染:  纯 Python + 世界快照          常驻                    <100ms

4.2 它要回答的问题(agent 游玩时会问的)

问法 需要的能力
「我面前是什么?」 视线求交(DDA)
「这堵墙后面有没有通路?」 射线 + 邻域查询
「我站的地方是空的吗?」 block_at(已有)
「那边那座山是什么?」 远距离扫描 / 俯瞰索引
「周围一圈都有什么?」 局部切面(ASCII 最有用的场景)
「从这里能不能看到它?」 可见性测试(遮挡判定)

其中「ASCII 网格」只是其中一种展示形态 —— 它的价值在于 让 LLM 能直接读,而 PNG 需要多模态。

4.3 核心原语(借鉴 mineflayer / prismarine 的做法)

mineflayer 的 raycast / blockAt 是纯数据的视线求交 —— 我们不连服务端, 但同样的计算可以跑在存档/注入的世界快照上。

# 基础已有(mcfold/world.py):
#   World.block_at(x,y,z) -> BlockHit(name, state_id, loaded, source)
#   ★ 它已能区分 unloaded / unknown-state ⇒ mod 方块不会被当成空气

# 待加(mcfold/rough.py):
def cast_ray(world, origin, direction, max_dist) -> RayHit
    """沿方向步进(DDA,与 MC 方块网格对齐),返回首个非空气方块"""

def visibility(world, eye, target) -> Visibility
    """两点之间是否通视(被几个方块挡住)"""

def slice_view(world, eye, yaw, pitch, w, h) -> AsciiGrid
    """把视野切成 w×h 的 ASCII 网格(agent 直接读)"""

def scene_summary(world, center, radius) -> dict
    """结构化线索:方块分布 / 开阔度 / 遮挡率(LLM 读数字比读 ASCII 准)"""

4.4 「ass」的三种展示形态(服务于不同用途)

① ASCII 网格      视野切面 —— 人看 + LLM 直接读
② 结构化线索       开阔度/遮挡率/最近景物/可通行方向 —— LLM 推理用(比①准)
③ 可选:俯视 ASCII  局部地图(w×h 格,每格一个地标字符)—— 定位用

4.5 验证口径(★ 改成"各自对各自用途",不是互相预测)

粗渲染的判据(不需要细渲染参与):
  · block_at 已区分 unloaded / unknown-state ⇒ 不能把未加载当空气
  · cast_ray 的结果与人工推算一致(可构造的简单场景:单方块、空走廊、斜坡)
  · 局部切面的 ASCII 与直接读方块数据一致

细渲染的判据(不需要粗渲染参与):
  · 现有:注入地形 → distinctColors / chunksRendered 达阈
  · 现有:Sodium tracker 通知后 chunkReady > 0

★ **不再要求**「粗渲染能预测细渲染」—— 两者服务不同问题
  (用户口径:粗=日常游玩,细=出图与精细判断)
  唯一的交集是「拍照选机位」这一个用例,那里是**用法**上的配合,
  不是**正确性**上的依赖。

4.6 实现落点

mcfold/rough.py(新模块,纯 Python、不依赖 JVM) tests/test_rough.py(默认套件里跑,秒级)


5. 聊天框 → 拍照 行为链设计(⬜ 未开始)

5.1 事件流(借鉴 mineflayer 的事件触发形态)

玩家在聊天框发消息
  ↓
① 收消息          ClientboundSystemChatPacket(或玩家聊天)
  ↓
② 意图识别        "给我拍张照" / "和那座山合个影"
  ↓
③ 需要什么        玩家位置/朝向 + 景物位置 + 可用机位
  ↓
④ 规划            粗渲染(常驻)试 N 个机位 → 挑最好的
  ↓
⑤ 出图            细渲染(单次调用:起 JVM → Iris+光影 → 销毁)
  ↓
⑥ 送达            回聊天 / 发邮件(走现有 agentmail 通路)

mineflayer 的参考点:它的 bot.on('chat', ...) + bot.chat() 就是 这个闭环的极简形态。我们要加的是 ③④⑤(拍照特有的)。

5.2 三个必须先确认的事实(否则设计是空中楼阁)

  1. 玩家位置从哪来? 我们是离屏渲染,没有真玩家实体。
    • 若接服务端:从 ClientboundPlayerPositionPacket 拿真玩家位置
    • 若不接:由调用方指定一个"玩家"坐标(合成场景)
  2. "景物"怎么定位? agent 怎么知道"那座山"在哪?
    • 自然语言 → 坐标,这是另一个问题(可能需要俯瞰图 + 索引)
  3. 相机可摆范围? 不能在方块里、必须在世界高度内、不能超出视距

5.3 最小可用版本(建议先做这个)

不接服务端,用调用方指定坐标打通全链:

# agent 调统一接口
shot = capture_photo(
    world=<存档或注入的场景>,
    subject=(x, y, z),           # 景物
    subject_scale=20,            # 景物半径
    with_player=True,            # 是否要合照
    player_pos=(x, y, z),        # 合照时的"玩家"位置
    samples=20,                  # 粗渲染试几个机位(常驻,毫秒级)
)
shot.png  → /path/out.png
shot.rough → ASCII + 线索(给 agent 看它为什么选了这个角度)

6. 里程碑(按 §0 目的重排)

★ 重心:「让 agent 能玩」 = 工具包(感知 + 行动)。   渲染是工具之一,不是主线;机位/拍照是用例,不是目的。

# 内容 依赖 状态
M0 起空白测试服(§1c),版本隔离三件套 — ✅
M1 修 build.sh 编译 CP(加 Fabric 库)⇒ 1.21.1 构建跑通 — ✅
M2 协议客户端:连测试服 → configuration → registry_data → 进 play M0 ✅
(这是最大缺口:现有只到 login_start 冒烟级)
M3 状态层:World / entities / inventory 从服务端真状态来 M2 ✅
M4 事件层:粗粒度 + 细分层(照 mineflayer,省 token) M2 ✅
M5 操作层:chat / look / move / dig / place(能改世界) M2, M3 ✅
M6 粗渲染(常驻感知):基于 M3 的真状态 M3 ✅
M7 LLM 友好的 API 形状(§1d):自省 / 可读返回 / 限量 M3–M6 ✅
M8 细渲染(单次调用):出图,带光影 M2 ✅
M9 第一个用例:capture_photo()(粗搜机位 → 细出图) M6, M8 ✅

M2 实测结论(2026-09-26)

已达成的可复现事实(连 M0 测试服 127.0.0.1:25566):

login          phase=login  → success(服务端回 UUID 与离线算法一致)
configuration  phase=play   注册表=11(biome/damage_type/enchantment/…)
play           28 种包      map_chunk×9、position、declare_commands、entity_metadata
               position      (5.5, -60.0, -0.5)

踩过的 7 个真实坑(都是「世代差异」或协议数据理解错误):

症状 根因
Failed to decode packet 'hello' 1.21.1 login_start.playerUUID 是必填 UUID;1.20 是 ['option','UUID']
configuration 收不到包 login_acknowledged 包 id 写死 0x00,实际 0x03
Badly compressed packet 写侧没实现压缩帧:varint(dataLen)+数据,小包 dataLen=0
服务端发完 select_known_packs 不动 configuration 是双向协商,必须回 select_known_packs/发 settings
registry_data 解不开 缺 anonymousNbt;旧 NBT 只是占位符
declare_recipes 流错位 数组计数有 countType(103处) 与 count(引用 ctx,6处) 两种
player_info/declare_commands 解不开 compareTo 可用路径:flags/x、../flags/x(12 处)
entity_metadata 越界 终止符是光秃 0xFF 一个字节,不含 type/value

关键架构发现(修正项目前提):vanilla 1.21.1 只在线同步 11 个动态注册表, 不含 minecraft:block / minecraft:item。静态注册表不走线同步 —— 客户端用自己 jar 里的。⇒ Java 侧读 client jar 的路线对 vanilla 正确; 但 mod 新增方块 vanilla 不会告诉客户端,那条要靠加载器自身的同步机制。

已知上游数据缺口(非本仓 bug,已容错):declare_recipes(108KB) 在官方 minecraft-data 3.117 + minecraft-protocol 上同样解不开 (PartialReadError: found 108271 / expected 114153)。 处理:长度前缀是帧层权威 ⇒ 坏包整包丢弃不断流(decode_failures 布告)。

M3 实测结论(2026-09-26)

新增 mcfold/state.py(状态层)+ mcfold/session.py(包流 → 状态)。

实测(连 M0 测试服):

connect   registries=11  phase=play  geometry=minecraft:overworld y∈[-64,320)
pump      9 区块 / 生命 20 / 饥饿 20 / entityId / gamemode 全落进状态
block_at  (x,y-1,z) → grass_block state_id=9  source=registry
列剖面     y=-64 bedrock → -63 dirt → -62 dirt → -61 grass_block
nearby    半径 6 → 3 种(dirt 338 / bedrock 169 / grass_block 169)
视野外    source=unloaded(与 out-of-world、unknown-state 可区分)

本轮挖出的真 bug:unload_chunk 协议字段顺序是 chunkZ 在前、chunkX 在后; 我按「x 先」读 ⇒ 坐标互换 ⇒ 卸载永不命中 ⇒ 区块只增不减(状态在撒谎)。

设计立场(重要,防越界):

  • 只陈述观察,不做决策:nearby_blocks 给种类统计而不给「该挖哪个」。
  • 未知 ≠ 没看见:认不出的 state id 记 unknown_state_ids 并显式暴露, 绝不静默映射成别的方块(静默映射会让 agent 基于虚构世界决策)。
  • 超范围要拒绝并说明,不静默缩小(否则 agent 以为扫全了)。
  • 几何从服务端 dimension_type 注册表吸收,不写死 24/384。

判据:tests/test_state.py 14 项;4 项变异全部被抓 (相对位移改绝对 / 未知方块静默映射 / unload 坐标交换 / keep_alive 不回应)。 ★ 其中「未知方块静默映射」第一次变异是空操作(改错了文件)⇒ 假绿; 核对变异是否真命中后重做,才确认判据有效。变异必须先验它落地了。

M4 实测结论(2026-09-26)

新增 mcfold/events.py(事件日志)+ session 的事件接线。

关键设计:不是回调,而是「环形缓冲 + 游标」 mineflayer 用回调(bot.on('physicsTick')),适合长跑机器人; 但 LLM agent 是回合制的 —— 回调对它有三个坏处:事件在两次观察之间 静默丢失、状态散在闭包里不可查、粒度不可控(physicsTick 20/s 烧穿 token)。 ⇒ 用可按需 drain() 的日志 + 单调 seq,agent 能问「我看过 seq=37 之后又发生了什么」。

实测(连测试服,含真实世界改动):

/setblock -2 -61 -5 minecraft:stone
  #27 方块变化 @(-2,-61,-5) grass_block → stone
  #26 系统: commands.setblock.success
以后 setblock ... air
  #33 方块变化 @(-2,-61,-5) stone → air
状态层: stone(state_id=1) → air(state_id=0)   ✓ 一致
细分层可查: block_changed:-2:-61:-5            ✓

本轮修的真 bug(3 个):

  1. dropped 恒为 0:deque(maxlen=) 丢元素完全无声(实测 maxlen=4 塞 10 只剩 [6,7,8,9])。这正是本层最不能犯的错 —— 事件丢了却不说,agent 以为世界没变。 ⇒ 改手动判满 + 显式计数。
  2. 事件键与渲染器不一致:我发 to_state_id、渲染器读 to ⇒ 显示 「grass_block → None」(看着有事件、其实没信息)。改为解出方块名, 且 from 区分「未加载/界外」而不是裸 None(把 unloaded 读成 air 会让 agent 误判脚下)。
  3. run_tests.sh 少报判据数:只数 ^ ✓,我特意用 ★ 标的回归判据没被计入 (test_events 16 条只报 7 条)⇒ 少报也是撒谎。修后总数 98 → 110。

判据:tests/test_events.py 16 项;4 项变异全部被抓 (drain 不推游标 / limit 变丢弃 / 细分层混入粗流 / 实体增量不除 4096)。

M5 实测结论(2026-09-26)

新增 mcfold/actions.py(16 个操作)+ mcfold/textcomp.py(聊天文本组件)。

实测(真服务器,真正改变了世界):

dig(7,-60,10)  → 方块变化 grass_block → air      ✓
place(7,-61,10, face=up) → air → stone            ✓
look(90,45) / select_slot / jump / sneak / sprint / swing  均被服务端接受、连接不崩

★★ 本轮最重要的发现:teleport_confirm 是必需前置包

1.19+ 的 position 包带 teleportId,客户端必须回 teleport_confirm, 否则服务端默默忽略所有方块操作(dig/place/use 全部无效)。

  • 症状:dig 一开始就失败、place 永远放不上去,没有任何报错
  • 我为此绕了一大圈:改了包名、改了字段顺序、改了 face 类型、 测了 sequence 递增、试了 5 种位置与 6 种面 —— 全都不对
  • 决定性一步是拿成熟实现 minecraft-protocol 做对照: 它发同样的包也放不上去 ⇒ 排除了「我的包写错了」
  • 顺带用 data get entity 排除了权限/物品/位置问题 (playerGameType=1 创造、SelectedItem={count:64,id:stone}、Pos 正确)
  • 补发 teleport_confirm 后立刻成功(air → stone)

⇒ 教训:静默失败要靠对照物定位,不要靠猜。 猜了 5 轮都不如 一次「同样的事别人怎么做」。

其他真 bug(判据抓到的):

  1. packet_interact / packet_swing / packet_animation 在 1.21.1 都不存在 (正确是 packet_use_entity / packet_arm_animation)⇒ 我一次写了三个不存在的包名
  2. dig 原写成 packet_block_place + 2 个多余 varint;正确是独立的 packet_block_dig ⇒ 多写字节把服务端解码器弄崩、连接被断(BrokenPipe),表面只是「挖了没反应」
  3. use_entity(mouse=0) 也带 hand 字段(我以为只有 mouse=2 带) ⇒ 包少 1 字节。这条是「把构造的包用官方 protodef 解回来」判据抓到的
  4. 1.21.1 背包字段是 itemId(驼峰),我写 item_id ⇒ 永远判「手上是空的」
  5. window0 的 slot 36..44 才是热键栏(0..8 是创作物品栏),索引搞错就看错物品
  6. 聊天是 NBT 文本组件,三重漏:只取 text→空串;translate 原样吐→键名不是人话; with 里数字存成 {"":1} 空键没读→「Gave Dirt to 」。 现在查 language.json 正确渲染成 Gave 1 Dirt to mcfold_act

判据:tests/test_actions.py 16 项;5 项变异全部被抓。 核心判据是「16 个操作的包都能被官方 protodef 精确解回(0 余字节)」—— 这是唯一能离线证明「包写对了」的办法(本层缺陷几乎全是静默的)。

M6 实测结论(2026-09-26)

新增 mcfold/rough.py(纯 Python、不依赖 JVM):agent 的常驻感知层。

回答的五类日常问题: raycast(挡不挡视线/前面是什么)、can_see、can_stand、walkable(走得通吗)、 slice_view(ASCII 切面)、surroundings(一次总览,省往返)。

实测(真服务器):

朝向 南(+Z)  脚下 grass_block  可站立 True  前面 clear
可行走网格 11×11:120 可站 / 1 障碍 / 0 未知
南北切面(宽 24 高 9):
  |                        |  ← 8 行空气
  |gggggggggggggggggggggggg|  ← grass 层
  |dddddddddddddddddddddddd|  ← dirt
  |dddddddddddddddddddddddd|

判据口径(plan §4.5):粗渲染不需要细渲染参与 —— 用构造场景判 (线上 flat world 太平凡,测不出射线对不对):单格薄墙、空走廊、斜坡、 未加载区、缺残注册表。

本轮修的真 bug(3 个):

  1. can_see 越过目标撞身后的墙:我射到 max_distance 再看首个命中 是不是目标格 ⇒ 目标 (1.5,2,0.5) 在墙前,射线越过它撞到 x=3 的墙, 把「看得见」误报成「被挡」。改为射程只到目标距离。
  2. 切面从脚下往上取 ⇒ 全是空气,看不出地形:改为基准下移, 让地面线落进画面(实测从「9 行全空」变成能看见 g/d 分层)。
  3. walkable 网格的 ? 覆盖不足:原来只统计「未加载」, 已加载但认不出名字的方块会被漏算。

判据:tests/test_rough.py 20 项;6 项变异全部被抓。 ★ 其中变异 F(认不出的方块改画 .)第一次没抓到 —— 因为我的 「未知格」判据只构造了未加载路径,覆盖面不足 ⇒ 补上「已加载但缺残注册表」 的场景后才抓住。变异抓不到时要先怀疑判据覆盖面,而不是以为实现没问题。

M7 实测结论(2026-09-26)

新增 mcfold/api.py:23 个工具(观察 14 / 事件 1 / 操作 7 / 会话 2), 外加 call(name, **kwargs) 派发入口。

维度 做法 判据
参数 全部 keyword-only(坐标必须按名字传) 反射检查参数种类
返回 每条结果带 describe(人类可读摘要) 12 个观察工具逐个查
失败 永不抛异常:ok:false + why + how_to 10 组垃圾输入全要结构化答复
规模 限量 + total/remaining/truncated 事件限量报剩余、超限拒绝并说明
发现 describe() 一次列全工具 + 约定 + 4 条提示 目录与实现双向一致

实测(真服务器):

status()  phase=play protocol=767 chunks=9 entities=4 decode_failures=1
where_am_i()  在 (-2.5,-60.0,5.5) 朝向南(+Z),踩着 grass_block
block_at(9999,60,9999)  未加载(该区块还没收到)      ← 不谎称空气
inventory()  手上(第 0 格): item#1(1.20.5+ 不传物品名)
dig(x=..,y=..,z=..)  ok=True → events 收到 block_changed

判据抓到的真问题:

  1. 坐标工具允许位置传参(block_at(0,0,0))⇒ LLM 看到 0,0,0 不知道哪个是 x/z。已把全部工具改成 keyword-only。
  2. 我第一版判据写成「不能有必填参数」,把「坐标必需」误判成违规 ⇒ 判据改为检查参数种类(关键字专用)—— 这两件事不是一回事。
  3. 5 项变异全部被抓(异常外泄 / 未知工具抛 KeyError / 物品编名字 / 不报剩余 / 自省少列工具)。

★ 一条操作纪律(我自己的失误):变异测试时用 /tmp/state.bak 恢复文件, 那份备份是过期的(少了 M6 新增的 113 行)⇒ 把好代码覆盖坏了, 且让其中一个变异「看起来被抓了」——其实是坏代码造成的。 ⇒ 恢复文件必须用 git(已知正确版本),且每个变异单独确认基线是绿的。 修好基线后重做该变异,才确认判据真的有效。

寻路(用户 2026-09-26 追加裁定,编号外的独立一节)

用户裁定要点:

  • 完整版寻路 + 可达性判断,且这两个是两个独立工具
  • 执行:两者都给(逐步 / 整段)
  • 未加载:走到边界为止 + 如实报告(绝不乐观推断)

新增 mcfold/pathfind.py + API 的 reachable / find_path(工具数 23→25)。

实测(构造场景 + 真服务器):

平地        reachable=True  find_path 10 步纯 forward      改世界 0
1 格障碍     reachable=True  step_up/jump 跨过             改世界 0
2/3/4 格墙  reachable=False  挖穿一列 2/3/4 格,无空气      改世界 N
深坑 3 格    reachable=False  place 搭桥 1 块
岩浆沟      reachable=False  place 搭桥
未加载边界   reachable=False  stopped_because=hit_unloaded
             停在 (15,-60,8),走了 19 步 +「再往前没数据」

本轮修的真 bug(6 个):

  1. is_ground 与 is_standable 语义混淆(差一格): 混用导致平地上找不到落脚点,寻路只肯搭桥。
  2. can_already_pass 判据用错对象(拿「更上方格子 standable」 当「已能通过」,而那是挖完才成立的)⇒ dig 永不生成,2 格墙走不通。
  3. dig 只支持挖 1 格 ⇒ 4 格墙永远走不通。改为「挖穿一列」, 代价按格数线性增长(挖 4 格远贵于挖 1 格 ⇒ A* 先试绕)。
  4. 代价权重失衡:1 格台阶被挖而不是 step_up (dig 6.0 vs 上下一级 2.7)⇒ 荒谬地挖穿一排墙。 调高 dig/place 代价 + 加「背离目标的额外代价」后修正。
  5. 未加载被报成 exhausted(听起来像「地形不通」): 根因是 neighbors() 在未加载格上不生成移动 ⇒ A* 里 「if not known(...)」永远执行不到。改为主动探测边界。
  6. Terrain.known 调用签名错、挖清单会包含空气(挖到墙顶之上)。

判据:tests/test_pathfind.py 19 项;5 项变异全部被抓。 ★ 其中「岩浆当地面」这个变异第一次没被抓 —— 查下去发现我的判据 测的是冗余代码:lava/water 本来就 solid=False, 去掉 hazard 检查也不会红。 真正需要 hazard 检查的是 solid=True 但致命的 (cactus/magma_block/campfire/scaffolding)⇒ 改用这些方块才抓到。 判据必须打在「真正承载语义的那段代码」上,否则测的是冗余。

M8 / M9 实测结论(2026-09-26)

新增 mcfold/fine.py(细渲染接真状态)+ mcfold/photo.py(拍照用例)。

关键发现:桥接比预想的干净。 我原以为要把 M3 的 state id 反算成 name[props],但查 Java 侧 set_blocks 发现它收的本来就是 state id(走官方 LevelChunk API), 而 state id 本身已含属性 ⇒ 无损、无需转换。

M9 真实出图(854×480,1902 种颜色,地形/天空/月亮/草地纹理都对):

机位候选(纯粗渲染观察,0 出图成本):
  [0] 正面   依据:站在目标正前方 3 格(0,-61,10 有 grass_block 支撑)
  [1] 斜侧   依据:侧 45°、距离 3 格(6,-61,12 有支撑)
  [2] 俯视   依据:目标正上方 4 格(朝向由位置反算)
  [3] 远景   依据:8 格外(近处视线:blocked)
  [4] 当前位置 依据:就用你现在的视角
出图:columns_rendered=9  hide_gui=True

本轮修的真 bug(4 个):

  1. HUD 没隐藏(plan 明确要求截图隐藏 HUD): 桥层 RenderBackend.set_view 早就实现了 hide_gui → 官方 options.hideGui, 但 WorldRenderer 根本没有这个入口 ⇒ 我调它时撞 AttributeError, 又被 except: pass 吞掉 ⇒ 出图带着生命条/饥饿/物品栏, 而返回值还写着 hide_gui: True(自相矛盾,没人发现)。 教训:桥层有能力 ≠ 上层有入口;且 except: pass 会把「以为设了」 与「实际没设」变成无法区分。现在 hide_gui 设不上就显式失败。
  2. 俯视机位的朝向不自洽:机位在目标正上方 4 格,水平 yaw 已退化 (dx,dz≈0),我却写死 pitch=-35 配水平算出的 yaw ⇒ 实测 3D dot=0.82(相机没真对着目标)。改为由位置向量反算 yaw/pitch。 顺带修了正面/斜侧/远景(高度不同但 pitch 写死 0)。
  3. inject_world 的 ok 自相矛盾:一个区块都没有时返回 ok=True, 而 note 却在说「没有可渲染的区块」。统一为「有成功注入」。
  4. 缺失的 section 被填 0(=空气)⇒ 把「没数据」渲染成空洞。 改为省略(Java 侧留空)。

判据:tests/test_photo.py 17 项;2 项变异全被抓 (删掉 set_view / hide_gui 又被静默吞)。 ★ 其中「机位朝向」那条判据我第一版只比水平分量 ⇒ 对俯视机位恒为 0, 会把正确实现判红。改为完整 3D 点积(dot > 0.99)。

M10 启动生命周期(用户 2026-09-26 定位:框架=agent 用的 MC 启动器)

用户原话:

「我们这个框架本身类似给 agent 用的 MC 启动器,只不过后端是我们魔改出来的。 所以多实例,多版本隔离,单个实例多次启动是自然而然被要求支持的」

盘点结果(对照这三句要求):

要求 现状 缺什么
多实例 目录隔离已在(instances/<name>,7 个实例) 缺「谁在占用它」的管理
多版本隔离 内容根 store/<loader>-<version>/ 分开 已有判据守着(test_version_isolation)
重复启动 后端用 Popen 起 JVM 无 pid 记录、无重入防护、无残留清理

新增 mcfold/launch.py:claim() 启动闸门 / register() 登记 / status() 三态查询 / stop() 带身份校验的终止 / cleanup() 只清记录。

为什么「重复启动」必须拦在启动之前:不拦的话两个 JVM 会抢同一个 run/ 与同一个 X display,报错指向渲染异常,完全指不到「重复启动」。

三态严格分开(agent 应对完全不同): free(没跑过/已正常退出)· stale(有记录但进程已死=上次崩溃残留) · running · foreign(pid 存活但不属于本实例 = pid 被复用)。

防误杀:pid 会被系统回收复用 ⇒ 终止前必须校验 cmdline 含本实例路径。 不校验就会杀掉无关进程。

实测(tests/test_launch.py 10 项,用真 java 进程):

三态分清 / 重复启动被拦(带 pid 与启动时间)/ stale 自动清后放行
两实例互不干扰(同版本多实例可用)/ 运行时目录在第三层
外来 pid 拒绝终止(未误杀)/ cleanup 只清记录不杀进程
运行中拒绝 cleanup / 完整循环 起→停→再起 / 会话记录可读 JSON

★ 判据必须用真 java 进程:sleep 3600 <path> 会拒绝多余参数退出; bash -c "sleep … # <path>" 会 exec 掉自己、cmdline 变成裸 sleep ⇒ 「模拟进程的形状」必须与真实启动一致(java … --gameDir <实例>), 否则测的是假形状。

★ 变异测试又抓到我自己的空变异:return str(inst) in cl or … → return True 看似改了身份校验,其实上面还有 if "java" not in cl: return False 早退了 ⇒ 判据全绿。必须先验证变异真的落地(打印改动后的行为),再信它的结果。

服务端侧缺口(下一步):server.py 是硬编码 vanilla 直起 (java -jar server.jar),装不了 mod。要装 mod 必须能起加载器服务端。 已下好 fabric-installer.jar(197KB,Fabric 1.21.1 → loader 0.19.5, 与我们客户端已装的一致)。

建议顺序:M0 → M1 → M2。

  • M0/M1 是解阻断(M1 是我已经查到根因的那个 classpath bug)
  • M2 是真正的门槛:从「能渲染」到「能玩」,缺的不是渲染,是完整协议层。 mineflayer 的 41 个 plugins 全建立在它之上,我们目前只到 login_start。

★ M8(细渲染)不是早期优先级 —— 它已经能跑(Iris 光影 + 地形, colors=113546 是实测),只是接不上服务端真数据。等 M2/M3 通了, 它自然就在链路上。

★ M9(拍照)放最后 —— 它是验证工具包立不立得住的用例, 不是工具包本身。

7. 工程纪律(本项目已确立的口径)

  1. Python 只路由,差异进 Java —— Python 侧 0 处 MC 类名引用
  2. 验证要能失败 —— 判据必须构造触发,不靠观察现状 (「今天有反例」与「今天没反例」都可能抓不到缺陷)
  3. extern 参数只传路径 —— 命名空间知识留在 jar 侧
  4. 报数必须带:来源 + 取数时刻 + 谓词/域
  5. 门禁要放默认套件 —— 放 --with-render 才跑的守卫等于没守卫
  6. 实例与内容根同级 —— 绝不嵌套;同版本可多实例
  7. 诚实标注射程 —— 「加载器能起动」≠「mod 能用」

8. 环境备忘

仓库        /home/newqqagent/plugindev/mcfold   (远端 ssh://git@jianfgit.xyz:220/jianf/agentmc.git)
内容根      /home/newqqagent/mcview/store/vanilla-{1.20.1,1.21.1}/
实例        /home/newqqagent/mcview/instances/<name>/
构建产物    内容根/.mcfold/{classes,classes-intermediary.jar,client-named.jar,
            names*.json,named2intermediary.srg,inheritance.map,classpath.txt,
            natives/,fabric/}
服务端      /home/minecraft  (neoforge 1.21.1,mods 若干,端口 25565)
mineflayer  /home/newqqagent/mcbridge/node/node_modules/mineflayer  ← 参考实现
Xvfb        :98

测试        ./run_tests.sh              66 项
            ./run_tests.sh --with-render 80 项(需 MCFOLD_RENDER_CLASSPATH 等)

渲染环境变量:

export MCFOLD_RENDER_CLASSPATH="$(paste -sd: $STORE/.mcfold/classpath.txt):$STORE/.mcfold/client-named.jar:$STORE/.mcfold/classes"
export MCFOLD_NATIVES_DIR=$STORE/.mcfold/natives
export DISPLAY=:98