# 已知坑清单（框架侧）

> 只记**已经踩过、且下次还会踩**的坑。每条都要有可验证的判据（断言 / 产物 / 症状），
> 不要写"注意事项"式的空话。新增条目请按「症状 → 根因 → 判据/做法」写。
>
> 相关：`AGENTS.md`（约定沉淀）、`doc/dev/workflow.md`（构建流程）、`doc/dev/ui-lessons.md`（UI 专项）。

---

## 1. 构建与产物

### 1.1 「构建成功」不可信 —— 判据是日志里的 `处理数据:` 行
- **症状**：CLI 打印「构建完成」并 exit 0，但 `dev/` 里是**上一次**的旧产物；改了代码毫无反应。
- **根因**：历史上 `runOnChild` 只看子进程 `exit` 事件、不看退出码；`scriptBundler` 的 catch 只 `console.error`。
- **现在**：子进程非 0 退出即抛；`scriptBundler` 打包失败即抛；CLI 顶层 catch → `process.exit(1)`。
- **判据**：构建日志必须有 `处理数据: <name> <root> <path>` 行；数量要与注册数一致。**没有这行就是没生成。**

### 1.2 生成物清单 —— 陈旧产物的唯一清理依据
- **位置**：`dev/.sapdon_generated_<项目名>.json`（`src/cli/load.js`）。
- **规则**：本次构建写出的产物路径进清单；下次构建**只删「上次清单里有、这次没有」的文件**。
- **不要**改成"扫目录按规则删"：`dev/*_RP/ui/`、`textures/` 里有用户 `res/` 拷进来的文件与手写文件，扫目录必误删。
- **改名/删条目**（清单管得到的）自动清理；**重命名项目**（清单记的是**当前**项目名）管不到 → 需**手工删** `dev/<旧名>_*`。实例：`examples/guidebook_demo/dev/ui_gated_demo_RP/`（旧项目名残留，已手工删除）。
- **没有清单的项目**（例如一直在构建失败的示例）里的陈旧文件也清理不到 —— 清单是"上次构建写过什么"，从没有过成功构建就没有清单。
- **历史遗留**：框架现在只往 RP 写 `blocks.json`，但早期误写在 BP 的那些文件不在任何清单里，故对 `${proj}_BP/blocks.json` 这个**确切路径**做无条件点名清理（不做目录扫描）。
- **另外两份清单**（2026-09 新增，同样"只删自己上次记过的东西"）：`dev/.sapdon_synced_<项目名>.json`
  管**游戏开发包目录**里的陈旧副本（`syncFiles.js` 的 `syncDevFilesServer()`，HMR 也走它），
  `dev/.sapdon_res_<项目名>.json` 管 `dev/<项目名>_RP` 里从 `res/` 拷进来的陈旧资源（`syncResourceFiles()`）。
  症状、取舍与负面约束见 `doc/dev/cli.md` 的「按清单 prune」小节；单测 `node tests/sync-manifest.test.mjs`。

### 1.3 `blocks.json` 属**资源包**；并且**自 2026-09 起框架不再往里面写任何方块条目**
- **位置**：`src/core/factory/blockFactory.js` → `GRegistry.register("blocks","resource","",…)` → `dev/<proj>_RP/blocks.json`。
- **规范**：`blocks.json` 是 RP 根目录文件；写在 BP 里会被 Bedrock 完全忽略。
- **历史**：`e1199cc` 的 `src/cli/load.js` 写的就是 `${proj}_RP/blocks.json`；`05bd104` 挪进 `blockFactory.js` 时误标 `"behavior"`，从此落在 BP。**已回归修复**。
- **键格式的历史（保留记录，但已无实际用途）**：`3715e74` 曾把键从"文件名安全名 `ns_name`"改成**完整标识符 `ns:name`**：
  - 权威源：<https://wiki.bedrock.dev/blocks/block-sounds> 的 `RP/blocks.json` 示例键为 `"wiki:chestnut_log"`。
  - 历史产物（预言机）：`git show e1199cc:examples/mob_chest/dev/mob_chest_RP/blocks.json` → `"mob_chest:chest"` / `"sapdon:falling_block"`。
  - `_` 形态的来历：复用 `block_name`，而当时根目录还写进 BP（不生效）→ **从没有项目依赖过 `_` 形态**。
  - ⚠️ `blocks/<name>.json` 的**文件名**仍必须是 `_` 形态（`:` 在 Windows 文件名里非法）—— 两者不可混用。
- ★ **2026-09 起：只写 `{"format_version": "1.20.20"}`，不写任何方块条目**（`blockFactory.js:34-59` 有完整依据）：
  - **症状**：键修成完整标识符后引擎**真的匹配上了**这些方块，于是**每个自定义方块**报一条
    ```
    [Blocks][warning]-<ns>:<block>: trying to override the Geometry component with blocks.json settings
      for a custom block. This isn't supported.
      Please remove any legacy texture definition or block shape specification for this block.
    ```
    真机计数：探针轮 **3** 条 → FZ 全量 **77** 条（= 该项目方块总数）。
  - **依据**（Microsoft Learn · blocks.json File Reference，原文）：
    "components in Behavior Packs, specifying `minecraft:geometry` and `minecraft:material_instances`, will override
    configurations here. Components are more powerful, and they're the recommended way to specify visual properties for
    blocks, leaving **blocks.json** as just a sound configuration system."
    <https://learn.microsoft.com/en-us/minecraft/creator/reference/content/blockreference/examples/blocksjsonfilestructure>
    ⇒ 自定义方块的 `textures` 是**被 `material_instances` 全面覆盖**的 legacy 机制，写了只会招警告。
  - **贴图到底谁提供**：`minecraft:material_instances`（BP 方块 JSON，6 面或 `*`）+ `terrain_texture.json`
    （`src/core/texture.js` / `cli/tools/textureSet.js` 扫描 `RP/textures/blocks/**.png` + 用户注册项生成）
    —— **不经过** blocks.json。
  - **护栏 `assertBlocksJsonKey` 已删**（不留永远不会触发的死护栏）：不再写条目后，"键必须是 `ns:name`"没有触发场景。
  - **⚠️ 未验证（需真机）**：只含 `format_version` 的 `blocks.json` 引擎会不会抱怨；以及矿挖/放置音效是否正常
    （音效本来就没配过 —— 框架从未写过 `sound` 字段，故预期音效行为与改动前一致）。
    真机验证方式：重进世界看那 77 条 `trying to override the Geometry component` 是否消失 + 挖掘/放置音效正常。


### 1.4 目录名大小写：只允许 `_BP` / `_RP`
- Windows 大小写不敏感才掩盖了这个问题；Linux/macOS 下 `_bp` 与 `_BP` 会分叉成两个目录（构建写 A、打包读 B → 空包）。
- **断言**：`src/` 里 grep `_bp`/`_rp` 只应命中注释与无关的 `data_bp.json`/`data_rp.json`。

### 1.5 Dev Server 端口可配（多项目并行构建）
- `build.config` 无关：端口由环境变量 `SAPDON_DEV_SERVER_PORT` 决定，默认 `49037`。
- 服务端（`src/cli/dev-server/config.js`）与客户端（`src/core/transport/client.ts`）**必须读同一个变量** —— 否则会出现「客户端 POST 到 A、服务端监听 B」的静默失联。
- 端口被占时 CLI 直接 `exit 1` 并提示「已被其他 sapdon 进程占用」。多 agent/多项目并行构建请各设不同端口。

### 1.6 `scripts/custom_components/index.js` 是**合并**而不是「存在即跳过」
- **症状（历史）**：新增自定义组件后索引不更新；改了 builder 里的 handler 毫无反应。
- **根因**：`index.js` 在 ts 模板里是**预置占位文件**（还被 `scripts/index.ts` import，删不得），旧逻辑 `if (!fs.existsSync(indexPath))` 于是**永远跳过**。
- **现在**：标记块 `// >>> sapdon:custom-component-registry >>> … <<<` 内的内容每次重建；标记块外的手写/模板内容**一律保留**（文件里若同时有旧版 `// Auto-generated by sapdon.` 生成段与标记块，会被合并成唯一一份）。
- **生成块用 `import { system as __sapdon_system }`**：追加场景下文件里可能已有 `import { system }`，同名重复声明会让整个脚本包 rollup 报 `Identifier "system" has already been declared`。
- **幂等判据**：连跑两次构建，`index.js` 哈希不变、日志出现「已是最新，未改动」。

---

### 1.7 物品的 `format_version` 决定「自定义 catalog 组名」的解析行为

**症状**：世界每次加载，`RP`/`BP` 里**每个**带自定义创造菜单分组的物品都报一条 warning（N 个物品 = N 条）：
```
[Item][warning]-.../item_catalog/crafting_item_catalog.json |
  The item fz:coolant_cell_singler was created with the group set to
  'minecraft:fz:itemGroup.name.reactor_items', but is now being set to 'fz:itemGroup.name.reactor_items'
```

