26 Commits

Author SHA1 Message Date
554ebc239a M10c:mod 环境下寻路 —— 问运行时而非猜名字(+ 语义表与覆盖率报告)
用户要求:Create 与 Aeronautics 会引入大量原版没有的方块与危险区,
寻路必须一起优化。用户裁定:危险区用「表 + 命名推测并标记」,
动态机械用「警告 + 提高代价」。

## ★ 关键实测结论(本轮最重要)

我先按「给 mod 方块建语义表」做,实测覆盖率:

    Create 643 个方块 → 显式表命中 9、命名规则推测 21、完全不识 613
    覆盖率 4.7%

⇒ **手写表是死路**(64 mod / 1465 方块永远追不上)。
⇒ 但寻路真正需要的属性,**运行时全都有**:
   Java 侧 blockinfo 已在读 BuiltInRegistries.BLOCK(**含 mod 运行时注册的方块**),
   并返回 isSolid / destroySpeed / lightEmission / isAir / hasBlockEntity。
⇒ 正确做法是**问**,不是猜。

## 改动

新增 mcfold/solid.py:
- SolidSource 抽象:RegistrySolidSource(本机,快但无 mod)
  vs RuntimeSolidSource(问 JVM,**含 mod**,准但要 JVM)
- 按 **state_id** 缓存(属性只依赖 state,不依赖坐标)
- attach_runtime() 批量探测(一次问一格要一次往返,批量才可用)

mcfold/pathfind.py:Terrain 改为「**先运行时缓存,再本机注册表**」;
  can_dig 用 destroySpeed 判可挖(基岩官方约定是 -1 = 不可破坏),
  不再靠名字猜「基岩挖不动」

新增 mcfold/modblocks.py(危险/会动这部分的表,配套覆盖率报告):
- 语义取���:ground/passable/hazard/breakable/solid/dynamic/carrier/unknown
- 三级来源:显式表 → 命名规则(**标记 inferred**)→ **如实说不认识**
- ★ vanilla 与 mod **分开算覆盖率**:vanilla 语义由注册表给,
  把它算成「未知」会让人误以为「连石头都认不出」

WorldState:加 solid_cache 字段

## 判据

test_pathfind 19 → 24 项,新增 5 项:
- mod 方块优先用运行时 isSolid(本机注册表确实认不出)
- 不可破坏吃 destroySpeed(含基岩 -1 约定)
- 两边都无数据 ⇒ 不知道(**不谎称可走**)
- 覆盖率报告如实(且区分 mod/vanilla)
- 语义查询永远返回带 inferred 标记的结构

★ 写判据时踩了两个坑(都是 fixture 形状错,不是代码错):
  1. `Chunk.sections[i]` 对应 y = MIN_Y + 16*i,MIN_Y=-64
     ⇒ 我按「第 0 个 section = y=0」写,查 y=0 拿到的是空气
  2. 同一 section 内 16 格**只能一个值** ⇒ mod 方块与空气落同一 section 时,
     空气格会变成 mod 方块(看着像代码 bug,其实是 fixture 形状问题)
  ⇒ 教训:**判据的 fixture 形状必须与被测代码的真实输入结构一致**。

226 项全通过。
2026-09-26 14:29:58 +08:00
9c5fed5808 M10b:加载器服务端(让 server.py 能起带 mod 的服)+ 11 项判据
用户裁定「加载器都做」+ 选项 A(测试服装 mod)。原 server.py 是硬编码
vanilla 直起(java -jar server.jar),装不了 mod ⇒ 选项 A 走不通。

新增 mcfold/servers.py:
- create_modded_server():在**实例层**装一个 modded 服务端
- fabric_installer / fabric_server_launcher / fabric_profile_meta:
  装前检查(版本/加载器显式给,不猜、不用「最新」)
- 沿用既有三层隔离,**内容根零写入**

隔离要点(tests/test_version_isolation 守同一条不变量):
  mods 落**实例层** ⇒ 同一内容根可起多个「装了不同 mod 的服」,
  它们互不看见对方的 mod(这正是版本隔离要防的崩因)。

★ 关键设计:未接的加载器(NeoForge/Forge)**明说未接且不留痕**
  - 静默降级成 vanilla 的后果很严重:服起来了但 mod 全没加载,
    而使用者以为装上了 ⇒ 后续所有观察都基于错误前提
  - 失败时不建实例目录(否则会留个「看着存在但没配好」的壳,
    list_instances 会把它列出来)—— 实测修掉了这个

顺手补上:instance.json 记全 version/loader/role
  (之前 7 个实例里有 2 个缺它,list_instances 只能报 has_meta: False
    ⇒ 隔离没有可核查的依据)

判据 11 项(tests/test_servers.py),全程用临时目录不碰真实 mcview;
fabric 路径替换掉下载函数 ⇒ 判据不依赖网络(否则网络一挂就假红)。

4 项变异全被抓:
  A mods 写进内容根(破坏隔离)
  B 未接加载器静默降级成 vanilla(最危险)
  C 缺 mod 静默跳过
  D instance.json 不记 loader/version

下一步(选项 A 的收尾):在 M0 测试服起一个装了 Fabric mod 的服,
验证 mod 方块能被识别与渲染。

220 项全通过。
2026-09-26 14:10:43 +08:00
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
4980d6e81f M8/M9:细渲染接服务端真状态 + capture_photo 拍照用例
新增 mcfold/fine.py:把 M3 状态层的区块灌进细渲染器
- chunks_to_sections:铺平顺序 = (y<<8)|(z<<4)|x(与 Java PalettedContainer 一致)
  ★ 错一位不报错、只是地形静默错位 ⇒ 判据用构造数据打这个点
- 缺失的 section **省略**而非填 0(0=空气,把「没数据」渲染成空洞)
- render_from_state:单次调用,失败全部返回 ok:false + why + how_to

新增 mcfold/photo.py:capture_photo
- propose_shots 用**粗渲染观察**生成机位候选(正面/斜侧/俯视/远景/当前位置),
  每个都带「为什么选它」的依据;候选阶段不出图(省时省钱)
- 构图好坏是审美判断=决策,工具不替 agent 选

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

修的真 bug:
1. ★★ HUD 没隐藏:桥层 RenderBackend.set_view 早有 hide_gui
   (→ 官方 options.hideGui),但 WorldRenderer **没有这个入口**
   ⇒ AttributeError 被 except: pass 吞掉 ⇒ 出图带生命条/饥饿/物品栏,
   而返回值仍写 hide_gui: True(自相矛盾)
   教训:桥层有能力 ≠ 上层有入口;except: pass 让「以为设了」与
   「实际没设」无法区分。现在 hide_gui 设不上就显式失败。
2. 俯视机位朝向不自洽(水平 yaw 退化却配写死的 pitch)→ 3D dot=0.82。
   改为由位置向量反算 yaw/pitch;正面/斜侧/远景同样修正。
3. inject_world 的 ok 自相矛盾(无区块时 ok=True 却说「没有可渲染的区块」)
4. 缺失 section 被填 0 冒充空气

实测出图:854×480、1902 种颜色,地形/天空/月亮/草地纹理正确。

判据 17 项;2 项变异全被抓。
★ 「机位朝向」判据我第一版只比水平分量 ⇒ 俯视机位恒为 0 会判红,
  改为完整 3D 点积(dot > 0.99)。

200 项全通过。
2026-09-26 13:52:33 +08:00
93243165bf 寻路:reachable 与 find_path 两个独立工具(完整版 + 可达性判断)
用户裁定:完整版寻路 + 可达性判断,且这两个是**两个独立工具**;
未加载走到边界为止并如实报告;执行方式两者都给。

新增 mcfold/pathfind.py:
- 移动词表对齐 mineflayer movements.js:forward/step_up/step_down/drop/jump/dig/place
- A*(八向 + 高度),可按「节点数/半径」有界
- reachable:只答「能不能到 + 大概多远」,**不给路线**,默认不挖不放
- find_path:给具体路线 + world_changes(要挖/放哪些方块,逐格列出)