**根因**：物品的 `format_version` 曾是框架默认值 **`1.21.40`**（`src/core/item/item.ts` 的
`formatVersion ?? "1.21.40"`）。该版本下引擎会把 `menu_category.group` 当"**隐含 `minecraft:` 前缀**"处理，
于是与 `crafting_item_catalog.json` 里的显式组名不一致 → 每条物品报一次。**产物里根本没有 `minecraft:fz:`**
（两侧都是裸 `fz:itemGroup.name.X`，与 [Bedrock Wiki · Item Catalog](https://wiki.bedrock.dev/items/item-catalog)
的 `wiki:itemGroup.name.ore` 同形）—— 这是引擎行为（对应 Mojira MCPE-224150），不是产物写错。

**修法（★ 2026-09 已在框架侧根治）**：`src/core/item/item.ts` 的默认值提到 **`1.21.90`**
（该行旁边有完整注释）。框架**仍支持按物品覆盖**：同时解构 `format_version` 与 `formatVersion`
两个键名，后者优先；`examples/digitCircuit/main.mjs:101,113,124,133` 一直显式传 `1.21.90`，
所以它的日志里**零**条该告警。

**实测（2026-09-11，真机）**：FZ 项目 157 个物品用默认值 → 每次加载 **156** 条；
把**单个**物品改成 `1.21.90`（只改已部署副本）→ 同一次加载变成 **155** 条、且该物品不再出现；
全量改完（`FZ_ITEM_FORMAT_VERSION` 常量）→ 玩家重进世界后告警**清零**。

**产物侧影响**：改用默认值的项目，其 `dev/<proj>_BP/items/*.json` 的 `format_version` 会变成 `1.21.90`
（**预期且期望**的变化）；显式传过版本的物品不受影响。
`ItemCatalog` 自己的 `format_version`（`itemCatalog.ts:40`，默认 `1.26.30`）是 **catalog 文件**的格式版本，
与物品无关，**不要**跟着改。


---

## 2. 自定义组件

### 2.1 注册时机只有两个合法位置
- ✅ 运行期路线 B：`@sapdon/runtime` 的 `registerBlockComponent` / `registerItemComponent`（框架内部走 `system.beforeEvents.startup`）。
- ✅ 构建期路线 A：`BlockCustomComponentBuilder` + CLI 生成的 `scripts/custom_components/*.js`（内部同样走 `system.beforeEvents.startup`）。
- ❌ `world.beforeEvents.worldInitialize`：**太晚**，症状是启动报
  `this component was found in the input, but is not present in the Schema`（方块 JSON 里写了 `"ns:xxx": {}`，但脚本没在正确时机注册）。
- **守卫**：`system.beforeEvents.startup` 已触发后再调用注册会**抛错**（那时没有任何注册时机了）；同一 id 重复注册也抛错；事件名拼错只 `console.warn`、不阻断。
  - ⚠️ 「同 id 重复注册抛错」只适用于**项目注册之间**。**框架内置**组件走 `registerFallbackBlockComponent`
    （项目没注册才注册；项目注册了 → 项目生效 + 一条 warn）—— 见 §2.4。

### 2.4 ★ `sapdon:block_with_entity` 必须由框架注册（否则**整份方块被丢**）
- **症状**：用 `BlockAPI.createTileBlock` 的方块全部不出现（连创造菜单里都没有），启动报
  ```
  -> components -> sapdon:block_with_entity: this component was found in the input,
     but is not present in the Schema
  ```
  **不是局部报错 —— 是整份方块 JSON 被引擎丢掉**（真机实测：FZ 的探针与回收机都靠脚本手工注册才活下来）。
- **根因**：`TileBlock` 的构造无条件给方块挂这个自定义组件
  （`src/core/block/tileBlock.js:232` → `BlockComponent.setCustomComponents(["sapdon:block_with_entity"])`），
  而框架的 `registerBuiltinComponents()` 到 2026-09 为止**只注册 5 个**
  （`crop_growth` / `fallingblock` / `head_rotation` / `intercardinal_orientation` / `guibook`）
  —— **没有** `block_with_entity`。挂组件与注册组件是两件事，只有后者能让 Schema 认识它。
- **修法（已落地）**：`src/oc/builtin/blocks/blockWithEntity.ts` 提供内置实现，
  由 `registerBuiltinComponents()` 用**兜底**通道登记（`registerFallbackBlockComponent`）。
  内置实现 = **模式 A**：`onPlace` 里 spawn `${block.typeId}_entity`（该坐标已有同种实体则跳过；
  读不出来时**不 spawn** —— 两个承载实体 = 两个容器 = 能复制物品）。
  **刻意不切** `sapdon:block_or_entity` 状态：切到 1 会命中 `TileBlock` 的透明变体，
  外观全交给实体（模式 B），框架无权替项目决定。
- **为什么是「兜底」而不是「直接注册」**：既有项目（`examples/mob_chest`、FZ）**早就手工注册过**这个 id，
  而且它们的实现是**模式 B / 自定义交互**。直接注册会让 `registry.ts` 的「同 id 重复注册 → 抛错」
  守卫在启动时炸掉这些项目；无条件覆盖又会**静默改掉它们的方块行为**（切透明与否）。
  兜底语义：**项目注册了就以项目为准**（一条 warn 说明）、没注册才用内置。
  判定发生在 `system.beforeEvents.startup` 回调里 —— 只有那时才能确定「所有模块是否都已加载完」。
- **判据**：
  1. `prod/oc/index.d.ts` 导出 `registerFallbackBlockComponent` / `skippedFallbackComponents`，
     `prod/oc/index.js` 含 `sapdon:block_with_entity`（改完必须断言 `prod/`，别只看 rollup 9/9）；
  2. 单测 `node tests/component-registry.test.mjs`（7 条：兜底生效 / 项目优先（两种登记顺序）/ 项目重复注册仍抛错 /
     兜底幂等 / startup 之后抛错 / 两张账互不干扰）；
  3. **最小项目实验**：只 `createTileBlock`、**完全不手工注册** → 产物里有 `"sapdon:block_with_entity": {}`，
     且脚本包里有内置实现（见 `.tmp/p0-1/`）。

### 2.5 ★ `createTileBlock` 的 `.d.ts` 漏声明 `group` / `hide_in_command`（TS2353）
- **症状**：项目写 `BlockAPI.createTileBlock(id, cat, tex, { group, hide_in_command })` 编译报
  **TS2353**（对象字面量只能指定已知属性），而**运行期 `BasicBlock` 明明会读 `options.group`**
  （`src/core/block/basicBlock.js:39`）—— 类型与运行期不一致。项目只能写成
  `const opts = { group, ... }; createTileBlock(id, cat, tex, opts)`（传变量绕过多余属性检查）来苟活。
- **根因**：`.d.ts` 是 `src/core/factory/blockFactory.js` 的 JSDoc 推导出来的；
  `createBasicBlock` 有 `@param {string} options.group` / `@param {boolean} options.hide_in_command`，
  `createTileBlock` **没有**（只有 `inventory_size` / `container_type` / `can_be_siphoned_from`）。
- **修法**：给 `createTileBlock` 的 JSDoc 补 `[options.group]` / `[options.hide_in_command]` /
  `[options.format_version]` / `[options.entity_texture]`（**都要写成 `[options.x]` 可选**，
  写成 `options.x` 会变成**必填** —— 那会让只传 `{ inventory_size }` 的既有项目反而编译失败）。
  **改源头，不要手改 `prod/`。**
- **同类缺口（同一次核对的结果，判据 = 运行期读的键 ∉ JSDoc 声明的键）**：

  | 工厂 | 运行期会读但**未声明** | 处理 |
  |---|---|---|
  | `createBasicBlock` | `format_version`（`basicBlock.js:33`） | 已补 |
  | `createBlock` | `format_version` | 已补 |
  | `createRotatableBlock` | `format_version` | 已补 |
  | `createTileBlock` | `group` / `hide_in_command` / `format_version` | 已补（本轮 P0-2） |
  | `createHeadBlock` | `tick_interval` / `custom_components` / `format_version`（`headBlock.js:14,19`） | 已补；它原有那三条 JSDoc（`ambient_occlusion`/`face_dimming`/`render_method`）是从 `createCropBlock` 误抄的**无效**声明 —— **保留**（删掉会让传了它们的项目从「被忽略」变成编译错误），但注释里标了「本工厂不读」 |
  | `createOreBlock` / `createGlassBlock` / `createFenceBlock` / `createStairBlock` / `createTrapdoorBlock` / `createGeometryBlock` / `createCropBlock` | 无 JSDoc ⇒ 推导成 `options?: {}` | **无 TS2353 风险**（TS 对 `{}` 不做多余属性检查），故未动 |

  ⚠️ `createBasicBlock` / `createBlock` / `createRotatableBlock` / `createHeadBlock` 的**已声明键是必填**
  （推导成 `group: string` 而不是 `group?: string`），于是 `createBasicBlock(id, cat, tex, {})` 也会报错。
  这是既有的另一类问题（**本轮未改**：把必填改可选是纯放宽，但要单独评估，见「给框架的建议」）。

### 2.6 自定义组件的 JSON **写法**：扁平化是现行规范，`minecraft:custom_components` 已废弃
- 框架 `setCustomComponents(ids)` 产出的是 `"ns:comp": {}`（直接写在 `components` 里）。
  **这是对的**，不要改成 `"minecraft:custom_components": [...]`：
  - Microsoft Learn · Scripting V2 Overview 原文：
    "`minecraft:custom_components` is deprecated in favor of **flattened custom components** …
     Instead, you can write your custom components similar to any other Minecraft component."
    <https://learn.microsoft.com/en-us/minecraft/creator/documents/scriptingv2.0.0overview#custom-components-v2>
  - Microsoft Learn · Block Components · `minecraft:custom_components` 页首 **Important**：
    "This type is now deprecated, and no longer in use in the latest versions of Minecraft."
    <https://learn.microsoft.com/en-us/minecraft/creator/reference/content/blockreference/examples/blockcomponents/minecraftblock_custom_components>
  - 1.21.90 更新说明："Custom Components V2 is now available with new capabilities."
    <https://learn.microsoft.com/en-us/minecraft/creator/documents/update1.21.90>
- ⚠️ **`Scripting/…/components-tutorial.md` 那篇教程的「Block custom components」小节仍在用数组写法**
  （`"minecraft:custom_components": ["example:crop_grow"]`）—— 那是**没跟上 V2 的旧正文**
  （该节 `format_version` 还写着 `1.21.10`），别拿它当规范。
- **产物回归判据**：真机与产物双向确认过框架写法可用（`examples/mob_chest` 的
  `dev/mob_chest_BP/blocks/mob_chest_chest.json` 里就是 `"sapdon:block_with_entity": {}`）。
  **改写法会让所有既有产物变化** ⇒ 除非有反例，**维持现状**。

### 2.7 `createTileBlock` 的实体贴图是**资源路径**，不是 terrain 短名
- **症状**：`createTileBlock(id, cat, ["machineblock_0"])` 的产物里
  `client_entity.textures.default = "machineblock_0"` ⇒ 客户端实体报 `Missing referenced asset`。
- **根因**：同一个 `textures_arr` 被**两个不同的贴图系统**消费 ——
  - **方块**侧（`material_instances.up/down/…`）要的是 `terrain_texture.json` 的**键**（terrain 短名）；
  - **实体**侧（`client_entity.textures.<name>`，`tileBlock.js` → `entity.js:19-21`）要的是**资源路径**
    （官方示例 `"default": "textures/entity/pig/pig"`，省略扩展名）：
    <https://learn.microsoft.com/en-us/minecraft/creator/reference/content/entityreference/examples/cliententitydocumentation/cliententitydocumentationintroduction>
  框架此前把 `textures_arr[0]` **原样**写进实体贴图 ⇒ 只给短名的项目必然指向不存在的资源。
- **修法（已落地）**：`createTileBlock` 新增 `options.entity_texture`（**默认 = `textures_arr[0]`**，
  即不传时产物**逐字节不变**）。只给 terrain 短名的项目传一次
  `{ entity_texture: 'textures/blocks/entity/normal' }` 即可，不必再像 FZ 那样在
  `declareTileBlock` 里 `tile.entity.resource.addTexture(...)` 二次覆盖。
- **判据**：单测 `tests/block-api.test.mjs` 的三条 `entity_texture` 用例；
  以及 `examples/mob_chest` 重建后 `dev/mob_chest_RP/entity/mob_chest_chest_entity.json`
  与改动前**逐字节一致**（它传的本来就是完整路径）。

### 2.8 `createTileBlock` 要求项目**自带** `geometry.cube`（与 terrain 键 `none`）
- `TileBlock` 的状态 1 变体与承载实体都用 `geometry.cube`
  （`tileBlock.js` 的 `setGeometry("geometry.cube")` / `addGeometry("default","geometry.cube")`），
  而 `geometry.cube` 是**自定义**几何（不是原版几何名）—— 框架只生成 JSON，**生不出 RP 里的几何文件**。
- **症状**：状态 1 下（以及承载实体）**没有模型**；引擎报 `Missing referenced asset` 一类。
- **做法**：项目在 `res/models/blocks/` 放一份 `identifier = "geometry.cube"` 的立方体几何
  （16³、pivot 在底面 —— 参考 `examples/mob_chest/res/models/blocks/cube.geo.json`）。
  **模板已随框架提供默认实现**：`src/templates/{js,ts}_sapdon/res/models/blocks/cube.geo.json`
  （`sapdon create` 出来的新项目直接可用；**既有项目要自己拷一份**）。
  透明变体用的 terrain 键 `none` 同样要存在（模板有 `res/textures/blocks/none.png`）。

### 2.2 路线 A 的 handler 会被 `toString()` 序列化
- **症状**：生成的 `scripts/custom_components/<name>.js` 里只有**调用**、没有定义 → 运行期 `ReferenceError: xxx is not defined`。
- **结论**：路线 A 只适合**自包含**的 handler；要 import 共享模块（S3 的机器基类这类）必须用路线 B。
- **`scripts/custom_components/<name>.js` 是「生成一次、之后归用户」**：存在即跳过（不覆盖用户改过的实现）。想让框架持续托管就别改它，或者改用路线 B。

### 2.3 物品自定义组件
- 物品要能触发 `onUse`，**必须**加 `minecraft:interact_button`（`ItemComponent.setInteractButton`），否则右键毫无反应。
- 物品用 `@minecraft/server` 的 `init.itemComponentRegistry`，与方块是两个注册表，别混。

### 2.9 ★ `minecraft:block_placer.block` 传**对象**会被引擎拒（schema 允许 ≠ 引擎接受）
- **症状**：物品带 `"minecraft:block_placer": { "block": { "name": "ns:blk", "states": { … } } }` 时，加载世界报
  ```
  [Item][error]- Failed to parse field ' -> components -> minecraft:block_placer -> block: invalid string'
  [Item][error]- Error Parsing Item 'ns:某物品':
  ```
  并且**整份物品定义作废** —— 连带 `Missing icon for data-driven item 'ns:某物品'`（每个物品刷几百~上千行，
  实测 5 个物品共 3962 行）⇒ 表现为「物品图标没了 / 右键没反应」，但**报错信息与图标无关**，极易误判成图标问题。
- **根因**：官方 DataForm（`@minecraft/bedrock-schemas` 的 `forms/item/minecraft_block_placer.form.json`）
  把 `block` 标成 `dataType: "object"`，`forms/item/blockdescriptorproxy.form.json` 的描述也点名
  `minecraft:block_placer` 用 BlockDescriptor —— 但**当前引擎只收字符串**。
  ⇒ 通例：**schema 是能力清单，不是可用性保证**；新字段先按最保守形态跑通，再谈花哨写法。
- **做法**：`block` 传字符串（`ItemComponent.setBlockPlacer('ns:blk')`，落方块默认状态）。
  「同一方块 + 不同状态（多种材质/变体）」要靠**脚本补写**：
  `beforeEvents.playerInteractWithBlock` 记手持物（此时还读得到）→ `afterEvents.playerPlaceBlock`
  按物品映射出状态值并 `setPermutation` + 写后回读。
  必须用 before-event 记：**生存模式放下最后一个时 after-event 里手中已经空了**。
- **出处**：真机 ContentLog（`%APPDATA%\Minecraft Bedrock\logs\ContentLog*.txt`），两轮加载
  `invalid string` 20 条 / `Error Parsing Item` 10 条 / `Missing icon` 3962 条。

---

## 3. 持久化（动态属性）

### 3.1 单个动态属性值约 32KB 上限，超限**抛错**
- **★ 绝不要用 try-catch 吞掉**：吞掉 = **静默丢存档**（症状：重进世界后数据回到早期快照）。
- 用 `@sapdon/runtime` 的 `saveChunked` / `loadChunked` / `clearChunked`（`src/oc/persist/chunked.ts`）：
  单值 ≤ `CHUNK_SIZE`(24000) 直存；超限切成 `<key>#0..#N-1`，主 key 存 `{"_chunks":N}`（与 `lr-framework` 的 `BaseEngine`、`digitCircuit` 的 `CIRCUIT_CHUNK=24000` 格式互通）。
- **提交点**：数据块先写、主 key 后写 —— 中途失败不会留下「半新半旧」的可读结果。
- **空值语义**：`loadChunked` 返回 `undefined` = 从没存过；返回 `''` = 存过空串。判断请用 `=== undefined`，**不要**用真值判断（`if (!v)` 会把空串当没存过 —— `BaseEngine.load` 就有这个坑）。
- **元数据歧义**：一个**内容恰好是** `{"_chunks":N}` 的字符串会被强制分块，避免读回时被误判成元数据。
- **分块损坏**（主 key 声明 N 块但块缺失）会**抛错**，而不是返回半截数据。

### 3.2 持久化 helper 在 `@sapdon/runtime`，不在 `@sapdon/core`
- `src/cli/build.js` 的 `rollupIgnores` 把 `@sapdon/core` 列为 external，**运行期脚本**里 import 它会在产物里留下无法解析的裸 `@sapdon/core`（Bedrock 不是这个模块的宿主）。
- `@sapdon/runtime`（`src/oc`）不在该列表里 → 会被**打包进**脚本包 → 运行期可用。
- **判断法则**：构建期（`main.ts` 生成 JSON）= `@sapdon/core`；运行期（`scripts/*`）= `@sapdon/runtime`。

---

## 4. 方块容器

> **一句话结论（2026-09 定型）**：`minecraft:inventory` 是**实体**组件，写在**方块** `components` 里
> 在任何版本上都不成立；方块侧的规范写法 `minecraft:block_entity.container` 在当前引擎版本上**也被拒**。
> ⇒ **当前唯一可用的方块容器走「方块 + 承载实体」**（`BlockAPI.createTileBlock(...)` 的 `inventory_size`）。

### 4.1 三条路线与它们的真实状态

| 路线 | 产物 | 引擎现状 | 框架 API |
|---|---|---|---|
| **实体（唯一可用）** | `<ns>:<id>_entity` 的行为文件里挂**实体**组件 `minecraft:inventory`（`inventory_size` / `container_type` / `can_be_siphoned_from`） | 可用 | ★ `BlockAPI.createTileBlock(id, cat, textures, { inventory_size, container_type, can_be_siphoned_from })`（新增，默认 27/`minecart_chest`/true）；已有 `TileBlock` 也可 `tile.entity.behavior.addComponent(EntityComponent.setInventoryProperties({...}))` |
| 方块·规范 | 方块 `components` 的 `minecraft:block_entity: { container: { slot_count }, dynamic_properties }` | **被拒**：`-> minecraft:block_entity -> container: this component was found in the input, but is not present in the Schema`（format_version 1.26.30 / 1.26.40 报同样的错） | `BlockComponent.setBlockEntity(true, { container: { slot_count } })`（新增；`slot_count` 官方文档 `[1,54]`，**超限抛错**） |
| 方块·历史 | 方块 `components` 的 `minecraft:inventory`（**实体组件放错上下文**） | **必然被拒**：`-> components -> minecraft:inventory: … not present in the Schema` | `BlockComponent.setInventory(...)` —— **已标 @deprecated**，产物**逐字节不变**（不制造"升框架就构建失败"），但每次调用打一条 warn 指向上面两条路 |

**依据（官方文档，别再靠猜）**：
- 方块侧 `minecraft:block_entity`（含 `container.slot_count`，原文 "Value must be >= 1. Value must be <= 54."）：
  <https://learn.microsoft.com/en-us/minecraft/creator/reference/content/blockreference/examples/blockcomponents/minecraftblock_block_entity>
- 实体侧 `minecraft:inventory`（`inventory_size` 只写 "Number of slots the container has"，**未给上限**；
  `container_type` 取值 `horse` / `minecart_chest` / `chest_boat` / `minecart_hopper` / `inventory` / `container` / `hopper`）：
  <https://learn.microsoft.com/en-us/minecraft/creator/reference/content/entityreference/examples/entitycomponents/minecraftcomponent_inventory>

**为什么 `setInventory` 选"标废弃 + 保持产物不变"，而不是改成产出 `block_entity.container`**：
1. 改成 `block_entity.container` 后，若与用户自己的 `setBlockEntity(...)` 合并，会被 `combineComponents` 的
   「后者覆盖前者」**静默吞掉容器**（同一个 JSON 键）；今天两者是不同键，这个坑不存在。
2. 一旦按方块路线校验 `[1,54]`，`inventory_size: 56` 那类既有项目（FZ 机器需要 56 槽）
   会从"静默无效"**直接变成构建失败** —— 这是明确要避免的。
3. 真正的错误（把实体组件当方块组件用）已由 warn 明确说出，并给出两条新路线；
   要规范写法的人可以显式用 `setBlockEntity(true, { container: { slot_count } })`。

**`inventory_size` 上限**：官方文档**没给**（实体组件那页只写"槽位数"）。框架只校验正整数，
**不要**把方块路线的 `[1,54]` 套到实体容器上。**⚠️ 待真机确认**：56 槽（FZ 机器所需）是否可用。

**自检守卫**（`BasicBlock.validate()`，由 `registry.submit()` → `runValidators()` 调用）：
1. 方块 components 里出现 `minecraft:inventory` → warn（"它是实体组件，必然被拒" + 两条替代路线）。
   这条能兜住**手写裸 Map** 的写法（FZ 项目当初就是这么绕的）。
2. `minecraft:block_entity.container.slot_count` 超出 `[1,54]` → warn（兜住手写组件对象）。

### 4.2 `minecraft:inventory` / `minecraft:block_entity` 在 Bedrock Wiki 上**没有条目**（引用时别引错）
2026-09 核对：<https://wiki.bedrock.dev/blocks/block-components> 的
「List of Vanilla Components」共 36 条（Chest Obstruction → Transformation），**没有 Inventory**；
全页也搜不到 `minecraft:inventory` / `minecraft:block_entity`。
⇒ 引用这两者时**不要**标注该 wiki 页为出处；**改引 Microsoft Learn**（上面的两个链接，两页都真有条目）。
（组件本身是真实存在的 —— 框架生成的产物里有，`minecraft:block_entity` 也与 `TileBlock` 链路相关；
问题只是**该 wiki 页不覆盖它**。）
- 该页倒是**有** `minecraft:connection_rule`（`accepts_connections_from`: `all`/`only_fences`/`none` +
  `enabled_directions`），与 `minecraft:connection` trait（提供 `minecraft:connection_*` 状态）配合，
  可能是「让管道连接非同种方块」的正路 —— FZ 的管道/线缆（S6）值得先试这条，**但未验证**。

### 4.3 `setInventory` 的 JSDoc 曾指向**不存在**的守卫（2026-09 已修）
- 原文（旧 `blockComponent.js:688-689`）：
  > 若只写了 `setInventory` 而没有 `setBlockEntity`，构建时框架会打印 warn 提醒（见 `blockFactory.registerBlock`）。
- **指向是错的**：`src/core/factory/blockFactory.js` 里 `block_entity` 命中数 = **0**，`registerBlock` 没有该检查。
  真正的守卫是 `src/core/block/basicBlock.js` 的 `BasicBlock.validate()`（`blockFactory.js:36` 与
  `basicBlock.js` 的注释里现在都写明了这一点），由 `src/core/registry.ts:26` 的 `runValidators()`
  在 `registry.submit()` 时调用 —— **不能**放在 `registerBlock`：`BlockAPI.createXxx()` 是「先注册、后 addComponent」，
  那一刻组件还没挂上。
- 复核命令（负面断言，必须为 0）：
  `Select-String -Path src\core\factory\blockFactory.js -Pattern 'block_entity' -AllMatches | Measure-Object`
- 另外**旧 `setInventory` 自检的前提也错了**（它查"有 `minecraft:inventory` 但没 `block_entity`"，
  而 `minecraft:inventory` 根本不是方块组件）→ 已改成上面 4.1 的两条。

### 4.4 承载实体默认外观与方块**共面** ⇒ z-fighting 闪烁（2026-09-12，fz-sapdon 真机）
- **症状**：`createTileBlock` 的方块在游戏里贴图不停闪烁（两个面在抢深度）。容器功能本身正常。
- **根因**：框架给承载实体（`` `${identifier}_entity` ``）的客户端定义是
  `geometry.cube`（满方块）+ `textures.default = textures_arr[0]`（`tileBlock.js` → `entity.js:19-21`），
  而实体又 spawn 在**方块正中心** ⇒ 实体与方块是两个**完全共面**的立方体。
  ⚠️ `textures_arr[0]` 在方块侧是 terrain 短名、在实体侧必须是资源路径；项目侧通常把它补成
  `textures/blocks/<短名>` —— 那正好就是方块贴图，于是闪烁必现。
- **做法**：**「方块可见 + 实体只当容器」这种模式（fz-sapdon 叫模式 A）必须给实体一张全透明贴图**
  （16×16 alpha 全 0，`entity_alphatest` 会整张丢弃）；「实体接管外观」那套反过来传方块贴图。
  透明贴图放 `textures/entity/` 下，别放 `textures/blocks/` —— 后者会被构建自动注册进
  `terrain_texture.json`（方块地图集），实体贴图按资源路径引用、不该占地图集。
- **别删实体的几何／材质／渲染控制器**：删了等于堵死「实体接管外观」那条路，静默无外观可用。
- 出处：fz-sapdon 真机日志（实体 spawn 坐标 = 方块中心）+ 该项目 README §S3b「真机修复」小节。

### 4.5 自定义容器界面：门控键 = `UISystem.name`（2026-09-12，fz-sapdon S3e 实测）
- **机制**（可用部分的正确姿势）：`ChestUISystem.registerContainerUI(key, rootPanel)` 会在
  `chest:chest_screen` 里插一条 `requires: ($new_container_title = '<key>')` 的门控，命中就把原版小箱子界面的
  `$screen_content` / `$root_panel` 换成你的根面板。
  - ★ **门控键 = `UISystem` 的 `name`（identifier 冒号后半段），不是 `setTitle()` 的值**
    （`chest.ts:5` 引入 `$container_title`、`:16` 字符串相等、`containerUISystem.ts` 的 `#register()` 传
    `this.system.name`、`system.ts:19` 是 `identifier.split(':')[1]`）。`setTitle()` 只写面板自己的标题文本。
  - ★ **实体容器的标题取自「实体名字」**（原版同路：`ui/horse_screen.json:35`）⇒ 用实体容器时要把键写进
    **承载实体的 nameTag**（写完**立即回读**；写错的表现是**静默退回原版箱子界面、零报错**）。
  - 代价：nameTag 若被渲染，机器上方会出现浮空名字（fz-sapdon 用开关 + 候选键兜底处理）。
  - **追加硬约束**：该键同时是 `ui/<name>.json` 的**文件名** ⇒ 见 §4.10。
- **坑 1（2026-09-12 已修）：`ContainerUISystem.addElementToMain(el)` 加进去的控件不会显示** —— 详见 §4.8。
- **坑 2（2026-09-12 已改善）：版面写死**，表达不了「背景图 + 绝对像素版面」（FZ 那种 256×128 机器面板）。
  现在 `ContainerUISystem` 的槽位与网格是**绝对像素定位**（`setPanel` / `setGridOrigin` / `addSlot`），
  不再只能靠 `ChestUISystem.registerContainerUI` 那一层自己写根面板；
  fz-sapdon 的 S3e（`src/ui/recycler_panel.ts` + `fz_recycler_geometry.ts`）仍是"完全手写根面板"的现成范例。
- **坑 3：换掉 `$screen_content` 时玩家背包也一起没了** —— 自定义根面板要自己把背包区摆回来，
  且根面板尺寸必须覆盖原版（否则 100% 宽的背包区与自定义区重叠）；FZ 面板组还必须排在
  `common_panel` **之后**（层序错 = 原版灰底盖住你的背景图，表现为"面板在、图没了"）。
  框架现在把背包区固定为 `inventory_panel`（`bottom_left` 锚、`100%×50%`、layer 2）。
- 出处：fz-sapdon `README.md` §S3e（含槽位换算、门控 4 条候选键、11 条真机清单）。

### 4.6 ★ JSON UI 里「控件不可交互」的属性名是 `enabled`，**不是** `enable`（2026-09-12）
- **症状**：用输出槽语义声明的格位，真机上照样能往里放东西 —— 标志位**从未生效**；产物里那个键叫
  `"enable": false`，是引擎不认识的拼写。
- **根因**：`containerUISystem.ts` 的 `addGridItem` 写成 `addProp('enable', …)`。
- **计数证据（可复现的负面断言）**：
  | 来源 | `"enabled"` | `"enable"` |
  |---|---|---|
  | 原版 UI 全树 `resource_pack/ui/*.json`（bedrock-samples 1.21.130.26 preview） | **26** | **0** |
  | `examples/mob_chest/res/ui/cooking_pot.json`（108 KB 手写容器面板） | **3** | **0** |
  - `cooking_pot.json:255` 的 `bot_left@chest.chest_grid_item` 明确写了 `"enabled": false`；
    `:318` / `:345` 两个进度槽写 `"enabled": true` —— 原版真名在同一份真实界面里被用过三次。
  - 对照件 `examples/mob_chest/res/ui/slot_test.json` 是**为这次判定专门写的手写面板**：
    槽 0-3 写 `"enabled": true`、槽 4 写 `"enabled": false`、槽 5 写 `"enable": false`，
    进一次游戏即可看出只有槽 4 与其它槽行为不同。
- **规避**：一律写 `enabled`。框架侧唯一出口是 `containerLayout.ts` 的 `SlotSpec.enabled` /
  `ResolvedSlot.enabled`：`output` / `display` **缺省**写 `false`、`input` 不写该键（继承原版默认 `true`），
  **显式 `enabled` 一律优先于这个缺省**（2026-09-12 补，见 §4.14）。
- **后续结论（2026-09-12 真机，已定案）**：该标志位**确实生效**，但它的语义是
  **整体禁用这一格** —— 既拦「放进去」、**也拦「取出来」**。
  ⇒ 产物格必须显式写 `enabled: true`，否则**产物拿不到手**（完整证据与修法见 §4.14）。
- **判据**：`node tests/container-ui-output.test.mjs` —— output / display 内层控件**缺省** `enabled === false`；
  **整个产物递归不存在 `enable` 这个键**（对象键遍历 + 文本层 `/"enable"\s*:/` 双查）；
  `node tests/container-layout.test.mjs` —— `resolveSlot` 的覆盖规则（显式值优先）。

### 4.7 `ContainerUISystem.setItemMatrix` 为何删除（2026-09-12）
三个独立缺陷叠在同一个函数里，**任一都不能在不改语义的前提下修好**，故整体删除：
1. `:153` 把 `indexOf` 当布尔用 —— `if (this.output_grids.indexOf(v))`：`-1` 是**真值**、`0` 是**假值**。
   于是「没声明输出槽」时**所有**格位都被当成输出槽；而第一个输出槽（索引 0）反倒走"普通输入槽"分支。
2. `:135-150` 把「矩阵格位」与「槽序号」混算 —— 偏移表按 `n` 建（`offset_marix[value - 1]`），
   矩阵值只是标记而非槽号，值 1 / 2 / 3 会算出**同一个** `[-36, 18]`。
3. `:134` 写死 `this.setGridDimension([1, n])`，丢掉矩阵真实形状（该行注释自己写的是 `n*n列`）。
- **替代**：`addSlot({ slot, pos, kind })` —— 槽号定槽位、像素坐标定版面，换算集中在 `containerLayout.ts`。
- **判据**：`node tests/container-ui-output.test.mjs` 断言 `typeof ui.setItemMatrix === 'undefined'`；
  旧调用点若仍在用会在**编译期**报错（不会静默走错分支）。

### 4.8 `addElementToMain` 曾经加进去的控件**永远不显示**（2026-09-12 修复）
- **症状**：`ContainerUISystem.addElementToMain(el)` 返回 `this`、不抛错、产物里也"看不出问题"，
  但界面上那个控件**不存在**。
- **根因**：`#updateSystem()` 组装的 `container_root_panel` 只含 `common_panel` +
  `inventory_selected_icon_button` + 一个 `container_panel`（标题 10% / 网格 40% / 背包 50% 三段堆叠），
  **从来没有把 `this.main_panel` 挂进根面板** —— 控件被加进一个游离 Panel。同类症状见 §4.5 坑 1。
- **修法**：`main_panel` 现在是根面板的正式子控件（`top_left` 锚、size = 面板尺寸、layer 4），
  `addControl(el, pos?)` 与 `addElementToMain(el)` 都真的落进产物。
  ⚠️ 别用 `main_panel.setControl(new Control())` 去改层级 —— 那会换掉 Control 对象、
  把已挂上的 `controls` 丢掉（框架内部因此只就地 `control.setLayer()`）。
- **判据**：`node tests/container-ui-output.test.mjs` 的「addControl(el, pos) 真的挂进主面板并带定位」
  与「addElementToMain 是 addControl 的别名，同样生效」两条 —— 直接查
  `container_root_panel.controls[*].main_panel.controls`。

### 4.9 ★ 容器版面坐标空间：校准点只有一处（2026-09-12；格位 18 与非原版 20 均已真机实测）
- **现状**：框架把 `pos`（面板左上角原点的像素）换算成格位 `offset`，前提有三条：
  1. 网格锚点在左上角；
  2. 格位基座 = 网格原点 + 该格在单行网格里的序号 × **网格统一格位尺寸**；
  3. 统一格位尺寸 = `setSlotDefaults({ cellSize })`（缺省 = 标定表 `cellSize`）；逐槽 `cellSize` 只是视觉尺寸。
- **`offset` 相对的是格位模板的锚点**：`chest.chest_grid_item` → `common.container_item`
  （`ui_common.json:4770`）的 `anchor_from` / `anchor_to` **默认是 `center`**，框架会显式覆写成标定表的 `anchor`。
- **规避（校准只改一行）**：全部换算集中在 `src/core/ui/systems/containerLayout.ts` 的 `SLOT_CALIBRATION`
  （`anchor` / `originPadding` / `cellSize` / `columns` / `defaultGridOrigin`）。改 `anchor` 会**同时**改换算与写进产物的
  `anchor_from`/`anchor_to`（内层控件的锚点由 `anchorProps()` 取，不各写一份，避免两处不一致）。
  `originPadding` 是整体平移用的最后手段；`defaultGridOrigin` 是未调 `setGridOrigin` 时的网格原点
  （默认 `[0, 24]`，给顶部标题让开一行）。
- **真机实测（2026-09-12，`examples/mob_chest` 的 `calib_test` 面板进游戏，GUI scale 3，截图逐像素量取）**：
  探针面板 = `setPanel({ size:[180,166] })` / `setGridOrigin([8,40])` / `setSlotDefaults({ cellSize:[18,18] })`，
  4 槽 `pos` = `[8,40]` / `[44,40]` / `[8,76]` / `[44,76]`。量得：
  - 面板原点落在截图 (72, 75) px，缩放 **3.0 px/UI px**（由「渲染行距 108 px ÷ 声明 36」定出，两轴同尺度）；
  - **四槽渲染位置与声明 `pos` 的残差均为 0.00 UI px**：两列各自解出同一 x 原点、两行各自解出同一 y 原点；
  - 标题（`offset [0,0]` + `100%` 宽 + `center` 对齐，走 label / `main_panel` 通路）**独立**解出原点 x = 71.5，
    与网格给出的 72 相差 0.5 px ⇒ 两条互不相干的代码路径互证，说明这不是拟合出来的巧合；
  - **统一格位尺寸被三路独立解出 = 18.00**：`slot1`（行 1，`offset.y = −18`）落回声明的 40 ⇒ cellH = 18；
    `slot2`（行 2）落回 76 ⇒ 2·cellH = 36；`slot3`（行 3，`offset.y = −18`）落回 76 ⇒ 3·cellH = 54
    ⇒ **§4.11 的「网格几何只认统一格位」在真机成立**；
  - **`anchor = top_left` 成立**：若真实锚点是 `center`，每个槽会整体偏半格 = 9 UI px ≈ 27 屏幕 px，未见；
  - 槽盒实测 54×54 px = **18.00 UI px** = 声明的 `cellSize`。
  ⇒ **结论：`pos` → `offset` → 渲染 这条链是逐像素正确的**（本条是 `cellSize` = 18 = 模板原生尺寸；
     非原版 20 的实测见下面「真机实测 ②」）。
- **真机实测 ②（2026-09-12，用户随后提供的对照截图）：★ 非原版统一格位确实被引擎采纳。**
  这一次量的是 `examples/mob_chest` 的**系统 A**（`sapdon_furnace`）：`setGridOrigin([8,40])`、
  `setSlotDefaults({ cellSize: [20,20] })`、4 槽 `pos` 全为 `[8,40] / [30,40] / [52,40] / [84,40]`。
  因为 4 槽同在 `pos.y = 40` 而分属网格第 0/1/2/3 行，框架算出的 `offset.y` 依次是 `0 / −20 / −40 / −60`，
  于是渲染 y = `40 + k·(引擎格高 − 20)`：
  - 实测四槽（含第 3 槽那个 `cellSize: [36,10]` 的宽条）**全部落在同一个 y**，间距完全是声明的
    `22 / 22 / 32`（截图 66 / 66 px ÷ 3.0）⇒ `引擎格高 − 20 = 0` 对 k = 1,2,3 同时成立
    ⇒ **引擎格高 = 20.00，正是 `setSlotDefaults` 声明的值**（若引擎只按模板原生 18 排版，
    k=1/2/3 会分别上移 2/4/6 UI px = 6/12/18 屏幕 px，肉眼可见）。
  - 同时，第 3 槽的视觉尺寸是 `36×10`（≠ 18×18 的格位），它**没有**因此偏离声明的 `pos`
    ⇒ 逐槽 `cellSize` 确实只是视觉尺寸、**不移动本槽的格位基座**。槽盒实测 60×60 px（= 20 UI）
    与 108×30 px（= 36×10 UI）都与声明逐像素相符。
  ⇒ **`setSlotDefaults({ cellSize })` 是有效的几何接口，`SLOT_CALIBRATION.cellSize` 只是缺省值。**
- **仍未验证**：
  1. **逐槽 `cellSize` 是否会影响它之后各行的格高** —— 实测只证明「不影响自己的基座」；
     要证明「不影响后续行」需要在一个怪尺寸槽**之后再放一行**（现有面板的怪尺寸槽都在最后一行）。
  2. 其它 GUI 缩放下的复现（两次实测都是 scale 3）。
  3. ~~`enabled: false` 能否真拦下「往槽里放东西」~~ → **已定案**（2026-09-12 真机）：它拦得住，
     而且是**双向一起拦**（连「取出来」也拦）⇒ 见 §4.14。
- **附带量取（2026-09-12，同一批截图，GUI scale 3）**：原版熔炉界面的排布换算成面板内 UI 坐标是
  —— 两格输入同列上下叠放、**间距 38**（18×18），产物格在右侧 **+58**（26×26，比输入大），
  且**垂直居中对齐于两输入的跨度**；火焰在输入列正中（13×13），箭头在输入与产物之间（22×15），
  两者垂直中心与两输入跨度中心重合。`examples/mob_chest` 的系统 A 即按这套比例排布。
  - 火焰/箭头**不是**从 GUI 大图里切的：原版 `furnace_screen.json` 用的是两张独立贴图
    `textures/ui/flame_empty_image`（13×13）与 `textures/ui/arrow_inactive`（22×15），
    直接 `setTexture` 引用即可，**不需要 uv/uv_size 切片**。
  - ⚠️ 原版的进度显示靠**另一对**贴图 + 绑定裁剪：`furnace.flame_full_image`
    (`textures/ui/flame_full_image`，`clip_direction: down`) 与
    `furnace.furnace_arrow_full_image` (`textures/ui/arrow_active`，`clip_direction: left`)，
    它们的 `#clip_ratio` 来自 `bindings` 的 `#furnace_flame_ratio` / `#furnace_arrow_ratio`。
    这两个绑定由**熔炉界面**提供；把面板挂在别的容器界面（如 `chest_screen`）上时取不到，
    `clip_ratio` 会留在默认值 ⇒ **只能放静态的「空态」贴图，进度要用别的手段**
    （例如用一个 `display` 槽、由脚本每 tick 换物品；见 §4.6 的 `kind` 语义）。
- **实测 ① 暴露的版面坑：自定义内容必须压在背包区之上（H = 166 时约 `y < 86`）**。
  面板下半区是原版背包（`inventory_panel`：`bottom_left` + `100%×50%`），其原版内容高约 88 UI px 且贴着底边，
  ⇒ **内容顶明显高于半区上边界**（H = 166 时半区从 y = 83 起，而背包槽首行顶实测在 **y ≈ 86**、
  其标签文字更探到 **y ≈ 76**，即内容**溢出**了半区）。
  ⚠️ 把槽声明在 `y = 76` 会与玩家背包首行**纵向重叠 7.7 UI px、横向仅差 1 UI px**（实测）——
  表现为你的槽正好盖在背包格上、背包标签的字从两槽缝隙里漏出来。H = 166 的可用安全区只有约 `y ∈ [24, 86)`，
  **只放得下 3 行 20px 格位**；要放更多行必须加大 `setPanel({ size })`（`H` 加大后安全区如何变化尚无实测，
  只有一个 H = 166 的数据点，别照公式外推）。
- **判据**：`node tests/container-layout.test.mjs`（锁住当前假设下的换算值）；
  `node tests/container-ui-output.test.mjs`（锁住产物里的 `offset` / `anchor_*`）。

### 4.10 ★ 门控键同时是 `ui/<name>.json` 的**文件名**：带点的键 = UI 静默不加载（2026-09-12）
- **机制**：`UISystem` 的 `name` 有两个身份 —— `chest:chest_screen` 里 gate 的比较值
  （`chest.ts:16` 字符串相等），以及 UI 文件名（`uiSystemRegistry.ts:12` 的 `path + name + '.json'`）。
- **坑**：`GRegistry.register` 会把**文件名**里的非法字符换成 `_`（`registry.ts:44` 的 `safeName`），
  而 `_ui_defs.json` 里记的是**未替换**的 `ui/<name>.json`（`uiSystemRegistry.ts:14` 用的是原始
  `ui_system.name`）⇒ 清单与磁盘文件不一致，UI **静默不加载、零报错**。
  写文件那一侧用的一直是替换后的名字（`cli/load.js:249` 的 `` `${name}.json` ``）。
- **规避**：门控键只允许 `A-Z a-z 0-9 _ -`。`containerLayout.ts` 的 `checkUIName()` 在
  `ContainerUISystem` 构造时打一条 warn（**只 warn 不抛**，避免直接打断既有项目的构建）。
- **判据**：`node tests/container-ui-output.test.mjs` 的「非法门控键只 warn 不抛错」。

### 4.11 逐槽 `cellSize` 曾把格位基座算歪 —— 网格几何必须取**统一值**（2026-09-12）
- **症状**：同一个面板里混用不同 `cellSize`（例如普通 18×18 槽 + 一个 36×10 的长条进度槽）时，
  某些槽的实际渲染位置与声明的 `pos` 差 `(统一格高 − 本槽格高) × 行号` 像素。
- **原因**：两套算法各取一个尺寸 —— 网格尺寸取 `max(各槽 cellSize)`（`containerUISystem.ts` 的 `#buildGrid`），
  而格位基座取**该槽自己的** `cellSize` 递推（`containerLayout.ts` 的 `cellBase`）。
  引擎的网格格位是**均匀**的（只有一个格位尺寸），所以混合尺寸下两者必然矛盾。
  量到的例子：`gridOrigin [8,34]` + 4 槽、第 4 槽 `cellSize [36,10]`、`pos [10,40]`
  ⇒ 旧算法给出 offset `[2,-24]`（隐含基座 y=64），而网格真实格高 20 ⇒ 基座应是 94（偏 30px）。
- **规避（现行语义）**：几何只认一处 —— `setSlotDefaults({ cellSize })`（缺省 = 标定表 `cellSize`），
  网格尺寸与基座换算都用它；逐槽 `cellSize` **只**写内层控件的 `$cell_image_size` / `size`，
  允许溢出格位（原版槽位模板不裁剪，长条进度槽就是这么画的）。
- **判据**：`node tests/container-layout.test.mjs` 的「逐槽视觉尺寸不参与基座」与
  `node tests/container-ui-output.test.mjs` 的「网格尺寸也只用统一格位」。

### 4.12 ★ 自定义容器面板**能/不能**拿到哪些格子数据（2026-09-12，原版包 bedrock-samples 1.21.130.26 通读）

问法通常是「能不能读容器某个格子的内容，做一条由真实数据驱动的进度条」。答案是**分两层**：

- **格子内部 —— 能**。`common.container_item` 的子控件声明了一批 `binding_type: "collection"` +
  `binding_collection_name: "$item_collection_name"` 的每格绑定（`ui_common.json`）：
  堆叠数量 `#inventory_stack_count`（:3615，原始名 `#item_stack_count`）、物品 id `#item_id_aux`（:3796）、
  整份 stack 数据 `#item_renderer_data`（:3758）、耐久 `#item_durability_visible|total_amount|current_amount`
  （:3644/3650/3656）、容量 `#item_storage_*`（:3704/3710/3716）。
  能驱动的目标属性以 `#visible` 最成熟；`#texture` 有确证先例（`inventory_screen.json:1578-1585`
  的 `#container_item_background_texture` → `#texture`）。
- **格子外部（贯穿面板的进度条）—— 不能**。四条互相独立的证据：
  1. 全 `ui\` 里形如 `#*_ratio` 的**供给名只有 7 个**（furnace_arrow / furnace_flame / brewing_bubbles /
     brewing_arrow / brewing_fuel / bundle_weight_bar / progressive_select_bar），**无一属于 chest/container**；
  2. `#furnace_arrow_ratio` / `#furnace_flame_ratio` 在整个原版包 grep **只有 2 命中，且两处都是消费端**
     （`furnace_screen.json:42`、`:62`）⇒ 引擎按界面硬编码，资源包只能消费、**不能自己提供**；
  3. `chest_screen.json` 全文**没有任何 `bindings` 声明**，`container_items` 上不存在 `#progress_percentage`；
  4. **`#clip_ratio` 不接受算术**，且 `binding_name_override: "#size"` 在全包 **0 命中**
     ⇒ 就算读到数量也换算不成长度。
  格子外唯一能读的是**光标上那一格**（`#inventory_selected_item` / `#inventory_selected_item_stack_count`，
  全局绑定、无 collection 限定，`chest_screen.json:117/162` 已挂载该按钮）与集合级总数 `#collection_total_items`。
- **★ 替代路线：借引擎自带的耐久条当进度条（零新贴图）**。`common.container_item` **自身就内联了**
  `durability_bar@common.durability_bar`（`ui_common.json:4838`）与 `storage_bar@common.storage_bar`（:4843），
  唯一门控是 `ignored: "(not $durability_bar_required)"`（:3629），而父级默认 **true**（:4778-4779）
  ⇒ 容器格子**默认就带**这两条（不是 HUD 专属；显式关闭的先例见 `inventory_screen.json:1951-1952`）。
  条的比例由引擎按每格 `#item_durability_*` 算好；`$durability_bar_size` / `$durability_bar_offset`
  **可被外部覆盖**（:3633-3634；原版口袋版就覆盖了它们 :4895-4896）⇒ 框架的 `addSlot({ vars })`
  写的正是这一类 `$x|default`。于是**脚本往槽里写一个「剩余耐久 = 进度」的可损耗物品，条就会自己动**，
  不需要任何进度条贴图。示例实现：`examples/mob_chest/main.mjs`（进度物品 + `vars`）与
  `examples/mob_chest/scripts/progress_bar.js`（`system.runInterval` 驱动）。
  - **真机已确认它会画出来**（2026-09-12，见下面实测 ②）；仍未知的只有：`#item_durability_visible`
    的判定条件（原版包内**查不到**，纯引擎内部计算）、`progress_bar_renderer` 怎么把 current/total
    合成宽度（引擎渲染器、包内无定义）。
  - 附：`$item_renderer_size` 默认 `[16,16]`（:4782），归零即可把物品图标藏掉、只留条。
- **★ 真机实测 ②（2026-09-12）：条确实会画出来，但尺寸改不动 —— `$x|default` 覆盖不到
  「后代自己声明了 `|default`」的同名变量。** 现象：框架用
  `addSlot({ vars: { durability_bar_size: [36,4], durability_bar_offset: [0,3] } })`（写成 `$durability_bar_size|default`）
  之后，真机截图里条**仍按原版默认 12×1** 画在格子底部中央（量得条宽 12.33 UI px、
  位置 = 格心 +5，正是默认 `$durability_bar_offset` `[0,5]`）。
  原因：这两个变量是**后代控件** `common.durability_bar` **自己**用 `|default` 声明的（`ui_common.json:3633-3634`），
  后代自身的 `|default` 胜过祖先实例层的 `|default`。旁证：`$cell_image_size|default` 覆盖得动，是因为消费它的
  `common.cell_image` 没自己声明同名 `|default`。原版口袋版之所以改得动，是它**裸写**这两个变量
  （**不带** `|default`，`ui_common.json:4895-4896`）—— 而**框架的 `vars` 只会写 `$x|default`**
  （`containerUISystem.ts:466-469`）。
  - **判据（一个变量能不能被 `vars` 覆盖）**：看它由谁声明。由**被实例化的那个控件自己**声明
    （如 `$durability_bar_required` 在 `container_item:4778`）⇒ 实例层能覆盖；
    由**更深的后代**自己声明 ⇒ 覆盖不到，只能改用下面的注入法。
- **★ 注入自定控件：`$cell_overlay_ref` / `$background_images`（都在 `item_cell` 内，保留每格 collection 上下文）**。
  这两个变量都是 `container_item` **自己**声明的（`:4775` / `:4784`，后者配 `$background_image_control_name` `:4785`），
  故实例层可覆盖。`$cell_overlay_ref` 默认是空壳 `common.cell_overlay`（`:3315` 只有 `ignored: true`），
  用在 `item_cell` 的 `item_cell_overlay_ref@$cell_overlay_ref`（`:4851`）。
  ⇒ 项目可注册一个自定控件（`type: "custom"` + `renderer: "progress_bar_renderer"` + 自己的三条 `bindings`：
  `binding_type: "collection"` / `binding_collection_name: "container_items"`，照抄 `ui_common.json:3642-3661`），
  再用 `vars: { durability_bar_required: false, cell_overlay_ref: "<ns>.<控件名>" }` 把自带那条关掉、换成自己这条
  —— **尺寸与绑定都自己说了算**。`examples/mob_chest/main.mjs` 即此法（先铺满整格的色块条，后改为箭头，见下条）。
  - ★ **框架已把这一整套封成 `ContainerUISystem.addProgressSlot()`**（参数语义见接口 JSDoc、
    用法见 `doc/dev/ui-architecture.md` §4.3），项目不必再手写 overlay 控件、三条绑定与那两个变量；
    判据 `node tests/container-ui-output.test.mjs` 的第 19–22 条。
- **★ `progress_bar_renderer` 只能画色块、给不了贴图 ⇒ 想要「原版箭头」那种形状必须自己裁。**
  该渲染器的全部可用属性只有 `size` / `offset` / `property_bag`（`is_durability` / `is_storage_bar` /
  `round_value` / `primary_color` / `full_storage_color`）与 `primary_color` / `secondary_color`
  （见 `ui_common.json:3631-3641`、`toast_screen.json:346-354`）—— **没有任何 texture 属性**。
  原版箭头是**两张图 + 裁切**：`arrow_inactive`（打底）与 `arrow_active`
  （`clip_direction: "left"`，`#clip_ratio` ← `#furnace_arrow_ratio`，`furnace_screen.json:30-46`）。
  本面板拿不到 `#furnace_arrow_ratio`，**比例只能自己算**：把每格的
  `#item_durability_current_amount` / `#item_durability_total_amount` 两条 collection 绑定
  （**不带 override**，让名字进入该控件的属性作用域）挂在同一个控件上，再加一条 view 绑定
  `source_property_name: "((#item_durability_total_amount - #item_durability_current_amount) / #item_durability_total_amount)"`
  → `target_property_name: "#clip_ratio"`。**Molang 算术写在 view 绑定里是框架自己用过的**：
  `src/core/ui/systems/hud/hud.ts:35` 就是 `"(not (%.7s * #hud_title_text_string = 'PREFIX'))"`。
  - ⚠️ **真机已验证（2026-09-12）：Molang 表达式读得到 collection 绑定** —— 箭头随进度动起来了（先前只在框架的 HUD 里见过它读 **global** 绑定）。
    兜底设计：把静态的 `arrow_inactive` 垫在下面，即使裁切不生效也还看得见一支箭头。
  - **★ 真机已验证：`#item_durability_current_amount` 是「已损耗量」，不是「剩余量」。**
    判据是**方向**：按 `current / total` 写箭头会**越走越短**，正确写法是取反 `((total - current) / total)`。
    引擎自带的 `durability_bar` 不取反也不反，是因为它的 `property_bag` 带了 `is_durability: true`
    （`ui_common.json:3637-3641`），方向由渲染器内部处理。
  - **`clip_direction: "left"` 的语义 = 显示左侧 `ratio` 那一部分**（不是裁掉左侧）。佐证：XP 条
    `full_progress_bar`（`hud_screen.json:510-522`）用 `clip_direction: "left"` + `#exp_progress`，
    而 XP 条是从左往右长的。同理 right / up / down 各显示对应那一侧：原版火焰
    `flame_full_image` 用 `down`（从下往上烧），`examples/mob_chest` 的燃烧槽用的就是它。
  - 附带：想让格子**没有浅灰底**，可把 `$background_images`（`container_item` 自己声明，`:4784`）
    指向一个 0×0 的空面板。
- **框架侧硬约束**：`ContainerUISystem` 把模板硬编码成 `chest.chest_grid_item`
  （`containerUISystem.ts:422`），槽位内层控件内部构造，对外只给
  `cellSize / background / itemRenderer / vars` ⇒ **今天无法给槽位挂 bindings**；
  `UIElement` 自己有 `dataBinding.addDataBinding()`（`dataBinding.ts:15`），但容器 API 没开口子。
  上面那条「借耐久条」的路线之所以可行，正是因为它**不需要自定义绑定**（模板自带）。
- **出处**：`examples/mob_chest` 的进度槽设计（2026-09-12），三路只读调研 + 原版包逐行核对；
  相关：§4.6（`enabled`）、§4.9（坐标/版面）。

---

### 4.13 ★ 格位落点跟 `controls` **数组顺序**走，`grid_position` 不参与定位（2026-09-12，fz-sapdon 真机）

- **症状**：容器面板里几个槽**整体错位**，但**产物完全正确** —— `offset` 逐条与声明 `pos` 吻合、
  `grid_dimensions` / `grids.size` / `grid_position` 全对；错的只有渲染位置，而且**只有 y 错、x 一直对**
  （实测 4 个槽的 x 全部落在声明值上）。
- **根因**：引擎把 grid item **依次铺进格位**，落点 = 网格原点 +
  **该 item 在 `controls` 数组里的序号** × 统一格位尺寸 + 该 item 的 `offset`；
  **`grid_position` 字段不参与定位**。框架此前按**声明顺序**发布格位 ⇒ 项目只要不是
  「按槽号顺序声明」（fz-sapdon 就是**先声明进度槽、后声明输出槽**），整块版面就会错开。
- **判据（4/4 吻合）**：fz-sapdon 回收机 4 槽，产物数组序 0/1/2/3 依次是
  输入(slot 0) / 箭头(slot 2) / 能量(slot 3) / 输出(slot 1)，真机实测渲染 y ≈ `29 / 12 / -14 / 62`：
  - 按**数组序**算 ⇒ `31 / 14 / -8 / 63` —— **全中**；
  - 按 **`grid_position`** 算 ⇒ `31 / 32 / 10 / 27` —— 三个错。
  （能量条那一格因此越出面板顶边 14 UI px —— 截图里那根红柱子就是它。）
- **规避（框架已修）**：`containerUISystem.#buildGrid()` 现在**先按 `grid_position` 行优先排序**
  再发布 ⇒ 「`pos` = 渲染位置」对任何声明顺序都成立。
  `examples/mob_chest` 一直没暴露这个问题，**纯属它的声明顺序恰好等于格位顺序**（巧合掩盖了坑）。
- **副作用提醒**：既然落点靠序号，就**不能有空洞** —— 只用 slot 0 与 slot 2 会让实际落点整体前移。
  `SLOT_CALIBRATION.columns` 为 1 时序号 = 槽号，所以「槽号连续」就是安全区。
- **出处**：fz-sapdon 回收机界面真机截图（2026-09-12），4 槽反解行号 4/4 吻合；相关 §4.9（坐标标定）、§4.12。

---

### 4.14 ★ `enabled: false` 是**整体禁用这一格**：产物格的产物也取不出来（2026-09-12，fz-sapdon 真机）

- **症状**：容器面板里那个**输出格**看得到产物，但**点不动、拿不出来**。用户原话：
  > 「帮我把输出槽改成启用，不然拿不了物品」
- **根因**：框架对 `kind: 'output'` / `kind: 'display'` 的槽位写内层控件的 `enabled: false`
  （`resolveSlot` 的 `isGatedKind()`）。这个 JSON UI 属性是**禁用整个控件**，
  不是「只拦放入、放行取出」—— 它把**双向交互一起**关掉了。
  §4.6 留的「该标志位能否真拦下"往槽里放东西"尚未真机确认」在这条上得到了**反向**答案：
  它拦得住，而且**连"取出来"也一起拦**。对一个输出槽来说这是**致命的**：
  产物永远拿不到手。
- **规避（框架已改）**：`resolveSlot` 里显式 `enabled` 现在**一律优先**于 `kind` 的缺省门控
  （`merged.enabled ?? (isGatedKind(kind) ? false : undefined)`）。想做出「产物能取走」的输出格：

  ```ts
  ui.addSlot({ slot: 1, pos: [108, 27], kind: 'output', enabled: true })
  ```

  **缺省行为一个字没变**（不传 `enabled` 时 `output` / `display` 仍写 `false`）⇒ 既有项目产物逐字节不变
  （`kind` 只影响这一个键，见 `containerLayout.ts` 的 `ResolvedSlot`）。
- **什么时候该用哪种**：
  - **产物格 / 输出槽** ⇒ `enabled: true`（**必须**，否则产物烂在格子里）；
    「只出不进」改由**加工逻辑**保证（只往它写），别再指望界面标志位。
  - **进度槽 / 纯显示格** ⇒ 保留缺省 `false`（正是想要的效果：玩家既放不进也取不走那件进度载体物品）。
- **副作用提醒**：`enabled: false` 会把整格从交互链里摘掉，所以**脚本仍必须每拍读回真实格子**
  （`readSlot` → 不是自己的物品就无条件补写）——不能因为「反正玩家动不了它」就省掉这步：
  结构快照还原、旧存档、调试工具都可能让格子里不是预期的东西。
- **出处**：fz-sapdon 回收机输出槽（2026-09-12 真机，用户反馈）；判据在
  `tests/container-layout.test.mjs`（`resolveSlot` 覆盖规则）与 `tests/container-ui-output.test.mjs`（产物形状）。

---

### 4.15 ★★ `item_despawn` 那一组**一个字都不能改**：加 `delay` 会让**整容器一个都不掉**（2026-09-12 真机，丢了真物品）

- **原始症状（需求起点）**：玩家破坏机器，地上除真物品外**还多出一个内部物品**
  （FZ 的进度 / 能量**显示载体** `fz:machine_progress`）。用户原话：
  > 「破坏方块的时候不要把进度物品掉落出来」
- **机制**：`TileBlock` 给每个承载实体挂的实体数据里

  ```json
  "item_despawn": {
    "minecraft:despawn": {},
    "minecraft:instant_despawn": { "remove_child_entities": false },
    "minecraft:transformation": { "drop_inventory": true, "into": "minecraft:air" }
  }
  ```
  破坏方块 ⇒ `minecraft:block_sensor.on_break` → `despawn_event` → 这一组 ⇒ **整个容器倒出来**。
  官方字段全集（`metadata/doc_modules/entities.json`，`minecraft:transformation`）：
  `add` / `begin_transform_sound` / `delay` / `drop_equipment` / **`drop_inventory`** / `into` /
  `keep_level` / `keep_owner` / `preserve_equipment` / `transformation_sound`
  —— **没有**任何「按槽位 / 按物品过滤掉落」的入口；`drop_inventory` 的原文是
  "Cause the entity to drop all items in inventory upon transformation"。

- **★ 第一次的修法（错的，真机翻了车）**：往 `minecraft:transformation` 上加
  `delay: { value: 0.1 }`，想用这段延迟让「破坏事件里的脚本先清掉内部格」。
  结果：**地上什么都不掉，玩家的真物品一起没了**。用户原话：
  > 「我的真物品也没有了」

  **原因**：同一组里还有 `minecraft:instant_despawn`（**立即**移除实体）。
  加了 delay 之后，实体先被立即删掉，**推迟的 transformation 再也没机会执行** ⇒
  连 `drop_inventory` 都不发生。之前之所以能掉，正是因为 transformation 与那两个移除组件
  **在同一瞬间**生效 —— 一旦把它推迟，它就落在那两者之后，等于没写。

- **规避（最终做法）**：

  1. ★ **绝对不要碰 `item_despawn` 组**（不要加 `delay`、不要删 `instant_despawn`、
     不要改 `drop_inventory`）。框架**不提供**任何「延迟 despawn」入口，`tileBlock.js` 顶部有警告。
  2. **脚本侧**：破坏路径的**第一步**读容器 → 把内部格（进度 / 能量）`writeSlot(i, undefined)`。
     ⚠️ 读容器时**必须传「被破坏前那个方块的 id」**，不能传 `event.block.typeId`
     （破坏后它恒为 `minecraft:air`）。
     ⚠️ 这一步与引擎的 despawn **谁先谁后未验证** ⇒ 它只是**尽力**，不能当成保证。
  3. **★ 真正保证结果的是「掉落物过滤」（2026-09-12 用户提的，实测方向正确）**：
     不去阻止掉落，而是**掉了之后删掉**。两个钩子，都只按物品 id 判：

     | 事件 | 说明 |
     |---|---|
     | `world.afterEvents.entityItemDrop` | 最贴切：`event.items` **直接给出被掉出来的物品实体**（`Entity[]`） |
     | `world.afterEvents.entitySpawn` | 兜底：覆盖没走前者的路径（爆炸 / `/setblock` / 活塞等**非玩家破坏**也走它） |

     ```ts
     world.afterEvents.entityItemDrop.subscribe((e) => {
         for (const item of e.items) if (isOurs(item)) item.remove()
     })
     ```

     **为什么可以无条件删、不必判位置**：内部载体物品（本例是 `fz:machine_progress`）
     是 `category: none`、不进创造菜单、没有配方 ⇒ 正常途径**拿不到**它，
     「世界上出现这个物品实体」本身就是泄漏。
     **为什么不可能误删真物品**：判据是**物品 id 逐字相等**。
     ⚠️ 热路径要求：`entitySpawn` 是**每一次实体生成**都触发的事件 ⇒ 判据必须
     「一次字符串比较 + 不命中立刻返回」，绝不能对每只怪都 `getComponent`。
     ⚠️ 掉落物实体的 `typeId` **通常就是物品 id**；若某版本给的是通用的
     `minecraft:item`，真正的物品 id 在 `minecraft:item` 组件的 `itemStack` 里
     —— 先比 `typeId`、**只在它是 `minecraft:item` 时**才去翻组件（省掉热路径开销）。

- **反面做法（都不要用）**：
  - 把 `drop_inventory` 改成 `false` 再由脚本自己掉真物品 ⇒ 失败模式是
    「脚本没跑 ⇒ 玩家的东西凭空消失」；
  - 给 transformation 加 `delay` ⇒ **就是本条目踩的那一脚**，失败模式同样是真物品消失；
  - 依赖「破坏事件里的脚本一定先于引擎 despawn 跑完」⇒ 实测**赶不上**（第一层清格试过，没赶上）。
  **判断准则**：任何改动只要可能让「真物品不掉」，就一律不做 ——
  最坏情况只允许是「多掉一个内部物品」（最终由上面第 3 条删掉）。

- **出处**：FZ 回收机（2026-09-12 用户两轮反馈：先是「不要把进度物品掉出来」，后是「我的真物品也没有了」）；
  字段定义取自原版包 `bedrock-samples-1.21.130.26-preview/metadata/doc_modules/entities.json`
  （`minecraft:transformation` 小节）；判据在 `tests/block-api.test.mjs`
  （`item_despawn` 组与历史产物**逐字节一致**、`transformation` 上不许出现 `delay`）。

---

## 5. 本仓库的构建方式（受限环境）

`npm run build` / `node scripts/build.cjs` 在受限沙箱里跑不了（`cp.exec` 走管道 → `spawn EPERM`）。
等价拆成 4 步直跑：

```
tsc                                   # node_modules/typescript/bin/tsc
tsc-alias                            # node_modules/tsc-alias/dist/bin/index.js
node scripts/buildTask.cjs           # rollup → prod/
# 拷贝 src/templates → prod/templates，然后删除 dist/
```

- ⚠️ **`tsc-alias` 不能漏**：漏掉它，`dist/` 里会残留 `@sapdon/utils/...` 裸别名，
  rollup 解析不到就当成 external → **`prod/cli/start.js` 会 import 无法解析的 `@sapdon/utils`**，
  表现为 `ERR_MODULE_NOT_FOUND: Cannot find package '@sapdon/utils'`。
- ⚠️ **构建"9/9 成功"不等于 prod 是新的**：改完要**断言 prod 内容**（例如 `prod/oc/index.js` 里有没有新导出），别只看 `Failed: 0`。
- 单测：`node --test` 会 fork 子进程（受限环境 `EPERM`）→ 直接跑文件：`node tests/persist.test.mjs`（需先 `tsc` 生成 `dist/`，测试 import 的是 `../dist/...`）。

---

## 6. 待真机确认（本环境无法启动 Minecraft）

- [ ] 容器（★ 现在只剩**实体路线**可用）：用 `createTileBlock(..., { inventory_size })` 放一个带容器的方块，
      右键能打开、能存取；**并确认大槽位**（FZ 机器需要 56）被引擎接受（实体组件文档没给上限）。
- [x] ~~**★ `"enabled": false` 能否拦住「往这个槽里放东西」**~~ → **2026-09-12 真机定案**：
      它拦得住「放进去」，**但同时也拦住了「取出来」** —— 语义是**整体禁用这一格**，
      不是「只拦放入」。⇒ 产物格必须显式写 `enabled: true`（框架已改成「显式值优先」），
      完整证据 / 修法 / 取舍见 §4.14。
- [ ] **★ 容器版面坐标空间校准（§4.9）**：拿一个 `setGridOrigin([0,0])` + 2~3 个 `pos` 取整十数的探针面板，
      量实际渲染位置与 `pos` 的差 ⇒ 决定 `SLOT_CALIBRATION.anchor` 取 `top_left` 还是 `center`、
      `originPadding` 要不要补偏移。**在此之前所有 `offset` 数值都只是"按假设算出来的"**。
- [ ] 容器面板的**层序**：`common_panel`（原版灰底）→ `panel_background`(layer 1) → `container_panel`/`inventory_panel`(layer 2)
      → `grids`(layer 3) → `main_panel`(layer 4) → `title`(layer 12) 这套层号在真机上是否真的按预期叠放
      （「面板在、图没了」就是层序错的典型表现，见 §4.5 坑 3）。
- [ ] 方块路线的 `minecraft:block_entity.container`：当前引擎版本报
      `-> minecraft:block_entity -> container: … not present in the Schema`（1.26.30 / 1.26.40 实测同样）；
      等引擎支持后 `setBlockEntity(true, { container: { slot_count } })` 是否即可用（`slot_count` 需在 `[1,54]`）。
- [ ] **物品告警清零**（P0-1 的最终验收）：用自定义 item catalog 的项目重进世界，
      确认 `The item <X> was created with the group set to 'minecraft:…'` 这类 warning 变成 **0 条**（默认值改 1.21.90 后预期）。
- [ ] **方块几何告警清零**（P0-3 的最终验收）：`blocks.json` 不再写方块条目后，
      确认 `trying to override the Geometry component with blocks.json settings for a custom block` 的 N 条（= 方块数）变成 **0 条**；
      并确认挖掘/放置**音效**与改动前一致（框架从未写过 `sound` 字段，预期无变化），以及只含
      `format_version` 的 `blocks.json` 引擎会不会有别的抱怨。贴图应仍走 `material_instances` + `terrain_texture.json`。
- [ ] **部署 prune 的真机效果**：从项目里删掉一个方块/配方/`res/` 资源后重新构建，
      确认游戏里对应的旧文件**真的消失**（不再报 `not present in the Schema`），且玩家自己放进开发包的文件仍在。
- [ ] 路线 B：`registerBlockComponent` 注册的组件在游戏内事件是否真的触发（本环境只用桩验证了注册时机与注册表）。
- [ ] **★ `sapdon:block_with_entity` 内置实现（模式 A）的真机行为**（本轮新增，全部未验证）：
      ① 只 `createTileBlock`、不手工注册的项目，方块**不再被引擎丢**（`not present in the Schema` 消失）；
      ② `onPlace` spawn 出来的 `${typeId}_entity` **落点正确**（脚底贴方块底面 = `block.center().y - 0.5`，
      参考形状出自 `examples/mob_chest`，但该参考只在本环境外被验证过）；
      ③ **右键能打开容器**（界面是引擎原生开的，`@minecraft/server` 没有「给玩家打开容器」的 API）；
      ④ `onPlace` 是否会因 `setPermutation` 再次触发 —— 内置实现靠 `getEntitiesAtBlockLocation` 查同种实体**防重复**，
      若引擎在放置瞬间还没把实体登记进查询结果，仍可能出双容器（**本环境无法证伪**）；
      ⑤ 破坏方块后 `minecraft:block_sensor` → `despawn_event` 是否真的不留幽灵实体。
- [ ] `options.entity_texture` 的真机效果：给 terrain 短名的项目传完整路径后，实体贴图 `Missing referenced asset` 是否消失。
- [ ] 内置 `geometry.cube`（模板）在新项目里是否真的被 RP 收录（`sapdon create` + 一次构建后看 `dev/<proj>_RP/models/blocks/cube.geo.json`）。
- [ ] i18n：`labels` 传 lang 键时，JSON UI 是否按预期解析（需要 `RP/texts/*.lang` 里定义该键）。

---

## 7. 仓库里**已知损坏**的东西（不是框架回归，别误判）

按 2026-09 那次全量核对的口径记录（判据：`git log ae6ae16..HEAD --name-only` 未触及相关模块）。

- **`examples/hello_ui` 构建必失败**（exit 1、0 行 `处理数据:`）：`main.mjs` import 了本框架**不存在**的 `ServerUISystem`，还调用了 `bindingTitlewithContent` —— 该示例停留在旧 API。**不是框架回归**；修它要改 examples 源码（本轮按"examples 是范本、不改源码"的约束未动）。
  - 它的 `dev/hello_ui_*` 里躺着 07-11/08-21 的陈旧产物：这是**长期构建失败**造成的（没有成功构建 → 没有清单 → 清不掉），**不是**改项目名残留，故未删。修好该示例后建议手工清一次 `dev/`。
- **`tests/ui-buttonpanel.test.mjs` 失败**：import 了早已不存在的 `dist/core/ui/systems/sapdon/sapdonButtonPanel.js`（该模块在 `63262bf` 之后就不在 `src/` 里）。
- **`tests/item.test.mjs` 失败**：`src/core/entity/componets/entityComponet.js` 把 `type.ts` 的 **type-only** 导出 `RideableComponentDesc` 当**值** import → 运行期 `does not provide an export named 'RideableComponentDesc'`（rollup 构建日志里也有同名 warning）。
- 上面两个测试**在 `ae6ae16` 之前就已损坏**，与本轮改动无关；本轮未修（超出范围）。

---

## 8. ★ `sapdon lib` 只在**框架仓库内部**可用（已知缺陷，待修）

**症状**（2026-09 由 FZ 项目实测复现，exit 1）：
```
Error: src and dest cannot be the same \\?\<proj>\node_modules\@sapdon\core
  code: 'ERR_FS_CP_EINVAL'
```

**根因**（`lib` 的实现，见 `prod/cli/start.js` 的 `ge()`）：
```js
const t = path.join(path.dirname(fileURLToPath(import.meta.url)), '../')   // ← 框架源 = CLI 自己的兄弟目录
cpSync(path.join(t,'core'), path.join(n,'@sapdon/core'),    {recursive:true, force:true})
cpSync(path.join(t,'cli'),  path.join(n,'@sapdon/cli'),     {recursive:true, force:true})
cpSync(path.join(t,'oc'),   path.join(n,'@sapdon/runtime'), {recursive:true, force:true})  // 注意 oc → runtime
```
`lib` 把**「CLI 所在目录的父目录」**当成框架源码根。这对框架自己的 `prod/` 成立
（`prod/{core,cli,oc}` 就是框架源），但对**任何从项目 `node_modules/@sapdon/cli` 解析到 CLI 的项目**
都不成立 —— 那时 `t` 就是项目自己的 `node_modules/@sapdon`，于是 `src === dest`。

**范围**：npm 安装的正常用户布局**正是**「CLI 在项目的 `node_modules/@sapdon/cli`」，
所以 `sapdon lib` 实际上**对所有按正常方式装依赖的用户项目都不工作**，不只是某一个仓库。

**第二重问题（读码 + 列目录，未实跑）**：目标名是 `runtime` 而源目录名是 `oc`。
`prod/` 下有 `oc/`；`D:\Projects\sapdon\node_modules\@sapdon\` 与
`examples/guidebook_demo/node_modules/@sapdon/` 下**都只有 `cli`/`core`/`runtime`、没有 `oc`**。
⇒ 即使 `src === dest` 被修掉，只要 CLI 来自任何 `node_modules/@sapdon/` 布局，
`cpSync(t/'oc', …)` 仍会因**源不存在**失败（ENOENT）。

**临时绕过**（项目侧可用，不改框架）：显式指定框架构建产物的 CLI，例如
`SAPDON_CLI=D:/Projects/sapdon/prod/cli/start.js node tools/sapdon.mjs lib`
（`prod/` 是**唯一**可用的 lib 源：那里同时有 `core/`、`cli/`、`oc/`）。

**修法方向（待定）**：给 `lib` 一个显式来源（环境变量 / 框架根参数 / 从依赖解析 `@sapdon/core`
的真实安装位置），并把内部的 `oc` 与发布名 `runtime` 的映射落定。

**★ 更简的修法建议（2026-09 S3b 复核后补充，本轮只记不做）** —— 不引入任何新参数即可让 `lib`
在正常用户布局下**不炸**，代价只是「按布局决定做多少事」：

1. **先判「源 == 目标」再拷贝**：对每个包算 `path.resolve(src)` 与 `path.resolve(dest)`，
   若两者相同（含 `dest` 落在 `src` **内部**的情形）→ **跳过该包**并打印一行说明
   （例如 `跳过 @sapdon/core：CLI 就来自项目自己的 node_modules/@sapdon，源与目标同一处`）。
   这条直接消灭 `ERR_FS_CP_EINVAL: src and dest cannot be the same`，
   且**不改变**「CLI 来自框架 `prod/`」时（框架仓库内部）的既有行为。
2. **源目录探测同时接受 `oc` 与 `runtime`**：先试 `path.join(root, 'oc')`，不存在再试
   `path.join(root, 'runtime')`；两者都不存在 → 跳过该包 + 一行说明。
   理由：目标名一直是 `runtime`（发布名），而框架 `prod/` 下的源目录叫 `oc` ——
   任何从 `node_modules/@sapdon/` 解析到 CLI 的布局都只有 `runtime`、没有 `oc`，
   现状会以 ENOENT 失败（§8 第二重问题）。
3. 两条合起来的语义：**「能同步的就同步，同步不了的（源就是目标 / 源不存在）明确说明并跳过，
   而不是整条命令 exit 1」**。真正的错误（权限、磁盘满）仍然抛。

> ⚠️ 判据（将来实现时必须给）：框架仓库内部 `node prod/cli/start.js lib` 行为不变
> （`examples/*/node_modules/@sapdon/{core,cli,runtime}` 时间戳更新）；
> 从项目自己的 `node_modules/@sapdon/cli` 解析时**exit 0** 且打印「跳过」而不是 `ERR_FS_CP_EINVAL`。

---