修的真 bug(6 个):
1. is_ground(这格是实心底)与 is_standable(脚能停这格)语义混淆,差一格
   → 混用导致平地找不到落脚点,只肯搭桥
2. can_already_pass 判据用错对象(拿「更上方 standable」当「已能通过」,
   而那是挖完才成立)→ dig 永不生成,2 格墙走不通
3. dig 只支持挖 1 格 → 4 格墙永远不通。改「挖穿一列」,代价按格数线性
4. 代价权重失衡 → 1 格台阶被挖而不是 step_up(挖穿一排墙)
   调高 dig/place 代价 + 加「背离目标的额外代价」后修正
5. ★ 未加载被报成 exhausted(听起来像「地形不通」):根因是 neighbors()
   在未加载格不生成移动 ⇒ A* 的未加载检测永远执行不到
   → 改为主动探测边界,现在报 hit_unloaded + 停在边界 + 已走步数
6. Terrain.known 调用签名错;挖清单会含空气

诚实性(用户最在意的一条):
- 未加载格所有判定回 None,**不当成空旷**
- 岩浆/水不当地面;solid=True 但致命的(仙人掌/岩浆块/篝火)也被排除
- 认不出的方块 → 不知道(None),不猜
- 碰撞粒度只有 block/empty 两档这个限制**写在返回值里**

判据 19 项;5 项变异全被抓。
★ 「岩浆当地面」变异第一次**没被抓** —— 查下去发现我的判据测的是冗余代码
  (lava/water 本来 solid=False,去掉 hazard 检查也不会红);
  真正承载语义的是 solid=True 但致命的那批方块 → 改用仙人掌才抓到。
2026-09-26 13:42:32 +08:00
69f54dc5a6 修复状态层撒谎:自身位置是客户端权威,move 必须更新本地状态
★★ 真 bug(用户问「寻路呢」时暴露出来):move() 之后本地坐标不变,
   而服务端认为玩家真的移动了。

实测证据:
  连发 5 次 move 后
    本地以为:  (0.5, -60.0, -3.5)    ← 没动
    服务端认为: (5.5, -60.0, -3.5)    ← 真的动了
  修后:本地 (10.5,-60,-3.5) == 服务端 [10.5d,-60.0d,-3.5d] ✓

根因:MC 的自身位置是**客户端权威** —— 客户端跑自己那侧物理并上报,
服务端只做校验(穿墙/超速会发 position 拉回),普通移动**不回包**。
我把这条规则错用成「等服务端权威」⇒ 本地状态永久陈旧。

与另一条的对比(别搞成一套):
  自身移动(move/look)  客户端权威 ⇒ 本地必须立刻更新,标 pending 待确认
  改世界(dig/place)    服务端权威 ⇒ 本地不动,等 block_changed 事件

★ 同时纠正一条**我自己写错的判据**:
  test_move_does_not_falsely_update_local_state(断言「不该改本地」)是错的,
  已替换为 test_move_updates_local_state_because_client_is_authoritative,
  并补 test_dig_place_do_not_touch_local_world_state 守住相反的那条。

这个 bug 影响很大:寻路/碰撞/可达性判断全都建立在玩家位置上,
状态陈旧会让整个工具箱在错误前提下工作。
2026-09-26 13:21:44 +08:00
94bcd37e66 M7:面向 LLM 的 API 形状(23 个工具)+ 17 项判据
新增 mcfold/api.py:把 M3/M4/M5/M6 包成 agent 能自己摸清的工具箱
- 观察 14:where_am_i/block_at/column/see/raycast/walkable/slice/
            nearby_blocks/nearby_entities/inventory/surroundings/look_at
- 事件  1:events(含细分层按前缀查)
- 操作  7:chat/command/look/look_at/move/dig/place/use_item/select_slot
- 会话  2:status/describe
- call(name, **kwargs) 派发入口

照 plan §1d 五个维度落地:
- 参数:全部 keyword-only(坐标必须按名字传,LLM 不会搞混 x/y/z)
- 返回:每条带 describe(人类可读摘要)
- 失败:永不抛异常 → ok:false + why + how_to
- 规模:限量 + total/remaining/truncated,明说被截断
- 发现:describe() 一次列全工具 + 约定字典 + 4 条提示;目录与实现双向一致

判据抓到的真问题:坐标工具原允许位置传参 ⇒ 已全改 keyword-only。
★ 判据自身也修正过一次:我把「不能有必填参数」误当成规则,
  实际要判的是参数**种类**(关键字专用)——「坐标必需」与「可位置传」是两件事。

★ 操作失误:变异测试用过期 /tmp 备份覆盖了 state.py(丢 113 行),
  导致一个变异「被抓」其实是坏代码造成 ⇒ 恢复必须用 git,
  且每个变异单独确认基线绿。重做该变异后才确认判据有效。

163 项全通过;5 项变异全被抓
2026-09-26 13:17:17 +08:00
27b5c448b0 M6:粗渲染/常驻感知层(纯 Python,无 JVM)+ 20 项判据
新增 mcfold/rough.py:agent 日常游玩的感知层
- raycast(Amanatides & Woo 逐格体素遍历,不采样不插值)
- can_see / can_stand / walkable / slice_view / surroundings
- BlockRegistry 扩展 solid/transparent(从 blocks.json 读,不编)

修的真 bug:
- can_see 射到 max_distance ⇒ 越过目标撞身后的墙,把「看得见」误报「被挡」
  → 改为射程只到目标距离
- 切面从脚下往上取 ⇒ 全是空气看不出地形 → 基准下移让地面入画
- walkable 的 ? 漏统计「已加载但认不出名字」的方块

纪律:
- 有界(超范围拒绝并说明,不静默缩小)
- 不撒谎(unloaded / unknown-state / None 三态分明;把「没见过」当「空旷」
  是这层最危险的错)
- 不决策(只给观察,不告诉 agent 该走哪)

判据用构造场景(plan §4.5:粗渲染不需要细渲染参与):
单格薄墙、空走廊、未加载区、缺残注册表。
146 项全通过;6 项变异全被抓(其中一项初次未抓到 ⇒ 补判据覆盖面后才抓住)
2026-09-26 13:13:15 +08:00
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
d2b4c5029d M4:事件层(可按需排空的日志 + 游标)+ 16 项判据
新增 mcfold/events.py:EventLog(环形缓冲 + seq + 游标)
- 不做回调式推送:LLM 是回合制的,回调会让事件在两次观察之间静默丢失
- drain(limit) 只取本轮、剩余留待下次(不丢)
- 细分层(block_changed❌y:z)独立可查,不污染粗粒度流
- render_events() 输出紧凑文本行,省 token

session 接线:block_change / multi_block_change / chat / damage /
entity spawn·move·destroy / held_item_slot,并加 chat()/command() 发送

修的真 bug:
- dropped 恒 0:deque(maxlen=) 丢元素无声 ⇒ 改手动计数(事件丢了必须说)
- 事件键 to_state_id 与渲染器的 to 不一致 ⇒ 显示 None,改为解出方块名;
  from 区分「未加载/界外」而非裸 None
- run_tests.sh 只数 ✓ 不数 ★ ⇒ 判据少报(98 修正为 110)

实测:/setblock 后事件正确报 grass_block→stone→air,与状态层一致
判据:110 项全通过;4 项变异全部被抓
2026-09-26 12:27:28 +08:00
40b2e1d60f M3:状态层(服务端真状态)+ 包流灌入 + 14 项判据
新增:
- mcfold/state.py   WorldState / WorldGeometry / PlayerView / Entity
- mcfold/session.py StateSession:connect() 走完 login→configuration→play,
                    pump() 把包落进状态,on() 留 M4 事件口

修的真 bug:
- unload_chunk 协议顺序是 chunkZ 在前、chunkX 在后;按 x 先读会坐标互换,
  卸载永不命中 ⇒ 区块只增不减(状态撒谎)

设计立场(防越界):
- 只陈述观察、不做决策(nearby_blocks 给统计不给「该挖哪个」)
- 未知 ≠ 没看见:认不出的 state id 显式记账,绝不静默映射成别的方块
- 超范围拒绝并说明,不静默缩小
- 几何从服务端 dimension_type 吸收,不写死 24/384

实测:11 注册表 / 9 区块 / 生命 20 饥饿 20 / 列剖面 bedrock→dirt→grass_block
判据:91 项全通过;4 项变异全部被抓(含「变异空操作 ⇒ 假绿」的核对)
2026-09-26 12:21:18 +08:00
547f4beb7e 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 项通过
2026-09-26 12:15:48 +08:00
480fe8e225 M0 完成:空白测试服(vanilla 1.21.1 / 0 mods)
## 为什么不用现役服(用户定向)

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

理由不只是"干净",而是**可预期**:
现役 `/home/minecraft` 是 world 15G + 64 mods + neoforge ——
任何协议层改动都可能污染它,且**测不出边界**(模组太多、状态太杂)。
我们要验的是**协议与行为**:已知方块集、已知出生点、可重置的世界。

## 形态

    store/vanilla-1.21.1/server/    内容根  jar + 配置 + world
    runtime/test-server/            运行期  server.log / server.pid

- **vanilla 1.21.1**,0 mods(协议最干净,先验协议再逐个加 mod 验差异)
- **超平坦 + 固定种子** `mcfold-test-2026`(地形可预测)
- creative / peaceful / 无怪物 / 无结构 / spawn-protection=0
- **端口 25566**(避开现役 25565)
- server.jar 下载后**校验 sha1**(`59353fb...`,实测一致)

## mcfold/server.py:起停 + 探活

`python3 -m mcfold.server {start,stop,status,reset}`

★ **status 是协议级探活**(Server List Ping,state=1),不是"端口开着就算活":
必须回出 version / protocol / motd 才算真起来。
实测:`1.21.1 protocol 767  players 0/4  motd=mcfold test server`

## tests/test_server.py(4 项,默认套件)

  ✓ 配置生成一致  —— ★ 守护一个真实踩过的坑:
      properties 里写**行内注释**(`k=v  # 注释`)会被解析成值的一部分。
      我第一版 `level-type=minecraft\:flat  \# 超平坦` 就是这样坏掉的
      (服务端把它当成值,且 spawn-protection 也回到默认)。
      ⇒ 生成器**不写行内注释**,这条判据守它。
  ✓ 三层隔离(内容根 vs 运行期;日志不落内容根)
  ✓ 端口避让(25566 ≠ 现役 25565)
  ✓ **协议级探活**(起服时验 protocol == 767;没起服则跳过,不误报)

## 变异验证(工程纪律 #2:判据必须能失败)

把 `PROTOCOL = 767` 改成 `999` ⇒ `test_status_proves_protocol` **变红**
("协议号不符:767 != 999"),还原后恢复绿。
⇒ 这条判据不是恒绿。

## 测试

  ./run_tests.sh  → **70 项**(原 66 + 本套件 4),0 失败
2026-09-26 11:12:40 +08:00
a68497089b 加载器联测加强 + Sodium 诊断探针
## tests/test_loader_render.py

★ 划清本文件的**射程**(诚实标注):
它证明的是「Knot 能被我们驱动,且在加载器下渲染能力照常」。
它**不**证明「真实 mod(带 mixin 的)能工作」—— 因为它用空 mods 目录。

  实测原因:真实 mod 的 mixin 目标是 intermediary 名,而我们跑 named
  ⇒ 同一 JVM 只能一个命名空间 ⇒ 真实 mixin mod 在本路线下不生效。
  ⇒ 「加载器能起动」与「mod 能用」是**两件事**,别把前者读成后者。

这条注释是被自己坑出来的:早先那份 6/6 全绿的报告,
读起来像"加载器路线可用",实际只验证了"Knot 能起来"。

## tools/probe_sodium.py

诊断脚本:在装了 Sodium 的实例上跑一次注入 + debug,
打印它自己的状态(chunkStatus / chunkReady / loadQueue / rebuildQueue /
visibleChunks / chunksDebug),并出一张图。

★ 为什么需要它:vanilla 的 `countRenderedChunks()` 在 Sodium 下
  **永远返回 0**(它数的是 vanilla 的网格),光看这个数会把
  「换了渲染器」误判成「没有数据」。而 blockinfo 能读方块 ⇒ 数据是在的。
  ⇒ 排障必须问**渲染器自己**,不能问 vanilla 的计数器。

(debug 命令里的 sodium* 字段就是这条思路的落地:
  sodiumLoaded / sodiumTracker_chunkStatus / sodiumTracker_chunkReady /
  sodiumRebuildQueue / sodiumChunksDebug …)
2026-09-26 10:18:36 +08:00
330867c6df 版本隔离守卫(新套件立即抓出两处真实回流)
## 修掉渲染联测的嵌套实例目录

`test_render_bridge.py` 把实例写成 `<store>/instances/test-mcfold`。
而权威口径(mcview/lib/versions.cjs:59/61)是:

    contentRoot = BASE/store/<key>       共享只读(jar/libraries/assets)
    instanceDir = BASE/instances/<name>  实例独占(mods/config/options/saves)

⇒ 那是「一个版本的内容根里长出了实例态」。而版本隔离要防的崩因正是
「多实例共用 mods」—— 内层目录会把它重新引回来,而那条路径看起来完全合法。
改为 `BASE/instances/test-mcfold`。

## 新增 tests/test_version_isolation.py(4 项,默认套件里跑)

★ 它**不需要 JVM**(纯路径断言),所以必须落在默认测试里 ——
放进 `--with-render` 才跑的套件等于没守卫(大多数人不会加那个参数)。

  ✓ 三层同级(store/ · instances/ · runtime/ 是 BASE 下的兄弟)
  ✓ 内容根干净(无 instances/ 嵌套、无 mods/config/options.txt/saves)
  ✓ 测试实例归位(落在 instances/ 层,不在任何内容根下)
  ✓ 内容根无实例配置(查内容,不只查存在)

## ★ 守卫首次运行就抓出第二处(我此前漏掉的)

```
  ✗ 内容根被实例化:
    - store/vanilla-1.21.1/instances/(内容根里嵌套了实例层)
```

内容是 `instances/default/{options.txt,downloads/log.json}`(生成态,
正式 `instances/default` 已存在)⇒ 已删。两个内容根现已干净。

⇒ 这条说明:**「我检查过了」不等于「有一条会自己跑的检查」** ——
  我上一轮手工核实过 1.20.1 那处并修掉,却没发现 1.21.1 那处;
  守卫一运行,两处一次全暴露。

## 验证

  ./run_tests.sh               → 66 项
  ./run_tests.sh --with-render → **80 项全绿**
2026-09-26 07:23:54 +08:00
8b1fc9289d Fabric 加载器路线跑通并落地验证(launcher="fabric",6/6 绿)
上个会话把 `launcher="fabric"` 写进了后端,却**没有留下任何可重跑的验证** ——
「经加载器能起来」这件事只活在对话里。本次把它变成可复算的。

## 机制(路线 C,不是 LOADERS.md 原来写的 A/B)

A/B 都假设「游戏由 Loader 起」。但 `Knot.init()` 是 public 的:它做完
「发现 mod → 建 KnotClassLoader → 应用 mixin → invokeEntrypoints(preLaunch)」
后**返回**,不进入官方主循环(那在 GameProvider.launch())。
⇒ `knot.loadIntoTarget("McfoldRenderServer")` 把我们的服务端注入同一个
KnotClassLoader —— **mod 生态与我们的控制权同时拿到,不必把服务改写成 mod**。

## 主要改动

### ① `RenderBackend._fabric_classpath()` —— 组装从用户手里移到后端

两个变换是实测必需、且用户不该自己拼的机械操作:
  - 剔除 1.20.1 自带的 asm(9.2 与 9.6 两版并存)—— 否则 Knot.<clinit> 抛
    "duplicate ASM classes found on classpath"
  - 追加 Fabric 的 8 个库(asm×5 / sponge-mixin / intermediary / fabric-loader)

库来源改为内容根 `.mcfold/fabric/`(原在 /tmp/fab,tmpfs 且违反版本隔离)。
实测 67 条 → 去 7 asm + 加 8 fabric = 68 条。

### ② `tests/test_loader_render.py`(新增,6 项)

  ✓ 启动架构与世代正交(动态子类,不写 N 个类)
  ✓ 两个架构可枚举
  ✓ classpath 组装(67→68:去 7 asm,加 8 fabric)
  ✓ 非 asm 库原样保留(去 asm 是外科手术,不是重写)
  ✓ **Fabric 加载器真启动并渲染**(KnotClassLoader / chunks=10..25 / colors=4391..4547)
  ✓ **Fabric 拒绝不兼容 mod** ← 反向对照:缺 fabric-api 的 Explorify 被拒

最后一条是**两条路线的可判差异**:零加载器是「无脑注册」,不知道 mod 要
fabric-api;Fabric 会。没有它,"加载器真的介入了"这句话就没有反面证据。

### ③ `run_tests.sh` —— 慢套件改为列表驱动 + 全跳过不再报绿

- SLOW 列表含 test_render_bridge / test_loader_render(默认不跑)
- ★ 全部跳过的套件**不算绿**:报"跳过 N 项"而非计入通过数 ——
  否则"没测"会被读成"测过了"。

## 验证

  ./run_tests.sh               → 62 项(2 个慢套件标 ⊘)
  ./run_tests.sh --with-render → **76 项全绿**
    (62 单元 + test_render_bridge 8 + test_loader_render 6)

## 仍未做

  Iris 光影真实加载(加载器已通,未验过一次 shader pack)
  forge / neoforge 后端(gen_1_20 / gen_1_21 均待设计)
2026-09-26 07:12:41 +08:00
35534b1564 feat: get_block_info —— 方块信息查询(Java 侧回答,Python 只路由)
新增 cmd=blockinfo(两代后端编译通过、实测):
  返回 block 名(含 mod 方块)、stateId、属性、isAir/isSolid、
  光照/硬度、贴图缺失的程序化判据、是否来自 mod。

★ 修掉一个真实缺陷(附录 D 的语义区分落到了实处):
  未加载区块原先返回 void_air(known=true),实测发现两个 API 都不可用:
    - ClientLevel.hasChunk()      反编译实测**恒返回 true**
    - ChunkSource.hasChunk()      走 getChunk(...,false),而 ClientChunkCache
      覆写后在该参数下返回 emptyChunk(占位,非 null)⇒ 也恒 true
  正解:getChunk(..., requireChunk=**false**) 判 null
    (反编译:return requireChunk ? emptyChunk : null;)
  ⇒ 现在未加载明确回 known=false + reason=chunk_not_loaded

实测(1.20.1):
  草地 → minecraft:grass_block props={'snowy':'false'} missing=False
  原木 → minecraft:oak_log       props={'axis':'y'}
  空气 → minecraft:air
  远处 → known=false reason=chunk_not_loaded        ← 与空气区分

实测(1.21.1 + mod):
  mts:mtsofficialpack.fancydirt_block   mod=True tex=mtsofficialpack:block/fancydirt
  createbigcannons:steel_block          mod=True tex=createbigcannons:block/steel_block
  ⇒ mod 方块同样能查到正确贴图名与 fromMod 标记

顺带修 gen_1_21 两处世代差异(ChunkStatus 换包、bridge 编译顺序)。
测试:69 项全绿(新增 test_get_block_info 6 条判据)
2026-09-26 00:24:29 +08:00
16f2e04fff 架构重构:差异全部下移到 Java,Python 只做路由
用户口径:差异放在 Java 层,Python 相当于路由。

下移到 Java(新增 cmd=setblocks):
  Python 只发 RLE([[stateId,count],...]),Java 用官方 API 完成
  调色板组装 / 位打包 / 官方序列化 / 入 cache / 渲染。

★ 意外收益:走 ClientboundLevelChunkPacketData(chunk).getReadBuffer()
  + replaceWithPacketData(...) 这条官方路径,两代签名完全一致
  ⇒ 原先的 Storage.chunks 反射注入**整段删掉**。

新增 cmd=capacity:世界尺寸(sectionsCount/minSection/defaultBiome)
  由运行时回答,Python 不再硬编码 24/-4(1.16 时代是 16/0)。

Python 侧删除(防回归,见 test_import_surface):
  render_bridge.py(旧桥,无世代概念)
  render._pack/_long_array/_encode_paletted/_encode_chunk/_varint
  anvil.encode_block_section/encode_chunk_payload/save_chunk_to_payload
  合计 454→374 行(render.py)、300→177 行(anvil.py)

保留在 Python(都是「不随版本变」的知识):
  RLE 压缩(rle_encode/rle_decode,具名可测)
  存档读取(region/NBT)
  方块名→id 的路由(查询在 Java)
  NDJSON 协议

测试:62 项单元 + 6 项渲染联测全绿
  新测试反映新架构:RLE 边界、运行时容量、世代分派、新路径渲染
  test_anvil 里原有 8 项协议位打包测试已删(那已下移到 Java,
  由两代后端各自的编译验证覆盖)

新增 scaffold/ARCHITECTURE.md(分层原则 + 加新世代步骤 + 防回归清单)
2026-09-25 19:21:45 +08:00
34ed70ab53 修复视距不生效 + 接入统一测试
视距(用户报「无法调整视距?」)——根因是三处不同步:
  1. options.renderDistance        本地设置(原先只设了这个)
  2. options.setServerRenderDistance 服务端下发上限(getEffectiveRenderDistance = min(两者))
  3. ClientChunkCache.updateViewRadius storage 半径
第三处最关键:官方只在收到服务端半径包时才调它(ClientPacketListener:2316),
我们不连服 ⇒ Storage.inRange 用旧半径判定,超出区块被**静默丢弃**。

另一个前置:加载半径必须跟随视距。视距 12 只喂 3 圈数据,
画面看起来就像没生效(最容易误判的坑)。

实测(同相机同存档):
  视距 4  → 注入 81   渲染 55   色彩 15226
  视距 12 → 注入 595  渲染 447  色彩 18477
画面差异显著:RD=12 能看到延伸到地平线的森林与完整海岸线。
(sample-render-distance-{4,12}.png)

测试:新增 tests/test_anvil.py(13 项,存档/NBT/编码)
      tests/test_render_bridge.py(3 项,桥协议+合成渲染+真实存档渲染)
      run_tests.sh 改为默认跑单元测试、--with-render 跑联测
      合计 61 项全绿(原 48 + 13)
2026-09-25 17:47:58 +08:00
pi
5038c7f679 feat(mcfold): P7 实例层(传入文件夹→拉起实例→读mod注册方块表→连服,含端到端验证)+ 统一测试入口 2026-09-25 14:36:21 +08:00
pi
b0182de8c7 feat(mcfold): P6 世界模型(多区块聚合/负坐标定位/unloaded与air语义分明/mod降级统计) 2026-09-25 14:33:26 +08:00
pi
85ad7e580e feat(mcfold): P5 mod jar 解析(三种加载器+多加载器诊断+方块名提取,含诚实边界标注) 2026-09-25 14:32:39 +08:00
pi
40c5739cae feat(mcfold): P4 区块解析(位打包/三种调色板分支/注册表显式降级)+ prismarine 真实数据交叉验证 2026-09-25 14:25:34 +08:00
pi
a4914b932e feat(mcfold): 最小客户端 + 跨实现互操作测试(Python 客户端握手 Node 服务端成功) 2026-09-25 14:12:06 +08:00
pi
fc5a1aae91 feat(mcfold): 声明式 protodef 解释器(原生类型+阶段作用域+switch/mapper/bitfield),真实握手字节验证通过 2026-09-25 14:10:40 +08:00
pi
22cdee35e3 feat(mcfold): P1 按实例文件夹解析版本(inheritsFrom 归一化 + 加载器判定)+ 回归测试 2026-09-25 14:06:55 +08:00