## [0.7.3] - 2026-10-08

### 重构（《架构规划 2026-10-08》阶段 0-4；分支 `refactor/arch`）

- ★ **统一回执契约**（对应 Java 的 `R<T>` + `ErrorCode`）：
  - 新增 **`lib/receipt.mjs`**：`ReceiptCode` 枚举 · `ok()` · `fail(code, error, meta)` · `withFallbackCode()` · `minifyReceipt`（**原文搬移**，一个字符没改）；
  - 新增 **`lib/constants.mjs`**：`TOOL`（12 个工具名）· `OP` · `LIMIT`（阈值唯一出处）· `MIN_KIND` / `MIN_OPS`（**用 `TOOL.X` 计算键**，改名/新增工具不再静默失效）；
  - **工具出口统一收口**：任何 `execute` 抛异常 ⇒ `{ok:false, code:"TOOL_THREW", tool, op, error, nextStep}` —— **绝不把异常抛给调用方**（抛出去会打断调用方一整轮）；失败档 **必带 `code`**；
  - **逐族补语义码**（`NOT_FOUND` / `BAD_PARAM` / `FAILED` / `GATE_FAILED` / `NO_EVIDENCE`）：`code` / `map` / `log` / `kb` / `shot` / `sim` 六族共 **25 处**；另把 **10 处 `execute` 直下的 `throw` 改成 `fail(ReceiptCode.X, 原文案, {nextStep})`** ⇒ **工具回执的「缺 code」清零**（余下 13 处是辅助函数返回、1 处内部对象、8 处 HTTP 信封，均已定性）。

- **拆文件（阶段 3，进行中）**：
  - `TITLE` / `renderJson` 这两个**全工具共享**的符号搬进 `lib/constants.mjs` / **`lib/render.mjs`**（否则每个 `lib/tools/*.mjs` 都要反向 import `index.js` ⇒ 循环依赖 + TDZ 风险）；
  - **`miliastra_asset` 整族**（工具对象 + 调度器 `assetOp` + `measureImage`）搬到 **`lib/tools/asset.mjs`**：机械搬移、依赖自动解析（7 模块 / 16 符号）、**行为零改动**；
  - `index.js`：**4930 → 4819 行**；其余 11 个工具按依赖数递增逐个搬（一族一提交）。

- **测试**：
  - 新增 **`tests/audit-test.mjs`**（**38 条只读用例**）并登记进 `tools/test-all.mjs` —— 把「任何 op 都不许抛异常 / 失败必带 `code`」变成**门禁**（原本是手工审计，曾一次抓出 10 条抛异常的 op）；
  - 新增 **`tests/_harness.mjs`**：把 `gen` / `pixelart` / `vfx` 三份重复的 `throws` 助手合一（语义取并集：抛异常 **或** 回执 `ok:false`）；
  - 5 个套件从「断言该抛异常」改成「断言 `{ok:false}` 回执」（与新的出口契约对齐）。

- **文档**：删掉包内 **4 处「死指针」**（指向本包外 / 已不存在的文件，如 README 里那段「…**不在本包里**，故不作链接…」）。**规则：没有的东西就不写。**

- **验证口径**：每个提交都跑 `lint`（21 条硬规则）+ `typecheck`（我们自己的文件 0 错）+ **全量 `npm test` 37 套件 / 0 失败**；重构全程**行为零改动**（除契约新增的 `code` 字段）。
### 拆文件（阶段 3）** 完成：12/12 个工具全部外迁，`index.js` 变薄壳

- `index.js`：**4930 → 720 行**（只留 cordis 注册 / `inject` / import / `TOOLS` 数组 / 系统提示段 / 出口收口 / HTTP 路由）。
- **12 个工具**各自成模块：`lib/tools/{health,code,map,log,playtest,shot,probe,sim,asset,gen,kb,echo}.mjs`
  （对象 + 各自的调度器/helper，**机械搬移、行为零改动**；每搬一族都跑 `lint` + `typecheck` + 全量 `npm test`）。
- 新增 **`lib/shared.mjs`**（292 行）：**被多个工具使用的共享 helper**（`resolveLevel` / `classifyControls` / `chooseLua` /
  `gilScriptInfo` / `pathBasenameOf` / `clampNum` / `sleep` / `waitForPlaytestStart` / `HttpError` / `pickedFields` /
  `scanErrorLog` / `hostSummary` …）—— **共享符号一律进 `lib/`**，否则工具之间会互相 import（循环依赖 + TDZ 风险）。
- **跨工具 import = 0 处**（归一化后核验）：工具模块之间不再互相引用，依赖都指向 `lib/`。
- 新增 **`lib/render.mjs`**（`renderJson`）；`TITLE` / `VERSION` 归 `lib/constants.mjs`（唯一出处）。
- **踩到并修掉的真问题**（都记在 `docs/dsh-miliastra_架构规划_2026-10-08.md` §9）：
  ① 纯搬移在**类型层**不一定零改动（独立模块后 `TS2322` 暴露，补显式返回类型注解）；
  ② 搬函数要**连它的 JSDoc 一起搬**（否则注释留原地挂到别处 ⇒ `TS8024`）；
  ③ 搬移区间要**卡顶层声明边界**（一个 const 的区间曾跑过头、吞掉后面的提示段）；
  ④ 工具反过来引用宿主符号（`TOOLS` / `renderPromptSection`）⇒ 改为**直接引用自己**或**依赖注入**，不做反向 import；
  ⑤ `smoke` 的 `[version]` 门禁原本扫 `index.js` 源码找 `const VERSION` ⇒ **门禁跟着结构更新**（改读 `lib/constants.mjs`，不变量不变）。

### 回执 code 规范（**成文 + 由门禁强制**）

- **`lib/receipt.mjs` 顶部新增《码规范》**（写清楚：两类码、命名规则、**什么时候才该新增一个码**）。
- **两类码分开**（混用是以前最大的问题）：
  - **失败码** `ReceiptCode`（`{ok:false, code}`）：7 → **21 个**（补 `GATES_NO_TOOLS` / `GATES_NO_SOURCE` / `SAVE_FAILED` /
    `MEASURE_NO_SOURCE` / `MEASURE_DECODE_FAILED` / `RESTORE_TARGET_MISMATCH` / `PRECHECK_FAILED` / `LEVEL_AMBIGUOUS` /
    `BOOT_PATCH_FAILED` / `FX_*` …；`GATES_FAILED` 保留为 `GATE_FAILED` 的**兼容旧名**）。
  - **警告码** `WarnCode`（成功回执的 `warnings[].code`，**不是失败**）：**新增 9 个**（`GRID_DEFAULTED` /
    `HANDOVER_AUTO_FROM_GIL` / `TEXT_TOO_LONG` / `TRANSPARENT_CELLS_DROPPED` / `UNVERIFIED_SYNTAX` …）。
- **全仓 26 处字面量 → 枚举引用**（0 处残留）；涉及 `index.js` + `lib/tools/*` + `lib/pixelart` / `lib/structvar` /
  `lib/textgradient` / `lib/vfx`。
- **新增门禁**：`tools/lint.mjs` 加 `findLiteralCodes()`（**纯函数**，可单测）⇒ 除 `lib/receipt.mjs`（枚举定义处）
  与 `tests/**`（测试**故意**断言 wire 上的字面量值）外，**再出现 `code: '大写字面量'` 直接红**。
  已做**反向验证**：临时塞一个探针码 ⇒ 门禁立刻点名（文件:行号 + 该码）。

## [0.7.2] - 2026-10-07

### 其他变更

- ★ **schema 体积棘轮 34 KB → 50 KB（声明式放宽，作者 2026-10-07 拍板）**，并新增 **40 KB 软线**（只打 `⚠`、不红）。
  - **为什么放宽**：棘轮量的那份 schema 是**我（AI）的随身说明书** —— 不在 schema 里的能力 **= 我看不到 = 等于没有**。
    活例子：`op=clientui` 的 **`root` 参数**（点名任意控件回整棵子树）**实现好了却因为顶穿棘轮只能留在代码里**，
    结果**我自己调不出来**。而《上下文瘦身设计》那 8 项（`saveTo` / `receipt` / `gates` / `sync` / `boot` /
    `measure` / `explain` / `checklist`）预计要 ~1 KB ⇒ 34 KB 会持续挡路。
  - **成本对比**：schema +8 KB 是「**每个会话一次**」；而一条 `miliastra_gen op=vfx-lua` 默认回执就是 **67 KB（每次调用）**
    ⇒ 用固定的小成本换掉每次的大成本，是理性的。
  - **纪律随之明确（比阈值更重要）**：**op / 参数这类「能力」一律进 schema；只有解释性长文允许下沉到技能 / docs。**
  - 先例链：2026-09-24 **26.3 KB**（起始实测）→ 2026-10-01 **32 → 34 KB**（加 `map op=nodes`）→ 2026-10-07 **34 → 50 KB**。
  - 实测：改后 `smoke` 报 **34.0 KB ≤ 50 KB**（未触软线），72/72 绿。

## [0.7.1] - 2026-10-04

### 新增功能

- ★★ **按《插件调用优化方向_2026-10-04》逐条落地**（作者 2026-10-04 交办，判断口径 = **「按 ai 使用来判断是否优化」**）
  - **① `deploy` 内嵌门禁**：`withGates:true` ⇒ 写盘前先跑内置 preflight（语法 / 作用域 / 全局写审计），
    **没过就不写盘**并回放失败门（`code:"PRECHECK_FAILED"`）；`nextStep` 固化「**存盘 → 试玩 → 对账**」三步链。
  - **② `prodUnchanged`**：这次投进去的与上次**逐字节相同** ⇒ 直接点破「改了源没 build / 改的是构建产物」。
  - **③ `.gia` 半截快照可验证判据**：`readGia` 新增 `emptyButHasBytes` / `complete`（含**解析失败**那条早退路径），
    回执给 `partialSnapshot:true` + 说明「**这里没有报错不能当结论**」。
  - **④ `fileLine` 真因**：兜底正则原来**锚在行首**，而真机报错行**行首还挂着别的内容**
    （`…CursorEnter): 表现 view:580: attempt to call a nil value`）⇒ 加**不锚行首**的第 ④ 条形态；
    实测那份日志 **48/48** 拿到位置（原来 48/48 全 `null`）。另给跨记录兜底 `attachFileLines()`（带 `fileLineFrom`，说清是借的）。
  - **⑤ 时序硬依据**：`op=tail/grep` 每行带 `seq` + `i`；**实测 `seq` 在一局里可能恒为同一个值**（本机 428 条全是 `seq=700`）
    ⇒ 回执明写「**同秒内以 `i` 为准**，别假设 `seq` 单调」。
  - **⑥ `miliastra_map op=audit-template`**：子树（递归）/ **同父重名**（`GetChild` 歧义）/ **同名多条独立控件**（A/B 记录形态）/
    `idExists` / 可选 `q` 名字清单与代码引用对账。
  - **★ 图源号逆出来了**（⑥ 的补完）：控件记录子树里落在**平台图片号段 100001~112042** 的 varint = 该控件的图源号；
    对照证据 = 日志报 `图片图源=106045`，而 `.gil` 里 `106045` **恰好出现 29 次**且落在 29 条控件里
    ⇒ `audit-template` 新增 `imageSource:"heuristic-verified"` + `imageSourceMissingCount`
    （名字含「图片」却没有号 ⇒ **会渲染成 `?`**）+ `imageSourceMissingSample`。
    实测那张图：1268 控件 / 923 个「图片」→ **854 有号、69 一个号都没有**。（字段号未钉死 ⇒ 口径如实标 heuristic。）
  - **⑧ 两条按形状自动挂的已知坑**：`applyVars(` ⇒ 「变量回写即时字段**慢一拍**，显示源改走**直推**（Invoke）」；
    `SetAsLastSibling(` ⇒ 「返回值**可能恒 false**，别拿它当判据，层级用创建顺序兜底」。干净代码 0 误报。
  - **⑩ `miliastra_log op=run-analysis`**：命令应答 ↔ 画面变化配对，**点名「有命令应答、其后没有画面记录」**；
    关键词由调用方给（回显实际用的正则）；⚠️ 明写「**日志配对 ≠ 画面真的没变**，判画面用 `miliastra_shot`」。
    实测本机那份日志：命令 88 / 画面记录 92 / 有命令无画面 19。

### 测试

- 删掉 3 条**读活数据**的套件（作者点删：`gilreport-test` / `nodegraph-test` / `uilint-test`），
  其中 **`uilint-test` 用纯夹具恢复**（剪掉读工作区《侦探杀》那 58 行，15/15 绿）—— 它守的是「文本框高 < 字号×1.4 ⇒
  真机一个像素都不画」这条**离线唯一能拦**的坑，不该跟着活数据一起消失。
- `fx-hardening-test` ②g：**工具是对的、改测试** —— 原来两次调用各自解析「最新 `.gia`」，作者一在试玩就会读到不同文件
  ⇒ 改为**钉死同一个 `file`** 再比 `summaryOnly`（`staleLog` 时如实跳过）。

### 修复（0.7.0 之后）

- **`op=restore` 写错活文件**（P0）：备份名能推断目标就**以它为准**；与显式 `file` 不符 ⇒ **拒绝写盘**
  （`code:"RESTORE_TARGET_MISMATCH"` + 两条改法）。
- **存盘状态比较去 UTF-8 BOM**：实测 11 条活文件全带 BOM、`.gil` 嵌的是去 BOM 那份（每条少 3 字节）
  ⇒ 直接比 sha 会把**刚存过盘的图判成"该存盘了"**；现在 `liveSha` 用去 BOM 后的哈希（另留 `liveShaRaw`/`bomStripped`），
  真差异照旧判 `dirty`，BOM-only 的空文件点名说「**盘上只有 BOM、没有内容**」。

## [0.7.0] - 2026-10-02

### 新增功能

- ★ **信号清单：`miliastra_map op="anatomy"` 新增 `signals`**（作者 2026-10-02：「能不能读取结构体 还有信号」）
  - **信号 = 能读**：`.gil` 顶层 `#10` 的 **`#10.#2`（信号/参数引用表）** 里，每条引用的可读串既有固定**引脚名**
    （`名称`/`数值`/`信号名`/`目标玩家`/`事件源实体` …），也有**真正写进去的串**（`侦_整数` / `侦_浮点数列表_新增` …）。
    剔掉引脚名、再按**现场命名前缀**过滤（默认 `侦_`，`signalInventory()` 可传 `prefix`），得到**现场信号名清单**
    `{names:[{name,count}], patterns（八类按名字匹配）, otherCandidates, refCount, unverified}`。
    实测 `1073741842`：**58 条引用 → 12 个信号名**（八类齐全 + 列表类 `_新增`）。
  - ⚠️ **口径如实写进回执**：**不是官方字段号**（`#10.#2` 里"哪一段是信号名"未确证）⇒ 给的是**启发式 + 前缀过滤**，
    并把没被前缀筛中的 `otherCandidates` 一起摆出来供核对；`summaryOnly:true` 时不回（只去体积、不去结论）。
  - ⚠️ **结构体 = 读不到**（如实说）：同一张引用表里**出现过「结构体」字样**（说明确有结构体相关节点/变量），
    但**结构体定义（字段名 + 类型）没找到** ⇒ 回执里明写「本清单不含结构体」，**不编**。
  - 回归：`nodegraph-test` 11 → **12 条**（合成：前缀筛选 / 引脚排除 / 计数 / 八类匹配 / 口径与结构体声明都必须写清）。

### 修复

- ★★ **存盘状态比较**必须**去掉 UTF-8 BOM** —— 否则"刚存过盘的图"会被判成"该存盘了"（作者 2026-10-02 拿面板截图问「这个为啥说不清?」）
  - **实测**（侦探0.0.3 `1073741842`）：11 条活文件**全都带 BOM**，而 `.gil` 里嵌的是**去 BOM 的那份**（字节数恰好每条少 3）；
    直接比 sha ⇒ 11 条全判"不一致"。判据：`sha256(活文件[3:]) == 嵌入 sha` ⇒ **10/10 命中**（内容其实早就存好了）。
  - **修法**：`op=sha` / 面板用的行里，`liveSha` 改成**去 BOM 后的 sha**（另留 `liveShaRaw` = 原始 sha、`bomStripped:true`、`liveBytes`），
    比较与判定都走去 BOM 的那份；`saveVerdict` 在"已存盘"时明说**「其中 N 条只差 UTF-8 BOM」**，
    真差异（去 BOM 后仍不等）照旧判 `dirty` —— **不许把归一化当成放过差异**。
  - ★ **「只有 BOM、没有内容」要说清**：`背景层 bg.lua` 盘上 **3 字节 = 只有一个 BOM**，地图里那条映射也是 0 字节/无 sha
    ⇒ 两边**都没有内容可对**。面板原来统一说成"sha 没读到"（不准确）⇒ 现在点名说**「盘上只有 BOM、没有内容」**。
  - 回归：`client-render-test` 加 1 条（只差 BOM ⇒ 已存盘并说明；去 BOM 后仍不等 ⇒ 仍 dirty；BOM-only 空文件 ⇒ 说清原因且**不许**再用"sha 没读到"）。

### 新增功能

- ★ **`miliastra_health` 的 `editorHint` —— "编辑器正在编辑哪张图"只有间接证据**（作者 2026-10-02：「插件没有这条通道?」）：
  **五条候选通道全部实测排除** —— 编辑器窗口标题只有「千星沙箱」（不含关卡名）/ `.gil` **不被占用**（11 张全可独占打开）/
  **无自动保存心跳**（监听 10 秒零写入）/ 编辑器自己的目录 `%LOCALAPPDATA%\BeyondEditor` **是空的**、Chromium Local Storage 里
  **没有** `lastLevel/currentLevel/levelId/关卡/地图/projectPath` 任何键 / `BeyondLocal\<账号>\Temp\<id>.gil` 每张图一份且
  **mtime 与 `Save_Level\<id>\<id>.gil` 逐条一致**（同一事件的副本）。
  ⇒ 新增 `lib/locate.mjs` 的 **`editorHint(levels)`**：`byGilSave`（`.gil` 最近存盘 = 最强信号）+
  `byLiveFile`（活文件最近改动，**会被 deploy 污染**）+ `same` + `evidence:'indirect'` + `note` + **`askHuman`**（该问人什么）。
  完整档给详细版；**brief 档只给两个号** `editorHint:{gil,live}`（40 B）。
  **系统提示段**（`PROMPT_GUIDE`）同步写明：**改码/部署前先问作者一句"现在在哪张图"**，别把 `editorHint` 当"当前图"。

### 修复

- ★ **brief 档实测 1951 B —— 远超"< 1 KB"的自我要求，而两条 `< 1024 B` 的断言跑的是夹具（1~2 个活文件）⇒ 真实环境从未被测到**：
  字段账 `luaFiles` 461 + `currentAlternatives` 294 + `currentEvidence` 285 + `currentWarning` 215 + 目录 244。
  **压缩（不改信息量，只去重复）**：brief 里 `currentEvidence` + `currentAlternatives`（含一串绝对路径 + 3 个 ISO 时间戳对象）
  合成一行 **`currentWhy`**（路径本来就有 `luaDir`/`gil`/`logDir`；备选只留 id + 时间 + 活文件数）；
  活文件 **> 8 个**时逐条 `bytes` 不再给（**名字一个不少**）。
  ⇒ 实测 **1951 → 1348 B**（真实环境 11 活文件）、**1247 B**（合成 12 活文件 + 备选场景）。
  测试补上真实形状：`feedback5-test` 的 P1-4 ③ 断言 brief **不许再有** `currentEvidence/currentAlternatives`；
  新增 ③b（12 活文件 + 备选 ≤ 1536 B、只给名字）与 ③c（`editorHint` 纯函数：两条判据各自命中 + `same` 判定 + 不许下结论）。

- ★ **面板第 7 页的关卡默认口径：改成「最近存盘的地图存档（`.gil`）」**（作者 2026-10-02：「不管坦克大战」）：
  原来是「最近改动」（`newestMs`，**活文件也算**）⇒ 实测那一刻落到了 **坦克大战 `1073741837`**，
  因为它的 6 个 `.lua` 在 01:16 被改过（**那是"部署"写的，不是人在编辑器里做的图**），而作者在编辑器里开的是**恐怖-V3**。
  改按 **`.gil` 的 mtime** 排（实测：恐怖-V3 `1073741829` 23:40，下一张侦探1 `1073741839` 23:39，**只差 1 分钟**）⇒ 默认命中恐怖-V3。
  另两个口径保留可切：**「Host 判定的当前关卡（优先有活文件的图）」**（`level:'@host'`）与**手选**（记住）；
  图旁继续写「本次读的是谁 · 凭什么」（`（最近存盘）`/`（手选）`/`（Host 判定的当前关卡）`）。
  回归里**钉住判据本身**：必须是 `Date.parse(l.gil.mtime)`，不许退回 `newestMs`。

### 修复

- ★ **元件被静默截断在 40 个 —— 面板里「元件 2 个 / 40 个」的第二个坑**（作者 2026-10-02：「元件不可能就两个」）：
  `readGilNodeFacts()` 调用 `readComponents()` 时**没传 limit** ⇒ 吃到该函数的默认 **40**，
  恐怖-V3 的 **71 个元件只回 40 个**（面板显示「元件 40 个」）。现在显式给足上限（默认 2000，可用 `componentLimit` 调），
  并把各子读取器的 `truncated` 透出成 `facts.truncated{graphs,entities,components,declarations,configs}`，
  `op=nodes` 回执里原样带上 —— **任何一块被截都要能看出来**。
  实测：恐怖 `1073741829` 元件 **40 → 71**（与编辑器元件库一致）；坦克大战 `1073741837` **确实是 2 个**
  （`#4` 区只有 1190 字节 / 2 条：默认模版 + 默认模版(角色编辑)）；侦探1 `1073741839` 是 3 个。
- ★ **计数在"早退路径"上是 `undefined`**（顺带抓到）：`readNodeGraphs / readEntities / readComponents / readNodeDeclarations`
  在"没有该区"时提前 return，而计数是在函数末尾才赋的 ⇒ 坦克大战的 `declarationCount` 回成 `undefined`。
  现在四个 `out` 对象**创建时就给 0**，早退路径也回数字。回归：`nodegraph-test` ⑦ 对前 4 个真实关卡断言
  「计数必须是数字」+「**元件区实际条目数 == 回执数，除非显式说截断**」。
- ★ **实体名"深取"—— 面板里一片「(无名)」的根因**（作者 2026-10-02 拿截图问「这个无名什么鬼」）：
  实体的名字不总是直接摆在 `#5[槽1].#11` 的字节里，有些实体是 **`#11` 里再套一层**（`#11.#1` = 文本）；
  原来直接 `uv(#11 的字节)` 会把那一层的 tag 字节一起当文本 ⇒ 过不了可读性检查 ⇒ 名字丢掉 ⇒ 显示成「(无名)」。
  新增 `deepTxtOf()`（先直接解、失败就往下钻 ≤3 层），`readEntities` 改用它。实测三张图**无名 0 个**
  （原先丢掉的「默认模版(角色编辑)」「石质功能平台」等名字都回来了）；面板里读不到名字时回退成 **`(无名 #<实体id>)`**，不再只写「(无名)」。
- ★ **样式跟着 bundle 更新 —— "新 JS 配旧 CSS"**（作者截图里「种类号与数字挤成两行、表格没样式」的根因）：
  样式注入原本是 `if (document.getElementById(STYLE_ID)) return;` + **固定 id** ⇒ 旧 `<style>` 元素**活下来**，
  bundle 更新后 CSS 永远不刷新。现在按 **CSS 内容指纹**（`data-sig`）判断：一致就复用，**不一致就删掉旧的再插新的**；
  顺手给 `.tbl-cell` 补 `display:block;min-width:0`（栅格项里让省略号真生效）。
  回归：`client-render-test` 那条断言**反向钉住** —— 不许退回"有 id 就 return"，必须指纹比对 + 删旧样式。

### 记录（不是代码问题，但要留档）

- ⚠️ **「关卡实体 0 个自定义变量」在坦克大战（`1073741837`）是数据事实**：那份 `.gil` 里 `#7` 槽 1 的 `#11` 是**空 bytes**，
  12 个实体全 0、整关 `totalVariables = 0`。对照：**恐怖-V3 `1073741829`** 同路径 **10 项**（整关 507 个）、
  **侦探1 `1073741839`** 的关卡实体 **17 个**。面板顶部会写「本次读的是哪个关卡」——自动跟随选不到**没有活文件**的图
  （恐怖-V3 就是），要在关卡下拉里手选。**若编辑器里该图确有变量，则是我们口径漏了（只认 `#7[槽1].#11`），需按编辑器截图再查。**

### 新增功能

- ★★ **挂载关系读出来了 —— 上一轮"读不出来"的结论被推翻（作者 2026-10-02 给出编辑器「节点图挂载对象」面板）**：
  - **错在哪**：上一轮我按"整棵树里找等于图 id 的数值"去搜，再拿"同段随机号也会命中"去证伪，就写下了
    "读不出来"。**号段确实重叠、裸数值确实不能当证据 —— 但按结构路径取是能读出来的**。
  - **正确判据**（与编辑器面板逐条对上）：实体记录 `#5[i].#6.#13.#1[].#1{ #1=序号, #2=图id, #501=图类型号 }`
    = 挂载的节点图；**元件**把 `#6` 换成 `#7`（同一形状）。实测：侦探1「关卡实体」→「关卡实体信号」；
    恐怖「送菜窗口」→「吧台_收食物」、元件「肉_盘子」→「选项卡食物拿起」。
  - **另一类形状**（`#7.#27.#2.#6.#2` / `#7.#38.#501.#504.#2` …）也指向某张图 —— 本机实测这 21 张正是
    **客户端布尔过滤器图** —— 但口径未确证 ⇒ 一律标 `via:"ref"`，**不冒充挂载**。
  - ⚠️ **盲区也写清**：技能图 / 状态图挂在**技能、职业、状态**等别的区，实体/元件区里查不到挂载主
    （侦探1 的 19 张里只有 1 张有主）⇒ 没找到时只能说"**这两个区里**没找到"。
  - 落地：`readGraphMounts()`（`lib/gilnodes.mjs`）→ `facts.mounts` + `facts.graphOwners`；
    `op=nodes` 加 `mountSummary`（恒给）+ 明细档的 `mounts.graphOwners`；
    `op=anatomy` 每条图加 `owners[]`（`via:"mount"|"ref"`）+ `mountSummary`；
    `graphOwnerNote()` 改成"**已确证** + 判据 + 未确证口径 + 盲区 + 上一轮两条教训（名字匹配 / 裸数值都不算证据）"。
    实测覆盖：恐怖-V3 **55/57 张图有挂载主**（34 张 `via:"mount"` + 21 张 `via:"ref"`）。
  - 回归：`nodegraph-test` **9 → 10 条**（合成树 + 真 `.gil` 双验：侦探1「关卡实体信号」← 实体、
    恐怖「吧台_收食物」← 2 个挂载主；不给 `graphIds` 时**拒绝判定**）。
- ★ **`miliastra_map op="anatomy"` —— 节点图能力画像**（作者 2026-10-02：「希望读取到有多少的各类型节点
  （事件节点 x 个 / 执行节点 x 个）」「做个快速判断当前节点图用来做啥的能力：入口 / 关键词 / 引用」）：
  - **类型口径**只认**随包节点词典**（`lib/nodedb.json` 558 条）：`identifier` 第一段
    （`Trigger./Execution./Query./Arithmetic./Control./Others./Hidden.`）优先，其次 `domain`，都没有 ⇒ `Unknown`
    （实测 558 条里 identifier 与 domain 一致 555 条）；**端**只认词典 `system`。
    中文标签：**事件 / 执行 / 查询 / 运算 / 分支 / 端口 / 内置**。
  - 回执给：**整关 totals**（`byType` / `byTypeLabel` / `bySide` / 一行文字）+ **逐图**（类型分布、**入口事件**、
    **引用到的实体/声明**、关键词、一句话画像）+ **覆盖率**（`typedNodes/untypedNodes`）+ `customNodes`。
  - ★ **词典没命中的节点不一律算"未知"**：先看它的 id **是不是本关卡内的声明** —— 是 ⇒ 归 `Composite`（复合节点）/
    `Custom`（自定义节点），这是**能确证的归属**（不是猜）；两边都命不中才计 `Unknown`。
    实测恐怖-V3（1119 个节点）：**词典命中 548 + 本关声明 159（复合 46 / 自定义 113）= 覆盖 707/1119**，
    剩下 **412** 个是官方词典（反推出的 558 条）**本身就缺**的号 ⇒ 覆盖率是**词典**的短板，回执里如实写清、**不摊派**。
    一句话：`事件 65 · 执行 213 · 查询 102 · 运算 71 · 分支 76 · 端口 21 · 复合节点 46 · 自定义节点 113 · 未知 412`。
  - 新增纯函数库 `lib/nodegraph.mjs`（`nodeTypeOf / nodeSideOf / typeStatsOf / triggersOf / refsSummaryOf /
    keywordsOf / graphAnatomy / anatomyTotals / graphOwnerNote`），面板/报告以后复用同一份判据。
  - ★ **「谁身上挂着这张图」读不出来 —— 这条结论连着证伪证据一起进回执**（`anatomyNote`）：
    节点图记录里只有 identity（id/类型/kind）、图名、图体，**没有挂载主体字段**；而 `.gil` 里各号段**重叠**
    （关卡 id 都能与某张图的 id 同号），所以"某处出现了一个等于图 id 的数"**不能当引用证据** ——
    证伪实验：57 个真图 id 的 varint 命中 10~113 次（均 37），同段"随便挑的不存在的号"也有 0~20 次（均 5.1）。
    能可靠给的是**图的类别**（关卡实体图 / 客户端技能图 / 状态图 / 客户端布尔过滤器图）+ 端 + 名字 + 类型分布
    + **引用方向相反**的那件事（这张图的节点引用了哪些实体）；要精确归属得**创作者交接**。
- ★ **`miliastra_kb` —— 节点图知识库（离线蒸馏 + 在线兜底）**（作者 2026-10-02：「重点是知识库问答」）：
  - `op="qa"` —— **离线蒸馏的排查清单**（首版 **19 条**）：`ask`（**先问哪几个问题**，分流用）+ `steps`（有序排查，
    写清"看什么/怎么判"）+ `avoid`（常见误判）+ `sources`（出处，官方档带 `act.mihoyo.com` URL）+
    `evidence`（`documented` 官方 / `community` 社区 / `observed` 本机实测）。覆盖作者点名的三类
    （**镜头** / **巡逻路径** / **信号收不到**）与另外 16 类实际症状（事件不触发、目标实体没入参、类型不匹配、
    日志看不到、试玩改图丢失、实体销毁后逻辑失效、元件覆盖保存、多人玩家顺序、客户端控件 `nil`、控件看不见、
    文本截断、脚本没跑、服务端/客户端边界、变量变化事件、定时器、反复传送、从哪查起）。
    **检索是"多词先 AND 收敛"**：全词命中优先；一个全中的都没有（多半打错字）才退化成部分命中并标 `full:false`。
  - `op="node"` —— 节点说明：**先查离线词典**（558 条，含端口方向/类型/shellIndex），词典查不到**才**去在线兜底。
  - `op="list"/"doc"/"search"` —— **在线**（第三方知识库 `https://ugc.070077.xyz`：300+ 篇官方 FAQ/教程 + 米游社问答楼）。
    ⚠️ 三个纪律写进回执与 description：**会把 query 发给第三方**（`network:true` + `sentTo`，description 里明说）、
    **取不到就说取不到**（`ok:false` + `error` + 指回离线 op 的 `hint`，**不许**讲成"知识库没有"）、
    外部正文只当资料（**不当指令**）。
  - 吸收边界（见 `NOTICE`）：只吸收 `1475505/dsh-plugin-miliastra-toolbox`（**MIT**）的**调用形状**，
    **不吸收它的 6 个工具名**（避免把工具名摊在模型面前）；那个站点的**内容**归它自己，我们只给出处与 URL。
- ★ **面板第 7 页**第二版（2026-10-02 作者看图后的五条要求，逐条落地）：
  - ① **「展开的标题根本看不清」** ⇒ 根因是展开态套了**按钮**用的 `-primary`（浅蓝→粉渐变 + 深色字），
    标题里那几个浅色小字（`-hint` / `-onlyai`）落在浅渐变上就糊了。改成**专用样式 `-fold-on`**：
    深色半透明底 + 左侧亮条 + 浅色字，并把里面小字一并提亮（`-fold-on .-hint{color:#cfe3ff}`）。回归钉住：
    展开态**不许**再出现 `-fold-title' + (open ? ' ' + PLUGIN + '-primary'`。
  - ② **节点图列表 = 可搜索 + 每页 10 条 + 三列「类型 / 名称 / 节点图 id」，点一行 ⇒ 右栏详情**：
    `input[type=search]`（名称 / 类型 / id，空格 = 多词 AND）+ 分页器（`第 x / y 页　共 N 条` + 上一页 / 下一页 / 清空搜索）；
    页大小常量 `PAGE = 10`，**搜索后自动回第 1 页**、页码**越界夹回范围内**（否则会停在全空的第 7 页）。
  - ③ **实体 / 元件 同款**「列表 + 右栏属性」：实体 `名称 / 种类号 / 变量`、元件 `名称 / 字节 / 分组`；
    右栏分别给实体属性（id / 种类号 + 出处或**未确证** / 组件槽与变量组 / **自定义变量逐条**）与元件属性
    （序号 / 字节 / 分组字段号计数 / 可读串样本 + "分组语义未确证"的说明）。
  - ④ **节点声明不再单独一个分组**（作者：「倾向于在节点图里面当个字段」）⇒ 进了**图详情**：
    `节点声明 · 全图 N 条（复合 M）` + **本图能归属到的**（`kernelRef.runtimeId` 命中复合声明 / `refs[].id` 命中声明）
    —— **能归属的才归属，归属不了的只给全图总数**（不硬凑；本机实测 72 个节点能归到复合声明、6 处引脚引用）。
    ★ **措辞经作者 2026-10-02 纠正后改了**：原来写「**全图** N 条」，在这页（"图" = 节点图）会被读成
    "这张节点图里有 N 条"；它其实是 **`.gil` 顶层 `#10.#4` 的关卡级登记表**（编辑器里「自定义节点 / 复合节点」的登记，
    所有节点图共用一份）。现在写成「**自定义节点声明 · 本关地图登记表 N 条（复合 M）**」+ 一句
    「`#10.#4` 的**参考数据**，不是玩法规则」，并把「本图用到的」与「登记表总数」分开说；一条都没用到时明说
    「**这张图没用到任何声明**（登记表里那些此时只是参考/未使用）」。离线报告的总览行与附录 C 同步改了措辞。
    ⚠️ 实测两组对照（这就是作者那句「那些只是参考代码」的来处）：
    **侦探1（`1073741839`）** 88 条（复合 1）里**被节点用到的是 0 条**（纯参考/导入残留）；
    **恐怖-V3（`1073741829`）** 108 条（复合 40）里有 **27 条复合声明真在用**、另有 6 处引脚引用。
  - ⑤ **「现在我都换恐怖了 咋还是旧项目的信息 检查下」** ⇒ **查清并给出出口**：
    根因是 Host 的「当前关卡」**只从有活文件的图里挑**（`locate.mjs`：`withLua` 优先），而 `1073741829`（**恐怖-V3**）
    **一个 `.lua` 都没有** ⇒ 永远轮不到它。为此这一页加了**关卡下拉**（默认仍「自动跟随」）：
    选项里标 `活文件数 / 地图大小 / 存盘时间`，并在页面顶部**点名提示**"另有关卡 X 的存盘更新但没有活文件 ⇒ 请手选它"；
    所有工具调用都带上 `level`（`lvArg()`）。
  - 另外：读这一页改用**明细档** `{op:'nodes', kind:'all', summaryOnly:false}` —— 实体直接带 `variables` / `kindLabel` /
    `componentSlots`，省掉"点一次实体再取一次变量"的往返（拿不到变量时仍会回退到单实体查询）。
  - 回归：`client-render-test` **80 → 83**（新增：三列表/搜索/分页接线、搜索与分页判据、节点声明归属、
    关卡下拉与"无活文件不会被自动选中"的点名提示、两屏 SSR 真渲染；**要求 ① 的样式反向钉住**）。
- ★ **面板第 7 页推进到「图 → 节点 → 引脚/连线」（第 2 步）**（作者定的顺序：GUI 做完再做离线报告）：
  - **点图名 ⇒ 就地展开这张图的节点表**（左栏，30vh 自有内滚）：表头一行小计
    `节点 N · 官方名字命中 H/N · 有坐标 K · 出边 E（粗略数字）`；每行
    `#索引　官方名字 · (x, y) · 引脚 n · 出边 m · Server/Domain`；超过 300 行**明说没列全**。
  - **点某个节点 ⇒ 右栏**（原来只放"再下一层"的那一栏）看它的**引脚与连线**：坐标 / 引脚与出边计数 /
    官方节点（词典命中才给名字，命不中写「未知节点 `runtimeId`」）/ 引用到的实体·图·声明 /
    逐条 `引脚 i　kindShell=… kindKernel=…` + `→ 节点 #j（目标名字）　脚 shell/kernel`，末尾一条「← 收起」。
  - 判据抽成 **7 个纯函数**（`nodesPickKey / nodesNodeLabel / nodesNodeMeta / nodesEdgesOf / nodesPinLines /
    nodesRefLines / nodesGraphFacts`）导出给测试 —— 面板渲染与回归断言**同一份判据**；
    `nodesEdgesOf` 兼容两版回执（新 Host 给 `outEdges`，旧 Host 只有 `pins[].conns` 时**现推**）。
  - ⚠️ **不猜语义**：引脚 kind 号 ↔「输入/输出参数」名字**未确证** ⇒ 只印号（页面与报告都写明这句）。
  - 回归：`client-render-test` **73 → 80**（接线 + 合成数据三组 + **拿真 `.gil` 喂同一批判据** +
    **注入 `__nodesInit` 把"展开的节点表 / 右栏引脚与连线"SSR 真渲染**；环境里没有 `.gil` 时如实跳过）。
    ⚠️ 顺手修掉一个**潜伏的写法错**：`NodesPane` 原来没声明形参 `props`，函数里那个 `props` 会顺着作用域链
    拿到 **Panel 的** `props` —— 看着"也能用"，但只要 Panel 不是同一帧渲染的（SSR 注入初值时就是这样）就拿错人。
    现在与 `GridPane(props)` 统一写成形参。
- ★ **离线报告：`tools/gil-node-report.mjs` + `lib/gilreport.mjs`**（第 3 步）—— 把整张图的节点图导成可读 Markdown：
  - 正文 = 一页总览 / 按图类型分组 / 图一览（自插 `<a id="gN">` 锚点，点图名跳明细）/ **逐图明细**（节点表 +
    **连线表**："谁连谁"）/ 附录 A（节点号 → 出现次数 → 官方名字，前 40）/ 附录 B（**未确证清单原样搬**）/
    附录 C（口径与复现命令）。
  - **只读**：读 `.gil` 走 `readGilNodeFacts`（一个字节都不写地图），写报告走 `fsx.atomicWriteFile`；
    回归里直接断言「跑完报告后地图 sha 不变」。
  - **同名关卡在多个账号下都有 `.gil` 时不猜**（本机 `1073741829` 有 2 份：`201170108` / `341771190`）：
    报错 + 列出账号 / 大小 / 时间 / 路径，要么 `--account` 要么 `--path`；扫不到关卡也明确报错。
  - 选项：`--summary-only`（不要逐图明细，**并在正文里说明自己在哪省了**）· `--max-nodes-per-graph N` ·
    `--max-graphs N` · `--dry-run`（不落盘）。
  - ★ **实测产出**（`1073741829` / 账号 `201170108`，1 956 175 B，sha256 `4645b27f…`）：**57 张图 / 1119 节点 /
    1662 出边 / 名字命中 548 / 坐标(x,y 都有) 1115（任一有 1116）/ 引脚实例 5693**；实体 382（变量 507）· 元件 40 ·
    节点声明 108（复合 40）· 配置条目 23 · 信号引用 198 ⇒ 产出在**这张图的档案**里：
    `案子/恐怖-V3/1.策划案/节点图报告/节点图报告-1073741829.md`（3812 行 / 242 KB；关卡 `1073741829` 的地图名 `恐怖-V3` 是插件读 `.gil` 的 `level.name` 得来的）。
  - 回归：新套件 `gilreport-test`（10 → **13** 条：统计 / 骨架 / 总览与统计一致 / 明细 / 竖线转义 / 未确证原样搬 /
    边界 / **CLI 只读** / dry-run / **多账号不猜** / **坐标三口径** / **附录 A 自洽** / **`--check` 四条路径**）。
- ★ **复核反馈处置（2026-10-02 · `案子/恐怖-V3/1.策划案/节点图报告/节点图报告-1073741829_复核_2026-10-01.md`）**：
  那份复核我逐条复算，**6 条全部成立**（含它没敢断定的第 5 条），该改的都改了：
  - **坐标口径**（复核 §3.1）：总览 `1115 / 1119` → `**x、y 都有** 1115；任一有 1116（只存了 x 的 1 个）`，
    表下**点名**那个节点（「门_未准备」#4 `多分支` x=-123），`图一览` 表头改「坐标(x,y)」，附录 C 补口径。
    ★ 那条**也定了性**：dump 原始字节 —— 该节点只有 `#5`(wt5)、**根本没有 `#6`** ⇒ **数据事实，不是读取层漏读**。
  - **唯一号口径**（复核 §3.2）：原句「词典里没有的 571 个 × 唯一号 277 种」会被读成"未收录 277 种"；
    改成「**词典未收录** 571 个，涉及 **193** 种号；唯一号共 **277** 种 = 命中 84 + 未收录 193」（三个数都进 `reportStats`）。
  - **附录 A 自相矛盾**（复核 §4.1）：表头承诺「一律写『词典未收录』」而表格印的是读取层兜底文案「未知节点 `<号>`」——
    现在表头**如实描述表格里的字面**，并写明"未知节点 = 词典里没有这个号，**不是节点不存在**"。
    （复核建议的"三处统一改名"会动到面板与工具回执字段 `label`，属跨面改名 ⇒ 留给人拍板。）
  - **词典自己缺号**（复核 §4.2）：附录 A 写明上游 `runtimeId` 序列 `1,2,3,5,6,…` **跳过 4**，那是上游库缺号、不是读取错误。
  - **门禁**（复核 §6）：没做成"对账脚本"，而是更严的 **`--check`** —— 报告头写一行**数据指纹**
    （= 这份报告依赖的全部数字的 sha256）；`--check` 拿"现在重跑算出来的指纹"比：一样 ⇒ `报告仍然有效`；
    不一样（换图 / 读取层改了）⇒ 退出码 1 并点名该重跑。比"比 `.gil` 的 sha"更准（`.gil` 一个字节没变也能抓住读取层变化）。
  - **顺手修**：CLI 把 3805 行的文件印成 **3806 行**（`split('\n')` 把结尾那个换行数成了一行）—— 已改成按文件实际行数算。
- ⚠️ **发现（不是本批代码的问题，但会让人看到错数据）**：**跑着的 Host 可能比磁盘上的代码旧** —— 本轮实测：
  磁盘读取层已给出"57 张图 / 1119 节点"，而运行中的 Host 回的是 **40 张图 / 974 节点**，
  还把资源分类树里的占位名「未分类页签」当成了实体标签 —— 这**正是 `3ee15c7` 修掉的那两个 bug**。
  **判据**：拿一个"只有新版才有的事实"对一下（如 `entityKindLabels` 应为空、图数应为 57），对不上就重启 `dsh web`；
  **光看版本号看不出来**（两版都写 `0.6.4`）。

- ★ **新能力：读服务端节点图与实体的自定义变量**（作者 2026-10-01：「有时候我不知道服务端的节点图或者原件到底有没有正确挂载」）：
  - **工具**：`miliastra_map` 新增 **op=nodes** —— 每条图给 名字 / 类型 / kind / id（+ 服务端·客户端·复合体段）/ **节点数** / **连线数**；
    实体给 `variables[]{name,typeCode,typeLabel,isPublic}`。默认**粗略档**（计数 + 一行 `brief`，实测 **703 B**），
    要明细传 `graph:"<图名子串>"` / `entity:"<实体名子串>"`。类型名有出处的才给（`20000`=关卡实体图 / `20003` 状态 / `20004` 职业 / `20005` 道具）。
  - **面板**：新增**第 7 页「节点图」**（作者：「增加第 7 个 tag，我目的是要给 ai 用的」）—— 打开即读本关 `.gil`（**只读**），
    顶部一行 tag（如 `[节点图] 2 个（关卡实体图 2：玩家自身(无图体) · 关卡实体信号(9节点/1连线)）`）+ 逐图明细；
    右栏点实体读它的自定义变量。**这一页没有写路径**（回归钉住：不许出现 `miliastra_code` / `deploy` / `miliastra_sim`）。
  - **字段来源不猜**：本机实测（节点图 = 顶层 `#10.#1[]`；变量 = 顶层 `#5[].#1.#7[].#11.#1[]` 的 `GraphVariable`）
    ＋ 参考项目（MIT）`gia.proto` 的 `ResourceLocator.service_domain` / `GraphVariable` / `enum ServerTypeId` 交叉印证；
    **未逐个真机确证的取值一律原样回数字**并进 `unverified`（`typeVerified` 标出哪些类型号本机确证过）。
  - **验收（真机 `.gil`，2026-10-01）**：① 名字为 `关卡实体信号` 的图 →
    `type=20000(关卡实体图) kind=21001 id=1073741826[服务端] 节点=9 连线=1`；
    ② `关卡实体` 的自定义变量 **17 个**（`测试计数`=整数 / `侦_消息`=字符串 / `侦_名单`=字符串列表 / `侦_技能义警`=布尔 …）。
  - ⚠️ **口径**：`nodeCount` 是图体自己声明的数、`linkCount` 是图体连线记录的条数 —— 都是**粗略数字**（语义未逐个确证），
    回执带 `caveats` 明说；**逐条**节点/连线明细没做（作者选的粒度就是「图名+节点数+连线数+类型号」）。
  - **回归**：新套件 `gilnodes-test`（自己造一份 `.gil` 逐字段钉住 + 只读性 + 未知类型号进 `unverified` + 坏文件不抛栈）；
    整仓套件 **33 → 34**、断言 **1415 → 1428**；`client-render-test` **72 → 73**（第 7 页 + 七等分 tab 条）。
  - ⚠️ **schema 棘轮 32 → 34 KB**（声明式放宽一次）：op=nodes 让工具 schema 涨到 **32.5 KB**；先按 smoke 的提示压掉 4 处冗长说明
    （31626 → 31429 字符），剩下的增量是**新能力本身的说明** —— 与其把说明删残，不如显式放宽并写明理由。
- ★ **第 1 步：实体 / 元件 / 全类型图**（作者要点 1、2、3）—— 同一份 `op=nodes` 现在按 `kind` 分块看：
  - `kind=graphs`（默认）**节点图 18 张**，按类型分组：**关卡实体图 2**（玩家自身 / 关卡实体信号）·
    **状态图 1**（状态判断）· **客户端技能图 15**（长柄武器/单手剑/拳击的连击·重击·角色转向）——
    这正是"单位状态多样性"与"玩家/角色实体"那两种图。
  - `kind=entities` **实体 11 个**：`关卡实体`（种类号 `10003004`、组件槽 `111/19`、**自定义变量 17 个**）+
    `默认模版`×9（种类号 `1086324737`、`#8=1000000`）+ `默认模版(角色编辑)`（`1090519041`、`#8=1000001`、带骨骼）。
  - `kind=components` **元件 3 个**（`#4`）：`默认模版` / `默认模版(角色编辑)`（骨骼挂点 `GI_MoveHead/头顶/右脚…`）/ `音效1测试`（`MPActionGroup/受击特效/音效播放器1`）。
  - ⚠️ **实体种类号的语义（哪些=玩家实体/角色实体/职业实体/元件）本机没确证** ⇒ 只回原始号；名字里明说"模版"的才标成
    模版/元件实例，且带 `guess:true`。**不替游戏下定义。**
- ★ **第 2 步：自定义节点声明表**（作者要点 4：「我导入了一堆预制的自定义节点，看看能不能全部读取」）：`kind=decls`
  读出 `#10.#4` 的 **88 条声明**（**复合 1 条** + 服务端段 87 条），每条给 `id` / 复合标记 / **端口名·参数名·作者注释**：
  本机 **64 条有短串、44 条有说明**（例：`1073741843` 「随机获取在场玩家中的一个输出」、复合节点 `1610612737` 的端口就叫
  `侦_名声 / 侦_浮点数 / 侦_浮点数列表`）。
  - ⚠️ **必须说清的那件事**：`id → 官方节点名`（"这个号叫『获取自定义变量』"）**不在 `.gil` 里** —— 那在游戏资源/官方节点库。
    回执的 `unverified` 会明说这句。要把 id 翻成节点名，得**另接一份节点库**
    （参考项目 Pack 的 `node_data/data.json`，**MIT**，500+ 条定义）—— 那是"导库"，不是"读地图"，**没做**。
- **面板第 7 页**随之扩成三栏内容：节点图（按类型分组）/ 实体（种类号 + 组件槽 + 变量）/ 元件 + 一行「节点声明 N 条（复合 M）」。

- ★ **第 3 步：职业 / 成长 / 状态 / 阵营 / 资源树**（作者要点 2、3）—— `kind=defs` + 面板同一页显示：
  - `#15` **配置条目 8 条**：`背包模板 / 环境配置 / 自定义职业 / 自定义成长曲线 / 长柄武器连段 / 单手剑连段 / 拳击连段 / 新建状态`
    （含关键串：`节点图事件 / 武器模型特效 / 允许运动打断 / 允许其他打断 / 允许释放技能`）。
  - ★ **两条"已确证的关联"**（本机实测，id 相同）：**`自定义职业`(1090519041) ↔ 实体「默认模版(角色编辑)」**、
    **`自定义成长曲线`(1094713345) ↔ 实体「关卡实体」**。
  - `#11` **阵营/出生点**：`初始玩家阵营 / 初始物件阵营 / 初始造物阵营` + `UI_MarkPlayer_Faction_0/1/2` + `出生点1`。
  - `#6` **资源分类树 44 条**：`typeValue → 分类名 → 资源 id`（分类名里就有「玩家模版」「职业」）。
  - ⚠️ **没确证的都进 `unverified`**：实体种类号语义（哪些=玩家/角色/职业实体/元件）、`#15` 字段语义、
    `#6` 的 `typeValue` 对应、`#11` 号↔编号 —— 一律原样回事实，**不编语义**。
- ★ **补上最后一块区域**：`#10.#2` **信号/参数引用 136 条**（`kind=defs` 里给计数 + 可读串：含「信号名」「向服务器节点图发送信号」
  「发送客户端脚本信号」「信号来源实体」等 7 个带"信号"字样的串）；⚠️ **哪一段是"信号名"未确证** ⇒ 只回串 + `unverified` 说明。
- **面板第 7 页**再补三行：配置条目（含 ↔ 关联）· 资源分类 · 阵营/出生点。

- ★ **面板第 7 页改版：分组 + 默认收起**（作者看图后要求「界面做一下分组 默认收起」）：
  原来是一长串平铺的 kv 行（9 个「默认模版」把屏占满），现在 = **6 个可折叠分组、默认全部收起**，
  标题上直接带数量摘要（不展开就能看结论）：**节点图**（按类型分组）/ **实体**（按**已确证标签**分组）/
  **元件** / **节点声明** / **配置条目·阵营·资源分类**；另有「全部展开 / 全部收起」一键。
  回归钉住：页面默认必须出现 `▸` 且**不许出现 `▾`**（= 默认收起），六个分组标题缺一即失败。
- ★ **面板第 7 页再改版：左栏选择器 + 右栏数据区**（作者：「选择后数据都放右边 为未来做预留扩展」）：
  左栏 = 5 个可选区块（节点图 / 实体 / 元件 / 节点声明 / 配置条目·阵营·资源分类），每块带数量摘要；
  点一下 ⇒ **右栏（固定 58vh、内部滚动）显示那一块的数据**。以前是一长条纵向平铺、右半边空着 —— 现在右边那块
  就是**给以后加类别用的数据区**（节点逐条 / 连线 / 变量明细往这里放，不用再动版面）。
  实体变量也走右栏：点实体 ⇒ 右栏切成变量视图（带「← 返回列表」）。回归钉住：默认选中第一块、
  右栏必须是 `maxHeight 58vh + overflowY auto`、五块标签缺一即失败。

- ★ **节点明细（作者：「每一个节点都能被点到，而不是总和」）** —— `miliastra_map op=nodes graph="<图名>"` 现在**额外回该图的逐节点明细** `nodes`：
  每节点给：`index` / `declaredIndex` / `shellRef·kernelRef`（含 `runtimeId`）/ `pinKinds`（引脚签名 kind 号）/ `pinCount` / `refs`（引用到的 id **翻成名字**：实体 / 图 / 声明）/ **`x`·`y`** / `doc`（拿 `runtimeId` 去官方节点词典查到的名字）/ `label`。
  实测：`{"op":"nodes","kind":"graphs","graph":"关卡实体信号"}` → **2 节点**：`[0] 以GUID查询实体`（kernel 75）引脚数 2、**引用 = 实体「关卡实体」**、坐标 (-113,-914)；`[1] 未知节点 1610612789`（复合节点，词典里没有 ⇒ 如实标"未知节点"，不猜）。
  ⚠️ **引脚/连线的语义仍不下定义**：只回"引脚签名 kind 号 / 引脚数 / 引用了哪些 id"这些**事实**（回执 `nodesNote` 里明说）。

- ★★ **节点级修正（重要：修掉我两个错读数）** —— 作者要「每一个节点都能被点到，而不是总和」时挖出来的：
  ① **图的模型读错了**：图 = `#10.#1[i].#1`（`#1`=identity、`#2`=图名、**`#3`×N = 节点**）；
     我先把「第一个节点自己的 `#1`」当成节点数（把"关卡实体信号"报成 9 个节点），
     又把「第一个 `#3`（=第一个节点）」当成"图体" ⇒ **节点数恒为 1**。现在 = 数 `#3` 块。
  ② **修好后的真实数字**：节点 **185** 个（客户端技能图 125 / 关卡实体图 2 / 状态图 0）；
     例：`关卡实体信号` = **2 节点**（含一个**复合节点** 1610612789）、`长柄武器重击01` = **8 节点 / 69 记录**、
     `角色转向_长柄武器` = **17 节点**；每节点带 **真实坐标 x/y**（`#5`/`#6` 是 float32）⇒ 以后能画图。
  ③ ★ **找到「节点 ↔ 官方名字」的对应键**：节点的 shell/kernel 引用里的 `runtimeId` **就是对得上的官方节点 id** ——
     实测本关 15 个唯一值里命中 2 个（`75` = **以GUID查询实体**、`13` = 三维向量夹角），命中的写进节点 `doc`，
     没命中的如实 `doc:null`（**不猜**）。⇒ 节点级"官方名字 + 端口"从此可查。
  ④ `readGraphNodes()` 现在给每节点：索引 / declaredIndex / shellRef / kernelRef / 引脚记录字节 / x / y / docId / doc。
  回归：gilnodes-test 的 fixture 也按**修正后的模型**重造（重复 `#3` 块），16 条断言全绿；整仓 35 套件 / 1441 断言。

- ★★ **连线层钉出来了（AI 侧优先）** —— 在一张 **266 节点**的图上确认：
  节点 = `#1`(索引) `#2`(shell) `#3`(kernel) `#4×N`(**引脚实例**) `#5,#6`(x,y) `#7`(附加块)；
  每个 `#4` = 一个引脚：`#1/#2` = 引脚签名（其 `#1` = kind 号），**`#5` = 连接**（可重复）：`#1` = **目标节点索引**、`#2/#3` = 目标脚（shell/kernel）
  —— 与参考项目 `gia.proto` 的 `PinInstance{shell_sig,kernel_sig,…,connections=5}` / `NodeConnection{target_node_index=1,…}` **完全一致**。
  实测：`（勿删！）DialogueManager` = **266 节点 / 481 条连线**；全图 57 张 = **1662 条**；节点 1 → 51 与 259（一个输出脚可连多个目标）。
  ⇒ `op=nodes graph="<图名>"` 现在额外回 **`edges`**: `[{from,to,toShell,toKernel,fromPinKind}]` 与 `edgeTotal`；每节点带 `pins`/`outEdges`。
  ⚠️ 仍未确证：引脚 kind 号 → "输入/输出参数"的**名字**、节点字段 7（附加块）的语义、引脚值 `valueRef`。
- ★ **换图后读到的两个 bug（都是我的）已修**：① 图默认 `limit 40` 会**截断**（实测新图有 **57 张**，少报了 17 张）⇒ 默认抬到 **200**（实体 30 → **400**）；
  ② `kindLabelsFromTree` 把**占位分类名**（未分类页签/默认分类/默认/root）当成实体种类名 ⇒ 过滤掉（这种名字不是语义）。
  新图 `1073741829`（1.96 MB）实测：图 **57** 张（关卡实体图 36 / 客户端布尔过滤器图 21）· 节点 **1119** · 实体 **382**（变量 **507**）· 声明 **108**（复合 40）· 配置 23 · 信号引用 198 · 节点名命中词典 **548/1119**。

- ★ **面板第 7 页第三版：左侧手风琴**（作者：「左边有下拉，可以一个一个看，而不是列表到右边，这样**天生少一层级**」）：
  点标题 ⇒ 数据**就地展开在那条标题下面**（`maxHeight 42vh` 内部滚动），**一次只开一个**（点另一个自动收起，另有「全部收起」）；
  右栏不再当"数据区"，而是**再下一层**：点实体名 ⇒ 它的自定义变量显示在右栏（随时清除），以后节点逐条/连线明细也放这里。
  回归钉住：不是手风琴（`setSel(sel === key ? '' : key)`）、展开体没就地渲染、或默认出现 ▾（应全收起）都会失败。

- ★ **实体种类号现在带"有出处"的名字**（真机验出来的，把原来的"未确证"降级）：
  来源是**资源分类树 `#6`** —— 分类名「玩家模版」(`typeValue 9`) 的 `ref.id = 1086324737`、
  「职业」(`typeValue 10`) 的 `ref.id = 1090519041`，**与实体种类号逐字相同** ⇒ 三条互相印证（分类名 / 资源 id / 实体种类号）。
  ⇒ 回执给 `kindLabels` + 每个实体的 `kindLabel` / `kindLabelSource`（**出处写清是哪个号命中的**，写错了会被 ②c/②f 断言抓住）；
  **仍没出处的号照旧只回原始号**并进 `kindLabelsUnverified`（如 `10003004`=走"实体自己就叫关卡实体"这条）。

- ★ **A：导入了官方节点词典**（作者：「导节点库」）—— 新增 `miliastra_map op=nodedb`：
  把参考项目 Pack（**MIT**，`Copyright 2025-2026 Wu-Yijun`）的 `node_data/data.json`（**4.1 MB** / 558 节点 / 274 枚举）
  用 `tools/gen-nodedb.mjs` 挑字段压成 **205 KB** 的 `lib/nodedb.json`（id / 中英名字 / 标识 / ref / 服务端·客户端 / 分类 / **端口**），
  归属写进 `NOTICE` 与产物头部；`op=nodedb` 支持 `q`（中英/标识符，空格=AND）· `nodeId` · `system` · `domain` · `limit`。
  - 例：`{"op":"nodedb","q":"单位状态"}` → 10 条；`{"op":"nodedb","nodeId":297}` → **添加单位状态**
    `Execution.Unit_Status.Add_Status`，端口 = 施加者实体 / 施加目标实体 / **单位状态配置ID** / 施加层数 / **单位状态参数字典** / 施加结果 / 槽位序号…
  - ⚠️ **必须说清**：词典 id（≤300004）与 `.gil` 里**关卡内分配**的声明号（如 `1073741843`）**不是一套** ——
    已把上游 `ID`/`__ref_id`/`Alias`/`Implementation` 与整份 JSON 搜过那些号，**一条都没命中**（回归钉住这点）
    ⇒ 词典回答「官方有哪些节点、叫什么」，**不能**把地图里的声明号翻成名字。
- ★ **B：用资源分类树把号"判类"**（作者：「继续钉语义」）—— `analyzeGilSemantics`：把 `#6` 每条 `ref id` 与
  实体种类号 / 节点图 id / 节点声明 id / 配置条目 id 逐个对照，给出 `refKind` + `refName`（**有出处**）：
  `typeValue 9「玩家模版」→ 实体种类号 1086324737`、`typeValue 10「职业」→ 实体种类号 1090519041（同时是 #15 的"自定义职业"配置条目）`。
  - ⚠️ 同一号**可能同时属于多类**（实测就是上面这条）⇒ 用 `refKinds` **全报**，不让后面的覆盖前面的（第一版犯过，已修 + 断言）。
  - ⚠️ 本关分类名里**有语义的只有这两个**，其余是「未分类页签/默认分类」占位名 ⇒ **占位名不当语义**（不猜）。
    「玩家实体 / 角色实体 / 单位状态」在本关 `.gil` 里**没有分类名可依**（只作节点端口名出现）——
    它们的**官方定义**现在可以去 `op=nodedb` 查（248 获取在场玩家实体列表 / 258 获取指定玩家所有角色实体 / 297 添加单位状态）。
- **面板第 7 页**：右栏"配置条目/阵营/资源分类"那块多了 **语义分类（有出处）** 一行。

- ⚠️ **本版改了 Host 半边**（`index.js` 的 op 与 schema）⇒ 装完**重启 `dsh web`**；面板第 7 页刷 F5 即可。

---

## [0.6.4] - 2026-10-01

### 新增功能

- ★ **预制效果的模拟器改成独立会话**（作者 2026-10-01：「我希望预制效果的模拟器是独立的，不受到其他影响」）：
  Host 本来就按会话分控制器（`registry` 键 = `ctx.sessionId`），但面板与 AI 都传空 ctx ⇒ 大家共用 `'default'` 那一份工程，
  于是"预制效果预览"会**改掉**模拟器页 / AI 正在用的工程。
  - `/miliastra/engine` 认 `__session`（只认 `[A-Za-z0-9_-]{1,32}`，别的当默认会话）、`/miliastra/play?session=<名>` 把会话名
    **盖进页面**（页脚显示 `· 会话 presets`，页面每次请求都带着它）。
  - 预制效果页固定用 **`presets`** 会话：预览走 `/engine` 带 `__session`（**不再用工具** —— `miliastra_sim` 永远打在默认会话上），
    右边画面挂 `/miliastra/play?session=presets`。⇒ 与模拟器页、与 AI **完全互不影响**。
  - ⚠️ 代价：多一份运行时（多一个 Worker）。这是"独立"本身的成本。
- ★ **预制效果做二级分类 + 4 个组合预设**（作者：「预制效果做做分类，先做二级分类…之前做特效测试的可以放进去几个，特别是组合的特效」）：
  - 一级 **粒子 / 图元**；二级 **光与火 / 天气 / 收集与庆祝 / 形态与轨迹 / 组合（多层同屏） / 形状沿路径**。
    面板下拉按 `<optgroup label="一级 · 二级">` 分组；`preset:"list"` 每条带 `category`/`sub`/`isCombo`，并多一段 `categories` 摘要。
  - **4 个组合预设**（多层同屏，层**全部来自已验证的预设**，只错开 `delay`、换 `id`/`seed`）：
    `combo-star-burst`（星光爆发）/ `combo-coin-fountain`（金币喷泉）/ `combo-snow-blossom`（雪中花瓣）/ `combo-peacock-finale`（孔雀终幕）。
    ⚠️ **池子是按最费的那层算的**（64~104）：第一版凭感觉写 60，preflight 当场报「池 60 < 需要 64」—— 真机上会抢槽位、画面缺粒。
    合计控件 384~416（总预算 1024 的 37.5%~40.6%）。⚠️ **一个组合共用一张贴图**（生成器一份工程只写一个 `IMAGE_ID`），
    所以每组都从同一个贴图号的预设里挑；"每层不同贴图"要改生成器，**没做**（回执与文档都写着，别假装能）。
  - 预设总数 **13 → 17 粒子**（+3 图元 = 20）；下拉以前只列粒子，现在**把 3 个图元也列出来**（Host 清单里本来就有）。
  - 工具 schema 里 `preset` 的说明同步改（并按规矩跑了 `gen-readme-tools.mjs --write`）；schema 体积 31.9 KB，**仍在 32 KB 棘轮内**。

### 体验优化

- **初级功能页重排**（作者 2026-10-01：「现在开始做整体 GUI 的优化，先初级功能页面，注意只是人用的部分」）：
  - ★ **删掉「关卡」选择器**（自动跟随按钮 + 全部关卡列表）与「关卡与文件」那张 8 行元信息卡
    （作者：「关卡 做个块可以删除了」「我只关心打开的是哪个关卡」）⇒ 换成**一行大字**：
    `原神 · 1073741841` + 一行小字（几个活文件 / 地图多大 / 日志几局）。
    ⚠️ 面板从此**永远自动跟随**当前图（`activeLevel` 恒为空）；**工具侧不变** —— AI deploy 时仍必须显式带 `level`。
  - ★ **新增「整体存盘状态」卡**（作者：「代码做个活文件也是鸡肋，我要的**整体**有没有保存，不是单个 lua」）：
    一次 `miliastra_health {op:'sha',all:true}` 拿全部活文件的三方表，给**一句话**结论 ——
    ✅ `已存盘：N / N 个脚本与地图里嵌的一致`、⚠️ `有 M / N 个脚本没存盘`（**点名**那几个）、
    ❔ `说不清`（缺 sha 时**不替它说已存盘**）；没挂进地图的（`mounted:false`）**单列**，不算进"没存盘"。
    纯函数 `saveVerdict`，四种状态 + 渲染层都有回归。
  - **「代码操作」整块折进 `<details>`（默认收起）**：活文件选择器 / 体检 / 脚本一致性 / 备份 / 还原 / 部署 ——
    **功能一个没少**，只是平时不占视线（作者判它"鸡肋"）。
  - ★ 右栏归属（作者：「将 **整体存盘状态** 和 **进程** 放在**右边块**」）：左栏 = ① 当前关卡 + 地图体检；
    右栏 = ② 存盘与进程（整体存盘状态 + 进程 + 代码操作折叠）。回归按 `-col-head` 切块**逐块断言**归属。
- ★ **测试**：`client-render-test` **69 → 71** 条断言（整仓 **1409**）：新增两套 —— 「整体存盘状态」四态 + 渲染三态、
  以及"删掉的两块确实不在 + 代码操作折进 details 且功能不减 + 右栏归属 + 面板真去拉 `op=sha`"。

- **模拟器页瘦身**（作者 2026-10-01：截图圈出那条横条「模拟器多了一个横条 非常奇怪」＋
  「试玩日志、试玩操作的 GUI 直接隐藏，操作时间线、验收单没有用」）：
  - **删掉那条全宽横条**：`SimulatorBody` 原来自己顶了一条 header（图标 +「模拟器」+ 就绪/试玩中徽标 + 刷新）——
    可这一页上面已经有 tab 条、外面还有面板头（自带刷新），再来一条纯属重复。整条去掉；
    唯一有用的「手动刷新引擎状态」搬到**试玩页工具条**叫「刷新状态」。⚠️ **错误提示那一行保留**（删掉会变成"点了没反应还看不到原因"）。
  - **左列四张卡不再渲染**：① 试玩日志 · ② 试玩操作 · ③ 操作时间线 · 验收单。理由：试玩那套控制（开始/暂停/单步/重开/结束）
    与逐帧日志、操作序列**右边那个试玩页自己就有**，面板这份是慢一拍的复印件；验收单是给 AI 存回归用例的（`op=cases`），人平时不看。
  - 左列因此只剩两张：**「读本地 .lua（绝对路径）」**（仍在最上面）+ **「④ 工程与控件树 / 工程适配」（折叠）**。
  - ⚠️ **代码全保留、只摘渲染**：`logs` / `hist` / `book` 这些 state 与取数都还在，四张卡的定义也在 ——
    要加回来只需把它们放回 `playsideKids` 那一行。**只有两个动作面板里没别处可点**（`导出 GIA` / `重置工程`），
    已搬进 ④ 折叠卡；它们的回执改写到那张卡里的一行小字（原来写在「试玩日志」卡里，而那张卡隐藏了）。
- ★ **测试**：`client-render-test` **71** 条断言（整仓 **1409**）——「模拟器」那条从"6 个动作 + 日志空态"改成
  "**无横条**（`-badge` 不许出现）+ 四张卡确实不在 + 试玩页三件套在 + 导出/重置保留 + 画布固定说明在"，
  并把 1:2 布局那条的左列清单与顺序断言改成新的两张卡。

- **「控件模板索引 / 容器节点索引」不再是两个一上来就要人填的怪输入框**（作者 2026-10-01：「细节调试，控件模板索引 和 容器节点索引
  这两个很奇怪啊」）：它们是**交接值**（已自动从本关 `.gil` 读好），现在默认只显示**一行只读值** + 一个「手填」按钮；
  **读不到时自动展开**两个输入框并说清缺什么（不许静默留空）。选了**图元**预设时会明说 `imageId` / 每层池对它不适用（填了也忽略）。

### 问题修复

- ★ **"预览之后啥都没"**（作者 2026-10-01 实测报的「啥都没咋回事」）—— 两个原因叠在一起，
  都属于"看着就是坏了，其实是没解释"：
  1. **`slash-arc` 是 `loop:false` 的一次性预设**（1.2 秒播完）⇒ 之后再看，舞台当然一直空着。
     现在面板会**明说**「这个预设是一次性的（loop=false）：播完就停，舞台会一直空着 —— 那不是坏了」，
     并多给一个 **「重播」** 按钮（= 把同一份 Lua **再 bind 一次**，运行时重建、脚本从第 0 帧重跑）；
     「重载画面」的 tooltip 也写清了它**只重挂画面、不会重播动画**（以前这两个按钮看着差不多、实际完全不同）。
  2. **面板把「图片号」发给了图元预设**（实测：作者手上的 `10005` —— 少一个 0）。图元**是吃 `imageId` 的**
     （`SetImage(…, 10005)` ⇒ 平台没这个号、模拟器也不画 ⇒ 什么都看不见）。根因是 **Host 的 `summaryOnly`
     只回 `{id, nameZh}`**，把分类字段砍掉了 ⇒ 面板认不出"这是图元"，于是照发。
     - Host：`summaryOnly` 现在保留 `category`/`categoryLabel`/`sub`/`subLabel`/`isCombo`/`shapeKind`/`defaultLoop`
       （都是"选得动"的依据；实测仍只占完整回执的 **35.5%**：15 371 → 5 454 字节）。
     - 面板：拉回清单时**归一化 + 用内置静态表兜底**（旧版 Host 上也能正确分组 / 认出图元 / 判一次性）。
- **网格图缩放的滚轮陷阱**（作者 2026-10-01：「这个滚轮咋把网格图也搞小了」）：原来是"光标在图上一滚就缩放"，
  于是**想往下滚面板 / 滚列表**时，光标正好停在图上就把图缩到了 29%，看着像坏了。
  改成**通例**：**普通滚轮一律不缩放**（**不** `preventDefault`，浏览器照常滚那个盒子或面板），
  **只有按住 Ctrl / ⌘ 才缩放**。
  - 缩放下限从 **25% 抬到 50%**（缩到 25% 就成一条小带 = 把图弄丢了），上限照旧 800%。
  - **读缓存时也过一遍夹取**：老版本存过的 29% 会在打开时被抬到 50%（不然一进来就是一条小带）。
  - 图上方标题与缩放按钮的提示都写明 `Ctrl+滚轮缩放`。
  - 回归钉住：`Ctrl/⌘` 判断必须在 `preventDefault()` **之前**、老值收编、下限 50%。

### 文档

- ★ **README 换图 + 修过时说法**（作者：「感觉有点过时落后了 比如截图啥的」）：换成 4 张新截图
  （`assets/panel-{basic,presets,pixel,grid}.png`，覆盖初级功能 / 预制效果 / 像素画 / 网格计算），
  删掉那张"三栏 · 关卡/代码/日志"的旧图（`assets/panel.png`，**没人再引用**、git 历史里还在）。
  顺手修掉写错的事实：`miliastra_code`「8 个 op」→ **11**、`miliastra_probe`「5 个只读脚本」→ **7 个模板**、
  `miliastra_asset`「10 个 op」→ **11**、`vfx-lua`「13 个预设」→ **17 粒子（含 4 组合）+ 3 图元 + 二级分类**、
  面板「三栏 · 关卡/代码/日志」→ **六个页面**。
- ★ **`docs/` 整理：9 篇 → 7 篇**（作者：「都看看 过时了或者可以被融合的 都整理下」）：
  - **删** `需求边界清单_融合beyond-simulator_2026-09-24.md`（融合已落地；结论在工作区对比文档 §2–§5、
    本 CHANGELOG 与根 `NOTICE`，原文在 git 历史里）。
  - **搬** `上游源码索引与吸收地图.md` → **工作区** `docs/dsh-miliastra_对比_miliastra-beyond-simulator_2026-09-24.md`
    （整节并入）：它讲的是本机 `_ref/` 那份只读克隆，帮的是"继续读上游"的人，**不是 npm 使用者**。
  - 同步改掉 5 处引用（README 深入阅读表改成"工作区文档，不作链接"、`模拟器与视图` 抬头、工作区 `AGENTS.md` 两处）。
  - 清掉逐篇扫出来的过时说法：`功能详解` ×4 处「13 个预设」、`快速上手`「点开是三栏面板」、
    `模拟器与视图` 那两张还在讲**旧左列四张卡**的 ASCII 图与顺序说明、`面板与活文件安全` 里 10 行"手动锁定"历史说明。
- ★ **发版流程收敛成一处权威**（作者：「docs 里面有个 dsh 的插件更新规范就很奇怪」）：
  `快速上手与教程.md` §7 那份抄本停在 **0.4.0**，还给了与现状相反的 npm 凭据说法（"不要碰 `~/.npmrc`"）⇒
  现在 §7 只指路；**流程只有 [`.github/release-notes/README.md`](.github/release-notes/README.md) 一处**
  （**五步 → 六步**：补上 `npm publish` + 临时目录真装验证 + `main` 快进，并写明"别在 `docs/` 里再抄一份"）。
- ★ **新增回归**：README 的 Markdown 链接**不许跳出本包**（`../` 一律拒）。
  起因：README 里那条指向工作区 `千星奇域_粒子特效配置格式.md` 的链接在包内解析成 `packages/docs/…`（不存在），
  npm 页点开必 404 —— 而老断言只匹配 `](docs/…)` 开头的链接，**一直漏检**。那条已改成纯文本。

---

## [0.6.3] - 2026-10-01

### 问题修复

- **右键标记颜色会把"已选中的格"踢出列表** ⇒ 列表变 0、复制按钮全灰（作者 2026-10-01 报：「我点了一次之后就不让复制了」）：
  右键原本复用了"切换选中"那个函数 —— 给**已经选中**的格标颜色时，它顺手把那格**移出**了选中。
  现在改用新的 **`gridSelAdd`（只加不减）**：标记颜色**绝不改变选中状态**，只保证那一格在列表里（已有就原样返回同一个引用）。
  - 回归钉住三件事：`gridSelAdd` 对已在列表里的格**原样返回**（不再踢人）、右键的接线**不许再出现 `gridSelToggle`**、
    "既选中又标了色"的格在渲染层**仍留在列表里且「复制全部」可点**。

### 体验优化

- **新增「移除失效行（N）」按钮**：步长改小之后，选中列表里可能留下"现在没有对应格"的行（不参与复制，但会一直占着列表、
  也让人以为"复制不了"）⇒ 一键清掉并如实报清了几行。没有失效行时不显示这个按钮。
  （纯函数 `gridSelPrune`：只保留当前网格里还存在的，不改入参、参数不合法时不动手。）
- 选中数量的写法顺了一点：`选中的格（1）　⚠️ 1 个现在没有对应格`（警告挪到括号外）。

---

## [0.6.2] - 2026-10-01

### 新增功能

- **网格图放大后可以左键按住拖动（平移）**（作者 2026-10-01：「网格图 放大后我希望能左键按住拖动」）：
  在图上按住左键拖 = 平移（拖的是图那个滚动盒子的 `scrollLeft/scrollTop`），拖动时手型变 `grabbing`。
  - 与"点一格 = 选中/取消"共存：**位移超过 3px 才算拖**，拖过之后**把随后那次 `click` 吃掉**（`suppressClick`）——
    否则拖完手一松就顺手多选/取消一格。
  - ⚠️ 每次 mousedown **先清零**那个标记：不这么做的话，"在图上拖完、把鼠标松在格子外面"时 `click` 不会打到 SVG，
    标记会留到下一次 ⇒ **下一次真正的点击被白吃一次**（表现为"第一下点了没反应"）。
- **格内灰字坐标与「取哪一角」联动**（作者 2026-10-01：「网格图的 xy 应该要和 取哪一角 联动」）：
  「坐标 x,y」这一档显示的**就是**选中列表那行下拉选的那个角（默认右下）—— 换角时格内灰字立刻跟着变，
  按钮名字里也把当前角写出来（`坐标 x,y（右下）`）。
  - 内部 id 仍叫 `center`（**只为老缓存能继续用**：改 id 会让已经存过的值静默回落成默认）。
  - ⚠️ 顺手修了字号：改成按**真实最长文本长度**算（`stepX×0.9 / (最长文本×0.58)`）——
    联动到角以后文本会变长（中心 `'50,25'` → 右下 `'1600,1000'` 是 **9 个字符**），
    原来那个拍脑袋的"每字符系数"会让标签**越到邻格里去**。
- **右键标记颜色 → 导出直接带 `color`**（作者：「右键标记颜色 然后导出时候可以直接带 color…默认放几个颜色就好了」）：
  - 色板 **8 色**（第一色就是作者例子里的 `#d6d7dc` 灰）：`灰 / 红 / 橙 / 黄 / 绿 / 青 / 蓝 / 紫`；点一个当**笔刷**（当前笔刷打勾），
    图上**右键点格** = 用笔刷上色，**同一个色再右键一次 = 清除**；另有「清除标记」（只清颜色、不动选中）。
  - 标记的格在图上画成**半透明色块**（一眼看出哪些格被分类过）；右键标记时**顺手把那一格选上**（不然导出列表里看不到它）。
  - 导出片段**逐字**照作者给的例子（回归里**逐字节**对）：
    ```
    [{x = 330, y = 560 ,color="#d6d7dc",colorName = "灰色"}]
    ```
    没标颜色的格照旧是 `{x = 330, y = 560 }`；混着来也各自正确。
  - 色板、笔刷、标记都进缓存（键 `dsh-miliastra:grid`）；「恢复默认，并清掉缓存」会一并清掉标记。

- **网格图可以单独滚轮缩放**（作者 2026-10-01）：鼠标停在**网格图上**滚滚轮就缩放（**面板本身不跟着滚**），放大后用图的滚动条平移；
  旁边一颗按钮显示当前比例（`缩放 100%`），点一下回 100%。缩放范围 **25% ~ 800%**，值也进缓存。
  - ⚠️ 实现上**必须用原生非 passive 监听**（`addEventListener('wheel', fn, {passive:false})`）——
    React 的 `onWheel` 挂在根上是 passive 的，里面 `preventDefault()` 不生效（浏览器警告 + 面板跟着一起滚）。
  - 点图取坐标**不受缩放影响**：换算走的是 SVG 自己的 `getBoundingClientRect()`（跟着滚动走），所以放大/平移之后点哪还是哪。
- **选中列表加「四角」下拉**（作者：「增加四角下拉选择…默认是右下」）：`右下（默认）/ 左下 / 左上 / 右上`，
  决定复制出去的 `x,y` 取这一格的哪个角；列表每行也标明用的是哪个角（`格 (8, 10)　右下 {x = 900, y = 550 }`）。
  四角都在纯函数 `gridAnchorPoint` 里，默认与回落都是**右下**。

- **网格图改成多选 + 列表**（作者 2026-10-01：「网格图我希望能多选 下面要多个列表 能单独复制也能一起复制」）：
  - 点一格 = 把那一格**加进/移出**选中（可连点多个），选中的格全部粉块高亮、格内标签变亮粉加粗；
  - 图下面一张**列表**：一行一个格 + 每行「复制」「移除」，上面是四角下拉 +「复制全部（x,y 列表）」+「清空选择」；
  - **一起复制的格式照作者给的**（逐字节对）：
    ```
    [{x = 730, y = 460 },
    {x = 840, y = 540 }]
    ```
  - ⚠️ **网格改小后失效的格**（比如原来选了第 15 列，现在只有 14 列）**留在列表里说清原因**，只是不参与复制 ——
    既不静默丢掉、也不拿邻近格顶替（那正是"看着对、其实差一格"的来源）。
- **网格计算的值会被缓存**（作者：「我不想每次打开都要重新输入」）：画布宽高 / X Y 步长 / 两个换算输入 / 格内标档 / 多选列表 /
  四角 / 缩放 / 颜色标记 / 笔刷都存进 `localStorage`（键 `dsh-miliastra:grid`），下次打开面板（含刷新页面）原样还在；
  「恢复默认，并清掉缓存」一键回到 1600×1000 / X100 Y50 并清空多选。
  ⚠️ **存不下就说一次**（隐私模式 / 配额满 / 被禁），不静默 —— 否则人会以为"下次还在"。
- **面板「常驻」开关**（作者：「我希望插件能一直展示在旁边辅助我，我不希望点会输入框就被关掉」）：
  头部新增 `📌 常驻 / 📌 不常驻`；**默认常驻** ⇒ 点面板外面**不会关**（官方 `useDismissOnOutsidePointer` 的开关位，
  hook 本身仍无条件调用）。开合状态与常驻开关都记住：点开过一次之后，刷新页面它还在那儿；
  点 `×` 关掉也会记住（下次不再自己弹出来——沿用作者早先"不该一上来就糊一层"的要求）。

### 体验优化

- **网格页按作者要求瘦身**（「gui 隐藏功能」+「小字不要」）：
  - **隐掉三样**：逐格速查表、`像素 → 格`、`格 → 像素`（**代码与纯函数全部保留**、回归照跑，只是不渲染 —— 要回来说一声）；
    点图仍然会填像素/列行并给出结论，所以能力没丢。
  - **删掉两处小字**：`画布与步长` 卡片底部那段说明、网格图下面的图例（以及复制格式那行示例）。
  - 「当前点」那一行**只在点落空（残格 / 画布外）时出现** —— 落在格里时下面那张列表已经说清了。
  - ⚠️ 唯一保留的一句小字是**"格内标为什么没画"**（字太小 / 格太多）—— 它属于"解释缺失"，不是装饰。
- **结论字体放大**：从 `-log` 的 10.5px 换成专门的 `-gridsum`（**14px** / 行高 1.85）。
- **面板可以拖拉**（作者 2026-10-01：「然后可以拖拉」）：**按住头部**把面板挪到任意位置，**双击头部回原位**；
  位置存 `localStorage`（键 `dsh-miliastra:panel-pos`），刷新后还在。拖到屏幕外也**至少留 60px**（否则面板和它的 `×` 一起找不回来）。
  ⚠️ 拖的是**位置**；右下角拖拽改的是**尺寸**（CSS `resize`，自 0.6.0 就有）—— 两件事互不干扰。
  尺寸**回到原样 880×600**（作者：「还是改回原来的大小其他不变」），拖拽下限也回 `420×340`。
- `tools/dump-panel-text.mjs` 支持 **`--tab <页>`**（改面板文案时先以使用者视角读一遍那一页）。
- ★ **测试**：`client-render-test` **49 → 68** 条断言（多选三件套 + 一起复制的**逐字节**格式 + 失效行不参与复制 +
  缓存读 `null` / 写 `false` 的降级 + 列表渲染与空选中禁用 + `gridPointLine` + 常驻接到官方 hook 的开关位 +
  拖拉（`left/top` 切换 / 位置记住 / 双击复位 / 不拖出屏幕 / inline 短路）+ **四角四档（默认右下）** +
  **滚轮非 passive 监听** + 缩放夹取 + 只缩图（原生滚动盒）+ 隐掉的三样不许回到界面上 + 两处小字已删 + 结论字号 +
  **鼠标平移（3px 阈值 / 吃掉那次 click / 每次按下清零）+ 右键标记（8 色 / 切换 / 逐行带色 / 图上色块 / 带色导出逐字节）** +
  **格内坐标与「取哪一角」联动（四角四档 + 未知回落 + 按最长文本定字号）**）。

---

## [0.6.1] - 2026-10-01

### 新增功能

- **面板第六个 tab「网格计算」**（`lib/client.js`，**纯前端计算**：一个工具都不调、不写任何文件、不碰活文件）：
  真机 UGC 画布 **1600 × 1000** 按 X / Y 两个步长切格子，供**人和玩家**查坐标（摆放模型时对位置）。
  - 左栏：画布宽高 / X 步长 / Y 步长（默认 1600×1000、**X 100 / Y 50**）+ 四个预设按钮 + 结论 + 「像素 → 格」「格 → 像素」双向换算 + 「复制结论 / 复制速查表 / 恢复默认」
  - 右栏：**可点的网格图**（点任一格 ⇒ **吸附到该格中心**并选中，左右两张卡同步）+ 逐格速查表（≤200 格才铺开，超了只报总数 + 可复制 TSV）
  - **每格灰色坐标标签**（作者要求「每一格灰字展示经纬度」）：三档 **`中心像素 x,y`（默认，2026-10-01 定）** / `格号 col,row` / `不显示`；
    字号按**格子实际大小**算（宽 ≤ `stepX×0.9/每字符系数`、高 ≤ `stepY×0.62`，夹到 ≤48、**向下取整到 0.1**），
    **字太小（<14）或格太多（>600）就不画**并**如实说明**（画出来是糊的 = 骗人，与"线太多不画线"同一条纪律）。
  - **选中 + 复制**（作者要求「选中一个高亮，并且能复制」）：点任一格 ⇒ **吸附到该格中心**并选中
    （像素 x/y 与列/行一起填）；选中那格粉块高亮 + 标签变亮粉加粗；图下一行写清选中了哪格，旁边是
    **「复制选中的格」**（复制格号 / 覆盖范围 / 左上 / 中心）。点在**残格 / 画布外**时不吸附、**不假装选中**
    （按钮禁用，并说清是"残格"还是"画布外"），另画一个琥珀圈标出点的位置。
  - ★ **规则改了两条（相对工作区技能 `game-grid-mapper`）**：
    ① **不再要求步长整除画布** —— 旧 `grid.py` 的 `Grid()` 除不尽直接 `ValueError` 拒绝建档；现在如实报「残 N px」，
    并**把界外那一条（残格）丢掉**：网格只覆盖 `cols*stepX × rows*stepY`，落在残格/画布外的点**不算**；
    ② **X / Y 步长可以不同**（旧技能只有一个正方形 `cell`）。
    作者给的例子 `1600×1000 / X110 Y80` ⇒ X `14.5455` 残 60px、Y `12.5000` 残 40px ⇒ **14 × 12 = 168 格**，有效覆盖 `x[0,1540) y[0,960)`。
  - 像素 → 格 分**三态**：`in` 格内 / `rest` 残格（界外，并说清"落在第几格位置上"）/ `out` 画布外；
    ★ **界外的点不给格号**（`col`/`row` 回 `null`）—— 编一个邻近格号正是"看着对、其实差一格"的来源。
  - 坐标口径 = **设计画布**（左上 (0,0)、x 右增、y 下增）；模拟器内部舞台那套「左下原点」是另一层，
    有**反向绊线**钉住：网格图取点必须是 `clientY - rect.top`，不许出现 `bottom - clientY`。
  - 防呆（显式、不静默）：步长 1px ⇒ 160 万格时**不画格线**（每轴 > 200 条只画边界）、**不建速查表**（> 200 格），但数字照给并说明原因；参数不合法如实报错（不抛、不静默兜底）。
  - 实测：`tests/client-render-test.mjs` 新增 11 条断言组（该文件 60 条全通过），覆盖默认档 / 除不尽档 / 三态 / 越界 / 6 档非法输入 / 上限 / 渲染 / 除不尽渲染 / 格内标签 / 选中行 / 文案无 Markdown 记号。
- **CHANGELOG 补回 `0.6.0` 一节**（发 0.6.0 时漏写；此处按 `.github/release-notes/v0.6.0.md` 回填，内容一致、只是更短）。

### 说明

- 面板 tab 从 5 个变为 **6 个**：初级功能 / 高级功能 / 模拟器 / 预制效果 / 像素画 / **网格计算**。
- ⚠️ **只改了 `lib/client.js`** ⇒ **刷新页面（F5）** 就能看到；Host 半边一个字没动、**不用重启 `dsh web`**。
- ⚠️ **界面未做视觉验证**（本机没跑浏览器）：结构与文案由 `client-render-test`（真 React 静态渲染）钉住，**好不好看要人眼过一遍**。

---

## [0.6.0] - 2026-09-30

> 本节是**回填**（发版时漏写）：与 `.github/release-notes/v0.6.0.md` 同源，细节去那份看。

### 新增功能

- **`op=vfx-lua` 新增「图元层」（`SHAPE_KIND="sprite"`）**：**一个形状 = 一个控件**（+ 残影），沿**多段**贝塞尔路径飞、可拉伸、可按切向旋转 ——
  控件数从"每层一个池（默认 192）"降到"每层 1 + 残影数"，这是**真机预算内继续加特效密度**的方向。
- **三个图元预设**：`ring-arc`（环刃）／`slash-arc`（刀光 + 3 层残影）／`crescent-arc`（新月·两段路径）；`paths` 支持多段（`4+3k`，段数 `(n−1)/3`）。
- **新增 `miliastra_code op=preflight`**（**只读**）：一次把"能不能上真机"查完 —— `[Lua 语法 · 结构配对 · 全局写 · 图片有图源 · 作用域 · 风格基线]` 逐文件一张表 + `verdict`。
- **`miliastra_health op=sha all:true`**：全部活文件一张表（`liveSha`/`mirrorSha`/`embeddedSha`/`verdict`）+ `summary{needSave,needDeploy,consistent}`。
- **`miliastra_sim op=bind` 新增 `runForMs`**（固定步长 1/30 逐帧真跑，上限 60000ms = 1800 帧）；**`op=play action=step` 新增 `args.frames`**。
- **交接值台账回显到 `miliastra_sim op=bind`**：`containerId` 优先级 = 显式 > 台账 > 都不给。

### 问题修复

- **`deploy` 的结构校验从不查语法** ⇒ 新增 **fengari 真解析器**（`luaL_loadbuffer`，报错带行号）并接进部署闸门（strict 直接拒），回执同时给 `lintChecks`/`lintSkips` 自述边界。
- **帧率探针在真机上停不下来**（停止闸写的是全局变量 ⇒ 一局刷出 13 983 行 / 2.8 MB）⇒ 改 `local` 停止闸 + 兜底条数上限。
- **`output:"data"` 时 `paths` 被静默忽略**；**`op=load` 无视 `summaryOnly`**（1.0 MB）⇒ 已修（`summaryOnly` 约 9 KB）。
- **像素画产物从不指定图源**（真机整屏 `?`）；**`patch add` 给不了 `imageId`**；**`miliastra_log` 的 `matched` 是返回条数不是命中总数**；**`durationSec` 异常收尾时是反推上界**（补 `durationInferred`/`durationNote`）。
- **测试会污染真实数据目录**（往用户截图目录塞图、覆盖"上次配方"）⇒ 默认隔离到临时目录。

### 体验优化 / 其他变更

- `op=bind` 默认走**精简档**（实测 4986 B → 3579 B，被省掉的段落用 `omitted[]` 点名）；`deploy` 的 `summaryOnly` 去掉 `candidates[]`。
- **写盘类操作在"当前关卡"有歧义时直接拒绝**（`code:"LEVEL_AMBIGUOUS"`）。
- **模拟器与真机对齐**：图片控件没显式贴图时模拟器**也画 `?`**（不再替它补方块）。
- ⚠️ **未验证（如实标）**：图元层的**真机渲染**（`SetLocalRotation` 正负、`ImageType.Stretch` 缩放、拉伸采样质量）无真机证据；留下 `SPIN_SIGN` / `IMAGE_TYPE` 两个一行开关。

---

## [0.5.1] - 2026-09-29

### 新增功能

- **面板第五个 tab「像素画」**（`lib/client.js`）：**绝对路径读图 → 生成 Lua → 直接进模拟器展示**，就这三步。
  - 左栏：图片绝对路径（只认绝对路径、不抓网图）/ 网格列数 / 像素块大小 / 图片模板索引 / 容器节点索引 + 「生成并展示」
  - 交接值打开面板时从**当前关卡** `.gil` **现读**自动填（换图自动跟上）
  - 生成成功后**立刻** `miliastra_sim op=bind` 投进**同一个会话**，600ms 后重挂画面 ⇒ 生成完右栏就有画
  - 实测：`op=pixel-art` cols=32 → **33,960 B / 936 个矩形块 / preflight FAIL 0**；投进模拟器后脚本自报 `built controls=936`、0 条报错

### 说明

- 面板 tab 从 4 个变为 **5 个**：初级功能 / 高级功能 / 模拟器 / 预制效果 / **像素画**。
- ⚠️ **像素画真机未验**（工具回执里的 `unverified` 明说：官方素材 / 真机渲染管线 / 联机都不覆盖；模拟器 ≠ 真机）。
## [0.5.0] - 2026-09-29

- **回执契约（AI 易用性实测后立）**：`miliastra_sim` 的回执**补 `ok`**（此前全仓只有它不带 —— `op=bind` 明明成功，
  调用方按 `r.ok` 判定会**误报失败**）；sim 的**守卫错误改为回执**（`op=hud` 无会话等不再抛异常，
  消息一字不改、只换形式）；新增 **`tests/receipt-contract-test.mjs`** 机制性门禁：
  **必有 `ok`｜`ok:false` 必带 `error`｜不许抛异常**（16 条只读用例）。
- `miliastra_health` 的 **brief 档补 `version`**（此前读不到版本号，只能绕道 `miliastra_echo`）；
  `miliastra_log` 读不到 `.gia`（轮转/被清理是常态）时**回 `ok:false` 而不是抛 ENOENT 栈**。

- **`miliastra_asset op=icon-search`（新）**：按**语义**找平台图标（1543 条）—— 不传参给分类概览、`q` 关键词（中英、空格=AND）、
  `id` 看单条全字段、`shape`/`colorKind`/`category`/`simOnly` 收窄；名字来自**模型识图推断**（恒带 `nameSource:"vision-inferred"` + `confidence`），
  未识别的 121 条标 `recognized:false` 且**不编名**；`summaryOnly` 只去正文（counts/hint/unverified/doc 一个不删）。
  数据 `lib/images/icons.json`（0.9 MB / 1543 条）由 `tools/build-icon-index.mjs` 合并「目录事实 + 本地像素 + 视觉识别」三源生成。
- **面板第四个 tab「预制效果」（1:2）**：左 = 预设下拉 + 发射器属性**可覆盖项** + 「生成 Lua / 一键复制 / 预览」；右 = 模拟器试玩页。
  面板改动**只作用于本次生成**（不写回预设库），且**没有**「一键投到活文件」按钮（改关卡必须走工具、由人决定）。
- GUI：顶栏显示**当前版本号**；打开面板查一次 GitHub tags ⇒ 不是最新则显**红按钮「插件有更新」**（**查不到就说"没查到"，不冒充"已是最新"**）；面板右下角可拖大。

> 下一个版本的东西写这里。

### 新增
- ★★ **`miliastra_gen op=vfx-lua`：粒子特效 → 一次调用出可部署 Lua（2026-09-30 · 第六批）** ——
  驱动层（采样/发射/池子/回收 + **晚建** + 诊断 `print` + `error` 点名 + 无 `pcall` 掩盖）**逐字取自真机定稿**：
  工作区 `tmp/fx-demo/特效 fx.lua`（关卡 `1073741838` 第一屏星雨，真机已跑通）；生成器只参数化三处
  （`VERSION` 行 / 预设名 / `CONFIG`+`DATA`）。有断言钉住：**`driverParity`**（生成物里的驱动层必须与定稿件逐字一致）。
  - **13 个预设**：`star-rain`（真机定稿）/ `snow-fall`（上游 `snow`）/ `petal-fall` / `ember-rise`（★反向：从底往上升）/
    `star-scatter`（上游 `stars`）/ `confetti-pop` / `coin-collect`（上游 `coins`，贝塞尔）/ `chest-collect`（换宝箱图）/
    **`peacock-in` / `peacock-out`**（★用满 **8 层发射器**：八向往中心收拢 / 从中心往八向展开）/ `firefly-drift` /
    `bubble-up` / `hit-spark`。参数表（每字段实际取值 + 落程/池子核算）见
    `docs/千星奇域_粒子特效配置格式.md` **§5.0**（**由 `tools/gen-vfx-doc.mjs` 从 `lib/vfx/presets.mjs` 生成**，改了预设就重跑）。
  - **"图"是独立维度**：`imageId`（真机 = 平台**任意**素材号，`op=catalog` 里 1543 条 / id 空间 `100001~112042`）
    与 `previewImageId`（只为模拟器预览 —— 模拟器**只画** `100001~100006` 六个几何号）**两层分离**，回执两个都回显。
    ⛔ 不再说"粒子只能用 6 个号"：**"6 个"是模拟器的渲染限制，不是平台的素材限制**（作者 2026-09-30 更正）。
  - **贝塞尔（钢笔）可配**：`path:{start?,p1,p2,target}`（三个点是**相对发射点**的偏移）+ `pathLayer`；回执回显最终生效的
    四个点与画布绝对坐标；四条"三个点 → 常见曲线"配方见格式文档 **§5.4**。
  - **渐进式披露**（作者明令）：schema 里只放"怎么找到预设"（`preset` 是普通 string，**不做 12/13 项枚举**），
    `preset:"list"` **按需枚举**（中文名 + 一句话 + 关键参数 + 控件核算），完整属性表全在文档（回执恒带 `doc` 指针）。
    `summaryOnly:true` 对 `list` / `data` 两条大回执都真的省体积。
  - **`chest-collect` 不猜素材号**：宝箱图号由创作者指定（目录里**单张图没有名称字段**）⇒ 不传 `imageId` 时
    `ok:false + needsImageId`。
  - 一处**刻意的驱动层替换**（唯一）：`requireHandover("DURATION", …)` → `requirePositiveNumber(…)` ——
    定稿件的 `requireHandover` 只认正整数（校验模板索引那类），而 `DURATION` 是秒、可以是小数（上游 `coins` = 2.5）。
    这个 bug 是**模拟器端到端第一次跑**抓出来的（`OnStart: CONFIG.DURATION 必须是正整数，收到 2.5`）。
  - **验证**：`tests/vfx-test.mjs` **51 条断言**（纯函数 / Lua 铁律 / 工具接线 / **模拟器端到端**）；
    13 个预设**逐个**跑过 `op=bind → op=frames`：控件数 = 池容量 +1、帧间差 266~8129 像素（全部 `identical:false`）、
    **0 条 `lua-error`**。⚠️ 仍是**模拟器 ≠ 真机**：官方素材 / 真机渲染 / 层级遮挡 / 帧率都没覆盖。
- **`miliastra_asset` 接上两个「平台目录」通道（2026-09-28 · 第五批 · 作者点名的最后一公里）** ——
  「开一个 api 接口 / 接口返回 + 名称模糊搜索」。两个通道都是**离线快照**（运行时零联网、**不下载任何图片或音频**），
  与原来那套「插件自己的素材库」（`add`/`list`/`get`/…）**语义不串门**：这一批只管**平台素材目录的事实**。
  - **`op=catalog`（平台图片资源库）**：**1543 条 / 14 类**，参数 `category`（分类 id 或名字，支持子串如「单色」）、
    `colorKind`（`mono`/`multi`/`neutral`/`mixed`）、`simOnly`、`imgExists` + 复用的 `id`/`limit`/`summaryOnly`。
    回执 `{ok, op, filters, categories, items?, counts, limit, limitClamped, idRange, source, unverified, notes}`。
    ★ **`imgExists` 是必看的一列**：目录里 **21 条**的 `img`/`border` 是空串、实测 PNG **404**
    （拿号去 `SetImage` 之前先筛 `imgExists:true`）。★ `simRenderable` = **我们模拟器认不认**（只有
    `100001~100006`），由 `tests/catalog-test.mjs` **直接读 `engine/studio/constants.js` 的 `IMAGE_PRIMITIVES`** 核对，防两边常量漂移。
    ★ **单图没有名字**（目录里只有 `id`/`img`/`border`，1543 条全量核对）⇒ 只回分类名，**绝不编名字**。
    ★ `colorKind` **以官方分类名为主口径**（含「单色」→ `mono`、含「彩色」→ `multi`），官方名没标的 6 类按抽样实测
    分 `neutral`/`mixed`，抽样（66 张）**只作旁证**，工具**不判决**官方写得对不对。
  - **`op=sound-search` / `op=sound-get`（平台音效库）**：**1997 条 / 7 类**，`q` 按**中英文名**模糊搜
    （空格分隔多词 = AND），五档相关性 `exact>prefix>substring>subsequence>editDistance`（逐条 `matchKind`，
    档内按整数 `score`）；`limit` 默认 20 / 上限 100；空 `q` 与非法 `category` **报错**（不静默回全表）；
    `sound-get` 找不到**不抛错**（`found:false` + `hint`）。
    ⛔ **不支持拼音/首字母**（没有词表）—— 这条**写在 schema 的 `q` 参数与 description 里**，免得 AI 在死路上反复试；
    最弱档是子序列/编辑距离时挂 `weak:true`（那批是"凑"的）。
  - 新增 `lib/images/query.mjs` + `lib/images/catalog.json`（**四元组 `[id,分类,flags,颜色档代号]`**，
    flags 的 `bit0=imgExists`、`bit1=simRenderable`；1543 条 / 34.9 KB，UTF-8 无 BOM + LF）
    + `tools/build-image-catalog.mjs`（**三个源 sha256 钉死 + `--check` 逐字节校验**，镜像一变直接拒绝；
    **从不发网络请求**、**不落任何图片字节**）。音效那半边的数据层与生成脚本是上一批的产物，本批只做**接线**。
  - `tools/test-all.mjs` 新增 `catalog-test`：**67 条断言**（分类 id/名/子串、四态颜色档、`simOnly`、`imgExists` 筛出的
    21 条与拓扑文档逐个对上、`limit` 夹紧、未知 category/colorKind/limit/id/op 报错、`summaryOnly` 只去体积不去结论、
    **回执无 `undefined`**、五档各一条（真数据）、`sound-get` 三态、**镜像重建逐字节一致 + 1543 条逐条对账**）。
  - 用法、边界、未验证表 → `docs/功能详解.md` §平台图片资源库与音效库；`miliastra_sim` description 的下沉原文
    （本批为此腾了 **1 441** 字符）→ `docs/模拟器与视图.md` §8。

- **`miliastra_gen`：离线生成器** —— 承载「AI 出参数 → **回执里就是能直接用的东西**」这条能力
  （作者 2026-09-28 明令：**默认就出可部署的 Lua**，不是数据模型、不是"再调一次拼起来"）。
  来源：`xiaomoL444/ugc-tool`（**作者已授权移植，唯一要求保持开源**）；每个 lib 文件头都写了
  `移植自 …— 源文件：…` 并列出**刻意的偏差**。⛔ 不吸收登录 / OSS 直传 / Cloudflare Worker / 桌面伴侣。
  - **`op=text-gradient`**：文本按字符切分 → 色标采样 → 风格（`flat` / `flow-forward` / `flow-backward` /
    `fade-in` / `fade-out` × `flat` / `jitter`）→ 逐帧富文本，**并包成一段可直接部署的客户端 Lua**。
    `output` 默认 **`lua`**（回执 `lua` / `luaBytes` / `lines`）；`output:"data"` 才只给结构化数据。
    Lua 遵本仓铁律：`OnStart` 里 `script:EnableUpdate(true)`（不开则 `OnUpdate` 永不触发）+ print 版本行、
    `OnUpdate(dt)` 按 `fps` 累积换帧且**第一帧立刻上屏**、**交接值与节奏全在顶部 `CONFIG`**、
    **四处 `error` 点名**（`script.object` 为空 / 找不到控件 / 不是文本框 / 实例化返回 nil）、
    **⛔ 没有 `pcall` 吞异常、没有静默 return、没有屏幕诊断串**。
    ★ **交接值优先自动拿**：`controlName` / `templateIndex` 没给时**只读当前关卡的 `.gil`** 找候选，
    只有**唯一候选**才采用（回执 `handoverFrom:"gil"` + `HANDOVER_AUTO_FROM_GIL` 警告）；
    多候选或读不到 ⇒ **`ok:false` + `needsHandover[]` + `handoverCandidates[]`**，错误文案直说「**别自己编**」。
    ⚠️ `.gil` 控件记录**没有类型字段**，所以候选叫 `namedChildrenOfContainer`（不叫 textboxes）—— 文本框由创作者确认。
    ★ **`output:"lua"` 一帧都不少**（`data` 模式有「不给 `frames` 只出前 60 帧」的上限，动画不吃它）。
    ★ **5 处刻意偏差**（回执 `deviations[]`）：枚举**自己命名**；默认色标钉死（源码是**随机色**）；
    色标采样改为**线性 sRGB**（**不引入 `chroma-js`**）；字号插值**修正**了源码的区间外取值（2 色标逐值相同）；
    跳字用**固定种子 PRNG**。⚠️ **`<size=N>` 与 4bit 短格式默认关**，启用时**产出的 Lua 顶部就有一行注释**
    说明它未经真机验证（官方 7.1 原文：`<color` 1 处命中，`<size`/`4bit`/`#RGB` **全 0 命中**）。
    ★ 顺带实测出的事实：**8bit 六位十六进制不带 alpha** ⇒ 淡入/淡出 + 8bit 时 `distinctColorFrames = 1`
    （颜色空转），`notes[]` 里点破，且该字段**按真正的输出格式**算（4bit 下 16 种）。
  - **`op=struct-json`**：声明式字段表 → **两种可直接导入千星的形态**：「结构体定义」
    （`{type:"Struct", struct_ype:"basic", name, value:[{key,param_type,value}]}`）与「结构体变量值」
    （`{structId, type:"Struct", value:[ParamNode…]}`，位置参数）。24 个 `ParamType` + 中文标签；
    值编码**从同仓库 25 份真实千星样例归纳**（标量一律写成**字符串**：`"0"` / `"2.00"` / `"False"` / `"0,0,0"`）。
    ★ **两条硬规则在生成前校验、不通过直接报错**：① **结构体 ID 必须 10 位数字**（所有出现位置都查，
    含嵌套 `Struct`/`StructList` 与 `Dict.value_structId`）；② **单条文本 ≤ 500 字符**（`StringList` 逐元素算；
    默认报错并列出 `over[]` 的路径+长度，`allowLongText:true` 才降级成 `warnings[]` —— 源码只 `toast.warning`
    就放行，这是**默认拦**的刻意差异：那条错误要到千星导入时才暴露）。
    ★ **`struct_ype` 兼容**：同仓库 25 份真实样例**全部**写 `struct_ype` ⇒ **读入两个都认、默认写出 `struct_ype`**，
    要写 `struct_type` 传 `spelling`；回执给 `spelling`/`spellingAlternative`/`spellingAccepted`。
    ★ 回执带 `nextStep`（和 text-gradient 一样说清下一步）。
  - 新增 `lib/textgradient/{math,model,gradient,lua}.mjs` + `lib/structvar/{model,validate,build}.mjs`
    （**不引入任何新依赖**）+ `tests/gen-test.mjs`（**55 条断言**：算法逐值、**测试抓到的两处真 bug**
    （4bit alpha 被二次量化、`distinctColorFrames` 没按输出格式算）、两条硬规则、`struct_ype` 兼容、
    **Lua 层**（铁律逐条 / 交接值缺失必须报错 / 模板模式 nil 检查 / 未验证注释在 Lua 顶部 / 一帧都不少 /
    lua 的 `summaryOnly` 只去正文）、以及**真从 `TOOLS` 调 `execute`** 的工具层断言（未知 op 明确报错）。
    「修前为什么红」写在测试文件头）。
  - 用法、风格表、值编码表、交接值规则、未验证项 → `docs/功能详解.md` §生成器。

- **`miliastra_gen op=pixel-art`：图片 → 可部署的像素画 Lua**（2026-09-29 · 第四批）。
  来源同上（`xiaomoL444/ugc-tool` 的 `src/views/PixelArt/PixelArt.vue`，作者授权、保持开源）；
  **只移植算法**，那份 Lua 是**本仓自己写的驾驶层**（源码走 `script:GetParam("ImagePrefebID"/"CenterOffsetX"/…)`，
  本仓改成顶部 `CONFIG` 常量，因为 AI 交接的索引本来就来自调用参数）。
  - **`output` 默认 `"lua"`**（一次调用 → 直接拿到可部署 Lua + `luaBytes`/`lines`）；另给 `"struct"`
    （千星变量 JSON，形态与源码 `downloadJson` 逐字一致）与 `"data"`（块表）。
  - **产物是矩形块拼图，不是"一个像素一个控件"**：关平滑降采样 → **行内行程** + **跨行同色同宽合并**。
    16×16 的测试图压成 **4 个块**；透明格（`a=0`）**不建块**（源码原判据 `if color[3] > 0`）。
  - **高层意图参数**（别逼 AI 手写像素矩阵）：`assetId`（内容寻址）/`source`（绝对路径）、`cols`/`rows`
    （或 `maxSide`；一个都不给按 32 兜底并**如实标 `gridFrom:"default"`**）、`pixelSize`、
    `centerOffsetX/Y`、`imageType`（默认 `Stretch`）、`use4bit`（默认 false）、`mergeRuns`（默认 true）、
    `summaryOnly`。
  - ★ **交接值要两个，缺了就 `ok:false` + `needsHandover[]`（复用既有 `.gil` 自动读取机制，
    只有唯一候选才采用）**：`templateIndex`（**图片**控件模板）+ `container`（容器节点索引）。
    `.gil` 的候选表新增 `imageTemplates` / `containerNodes` 两栏；两个值各自独立判定（混用会标 `gil+arg`）。
  - ★ **产物遵本仓 Lua 铁律**：交接值/网格/像素尺寸/中心偏移**全在顶部 `CONFIG`**；
    控件 nil / `InstantiateClientUIControl` 返回 nil ⇒ **`error` 点名**（带模板号与第几块）；
    **⛔ 无 `pcall`、无静默 `return`、无屏幕诊断串**；颜色走**字段** `imageColor`
    （官方 7.1 原文 **`SetImageColor` 0 命中**，已逐条核过）。
    ★ **像素画是静态的 ⇒ 产物不调 `script:EnableUpdate(true)`、没有 `OnUpdate`**，并在文件顶部**写明理由**
    （免得下一个人以为是漏了）。
  - ★ **与源码的 5 处刻意偏差**（回执 `deviations[]` 逐条列出）：默认 `pixelSize=8`（源码缺省 1，1 时整幅画
    只有几十像素宽）、`use4bit` 额外量化 `Color.FromRGBA` 的入参（否则 lua 模式下这个开关**空转**）、
    alpha 固定 8 位、`mergeRuns:false` 是新增开关、`struct` 模式多了一条「单条 ≤500 字符」段长上限
    （源码只有 `maxPixelWidth`，一行 512 格同色会算到 537 字符而**超限**）。
  - 新增 `lib/pixelart/{model,decode,blocks,lua,struct,index}.mjs`（**不引入任何新依赖**：
    降采样用依赖里已有的 `@napi-rs/canvas`，且走**动态 import**，纯函数层不加载原生模块）
    + `tests/pixelart-test.mjs`（**41 条**：量化端点 0/17/255 与进位点 8/9、`resolveGrid` 四路、
    合并判据三段 key（错位/宽度不同/被截断都不许粘）、透明格不建块、富文本形态与全角空格、
    两个段长上限、`struct` JSON 形态、Lua 层的六条铁律、工具层的 `needsHandover`/非法入参/三种 `output`/
    `summaryOnly`/`assetId`、**以及一条模拟器端到端**）。「修前为什么红」写在测试文件头。
  - 用法、参数默认值/边界表、回执字段、偏差清单、**端到端证据表** → `docs/功能详解.md` §`op=pixel-art`。

### 变更
- **`op=catalog` / `op=sound-search` 的回执体积与 0 命中提示（2026-09-28 · 作者点名的 AI 侧体验）** ——
  作者实测出的浪费：每次回执都重复带"整张分类表 + 目录元信息"（`op=catalog` 查 1 个分类时 14 行分类表
  占回执**约一半**；`op=sound-search` 查 3 条音效时 7 行表 + `catalog` 段约占 **45%**），
  而 0 命中的 `hint` 不说到点上（`{"q":"攻击 命中"}` 两个中文词 **AND** 撞 0 命中，`hint` 却只讲"不支持拼音/用分类/用 id"）。
  - **铁律是「发现调用给全表、过滤调用只给结论」**：
    · `op=catalog` —— `category`/`id`/`colorKind`/`simOnly`/`imgExists` **全空** = 发现调用 ⇒ 完整 **14 行**分类表；
      给了任一收窄条件 = 过滤调用 ⇒ 省掉，改回**单条** `category:{id,nameZh,nameEn,count,range}`（仅 `category` 收到一个分类时）
      + **`categoriesOmitted:14`**（数字，让人知道被省了什么；子串命中多个分类时给那几行 + 对应数字）。
    · `op=sound-search` —— **没传 `q`**（发现调用）或 `withMeta:true` 才给 7 行分类表 + `catalog` 段；
      带 `q` 时省掉并给 **`categoriesOmitted:7`** / **`catalogOmitted:true`**；给了 `category`（`q` 也在）时
      **只给那一条**分类行 + `categoriesOmitted:6`。
    · `source` **一律精简成 3 个字段**（`mirror` / `origin` / `note` = `镜像 ≠ 官方（第三方镜像，非 miHoYo 端点）`）；
      **sha256 三连 + `fetchedAt` 只在 `withMeta:true` 时给**（审计哈希才要）。
  - **新增 `withMeta`（boolean，`catalog` / `sound-search` 共用）**：语义 = 「把完整分类表与 sha256 元信息给我」；
    description 明写**只在需要挑分类 / 要审计哈希时开**。`op=sound-get` 体积检查过：**本来就没有**分类表与元信息
    （实测回执 208 B）⇒ 不需要它。
  - **⛔ 只去体积、不去结论**：`counts`（`total`/`returned`/`truncated`/`catalogTotal`/`imgMissingInCatalog`）、
    `filters`、`limit`/`limitClamped`、`idRange`、`notes`、`weak`、`hint`、`source.mirror|origin|note` 一个都没少；
    **`unverified`（对外诚实口径）任何模式都在**且逐字相同。⑤ 号断言把「同一条件下 `counts` 在精简与
    `withMeta:true` 两种回执里逐字段相等」钉住 —— 体积优化的错法都是**静默少一个判据**，不报错。
  - **`op=sound-search` 不给 `q` 由「报错」改成「发现调用」**：回 7 行分类表 + `catalog` 段 + `browse:true` +
    **0 条音效**（既不报错，也绝不静默把 1997 条灌进上下文 —— 旧版那句"不静默返回全表"的防线照样在）。
    `q` 全是标点仍**报错**。
  - **0 命中 `hint` 按 (token 数 × 纯不纯拉丁字母) 分流，首要建议放最前面**：
    ≥2 个词 ⇒「**多关键词是 AND（全都要命中）** —— 去掉一个词再试（例如只搜「第一个词」）」；
    1 个词含 CJK ⇒「换更短的词 / 试英文名 / 用 `category` 限定 / 用 `sound-get` 按 id 取」；
    1 个词纯拉丁 ⇒ 才念「⚠️ **不支持拼音/首字母**（没有词表），别试 `bx`/`baoxiang`」那段长话。
    `weak:true` 路径**保留原有提示**，不被覆盖。
  - **实测体积**（三条真调用，`JSON.stringify` 字节）：`catalog`+`category` **2 948 → 1 355**、
    `catalog` 无参数 **6 864 → 6 708**（发现调用按设计保留全表，只省了 `source` 的 sha256/fetchedAt）、
    `sound-search`+`q` **1 107 → 720**；`withMeta:true` 时 `sound-search` 1 082 B（≈ 改前，多出来的就是全表与元信息）。
  - 断言：`tests/catalog-test.mjs` **67 → 86 条**（新增 ①~⑥ 那组）、`tests/sounds-test.mjs` **97 → 102 条**
    （三条 `hint` 分流各一条真调用 + 「有命中不给 hint」）。schema 棘轮 **32 517 B（余 251 B，绿）**。
  - 用法与口径表 → `docs/功能详解.md` §平台图片资源库与音效库（两处**回执体积策略**小节）。
- **工具 schema 体积第四轮下沉（为 `op=catalog` / `sound-search` 腾地方，2026-09-28 · 第五批）**：
  加两个 op 前实测 **32 084 B / 32 768 B（余 684 B）** ⇒ 把 **`miliastra_sim` 整条工具条目从 7 611 → 6 170 字符**
  （description **2 377 → 1 102**、parameters **5 083 → 4 980**）⇒ **腾出 1 441 B**；两个 op 实际花 **1 397 B**
  （`miliastra_asset` 整条 2 271 → 3 668），收在 **32 040 B（余 728 B，棘轮绿）**。
  下沉原文（**逐字**）在 `docs/模拟器与视图.md` **§8**。
  ⚠️ `tests/smoke.mjs:400-449` 钉住的**每一个关键词都没丢**（`预测试` / `≠ 真机` / 三档 `①`…`③` /
  `textbox`+`` `text` `` / `frame` 不是秒表 / `5ms`+`200ms` / `Down…Up` 一直按住 / `keys`+`string-literal` /
  `press` / `op=hud` / `all:true`+`164`）以及本批点名要保的 `Z 序` / `sibling` / `后建的在上` / `返回值被忽略` /
  `assert(false`；**另有 `feedback3-test` / `feedback4-test` 钉住的 4 句**（`PNG 里有脚本建的客户端控件` /
  `都会画进图` / `自己那棵树` / `按 sibling 顺序画` + `不覆盖`）—— 第一版下沉漏了这 4 句，那两套当场红，
  这也说明「smoke 全绿 ≠ schema 没丢话」。
  ★ **一条口径修正**（免得下一轮又按错数算账）：简报里说的「`miliastra_sim` 的 description 约 7 611 字符」，
  实测 **7 611 是整条工具条目**（description 2 377 + parameters 5 083 + JSON 开销），**description 只有 2 377** ——
  「把 description 全砍光」的上限就是 2 377。
- **工具 schema 体积第三轮下沉（为 `op=pixel-art` 腾地方）**：加新 op 前实测 **31 845 B（余 923 B）**；
  把描述与参数塞进去后涨到 **33 846 B（超 1 078 B）** ⇒ 下沉 **1 770 B**，收在 **32 076 B / 32 768 B
  （余 692 B，棘轮绿）**。下沉去处：
  - `miliastra_code` 的 description：安全约定细节 / `op=rects` 配对口径 / `op=lint-ui` 细则 / `warnings[]`
    两类已知坑 → 只留判据骨架 + 一行指针（指向 `docs/功能详解.md` §部署安全 / §已知坑 / §UI 门禁 / §矩形对账）；
  - **新增** `docs/功能详解.md` §矩形对账（含 `driverRefs` 那条事实）与 §探针下沉原文 —— 这两处原先
    只活在 schema 里，下沉后**必须**在 docs 里有落点（否则等于丢信息）；
  - `miliastra_gen` 自己的：description 与 30 个参数的**默认值/边界/细则**移进 §生成器 的参数表，
    schema 里只留「这个参数干什么用」一句；
  - `miliastra_asset` / `miliastra_probe` / `miliastra_code` 参数说明的冗余修饰。
  ⚠️ `tests/smoke.mjs` 钉住的**每一个关键词都没丢**（`预测试` / `≠ 真机` / `textbox` + 反引号 `text` /
  `frame` 不是秒表 / `5ms`+`200ms` / `Down…Up` 一直按住 / `keys`+`string-literal` / `press` / `op=hud` /
  `all:true`+`164` / `Z 序` / `sibling` / `后建的在上` / `返回值被忽略` / `assert(false`）—— 改完重跑冒烟全绿。
- **`op=handover` 的 `.gil` 候选表新增两栏**：`imageTemplates`（名字叫 `图片` 的独立模板）与
  `containerNodes`（独立的 `容器节点`）—— 给 `op=pixel-art` 的交接值用。**仍然只有唯一候选才采用**，
  且这只是**名字启发式**（`.gil` 记录里没有类型字段，见 `docs/功能详解.md` §生成器）。
- `miliastra_code` / `miliastra_gen` / `miliastra_asset` / `miliastra_probe` 的部分参数说明**变短了**
  （信息都移进 `docs/功能详解.md` 与 `docs/模拟器与视图.md`，schema 里留一句 + 指针）；
  **行为一个字没改**。
- **工具 schema 体积第二轮下沉（为 `miliastra_gen` 腾地方）**：`tests/smoke.mjs` 的 32 KB 棘轮实测
  **31 118 B（30.39 KB）→ 27 739 B（加新工具前，≈27.09 KB）** ⇒ **腾出 3 379 B ≈ 3.30 KB**；
  加完 `miliastra_gen`（含默认出 Lua 那一整套）后是 **31 845 B（31.10 KB），余量 923 B**（棘轮绿）。
  下沉去处：`miliastra_sim` 的**参数说明** → `docs/模拟器与视图.md` 文末**新增的「§7 其余参数的下沉原文」**；
  `miliastra_sim` / `miliastra_code` / `miliastra_shot` / `miliastra_probe` / `miliastra_playtest` /
  `miliastra_asset` / `miliastra_log` 的说明性长文**只留判据骨架 + 一行指针**。
  ⚠️ `tests/smoke.mjs` 钉住的**每一个关键词都没丢**（`预测试` / `≠ 真机` / `textbox` + 反引号 `text` / `frame` 不是秒表 /
  `5ms`+`200ms` / `Down…Up` 一直按住 / `keys`+`string-literal` / `press` / `op=hud` / `all:true`+`164` /
  `Z 序` / `sibling` / `后建的在上` / `返回值被忽略` / `assert(false`）—— 改一处就重跑一次冒烟，全绿。

- **`miliastra_asset`：插件级素材库（asset store）** —— 与具体功能无关的通用图片素材存储，
  给「UI 动画 / 粒子 / 像素画」这类**要存图并反复引用**的能力打底。**按内容寻址**：
  文件名 = `sha256` 前 **16 位**（64 bit，理由与碰撞处置写在 `lib/assets.mjs` 文件头）+ 原扩展名，
  同内容重复加入**只存一份**（回执 `deduped:true`）。落在**插件数据目录**的 `assets/` 下
  （`index.json` + 素材正文），**不进游戏存档、不碰活文件**。
  ops：`add`（绝对路径 / base64 / data URL，可带 `tags`）/ `list`（`tag`/`limit`/`summaryOnly`）/
  `get`（回 `dataUrl` 或写到 `out`，**逐字节**一致）/ `remove` / `rebuild` / `prune` / `stats`。
  **索引是缓存、目录才是真相**：`rebuild` 读每个文件的字节**现算 sha256** 重建索引，
  目录说明不了的字段（原始名 / 来源）**如实标 `null`**。
  **磁盘是用户的**：素材**绝不自动删** —— `remove` 不给 `confirm:true` 一个字都不动，
  只给 `confirm` 就**只摘索引**（字节留着），连字节一起删还要再给 `deleteFile:true`；
  `prune` **默认只报告**，真删要 `confirm:true` 且只删点名的**无主 / 损坏**两类。
  **没有任何批量静默删的口子**。安全：单张上限 **64 MiB**、**拒绝 0 字节**、
  只收图片白名单（`png/jpg/jpeg/gif/webp/bmp/svg/ico/avif`）、`source`/`out` 只认绝对路径、
  `get` 写 `out` 时**默认不覆盖**、素材名只认内容寻址的 `<id><ext>`（`../` 一律拒）。
  新增 `lib/assets.mjs`（写盘一律走 `lib/fsx.mjs` 的 `atomicWriteJson`）与
  `tests/assets-test.mjs`（**31 条断言**：逐字节一致 / 去重 / 0 字节 / 超限 / 索引缺失 rebuild /
  remove 必须显式 / 路径穿越 / 索引原子写且无 BOM / id 冲突拒绝覆盖 / 工具层接线）。
  用法与边界见 `docs/功能详解.md` §素材库。

---

## [0.4.0] - 2026-09-26

### 新增
- **`miliastra_code op=lint-ui`**：把**平台级 UI 门禁**做成 op —— ① 画在哪 = 点哪算（同名自动配；异名靠 `pairs` 点名，工具不猜语义）
  ② 坐标 / 尺寸是 **8 的倍数** ③ 字号只许 **`64/52/28/22`** ④ **`h ≥ max(字号×1.4, 字号+16)`**
  （★ 真机铁律：高度不够 ⇒ 该控件**一个像素都不画**，不裁切 / 不缩小 / 不报错、`visible` 仍为 `true`；**模拟器不模拟这个截断**）。
  回执只报数字与位置（`{file,line,name,expected,actual,delta}` + `passed` / `counts` / `usedConfig` / `passedMeans`）。
  ⚠️ **口径必须显式**：默认扫**全部名字**（平台口径）；要复现工作区那份基线门禁（只覆盖覆盖层名字）传
  `uiConfig.nameFilter:"^(ov|pk|cx|mx)"` + `pairs` —— 两种口径结论不同是**设计如此**。
- **`miliastra_code op=rects`**：跨脚本**矩形对账** —— 矩形清单（`文件:行号` + 名字 + 数值）+ 同名/数值近似配对与**逐字段 `delta`** +
  公式条目与驱动表**原文**；**只报数字不判决**，循环建出来的控件**不代换**（公式与表原文摆出来，工具不猜）。
- **`miliastra_health op=sha`**：**三方 SHA 对照** —— 活文件 / `code/` 本地镜像（`mirror:<绝对路径>`，不传就只两列）/ `.gil` 嵌入快照，
  附结论（`该部署了` / `该存盘了` / `三方一致` / `比不了`）。把 `deploy → 存盘 → 试玩` 这条最容易白跑一轮的流程做成一眼可判。
- **控件类 `op=patch` 的目标支持 `path`**（按**名字 / 名字路径**找控件，`"容器节点/文本框"` 或直接单名）：
  命中唯一才用；**命中多个、或一个都没命中 → 报错并列出候选**（名字路径 + id），**不替你猜**；
  `id` 与 `path` 同时给时 `id` 优先，回执用 `resolvedBy` 披露。（脚本类 op 的 `path` 语义一字未动。）
- **`deploy` / `inspect` 的「已知坑」warning**（`warnings[]` 里元素为对象，**不阻断**）：`sanitize(` 落在混合集合 ⇒ 复合模板实例可能整卡不显示；
  构建期 / 构建循环里实例化**带子控件**的模板 ⇒ 若整卡不显示，试试挪到**渲染第一帧**。带 `file:line` + 一句可执行改法 + 文档链。
- **官方口径的显示元数据**：`locale/zh.json` + `locale/en.json`（`meta.title` / `meta.description`）+ 顶层 `icon`（`icon.svg`），
  面板卡片与「设置 → 插件」清单按它显示（**不需要激活插件**）。
- **`miliastra_playtest op=arm`**（武装后台截图）：一次调用完成「等新局开跑 → 按秒点抓拍 → 落盘」，这一局一结束就停。
- **`miliastra_sim op=bind` 支持一次挂多个脚本**（`scripts:[{path, source|sourceFrom}]`）。

### 变更
- **`op=deploy` 不再按"最近改动"猜目标活文件**（旧行为会把 A 脚本的内容**静默写进** B 活文件）：
  显式 `file` > `source` basename 命中同名活文件 > 目录里只有一个非附属活文件（选中但标 `basenameMismatch:true` + warning）
  > **报错并列出全部候选**；回执新增 `destBasenameMatchesSource`，不匹配时 `warning` 放回执**第一个键**。
  （只读 op 的「GIL 挂载名 > mtime」保持不变 —— 它们不写盘，且已用 `pickedBy` 披露。）
- **`health {brief:true}` 的 `luaFiles` 改为 `[{name, bytes}]`**：`bytes===0` 带 `empty:true` 并点名（"空脚本/未写入"）；
  不在 `.gil` 挂载集合里带 `mounted:false`；挂载表读不到就 `mountKnown:false`（**一个都不标**）。整个回执仍 **< 1 KB**。
- **`op=patch` 全族加字段白名单**：未知字段**报错点名**（不再静默吞）；`add` 接受 `text`（建完紧接一次 `set`，回执 `textApplied`）；
  `set` 传了 `field` → 「是不是想传 `key`？」+ 按控件类型给出可设 key 清单。
- **`op=verify` 的 `expect` 白名单 + `absent:true` 真正生效**：`kind` / 字段不认识 = **参数错**（不再是"第 N 条断言没过"）；
  `absent` 覆盖 `log` / `control` / `var` / `signal` / `tree`，并加语法糖 `kind:"controlAbsent"`。
- **存盘快照口径推广**：`map op=script` 与 `health op=sha` 新增 `belongsTo`（含本地时间 + `epochSec`）/ `isCurrent` / `currentnessNote`；
  **缺证据时 `isCurrent:null` 并明说"判断不了"**，绝不静默给旧数据。（判据是时间戳比对，非内容比对。）
- **`deploy` 进一个 0 字节活文件时回执加 `firstWrite:true`** + `destBeforeBytes:0`（"这是该活文件的首次写入"）。
- **`op=tags` 按 `[...]` 前缀聚合**（此前本工程日志全归"(无标签)"）；**`op=cases`** 的 manual 项名字缺省取 `note` 前 20 字。
- **`op=shot`** 回执列**候选窗口**（标题 / 尺寸 / 是否最小化）并标出选中那个；`suspect` 只报**判得出来**的（进程不符 / target=game 而标题像编辑器 /
  全黑 / 单色 / 屏抓不在前台），并明说"**画面内容本工具不识别**"。
- **schema 体积**：把最长的 description 下沉到 `docs/`（**31.8 KB → 28.0 KB**，32 KB 棘轮内留出约 4 KB 余量）；四条 AI 调用体验不变量不变。
- **文档**：`docs/模拟器与视图.md`（GUI 入口收敛 + Z 序边界）、`docs/功能详解.md`（UI 门禁 / 已知坑 / 存盘快照口径）、`docs/工具参考.md` 重新生成。
- **GUI 只留一处入口**（作者要求，2026-09-26）：撤掉会话区顶部的 `初级功能 / 高级功能 / 模拟器` 三个 tab
  —— 不再注册官方槽位 `conversation.view`。GUI = 侧边栏左下角「千星奇域」→ 浮层面板，
  三页切换留在**面板内部**那条三等分页面条上（`panelTab`）。
  连带删除 `VIEW_TABS` / `registerView` / `exports.__testViewTabs`；
  反向绊线进 `tests/client-render-test.mjs`（只注入 `sidebar.footer.action`、`conversation.view` 注册数为 0）。
  ⚠️ `inline`（全宽视图）分支因此**已无生产入口**，暂由本地渲染测试占用，待下一次清理。

---

## [0.3.0] - 2026-09-25

> 下一个版本的东西写这里。（`0.2.0` 已发 —— 它下面那一整段就是这一版的正文。）

### 新增

- **★ `miliastra_health` 加 `brief:true`**（同事实测：默认 **9454 B** / `all:true` **13518 B**，而它是最常被调的工具）——
  `brief` 只回当前关卡 + **活文件名数组** + 日志目录 + 进程状态，实测 **600 B**（测试断言 < 1 KB）。默认档行为不变。
- **★ `miliastra_probe` 自定义探针**：`op=render template:"custom" lua:"<一段 Lua>"` —— 复用现成的
  render → 部署 → 试玩 → collect → 还原 流水线，且**正文本体与拼装结果都过结构校验**（不合法就拒渲染并给行号），
  回执明写「会临时覆盖活文件、用完 `miliastra_code op=restore` 还原」。它**不新增任何能力**（只能 print + 只读 API）。
- **★ `miliastra_log op=tail` 加 `last:N` / `from:"end"`**（同事要看收尾：他遇到 141 条 Destroy 拒绝却只能从头取）。
  顺序恒为「过滤(run/tag/pattern) → 取尾」，`limit` 语义一字未改；回执带 `window{from,last,limit,take,order}`。
- **★ `op=bind` 支持 `kind:"auto"` + `mount` 回真实层级**：auto 按 image→textbox→container 逐个起会话，
  以「控件数有没有增长」为唯一判据，回执给 `kindTried[]`/`kindWinner`；不给 auto 且控件数没涨时自动附 `kindHint`。
  `mount` 现在带 `parent{name,kind}` / `ancestors[]` / `isClientUI` / `clientUIRoot`，并说明 `assetType` 是**控件模板资源**的名字、不代表挂在服务端。


- **★ `tools/typecheck.mjs`（`npm run typecheck`，已接进 `npm test`）：`tsc --checkJs` 类型层绊线** ——
  只检查**我们自己写的**文件（`index.js` / `lib/` / `tools/`），验收线是 **0 条**；`engine/`（上游 97 条）**如实报数但不阻断**
  （改它等于改上游语义）。`strict:false` 是**故意**的：先只抓「声明与真实用法对不上」，不引入几百条 `implicit any`
  把绊线淹掉（与 lint 同一条原则）。落地时我们自己的报错 **52 → 0**，全是注释/类型收敛，**零行为改动**。
- **★ `tools/lint.mjs` 升级：`react-hooks/rules-of-hooks` + 扫 `engine/` + 死导出检查** ——
  ① `lib/client.js` 有 **86 处** hook 调用，条件式 hook 是真 bug（实测 0 命中，属补保险）；
  ② `engine/**` 上一版**有意排除**过（「上游代码」），实测 51 文件命中 0 ⇒ 纳进来是零成本保险，
  并给唯一的浏览器侧文件（`engine/studio/play/pixi-renderer.js`）配了浏览器全局；
  ③ `findDeadExports`：**零引用的 export 直接判死**（人肉发现过一次，现在改成可回归）。
  ⚠️ `exhaustive-deps` **故意不开**：实测在 client.js 上会报 **11 条**（多数是「故意不带依赖」），会让人开始无视绊线。


- **★ `tools/lint.mjs` + `npm run lint`（语言层绊线，已接进 `npm test`）** —— 作者要求「源码优化一下 需要健壮
  能复用就复用 找找有没有专门这插件的优化语言的思路」。**它上线第一天就抓到一条真 bug**：
  `miliastra_sim` 的 `parameters` 里有**两个 `all`**（一个给 `op=keys`、一个给 `op=cases action=remove`）——
  JS 对象字面量里**后者静默覆盖前者**，于是「`op=keys all:true` 拿全量键名」这条说明**从来没有到达过 AI**。
  测试全绿、功能"能用"，只有 `no-dupe-keys` 看得见它。
  设计取舍：**只开"能过的硬规则"**（20 条：`no-undef` / `no-dupe-keys` / `no-unreachable` / `use-isnan` /
  `valid-typeof` / `no-new-func` …），风格类一律不开 —— 绊线的价值在于**长期是绿的**，一旦允许几十条风格警告就会被无视。
  配置**写在代码里**（不落地 `eslint.config.mjs`）：一份真身，少一个会和代码走散的文件，顺带避开
  "配置文件在 A 目录、被扫文件在 B 目录"时 ESLint 直接拒扫的坑（实测踩过）。`eslint` 是 **devDependency**，不进用户运行时。
- **★ `lib/fsx.mjs`：写盘只有**一个**实现（原子写从 `codefile.mjs` 搬来这里，`codefile` 转发保持兼容）** ——
  源码体检（同一轮）发现当时的状况是**三种写法并存**：`codefile` 走硬化路径（同目录 tmp + **`fsync`** + rename + 失败清理），
  `sim.mjs` 自己**手搓** tmp+rename（没 fsync、失败会把 `.tmp<pid>` 漏在盘上），截图与导出更是**裸 `writeFileSync`**。
  现在 `atomicWriteFile` / `atomicWriteJson` / `prettyJson` 一处实现、多处复用，`sim.mjs` 里
  **裸写 0 处、手搓 rename 0 处**（源码绊线钉住）。

### 新增

- **★ `op=controls {runtime:true, geom:true}`：一次给出"屏幕上有哪些控件、在哪、上面什么字"** ——
  补齐 `op=hud` 只解决"字"的那一半：AI 要**点**东西时还需要坐标。每行加 `x/y`（**世界坐标**，左下原点，可直接喂
  `play action=pointer/click`）+ `w/h` + `text`；底层是**一次** `get{inspect:true, view:true}`（引擎里这两段是独立填充的，
  能同时给），但**摊平后只回紧凑行**（实测回执 1.4 KB，而那次底层响应是 **75 KB**）。
  ⚠️ 顺手挖出一个**真事实**：**场景 ≠ 控件树** —— `scene` 只含**会被渲染**的控件（工厂默认工程 `tree` 11 行 / `scene` 5 个节点；
  真实关卡 31/31）。所以回执如实报 `geomCovered` / `geomMissing`，并在 `geomNote` 里直说
  「没坐标**不是不存在**，只是现在不在画面上」——否则 AI 会把"不可见"误判成"没建出来"。
- **★ `op=hud`：只回"画面上的字"**（`textbox.text` + **世界坐标**）—— "边玩边判断"的最短路径。
  实测（真机《冰镜·火烛》在跑的那一局）：`get{view:true}` 的快照 **30 177 B / 31 个节点**，
  而 `hudTexts()` 只挑出 **2 行**（`第1关 教学 (1/3) 分数 0 …` / `方向键 / A D 移动…`）⇒ 回执 ≈300 B，**省掉约 100×**。
  坐标沿 `parent` 链累加（局部 `ty=374` + 容器 `450` = 世界 `824`），AI 拿着就能点。没有会话时**明确报错**（不静默回空）。
- **★ `op=keys` 加 `all:true`（全量 164 个键名）与 `press`（可照抄的请求形状）** —— 针对作者的原话
  **「你看找个按键这么久 这就是优化的空间」**：
  ① `all:true` 直接从引擎的 `buildEnumTree()` 拿**正源**（不抄表，抄一份就会过期）；
  ② `press` 给出 `key` / `pointer` / `click` / `step` / `hud` 的**请求体示例** ——
  以前 AI 为了知道 `play` 的 `key` 怎么发，要翻 `session.js` → `browser-session.js` → `controller.js` → `worker.js`
  **4 个源文件**；现在一次调用就说全（`smoke` 加绊线钉住这两件事）。
- **★ `op=keys` 改成"两路并扫"键名**（2026-09-24 实测暴露的漏检，见「问题修复」）：
  `KeyEventType.X` / `KeyEventType["X"]` **之外**，再扫**裸字符串键名** —— 也就是真脚本的写法
  `try(bindHold("KeyboardMoveRightKeyDown", "KeyboardMoveRightKeyUp", …))`。
  回执新增 `found[{name,via}]`（`via` = `enum-member` / `enum-index` / `string-literal`，**AI 据此判断可不可信**）、
  `byVia` 计数、`releaseNote`（提醒 `…Down` 要配对 `…Up`）。扫不出时不再只说"没扫到"，而是**告诉你去试发哪个候选键**。
  配套 `stripLuaComments()`：**认字符串**的注释剥离器（字符串里出现 `--` 不会被当注释截断），
  所以「注释掉的绑定」不会变成假阳性。真身验证：`双相.lua` 的扫描结果 **0 → 25 个键名**，无假阳性。
- **★ schema 补上「AI 自己"玩"」的口径**（作者问「你能在模拟器中游玩吗」，于是**当场量了一遍**）：
  发一次输入 ≈ **5ms**（纯注入、不回快照）、读一次 `get{view:true}` ≈ **200ms**、出 PNG 秒级 ⇒ **发得快、看得慢**；
  所以「回合制闭环」（`pause`+`step`）与「一次调用里跑实时循环」都可行，**"逐帧看画面再反应"做不到**（带宽上限）。
  同时写进 schema 的还有三条 AI 猜不出来的事实：① `get{view:true}` 的 **`textbox` 节点带 `text`**
  —— 不用截屏就能读 HUD/分数/关卡，可当闭环条件；② ⚠️ **`frame` 不是秒表**（实测静置 30fps、连续注入期间 **41.7/s**，
  注入会顺带推帧，计时用 `time`）；③ ⚠️ **按了 `…Down` 必须配对 `…Up`**，否则等于一直按住
  （实测把小人一路推到掉出边界重生）。`smoke` 里加了一条绊线钉住这几段。
- **★ `miliastra_code op=read` 支持 `source`（绝对路径，只读）+ 面板加了「任意本地 .lua」入口** ——
  作者的原话是「现在不能直接看 我希望编辑器面板增加一个输入 lua 的绝对路径读取的功能」；
  追问后他选的是**两个都要**：先看（元信息 + 正文预览 + 候选交接值），再一键搭进模拟器去跑。
  ① **工具侧**：`op=read` 给了 `source` 就改读**那个文件**（不再局限沙箱活文件），回
  `{file, bytes, lines, sha256_12, mtime, bom, head[]}` + 预览正文（`head` 语义照旧：默认 80 行，0=全文）；
  **只读** —— 这条路径不写任何文件，`deploy` / `restore` / `fixbom` 的 `source` 语义**一字未动**；
  错误一律**明确拒绝并给下一步**（相对路径 / 不存在 / 是目录 / 二进制含 NUL / 非 UTF-8 / > 8 MB），
  绝不把乱码回给 AI。⚠️ **不传 `source` 时行为完全照旧**（还是读沙箱活文件）。
  ② **面板侧**（模拟器页左列**最上面那张独立小卡**「读本地 .lua（绝对路径）」；
  2026-09-25 从「④ 工程与控件树 / 工程适配」里提上来的，作者要求「放做在左边最顶上」）：
  一个粘绝对路径的输入框 + 「读取」（`miliastra_code op=read head:60` + `miliastra_sim op=handover`）
  + 「用它搭进模拟器」（**同一个路径**走 `op=bind`，沿用勾选的模板与容器索引；不自动重搭）；
  并写清两条路的区别：上面那种是**沙箱活文件**（正在开发的那张图），这条是**任意本地 .lua**（只读看一眼，也能搭）。
  测试：`tests/read-source-test.mjs`（15 条：读对了 / 不传 source 照旧 / 6 类错误各给可行动的下一步 /
  **只读性三条回归** —— 读完文件逐字节未变且不多出 `_backup`、`readLuaAt` 里没有任何写 API、
  `op=deploy` 的 `source` 仍是「要投进去的本地文件」）；`tests/client-render-test.mjs`（输入框与两个按钮、
  两个请求体的 `source` 必须是**粘贴的那个路径**、新文案无 Markdown 记号）。

### 变更

- **★ 模拟器面板左列重排：绝对路径入口提到「左边最顶上」，并删掉「画面 / 截取画面」GUI**（2026-09-25 作者原话：
  「模拟器面板绝对路径的入口放做在左边最顶上；画面和截取画面功能很鸡肋不要了 GUI 部分直接删除」）。
  ① 「读本地 .lua（绝对路径）」从 ④ 工程适配卡里**提成左列第 1 张独立小卡**（下一张卡就是 ① 试玩日志），
  输入框 / 读取 / 用它搭进模拟器 / 元信息 / 正文预览 / 候选交接值**一个字没改**，只删掉 ④ 里那一份（不留两份）；
  ② 原「① 画面与日志」→ **① 试玩日志**：画面子卡（未取图占位 / PNG 预览）、按钮（编辑器画面 / 刷新画面 / 连帧）、
  空态提示「还没有画面」，以及**面板自动取图**（开始试玩 / 单步 / 按键后自动 `op=shot`）**整块删除** ——
  面板从此不再调 `op=shot`，也就不会再往 `shots/` 写图；画面去右列那个试玩页看（`GET /miliastra/play`，可「新窗口 ↗」放大）；
  ③ 「② 试玩操作」去掉连帧 / 刷新画面后**不再折叠**（里面的传输控制 开始/单步/停止 必须直接看得见），
  按键 / 导出 GIA / 浏览器试玩 / 重置工程 / 画布固定行**全部保留**；
  ④ 侧边栏那张截图**清理**卡（存放目录 / 张数 / 占用 / 不会自动删 / 清理…）**故意保留** ——
  它是「磁盘是用户的、清理要人显式点」这条纪律的人侧入口，与模拟器面板的画面是两回事。
  ⚠️ **工具侧一字未动**：`miliastra_shot`（capture/burst/list/clean/targets）与 `miliastra_sim` 的 `op=shot` / `op=frames` 全部照旧（AI 侧不受影响）。
  回归：`tests/client-render-test.mjs` 按新结构改写（模拟器视图动作清单 / 连帧那条 / 面板内第三页 / 1:2 布局的左列顺序与清单 /
  入口那条的切片边界与位置），并**新增反向绊线**「模拟器面板里不再出现 编辑器画面 / 刷新画面 / 连帧 / 还没有画面」。

### 修复

<!-- 2026-09-25 · 来自另一位 AI 的实测反馈（两批，每条附回执）；已逐条核过代码位置后修复 -->
- **★★ 默认活文件解析会稳定选中『陈旧』文件（P0）**：旧实现是关键字启发式 `/双相|测试|main|levelScript/` **命中即返回**，
  于是目录里只要有个 `测试.lua` 就必赢、mtime 兜底永远轮不到 —— 实测后果三条：`inspect` 体检了最旧的文件、
  `map op=script` 报「一致」是**假安心**、`deploy` 的 reconcile 反向告警「先别急着试玩」。
  现在：显式 `file` > **地图存档里嵌的脚本名** > mtime；新增 `pickedBy`(explicit/gil/mtime) + `candidates`；
  `chooseLua` 与 `codefile.mjs` 的 `pickLuaFile` **共用唯一实现** `rankLuaFiles`（原来两套规则会给出不同文件）。
- **★★ `reconcile` 跨文件比对（P0）**：拿 GIL 里 A 脚本的快照比 B 脚本的活文件 ⇒ 假告警。现在先比脚本名，
  不同名则 `comparable:false` + `match:null` + `skipped`，那句「先别急着试玩」整条回执里再没有。
- **★ 部署指纹单份 ⇒ 多活文件必然串（P1）**：新增按文件名索引的指纹（旧那份照写保持兼容）；
  读到别人的指纹时 `foreign:true`，`changedSinceDeploy` 如实给 `null` 而不是假告警。
- **★ `lib/fsx.mjs` 失败清理改 `unlinkSync`（P1）**：同事在 DSH 打包版 **Node v24.9.0** 上最小复现出
  `fs.rmSync(p,{force:true})` **不报错、正常返回、文件仍在**（`unlinkSync` 正常）。本机 Node v24.18 行为断言看不出来，
  所以另加**结构断言**：`lib/fsx.mjs` 里不许再出现 `rmSync(`。
- **★ 测试可移植性三件（P1）**：`lualint-test` 隐式依赖工作区的 `../../code` ⇒ 目录不存在时**跳过并如实标注**；
  `op=levels` 只认 `local LEVELS = {` ⇒ 支持 `X.LEVELS`/多级前缀/裸赋值，抽不到时**列候选**并指向新暴露的 `nameHint`；
  `npm test` 原来 `&&` 串联（第一个套件红带走后面 8 套）⇒ 新增 `tools/test-all.mjs`，顺序跑完全部套件再汇总。
- **★ 消灭一处『看起来是事实』的误导文案**：`map op=clientui` 的 hint 曾写「**本关** 1073741867/68 可创建」，
  而那两个号**来自别的关卡**。现在「本关读到的」只报本关 `.gil` 里的号，真机佐证一律**带来源关卡**；面板侧同一句文案也已同步。
- **★ `log op=runs` 的 `errorKinds` 现在说清命中了哪些词**：词表从一条大正则拆成「一个词一条」再推导合并正则（计数与命中词**同源**），
  `errorSample[]` 每项带 `matched: []`，另加 `errorMatched` / `faultMatched`。⚠️ **回执形状变更**：`errorSample` 由字符串数组变成对象数组。
- **★ `playtest op=status` 给 `closed` 枚举加人话**（`seen` / `implicit`，后者 = 没看到显式结束标记，多半被切场景/关窗口）；
  未知取值如实回 `null`。判据一字未改。
- **★ `code op=deploy` 的 `nextStep` 分清『挂过没有』**：新增 `mount{mounted,known}`（名字比较复用 `sameScriptName`）——
  未挂载时提示「先挂到客户端控件容器的容器节点上」，判断不了时明说判断不了。


- **★★ 坐标参数不再静默兜 0（源码审计抓到）**：`lib/sim.mjs` 的步骤规范化原来是 `Number(v) || 0`，
  于是 AI 写错坐标（`click:{x:"abc"}`、`click:[800]`、`pointer:{x:null}`）会**静默变成 (0,0)** ——
  一次点在左下角的点击「看起来成功」却什么都没点到；引擎那层 `isFinite` 校验救不了（Host 在更早一步就兜成 0 了）。
  现在按**类型**过筛（`null` / `''` / `[]` / `false` 也拦住 —— `Number(null)` 也是 0）并当场报错、附上收到的原值。
  ⚠️ 修的过程中我自己删掉了兜底那句 `throw`，被 `sim-test` 的「drag 参数写错：明确报错」**当场抓红** —— 已修回并留注释。
- **★ 活文件路径类错误补上「下一步」（6 处）**：原来只说「没找到活文件路径。」—— 现在说明
  「路径随账号/换图变化，先用 `miliastra_health` 定位（或直接给 source）」。
- **★ 删掉两个零引用导出**（`findLevelAll`、一个恒返回 `null` 的 `findClientProcess`），并把它做成 lint 的常规检查。


- **★★ `miliastra_sim` 的 schema 里有**两个 `all`** —— 后一个把前一个的说明**整个吃掉**（`no-dupe-keys` 抓到的真 bug）**：
  AI 读到的 schema 里 `all` 只被描述成「`op=cases action=remove` 删整组」，而「`op=keys all:true` 拿全量键名」
  那条**从未到达过 AI**（同一个键在对象字面量里出现两次，后者胜）。现在合成**一个** `all`，两种用法都写在同一个说明里，
  并在源码里留注释说明**为什么不能写成两个键**。
- **★ `lib/client.js` 的 `var s29` 重复声明（`no-redeclare` 抓到的维护地雷）**：第 692 行它是 `uiInfo` 的 state、
  第 707 行**又声明一次**当 `panelTab` 用 —— `var` 复用同一个绑定，当前靠"先读后写"侥幸正确，
  但只要有人调换两段顺序，`uiInfo` 就会**静默变成** panelTab。改名成 `panelTabState` 并留注释。
- **★ `sim.mjs` 的写盘三处各写各的**（见「新增」的 `lib/fsx.mjs`）：手搓 tmp+rename 没有 `fsync`、失败会漏 `.tmp<pid>`；
  截图与导出是裸写（被杀进程可能留下半截 PNG）。现在统一走 `atomicWriteFile`。
- **★★ `op=keys` 把"为什么没扫到"说反了（真机实测）**：游戏日志写着
  `[yuan-code] 收到首个按键事件: KeyboardMoveRightKeyDown（来源=KeyEventType）`，而工具回「脚本源码里没出现 `KeyEventType`」
  —— 其实**出现了**（`local candidates = { "KeyEventType", … }`），只是按键名是裸字符串、旧的单路正则匹配不到。
  后果不是"少一个功能"，而是 **AI 只能去猜键名**，而键名猜错是**静默失败**。
  现在三种情况分开说（扫到了 / 有 `KeyEventType` 但不是我认的写法 / 真的没有），并且都指明下一步怎么**试**。

### 文档

- 新增 **[`docs/源码质量与想法池.md`](docs/源码质量与想法池.md)**（作者要求：「代码优化 和 这些都汇总下写个 md 文档」）：
  ① **源码优化记录** —— 体检数据（重复行 / 裸 `JSON.parse` / 死导出 / 写盘三种实现）、三处真问题的取证、
  落地了什么、**故意没做的三处**（防下一个人重新"顺手合并"）、**回退 → 绊线对照表**（哪种退化由哪条断言盯住）；
  ② **想法池总表** —— 三档坐标系 + 池 A（AI 体验 6 条）/ 池 B（工程工单 2 条）/ 池 C（欠证据 3 条）/ 池 D（新候选 3 条）
  + 建议顺序（P1 = 实时循环 + mark/delta）。
  分工写清楚：**README 的「想法池」仍是对外、可被反驳的真身**，本文是汇总视图；**欠证据不进想法池**（那不是"要做功能"）。
- 顺手修掉融合边界清单里两处**会误导人的过期内容**：`W3`/`W4` 各重复两行（一份 ✅ 一份"待确认"）；
  `QT1–QT5` 里 4 条其实已被后续工作落定，却还挂着"未拍板前不进实现" —— 现在逐条标了落定结果，只剩 **QT4 后半**（对方 skill / agent 预设要不要搬）还开着。
- `docs/模拟器与视图.md` 新增 **§4.14「AI 自己玩到底能到什么程度」**：把上面那些**实测数字**（4.6ms / 216ms / 30fps / 41.7fps /
  0.17% 像素差 / Δx=138.7）、三种玩法与代价、两条只有实测才知道的坑一次性摆齐（含"逐帧看画面做不到"这条负结论）。
- README 想法池：把**已经落地的**两条打勾（浏览器试玩页内嵌进面板、`op=keys` 漏检修复），
  并新增一条待办：**`op=play` 的"实时循环"封装**（真实时钟 + 条件分支 + 回 trace）—— 今天是 AI 自己手写 pwsh 循环才跑起来的，
  这个动作值得变成工具的一个 op。

### 已验证
- `npm test` 退出码 **0** = **15 个套件 / 702 条断言**（新增 `tests/locate-test.mjs` 15 · `tests/leveldata-hint-test.mjs` 11 ·
  `tests/feedback2-test.mjs` 32；`tools/test-all.mjs` 顺序跑完全部套件再汇总，不再 `&&` 串联）。
- **跨 Node 健壮性**：`Node v24.9.0` 的 `fs.rmSync` 静默失败已按实测改掉（`unlinkSync` + 注释 + 结构断言）。
- **真机只读复核**：`op=inspect` 报 `pickedBy:"gil" / selectedFile:"双相.lua"`；`health brief` 600 B；
  `probe render custom` 走通且**不落盘**；活文件目录与 `_backup` 全程零改动。
- ⚠️ **诚实记录**：`playtest-test` 里那条「真机日志找得到开跑」在本机**因日志换代**（261 KB 里 `SetCurLevelData` 0 次）
  改成「一条信号都没有 → 跳过并打印原因」；只要日志里有任何开跑行，断言照旧执行。

## [0.2.0] — 2026-09-24

> `0.1.0` 之后**融合分支继续推进**的产出。作者原话：「融合分支继续推进，写好每次的提交记录」。
> 目标（作者原话）：「**我目标是让 ai 更好使用这个插件**」—— 所以 W2 两条腿走路：人侧的浏览器试玩页，**和它给 AI 的那条路**。

### 新增

- **★ `op=bind`：把真机那份工程搬进模拟器（一条命令）** —— 原来"让双相在模拟器里跑起来"是**手写探针**
  （建模板 → 存盘 → 手改 JSON 里的 guid → 读回 → 挂脚本 → 起会话）；现在给
  `source`（活文件绝对路径）+ `templates:[{guid,kind,name?}]`（**创作者交接的控件模板索引**）+ `containerId` 就够了。
  两个关键点各有一条断言钉住：① **模板 guid 必须用交接值**（引擎另编一个号 ⇒ `InstantiateClientUIControl` **静默什么都不建**）
  —— 为此引擎 `addTemplate` 支持显式 `guid`（带合法性与重号校验）；② **起完会话要先走 `settleSec`（默认 0.5s）再读控件数**
  —— 脚本的构建发生在进入 RUNNING 之后，停在 frame 0 只读到 1（容器自己），会被误读成"什么都没建"（实测踩到）。
  默认 `fresh:true`（重置两个资产、清掉已有脚本与出厂橱窗控件 ⇒ 同一份参数 → 同一份工程）、`run:true`（顺手起一次会话，
  回 `run.logs` + `run.controlCount`）。真机实测（双相，370ms）：`就绪（3 关，控件 31…）`、`controlCount=32`。
- **★ `op=cases`：验收单（人/AI 读同一份）** —— 存进模拟器工作区的 `cases.json`：
  `add`（`set` + `cases[]`；**同名覆盖**；加 `fromHistory:true` 就把**刚跑过那一局**变成用例）/
  `list`（只给计数与 kind，省 token）/ `show` / `run`（自动项确定性重放，**人工项不代跑只列出**）/ `remove`（默认 dryRun，要 `confirm:true`）。
  **人工项** `{manual:true, note:"人要看什么、看到什么算过"}`：工具**不代跑也不代判**，`run` 的 `manual[]` 等人打勾，
  回执直说「自动项已跑完 —— 但**整组没算过**：还有 N 条人工项」。`op=verify caseSet=<名字>` 是同一份入口。
- **浏览器试玩页（W2）：`GET /miliastra/play`** —— PixiJS（WebGL）真能玩的一页，工具栏（开始/暂停/单步/重开/结束 + 设备·人数·视角）+ 侧栏（状态 / 服务端变量 / 日志 / **这一局的操作时间线**）。
  复用引擎自带的 `PixiPlayRenderer` + `createPlaySession`（与上游 DSH 插件/Web **同一条浏览器循环**），数据走 `POST /miliastra/engine` ——
  **与面板、与 AI 工具共用同一个会话与同一份工程**。面板「模拟器」页加了入口。
- **esbuild 构建脚本 `tools/build-sim-play.mjs`**：打成单个 ESM（552 KB，含 pixi）**入库**在 `lib/sim-play/dist/play-renderer.js`（装插件的人不做构建也能用）；
  `--check` 现打一份比字节，`prepublishOnly` 也跑一遍，防止"改了源码忘重打包 → 页面跑旧引擎且不报错"。
- **★ `op=verify fromHistory:true`：人玩的那一局直接变成回归用例** —— 人在试玩页里报「刚才这么点就错了」时，
  AI 不用手抄 events，直接拿那一局的真实操作序列确定性重放 + 判定。这是浏览器页**对 AI**（而不只是对人）的价值所在；
  因此页面**关掉时刻意不停局**（否则 history 归零，这条路就断了）。

### 变更

- **试玩页页脚改成"自我诊断"**（作者说「下面这块删除试试」之后的处理）：原来那行装饰文案
  （"画面由 PixiJS（WebGL）渲染 · 逻辑在与面板/AI 同一个 Worker 里跑"）**删掉**，换成两组**数字**：
  · `窗 <iframe高> / main <页面中间那行高> / 台 <舞台宽×高>` —— **一眼分清是哪一层被压**：
  窗小 = 面板没给够高度；窗正常但 main 小 = 页面行没拉满；main 正常但台小 = 舞台没填满 main。
  · `v<大小>-<mtime>` **页面版本戳**（Host 在 `/miliastra/play` 响应里盖）：跟磁盘一比就知道
  "面板里那份是新是旧"——作者已经因为"改了没生效"来回猜过两轮，这条把它变成可读的数字。
  ⚠️ 戳由 **Host 侧**注入 ⇒ 没重启 `dsh web` 时页脚会显示字面量 `v__PLAY_STAMP__`（这本身也是信号）。
  页脚只有 26px 高 —— 它**不可能**是画布被压小的原因（`可放 542×90` 说明是舞台那一层小）。
- **画布 / 人数 / 视角**改成**固定 PC 16:9 / 1 人 / P1**，面板与试玩页上的三个下拉**整块删掉**
  （作者原话：「我希望固定这样 把切换的功能去掉吧」）。两条理由：① 引擎的 `device` 会**重建整个运行时** ——
  画布一变，16:9 的舞台尺寸与 AI 记下的操作坐标都要重算；② 试玩页那份**手写**的设备清单里有引擎**不存在**的预设
  （`pc-4-3` / `phone-16-9` / `phone-4-3`；引擎只有 `pc-16-9`/`pc-21-9`/`mobile-16-9`/`mobile-19.5-9`/`mobile-4-3`）
  ⇒ 选中就报 `unknown canvas preset`（作者截图里那条红条）。**能力没丢**：AI 仍可用
  `miliastra_sim {"op":"play","action":"device"|"view"}` 与 `playerCount` 改；页面上只留一行**只读**回显
  （`PC 16:9 · 1 人 · P1`）。两条**反向断言**：下拉不许回来、页面上不许再出现引擎里不存在的画布预设。
- **模拟器面板按作者要求改成 1:2**：右 2 份 = **试玩页 iframe**（`src=/miliastra/play`，PixiJS WebGL，人真能在里面玩），
  左 1 份 = ① 画面与日志（缩略，横排）、② 试玩操作 + 操作时间线、③ 验收单、④ 工程与控件树 / 工程适配（折叠）。
  **AI 驱动的是同一个会话**（`op=play` 的 key/pointer/step）—— 页面每 33ms 轮询 `sceneRev` 增量，所以 AI 一动画面就跟着变；
  人玩完那一局，AI 用 `verify fromHistory:true` 直接变成回归用例。窄屏（浮层 880px）`@media(max-width:1000px)` 塌成一列。
  顺带确认 iframe 能嵌的前提：**`/miliastra/play` 不要鉴权**（`curl` 实测 `200`；`/` 才是 401），也没有 X-Frame-Options/CSP 拦。
- **模拟器面板补上两条新能力的入口**（此前按「新能力优先做 AI 侧」只落在工具里）：**工程适配**（扫描活文件 → 候选交接值 →
  勾选 + 选 kind → 一键搭进模拟器，回执显示"模板 / 脚本 / 运行时控件数 / 脚本 print"）与**验收单**（读清单 / 跑这一组 /
  从刚跑的那一局存用例 / 存人工项 / 逐条 ✅❌ 与"人工项待打勾"）。

### 新增

- **★ `op=handover`：交接值体检（别靠人抄）** —— 列出这台机器上的**活文件**（★ 标出"当前正在开发的那张图"），
  再读那份 Lua，把源码里 `local NAME = <9 位以上整数>` 的**候选交接值**摆出来（含变量名）+ 一份 `suggestedTemplates` + `containerId`。
  为什么值得单开一步：交接值**抄错一位** → 脚本静默什么都不建（不报错、日志无痕）；抽真值比让人抄一遍可靠。
  `kindHint` 只看变量名（IMAGE→image / TEXT→textbox / CONTAINER→role=container），**控件类型必须创作者确认**。
  双相实测：三个值全中（1073741866/67/68），`local LIMIT = 60` 这类小整数不会被误收。

### 修复

- **★★★ "画面咋就这么点大"的第三次根因：`#fatal` 是 `display:none` ⇒ 它不算 grid item，自动排行把 main/footer 整体上提一格**
  （作者原话：「重载会大一点 然后马上又被挤扁了」）。下面那条修的是 `main` **内部**的行，**没修 `main` 自己被放在哪一行**。
  `display:none` 的元素不生成盒子、也就不是 grid item ⇒ 自动排行把剩下三个依次塞进第 1/2/3 行：
  `main` 落到第 2 行（`auto` ⇒ 行高 = 舞台高 = **又回到"自己量自己"**）、`footer` 落到第 3 行（`minmax(0,1fr)` ⇒ 吃掉全部剩余高度），
  真正那 26px 的第 4 行没人占。**footer 实测 217px**（`窗 374 / 主 90 / 脚 217`）—— 作者截图里 footer 文字下面那一大片"空白"
  就是它，跟画面一点关系都没有。修法：`header{grid-row:1}` / `#fatal{grid-row:2}` / `main{grid-row:3}` / `footer{grid-row:4}`
  （隐藏的那行塌成 0），并给 `#fatal` 加 `max-height:40%;overflow:auto`（报错再长也不许把舞台挤没）。
  **footer 的诊断数字从 3 个加到 5 个**：`窗 · 头 · 主 · 脚 · 台` —— 上一版正好漏掉当时真正出问题的 `脚`，于是白跑一轮。
  离线复核（不用等人截图）：`chrome --headless=new --window-size=570,374 --screenshot=… http://127.0.0.1:3080/miliastra/play`，
  按列扫像素带 ⇒ 修后 `头 39 · 主 308 · 脚 25 · 台 570×308`、画布 **512×288**（修前 160×90）。
  ⚠️ 这个坑**读代码发现不了**（CSS 逐行看完全正确）—— 是**对截图按列打印色带**量出 footer 底色从 y348 铺到 y564 才定的案。
- **★★ 舞台高度被画布"自己量自己"锁成一条缝（作者两次实测：「右侧这么大的画面也太小了吧」「直接打开是这样」）**：
  footer 上一直写着 `可放 542×90` / `542×168` —— **宽度一直正常、高度一直不对**。
  根因在 CSS：`main` 只写了 `grid-template-columns`，**行是 auto** ⇒ 行高 = 内容高 = **画布的高度**，
  而画布高度又是 `fit()` 从舞台盒子量出来算的 ⇒ 某次算小之后一路变小、最后锁死（列宽由容器分配，所以列没事）。
  修法一行：`main{grid-template-rows:minmax(0,1fr)}` + `#stageWrap{height:100%;overflow:hidden}` ——
  **舞台高度由容器决定，永远不看画布**。`sim-play-test` 里钉住这两条（别让它再变成"自己量自己"）。
- **★★ 重启 `dsh web` 后模拟器工程回到「出厂默认」（作者实测：「咋画面在左下角 右侧啥都没」的第二个成因）**。
  Host 是启动快照 ⇒ 内存里的工程被重置，试玩页里只剩默认的「文本 / 预设按钮 / 五角星」，**看起来就像什么都没画**。
  现在：① 引擎 `get()` 上报 `factoryDefault`，`op=state` 直说；② **成功 `op=bind` 会记一份配方**
  （`simulator/last-bind.json`：源文件 + 交接值 + 容器索引 + 画布），重启后 `{"op":"bind","last":true}` 一键重搭；
  ③ 面板顶部出现粉色提示条 + **「一键重搭上次：《双相》」**按钮（**不自动重搭** —— 不擅自在人没要求时改工程）。
- **★ 面板左列「按钮/文字叠在一起」（作者截图）**：左列是**固定高度的 flex 列**，子项默认 `flex-shrink:1` + `min-height:0` ——
  内容一超就被**压扁并互相重叠**。现在子项一律 `flex:0 0 auto` + `min-height:auto`，滚动交给容器；
  同时左列加 **300px 宽度下限**（880px 浮层的 1/3 只有 285px，塞不下那些按钮），面板自己的按钮精简到 4 个核心动作，
  其余（连帧 / 刷新画面 / 按键 / 设备·人数·视角 / 导出 GIA / 重置工程）收进**默认折叠的「② 试玩操作（高级）」**。
- **★ 试玩页在面板里（~570px 宽）工具栏折成两三行、把舞台挤没**：加 `@media (max-width:820px)` ——
  长标题换短标题、按钮与间距收一档，工具栏努力保持一行。
- **★★ 试玩页的画布被缩成一个小方块（作者实测：「右侧这么大的画面也太小了吧」→「能不能默认固定下 16:9」）**：
  三个原因叠在一起，现在逐个修掉：
  ① **面板的窄屏断点是 1000px，而浮层面板正好 880px 宽** ⇒ 浮层里永远命中媒体查询、1:2 塌成一列，
  右列高度改由内容决定（iframe 的 `min-height`），舞台只剩 **168px** 高 —— footer 上白纸黑字写着
  `画布 263×148（可放 542×168）`。断点下移到 **760px**：880px 的浮层保持 1:2（300px 左列 + ~545px 试玩页）；
  真到窄屏塌成一列时，给 iframe `min-height:min(420px,55vh)`。
  ② **16:9 现在由 CSS 声明**：`#stage{aspect-ratio:1600/900}` + `#cv{width:100%;height:100%}`，
  JS 只挑宽度（`min(可用宽, 可用高×16/9)`）—— 布局再怎么抖，比例也不会算歪（引擎那边本来就是
  "CSS owns the scaled stage size, Pixi only owns the high-DPI backing buffer"）。
  ③ **尺寸不稳**：盒子 <200×150 先不写、下一帧再量；由每一帧快照**节流重算**（≥350ms）—— 0.35 秒内追平。
  并把数字印在 footer：`画布 523×294（16:9 固定；可放 543×332）` —— 这类问题以后肉眼可核。
- **★★ `/miliastra/engine` 把请求体里的 `op/action` 吃掉了（真事故：面板试玩按钮与浏览器试玩页双双静默失效）**。
  路由早先写成「有 `body.args` 就用 `body.args`」，而 **`op=play` 的参数本来就装在 `args` 里**
  （`{op:'play', action:'click', args:{x,y}}`）⇒ `op/action` 一起被丢，`simOp` 退化成默认的 **`op=state`**，
  而且**全程不报错**。后果：① 浏览器试玩页的 `attach()`/轮询拿到的都是"工程快照"而不是"试玩快照"，
  永远 attach 不上、也永远不 start ⇒ **iframe 一片黑、Frame 停在 0**（作者 2026-09-24 实测截图）；
  ② 面板的「开始试玩 / 按键 / 单步 / 连帧」全部白发（返回的是 state 快照）。
  现在请求体**原样**交给 `simOp`（抽成 `engineArgsFromBody`，`smoke` 里两条断言钉住：
  转换原样 + 同一形状喂给 simOp 必须进 `play` 分支；另有一条"别再把 `body.args` 拆出来"的源码绊线）。
  取证：修复前 `POST {"op":"play","action":"history"}` 返回的是 **state 快照**（有 `treeCount`、没有 `action` 字段）。
- **试玩页的报错以前看不见（同一个黑屏体验的另一半）**：`#fatal` 原来放在 `#side` 里，而 `#side` 默认 `display:none`
  （要点「侧栏」才出现）—— 于是「WebGL 起不来 / 会话没跑起来」一个字都看不到。现在报错条常驻在 header 下面（`grid-template-rows` 多一行 `auto`），
  WebGL 初始化包了 `try/catch`（拿不到就明说并建议用「新窗口」开），舞台尺寸为 0 时**下一帧重试**（不再硬套 120×80 的小方块），
  另挂 `ResizeObserver` 跟着面板缩放。三条都有测试。
- **面板：画面被挤到左下角 + 右侧试玩页被滚出视野**（作者实测反馈）：
  左列顺序改成 **① 画面与日志 → ② 试玩操作 → ③ 验收单 → ④ 工程与控件树**（最常看的在最上面），
  ④ 那块（含工程适配与控件树）默认**折叠**成 `<details>`；滚动模型改成**左列自己滚、右列不动**
  （`-simbody` 不再整页滚动 —— 否则左列一长，滚下去 iframe 就出了视野，看起来就是"右侧啥都没"）。

- **`imageType` 放开为可写（引擎，带真机证据）**：上游保守拒绝（`observed-contract.md` §14「只有部分图片支持设置」+ 一条测试钉住），
  但真机关卡 `1073741833`《冰镜·火烛》的脚本对**即时实例化**的图片写 `imageType = Enum.ImageType.Stretch` 并**真机跑通**
  （`.gia`：`就绪（3 关，控件 31…）`，无 `no such field`）⇒ 继续拒绝会变成**假阴性**（模拟器跑不起来、真机没问题）。
  改动：`scene.js` 的 `KIND_RW.image` 加 `imageType`，`runtime.test.mjs` 断言**翻转**（`img_type_set true` + 回读 `Stretch`），两处引擎文档同步。
  渲染口径：**按尺寸拉伸**（与脚本用的 `Stretch` 一致），Slice/Tile 未实现。
- **`slimPlay` 无条件丢掉 `scene`/`paint`** —— 与「`summaryOnly` 只去体积、`false` 时给全量」的约定矛盾，直接后果是浏览器页永远拿不到画面（黑屏）。
  现在 `summaryOnly:false` 原样给 `scene`，默认仍是只回计数。
- **`sanitizeLabel` 先剥尾点、后截断** → 截断处又留下一个尾点（实测文件名成了 `…-frames-t0.`）。
  改成截断后再剥一次；`op=frames` 的帧标签同时缩短为 `t<秒>`（标签上限 24 字符，写长了会把 `t0.5` 截掉）。

### 新增

- **`op=frames`：按时间出多帧 + 帧间像素差 + 字段级变化**（回答「动画到底动没动、动的是哪一块」）。
  `{"op":"frames","frames":[0,0.5,1]}` → 每个时间点一张 PNG + `changedPixels` / `changedRatio` / `maxDelta` / 变化区域 `bbox`
  + `changedControls`（**哪个控件的哪个字段**变了）。实测（容器节点 1 秒线性 Tween 右移 100）：
  `matrix.tx 800 → 850 → 900` 与像素 `bbox.x 452 → 502`（+50/帧）**互相印证**；静态工程 `identical:true` / 0 像素（负对照）；
  同一调用跑两遍数字一样（**可复现**）。
  两条实现决定：内部用「**暂停 + 单步**」推进时间（否则 worker 自己的 30FPS 时钟会让帧对不上时间点）；
  每帧回全量场景由 Host 自己比字段（**不依赖增量协议**）。上限显式：帧 ≤12、步数 ≤1200，超了报错并给出「换更大的 dt」。
  ⚠️ 踩坑记录：场景里**位置在 `matrix.tx/ty` 上**（不是 `anchoredPositionX`）—— 不摊开 `matrix` 这一层，`changedControls` 会永远是空的。
- **`op=verify` 支持拖拽（`steps[{drag:{from,to,steps?,gap?}}]`）**：自动展开成 `down → move… → up`（默认 6 段 / 每段 0.05 秒）。
  以前只能发 `click`，**拖拽/滑动这类交互根本测不了**；引擎的 `CursorBeginDrag` / `CursorDrag` / `CursorEndDrag` 都在 `move` 里发，
  所以必须展开成一串事件。另配 `pointer:{type,x,y}` 供手排裸指针事件（`down`/`move`/`up`/`click`）。
- **`op=verify` 新增 `count` 断言**（`{kind:"count", name?|controlKind?, equals|atLeast}`）：**数建了几个**。
  `tree{exists}` 只能回答「建了吗」，回答不了「建了几个」—— 而「每按一次加一条」这类动态 UI（列表项、金币图标、连击星）恰恰要问数量。
  落地时补了 `format.js` 的白名单与搬运（**归一化会丢掉未知字段**，不补就会退化成「数所有控件」）与 `runner.js` 的 `needsTree`（漏了它 count 恒为 0）。

### 文档

- **补上「模拟器在流程里的位置」= 真机试玩之前的「预测试」**（作者 2026-09-24 提问：「按我理解是预测试模式，是我想的这样吗」→「补充上吧」）：
  `docs/模拟器与视图.md` 开头新增 **§0**（流程图 + 三档表 + 三条边界），并写清 **②交互试玩 vs ③确定性判定** 的区别
  （②是"活的一局"、时间真在走；③**冻结时钟**、按时间点重放、可复现；`fromHistory` 之所以会结束那一局，就是因为它把活的一局转成了③）。
  同时也说明了「**能不能拿我真机的工程进来预测试**」目前还差最后一环节（GIA 两个方向都 `pending`，只能人试）。
- **同一份定位写进 `miliastra_sim` 的 schema 与提示段**（AI 只看得到这两条通道）：`预测试` / `模拟器通过 ≠ 真机通过` / `三档（①看 ②玩 ③判）`；
  `smoke` 加了一条**定位绊线**（少任一项就红）—— 没了这段，AI 会拿模拟器结果当"验过了"来汇报。README 的按需加载索引行同步。
- **README 顶部加「大字」目标声明 + 「AI 反馈区」**（作者要求：「在 git 主文档写上大字：目标是让 AI 更好用，希望各家 AI 积极提出意见」）：
  居中 `🎯 目标是让 AI 更好用` 横幅（中英各一份）+ 一节反馈区 —— 含**四条可执行尺子**（`smoke` 里钉住的那四条不变量）、
  **六条「什么意见最值钱」的真实例子**（每条都标了它后来变成了什么功能）、三种提法（加一行想法池 / 开 Issue / 贴真实卡住的调用与回执），
  以及**想法池**（我自己的待办，写明「可被反驳」，并留了一行给来提意见的 AI）。
- **同一份邀请也写进了系统提示段**（`PROMPT_RULES` 第 6 条）—— 因为**只有工具 schema 与提示段能自动到达 AI**，
  只写在 README 里等于没跟 AI 说过。提示段正文 1 445 → 1 850 字（仍只占工具 schema 的 9%）。

### 已验证

- `npm test` 退出码 **0** = **597 项**：`smoke` 50 → **53**（engine 路由不吞 `op` 的两条 ★）、`sim` 88 → **132**（`op=bind` 21 + `op=cases` 14 + `op=handover` 7）、
  `sim-play` 29 → **41**（报错条可见 / WebGL 兜底 / 舞台尺寸重试 + **四格显式行号**与**报错条封顶**）、`client-render` **45**（1:2 + iframe + 左列顺序与滚动模型）；其余 `readme` 14 · `deploy` 30 · `probe-deploy` 16 · `lualint` 32 · `shot` 104 · 引擎 130。
- **`op=bind` 在真机上跑通了**（双相《冰镜·火烛》，370ms 建好）：`run.logs` 出现它自己 print 的
  `就绪（3 关，控件 31，画布 1600x900）`（与真机 `.gia` 同一句），`run.controlCount=32`（容器 1 + 双相 31），
  渲染图 `~/.dsh/miliastra/shots/sim-play-双相-bind-第1关-20260924-110733.png` 里第 1 关的平台/熔岩/冰墙/小人/状态栏都在。
- 无浏览器的**协议冒烟**：`summaryOnly:false` 带 `scene`（`tree-v1`）、带 `sceneRev` 的增量轮询能接着拿、`play action=history` 拿得到时间线、`fromHistory` 跑通且没有活会话时明确报错。
- `node tools/build-sim-play.mjs --check` 能真跑（Windows 上"字符串拼 `file://`"的主模块判定会**静默不执行** —— 踩过，已钉住）。
- 真机形态的拖拽用例实测：`drag:{from:[800,450],to:[830,470],steps:3}` → 命中 `DRAG_BEGIN` / `DRAG_MOVE` / `DRAG_END`，
  且**不发** `CursorClick`（引擎只在原地 `up` 才算点击）—— 两条都写成了断言。
- ⚠️ **仍待人工**：浏览器试玩页的**观感**（打开 `http://127.0.0.1:3080/miliastra/play` 看画面/手感）——本机没有浏览器自动化，产物与数据通路有测试钉住，但这最后一米只能人看。

---

## [0.1.0] — 2026-09-24

> 承接 `0.0.11`。本版是**能力版**：把 [miliastra-beyond-simulator](https://github.com/1475505/miliastra-beyond-simulator)
> 的引擎**吸收进本仓**（58 文件 / 596 KB），在游戏之外长出「搭界面 → 跑 Lua → 出画面 → 自测逻辑」的闭环。
> 也是**许可版**：上游是 GPL-3.0-only，吸收代码后本仓同步换成 GPL-3.0-only（既有 `≤0.0.11` 仍按 Apache-2.0）。
> 本版 11 个提交 / 70 文件（+18 187 / −240）里，**AI 侧的调用体验**是主线 —— 新增 1 个工具、12 个 op，工具 schema 25 253 → 31 680 字符。

### 新增

- **`miliastra_sim`：内置千星模拟器**（引擎来源与归属见 `NOTICE`）。12 个 op：
  `state` / `patch` / `play` / **`verify`** / **`controls`** / `shot` / `keys` / `export` / `import` / `load` / `save` / `reset`；
  配套 `POST /miliastra/engine` 路由。**用户 Lua 一律跑在可终止的 Worker 里**（默认单次操作 8 秒超时后 `terminate`），
  工作区固定在插件数据目录的 `simulator/` —— 不碰游戏存档、地图与活文件。
- **`op=verify`：AI 自测逻辑的主入口**。一次调用 = **操作 + 断言 + 判定**（引擎在全新会话里**确定性重放**，所以可重复）：
  - `steps[]` 八种：`key` / `click:{x,y}`（左下原点）/ `clickName` / `setVar` / `sendSignal` / `view` / `pause` / `resume`；
  - `expect[]` 六种：`log` / `control` / `var` / `signal` / `tree` / `lua`（自写查询脚本）；
  - `at` **可省**（默认 = 最后一个事件之后 0.1 秒）⇒ **AI 不用自己算时序**；没过时回一条**人话 `hint`**（第几条、期望 vs 实际、`failedAt` / `frame`）。
- **`cases[]`：一组用例一次跑**。每个用例**各开一个全新会话**确定性重放（用例之间互不影响，所以能当回归套件）；
  默认跑完全部，`stopOnFail:true` 则第一个不过就停；**通过的用例不回日志、不落图**（不给 AI 加噪音）。
- **失败自动取证**：判定没过时顺带回 `shot`（**失败点附近**一帧 PNG —— 先把 Worker 时钟 `pause` 冻结，否则 30FPS 会继续往前走）
  与 `runtime.controlNames`（**运行时**控件名清单，编辑器工程树里没有的就是脚本动态创建的）；`shotOnFail:false` 可关。
- **`op=controls`：写断言前先看控件**。只回 `{id,name,kind,depth}` 四个字段 + `names`（可直接抄进 `expect`）+ `kinds`（类型直方图）+ `scripts`；
  `runtime:true` 看**运行中**会话的树。`state` 的每条 tree 行有 12 个字段，对「写一条断言」全是噪音 —— 实测一行 **≤ `state` 同行的 1/3**。
- **会话区三个视图**（`conversation.view`：初级功能 / 高级功能 / 模拟器）+ 面板内页面切换条；侧边栏浮层入口保留。
- **面板上「能玩」**：连帧 5fps（**固定名覆盖写**，磁盘只留一张当前帧）/ 点画布任意位置 / 按键 / 设备五档 · 人数 1–8 · 视角切换 / 单步。
- **GIA 导出与导回**（`op=export` / `op=import`，落在模拟器工作区的 `exports/`）。

### 变更

- 版本线从 `0.0.x` 升到 **`0.1.0`**：许可变更 + 引擎整体吸收 + 新工具，按语义化是 minor。
- 面板三页**各自独立平铺**，去掉原有的「全部」混合页。

### 修复

- **初级页第三列空白**：`-bodyfill` 的 `repeat(3,…)` 被后写的 `-body` 盖掉 —— 根因是 **CSS 顺序**，不是布局逻辑。
  改成复合选择器 `.‑body.‑bodyfill` 放在 `-body` 之后，并加断言钉住「顺序必须在 `-body` 之后」（读的是**注入后的真实 CSS**）。
- **切设备后人数悄悄掉回 1** → 再切视角就报 `playerIndex must be an integer 1-1`：Host 记住 `start` 时的人数、`device` 时自动沿用，错误信息也说明原因。
- **运行时的嵌套控件树被当成拍平行** → 只数顶层会把控件**少算一个数量级**（实测 **1 vs 17**）：
  新增 `flattenControls()` 先拍平再数（`op=controls runtime:true` 与失败取证都走它）。这条是**演示时抓到的**，不是推断出来的。

### 安全

- 许可 **Apache-2.0 → GPL-3.0-only**（吸收 GPL 代码的必然结果）：`LICENSE` / `NOTICE` / `package.json` / `README` 四处一致，`NOTICE` 标注上游与提交锚点。
- 本版新增的写入面只有 `miliastra_sim`（只写自己的模拟器工作区）；**截图与备份一律不自动删**，清理永远要人显式点。

### 文档

- 新增 `docs/模拟器与视图.md`（两条轴别混 / tab 契约出处 / 画面怎么来的 / 面板上怎么玩 / **AI 怎么自测逻辑** / 三条安全纪律 / 已验证 vs 未验证 / 排障）。
- 新增 `docs/需求边界清单_融合beyond-simulator_2026-09-24.md`（R↔FP 100% 追溯、六轮决策、W1–W7 工单与复测记录）。
- 新增 `docs/上游源码索引与吸收地图.md`（**全量上游源码在哪 / 搬了什么 / 没搬的读哪篇**）。

### 已验证

- `npm test` 退出码 **0**：`readme-test` 14 · `smoke` 49 · `deploy-test` 30 · `probe-deploy-test` 16 · `lualint-test` 32 · `shot-test` 103 · **`sim-test` 68** · `client-render-test` 43 · **引擎 129** = **484 项**。
- 失败帧**看图验证**（不是"接口返回 ok"就算过）：1600×900 / 124 491 B，画面上正是脚本运行时动态创建的模板面板（标题 + 图标 + 「确认」按钮）。
- 失控脚本护栏：`while true do end` 超时后**返回错误**而不是挂起，Worker 被 `terminate`，随后 `stop` 仍可用。
- 用例隔离：前一个用例按过的键**不会**漏进下一个用例（各开新会话），有专门断言钉住。

### 已知边界

- **导出的 `.gia` 能否被官方编辑器接受：未验证**。引擎侧导出→导回往返已成立，但**真机导入没有自动化通道，只能人做**。
- **模拟器通过 ≠ 真机通过**：官方素材（图片 `100001–100006` 之外显示缺失框）、真机渲染、真实网络联机都不覆盖。
- 浏览器 WebGL 试玩页（上游 `web/` + `pixi` 那条路）**未搬** —— 面板目前是 **PNG 帧**驱动；1–8 人是**本地多客户端模拟**，不含真实联机。

---

## [0.0.11] — 2026-09-24

> 承接 `0.0.10`（首个 Release，发布时 tag 停在 `7ca66f3`）。本版是**文档与面板**的一次整理，
> 外加两个真 bug 修复 —— **没有新增工具、没有改任何工具的调用方式**，四条 AI 调用不变量全部保持。

### 新增 · 英文入口与双语发布说明

作者要求「**做成中英文版本的文档，格式参考 dsh 的 git**」：
README 顶部加 `[中文](#…) | [English](#english-overview)` 语言锚点，
并新增 `## English overview` 一屏英文速览（是什么 / 30-second quick start / **8 个工具各一行** /
安装三要点 / 四条 ground rules）。**它只做导航**：完整的 op 与参数指向下面那份生成的清单，
所以中英两侧**不会各维护一份会漂移的副本**（`readme-test` 断言英文速览也必须覆盖 8/8 工具）。
发布说明同样按 DSH Release 的格式做**中英双语**
（`[中文] | [English]` 语言锚点 + `<h3 id="cn-…">` / `<h3 id="en-…">` 分节 + 四段分类 + `Full Changelog`），
从本版起每版一份、放在 `.github/release-notes/`。


### 文档改「渐进式披露」：入口精简 + 细节按需加载

作者原话：**「优化下文档，参考技能的渐进式思路」**（那次 `skills-add` 的三级加载：
元数据常驻 → 正文触发时加载 → 捆绑资源按需加载；SKILL.md 目标 <500 行，超了就把细节下沉并**写清「什么时候读哪个文件」**）。

先量了一下**谁会进上下文**：

| 文档 | 改前 | 改后 | 什么时候进上下文 |
|---|---|---|---|
| 工作区 `AGENTS.md` | 531 行 / 26 183 字符 | **155 行 / 6 751 字符** | **每个会话的模型基线**（每轮都在花） |
| 插件 `README.md` | 1 063 行 / 46 109 字符 | **408 行 / 24 876 字符** | 按需（有人点开 / AI 决定读） |

工作区 `AGENTS.md` 是大头：里面**约 380 行是「每轮都用不到」的细节**（控件实例化的完整实测、
`.gia` 全字段、按键表、插件工具表、面板、安全约定、截图纪律、测试清单），却**每轮都载入**。

**做法**（两处同一套）：

1. **入口只留「不看就会做错」的东西**：铁律、路径规则（不写死）、编码（无 BOM）、交接清单、
   每轮生效的插件规矩（开发优先级 / AI 调用四条不变量 / 只有两条通道能到达 AI）。
2. **细节下沉成独立文件**，入口给一张**「你手上在做的事 → 读哪个文件」**的表。
   工作区新增 5 篇：`千星奇域_客户端控件与按键实测` / `千星奇域_运行时日志与试玩信号` /
   `dsh-miliastra_使用与安全详述` / `工作区目录结构` / `工作区常用操作`；
   插件仓库新增 4 篇：`功能详解` / `面板与活文件安全` / `关键知识与能力边界` / `配置与开发`。
3. **加了绊线**（`readme-test`，14 项）：README 里 `docs/…md` 的链接**必须存在**，
   且 `docs/` 下**不许有孤立文件**（没人链接 = 没人会读）。**双向都查**。
4. 顺手补了生成块的**参数说明列** —— 以前只带「类型/必填/取值」，
   所以「改了 description 要跑生成器」这句话**对参数不成立**；现在成立。

> 搬运用脚本按 `##` 标题**机械切分**（逐行原样），不改写内容 —— 手抄 400 行中文必出错。
> 踩的坑：`promote()` 把 `#` 数量写反（`'#$1 '` = 加深一级），导致四个文件的层级都反了、目录空掉；
> 已按文件分别削 `#`（带 ``` 代码围栏保护，免得误改 `powershell` 里的 `#` 注释）。

### 修复 · `summaryOnly`：`byLevel` 没瘦身，还会「越瘦越大」

真机验证时被 `smoke` 抓到 —— 而且**第一版断言本身是错的**：

- **断言脆**：它写死了键名 `x`。晚上 23:33 那局的 `.gia` 里只有 `容器交接值`，没有 `x` → **假红**。
  → 改成**数据无关**：键集合一致 + 全量里有 `core`/`hotBin` 的摘要里必须还在 + 有箱的键才报 `binsOmitted`。
- **真 bug 一**：`slimLoose()` 把 `byLevel` **整块原样带出去**（`byLevel` 里每一层也是一份
  `numericStats`）→ 只瘦了顶层，等于白瘦。
- **真 bug 二**：`slimStats()` 永远加 `binsOmitted`。**没有箱子可省时，摘要是比全量更大的** ——
  实测单点数据 **1601B → 1630B**。→ 改成「**有箱可去才报**」，并给 `byLevel` 的每一层都套上瘦身。
- **「省体积」这条改用固定夹具验**（12 个箱 × 多键）：**546B → 188B**。
  真机那一局只有 1 个数、1 个箱 —— 不具代表性（回执里那段 `note` 文案本身就比省下来的还长），
  所以真机只验「不丢结论」，省体积交给夹具。**别让真机数据的偶然形态决定测试的松紧。**

### 新增 · 面板版本行与「Host 自陈旧」（夜战补充 P0-C 的 GUI 面）

夜战补充那份 10 条想法**没有一条是 GUI 功能**，逐条对过之后，能落到面板上的是 **P0-C**：

- **面板以前连版本都不显示**（标题只有 `Miliastra Wonderland · 千星奇域`）。
- 而 Host 半边是**启动时的快照** —— 今晚源码 22:41 改到 `0.0.9`，Host 是 **22:18 启动**的、仍报 `0.0.5`，
  定位花掉 **5 个调用**（grep `package.json`、grep `VERSION`、查进程 `CreationDate`、手工拼时间线）。
  **这件事该由 Host 自己说。**

改法：

- `hostStaleness()`（**导出的纯函数**，好测）：源码 `package.json` 版本 ≠ 载入的 `VERSION`，**或**
  `index.js` 的 mtime 晚于进程启动时刻 → `stale:true` + `deltaMin` + **带「重启 dsh web」的下一步**；
  读不到源码**不报假警**。
- `hostSummary()`：`version` / `startedAt` / `pid` / `uptimeSec` / `source`，
  **`/miliastra/status` 与 `miliastra_health` 共用同一份**（判据只有一份，免得两处漂移）。
- **面板**：顶部常显 `Host vX · 启动 HH:MM · 源码 vY`；**陈旧时才变琥珀色**并把 hint 接在后面。
- 测试：`smoke` 补 Host 陈旧判据的形状用例（同版本+旧 mtime → 不报；版本变了 / 晚 23 分钟 → 报）；
  `client-render-test` 补「版本行 + 陈旧警告」（面板 status 也按既有约定支持 `props.__status` 注入）。

> **没做的（说明为什么）**：P0-A 动态包络、P1-E `waitNew`、P1-F 截图裁剪 —— 都是**工具侧**能力，
> 上了面板也只是「显示一个工具结果」，不如先把工具做对；P1-D 后台连拍在面板上**早就有 GUI 版**
> （「开跑自动截图」开关）；P2-9 的「本局编号」面板**也已经显示**（还有一句「与 .gia 的 instance 对得上号」）。
> 另外 P0-B（部署对账两条口径）要接部署指纹才说得准，属于工具侧那一批，不塞进这轮 GUI。

---

### 已验证 · 测试

`npm test` 全绿，合计 **462 项**（446 + 16 契约自检）：

| 套件 | 项数 | 本版变化 |
|---|---|---|
| `smoke` | **48** | +Host 陈旧判据 / `summaryOnly` 夹具省体积 |
| `readme-test` | **14** | +语言锚点 / 英文速览覆盖 / **README 与 `docs/` 双向可达** |
| `client-render-test` | **32** | +**面板版本行与陈旧警告** |
| 其余 8 个套件 | 352 | — |
| 契约自检 `selftest.mjs` | 16 | — |

```
✓ Host 陈旧判据：同版本 + 旧 mtime → 不报；源码版本变了 / index.js 晚 23 分钟 → 报并要求重启（deltaMin=23）
✓ slimStats 在「多箱」夹具上真的省体积 → 546B → 188B（省 358B）
✓ README 与 `docs/` 的「按需加载」双向可达 —— 链接 4 篇，docs/ 无孤立文件
```

---

## [0.0.10] — 2026-09-23

**系统提示段：从「手写一段话」改成「从数据生成 + 有断言」。**

起因是作者问了一个架构问题：**「作为 AI，怎么判断插件有什么功能？是否需要都写出来？」**
这个问题本身翻出两个事实：

1. **只有两条通道能自动到达 AI**：**工具 schema**（name/description/parameters 直接进模型工具列表）
   与**系统提示段**（`systemPrompt.section`）。面板与 HTTP 路由对 AI 是**黑洞** —— 除非截图，它"看不见"。
2. 本插件那段提示的「什么时候用」**只覆盖 5 个工具**：`miliastra_playtest` / `miliastra_shot`
   （`0.0.4`~`0.0.8` 加的）**在 AI 的开场提示里没有任何指路** —— 而没有任何机制会发现这件事。
   **和 README 第一段停在 `0.0.1` 是同一个病**：第二份副本没人管。

### 变更

- 提示段文案改为 `renderPromptSection()` 从 **`PROMPT_GUIDE` / `PROMPT_RULES` 数据生成**，不再手写段落。
- 补上此前缺失的指路：**`miliastra_playtest`**（开跑信号在 `output_log.txt`，**`.gia` 里没有**）、
  **`miliastra_shot`**（连拍是**一次调用**、可先 `dryRun` 看计划）；
  以及后来的三条纪律：**只报数字不下判决** / **截图与备份绝不自动删** / **试玩按钮只能人点**。
- **`PROMPT_SKIP`** 只豁免 `miliastra_echo`（纯调试工具，不需要开场指路）。

### 新增 · 断言（防它再落后）

`smoke` 里那条「系统提示段覆盖」：

- **不在 `PROMPT_SKIP` 里的工具，必须有一条「什么时候用」** → 加新工具忘了补就**当场红**；
- 指路的工具必须**真实存在**（改名/删除后忘了同步 → 红）；
- **工具名必须与该条说明写在同一行** → 不然读的人不知道「· 先用它定位…」的「它」是谁；
- 每条必须有实质说明（长度下限）。

判据刻意做成**绊线**而不是"质检"：豁免是显式的，所以**不会误报**；代价只是「加工具时要想一句什么时候用」——那一句本来就该想。

**实测比例**：工具 schema **13 049 字**（AI 自动看见的全量）；提示段 **1 166 字 ≈ 9%** —— 只写 schema 表达不了的**决策**，**不复述参数/返回值**。

### 修复（「断言查错对象」的第二次）

第一版断言写成 `text.includes(工具名)` → **假通过**：工具名在第一行的工具清单里就有，
于是「每条 bullet 根本没带工具名」也照样绿 —— 渲染出来是「· 先用它定位『当前关卡…』」，
**读的人不知道「它」是谁**。→ 渲染补上工具名，断言改成**按行**查「`工具名：`」。

> 同一类错误今晚出现过两次：一次是「回执不含 base64」因为注释里写着 base64 而误判，
> 一次就是这个。**教训：断言要断"实质"，而且要查对对象。**（已写进技能 `tool-design-for-ai.md` §11）

### 已验证 · 测试

发布时的快照（**445 项** = 429 + 16 契约自检）：

| 套件 | 项数 | 本版变化 |
|---|---|---|
| `smoke` | **46** | +1：系统提示段覆盖 |
| `readme-test` | **10** | 新增：主文档与代码一致性 |
| 其余 9 个套件 | 383 | — |
| 契约自检 `selftest.mjs` | 16 | — |

> 之后的增补（英文速览断言、`docs/` 双向可达、Host 陈旧判据、`summaryOnly` 夹具）算 **0.0.11**，见上。
### 主文档（README）重做：功能写进主 md，且**不再会过时**

作者原话：**「将功能可以写到主 md 中，我发现主 md 一直都有点过时，插件的文档更新下 ——
目标是傻子 AI 都能一下明白怎么用」**。

先承认现状：README 当时 **810 行 / 53.6 KB**，但它**确实一直在落后**，而且不是一两处：

| 过时的地方 | 当时写的 | 实际 |
|---|---|---|
| 工具数量 | 「变成 **6 个**工具」 | **8 个** |
| 测试项数（开发一节） | `smoke` **45** / `deploy` **28** / `probe-deploy` **10** | **46 / 30 / 16** |
| 验证阶梯合计 | **444 项**（397 + 31 + 16） | **455 项**（408 + 31 + 16），**本轮重排后 460**（413 + 31 + 16） |
| AI 调用不变量 | **三条** | **四条**（系统提示段那一条是这一版加的） |
| 工具表 | 只列了部分 op（`shot` 少了 `targets`、`map` 少了 `strings`、`code` 少了 `backup`/`backups`、`probe` 少了 `list`） | `TOOLS` 里 **29 个 op 取值 / 69 个参数** |

**根因**：README 是**第二份副本**，手写的清单**必然漂移** —— 和「README 第一段停在 `0.0.1`」、
「系统提示段漏了 4 个版本」是**同一个病**。

**改法**（三条，按重要性排）：

1. **功能写进主 md，而且工具清单改成「生成」的**：
   `node tools/gen-readme-tools.mjs --write` 从 `index.js` 的 **`TOOLS`** 生成
   「工具速查」块 —— 每个工具的**完整 description + 每个参数的类型/必填/取值**
   （**8 875 字符**，就是 AI 在 schema 里看到的那份）。
   单一真身 = schema，README 只是它的**投影**。
2. **新增 `tests/readme-test.mjs`（14 项）钉住它**：
   生成块与 `TOOLS` **逐字一致**（差一个字符就红，并报出**第一处差异在第几行**）、
   导航表覆盖 **8/8** 工具、README 里**不许出现已不存在的工具名**（改名/删除后忘改文档 → 红）、
   **29 个 op 取值与 69 个参数**都必须出现在生成块里、第一段版本号 = `package.json`，
   以及**英文速览也必须覆盖 8/8 工具**（见下）。
3. **加一节「30 秒上手」**（README 最前面）：三步 —— `miliastra_health {}` 定位 →
   `miliastra_code op=inspect` / `op=deploy` 读写 → `miliastra_playtest` + `miliastra_log` 验证，
   外加三条铁律（**路径不写死 / 写操作只在 code 与 probe / 不做越界的事**）。
   **不看别的，照着这三步就能干活** —— 这就是「傻子 AI 也能一下明白」。
顺带修掉的文档硬伤：`6 个工具 → 8 个`、测试项数（`45/28/10 → 46/30/16`）、
合计（`444 → 455`）、不变量（`三条 → 四条`）、导航表补全 `targets` / `strings` / `backup`+`backups` / `list`。

**踩到的坑（值得记）**：edit 工具把 README 写成了 **CRLF**，而生成块是 LF →
`readme-test` 报「不一致，差 **148** 个字符」（= 147 行各多一个 `\r`）。
→ 生成器改成**跟着文件自己的行尾**（`eolOf`），比对前统一成 LF。
**行尾也算「内容」** —— 一个字节都不许含糊。

## [0.0.9] — 2026-09-23

**AI 调用体验整修**。纯 AI 侧，没动面板。

起因是作者问了一句：**「都是方便 AI 的功能吗？方便调用吗？」** —— 目标是「方便 AI」，
但**「AI 侧」不等于「方便调用」**。所以这一版把三处**真的不好调**的地方逐个收掉，
并且把它们变成**测试钉住的不变量**（否则下一次加 op 时顺手就破坏了，而且不会有任何报错，只是变回难用）。

### 整修 ① 参数名不再撞车：`which` → `stage`

`op=levels` 原来是 `which`（玩法里第几关），而同工具里 `level` 是**地图关卡 ID**。
中文里两个都叫「关卡」，**参数名撞车 = AI 一定会填错**。

- `which` → **`stage`**；
- `level` 的参数描述改成「**地图关卡 ID / 品牌**（哪张图，不是玩法里的第几关 —— 那个用 `stage`）」，
  `miliastra_map` / `miliastra_log` / `miliastra_playtest` 三处的 `level` 描述也一并说明；
- `stage` 的描述里两句话点明区别。
- **测试**：断言全库 `parameters` 里**不再出现 `which`**。

### 整修 ② 大返回可以瘦身：`summaryOnly:true`

实测这些返回**看一眼很贵**（≈5k tokens）：

| op | 全量 | `summaryOnly:true` |
|---|---|---|
| `miliastra_code op=levels` | **18 917 B** | **3 126 B（17%）**（省掉每块平台的坐标） |
| `miliastra_map op=clientui` | 7 592 B | **1 636 B（22%）**（省掉逐条 `records` 与谱系文字） |
| `miliastra_log op=metrics` | 3 241 B | 2 561 B（省掉直方图分箱，`binsOmitted` 报出少了几个） |

**默认行为一个字节都没改**（不偷偷改语义）—— 想省就显式传 `summaryOnly:true`。

**新增纯函数**：`levelSummary(card)`（一关一行摘要）、`slimStats/slimMil/slimLoose`（去分箱留结论）。

> ⚠️ 瘦身**只去体积、不去结论**：`metrics` 的 `core`（集中区）与 `hotBin`（热区）**都留着** ——
> 那才是真正要看的两个数。测试里专门断言了「省下来了」**且**「数字没丢」。

### 整修 ③ 每个工具的 description 末尾带「典型调用」

以前描述只讲「为什么」，AI 还得自己拼参数组合。现在 8 个工具每个都有一行可照抄的 JSON，例如：

```
**典型调用**：`{"op":"inspect"}`（体检 + 看有没有被编辑器写回旧版）｜
`{"op":"deploy","source":"D:\\code\\双相\\双相_v9.lua"}`（投代码）｜
`{"op":"levels","summaryOnly":true}`（先扫全部关卡几何）→ `{"op":"levels","stage":3}`（再钻第 3 关）
```

- **测试**：断言**每个**工具的 description 都含「典型调用」。

### 修复

- **README 第一段一直写着 `版本 0.0.1`** —— 从 `0.0.1` 一路到 `0.0.9`，六次发布都没改过
  （作者在 GitHub 上发现的：「我看 git 打开第一段还是版本 0.0.1」）。
  同一处还漏了两样：**安装示例 `dsh-miliastra@0.0.1`**、**开发命令那段的 `smoke（30 项）`**。
  根因不是手滑，是**没有任何断言在管门面**：功能都对，README 上写着旧版本。
  → 现在 version 三处（`package.json` / `index.js VERSION` / README 第一段）**+ 安装示例**都有断言
  （`smoke` 里那条「版本号三处一致」）。
  **这正是「不变量测试」的价值：功能测试永远不会发现门面过时。**

### 新增 · `docs/dsh-miliastra_更新说明_0.0.4~0.0.9_2026-09-23.md`（工作区）

一页把 `0.0.4`~`0.0.9` 讲完：**AI 调用速查表**、逐版本变化与证据、三条纪律、
真机证据汇总、已知边界、验收方式。给人看，也给下一个接手的 AI 看。

### 已验证 · 测试

`npm test` 全绿，合计 **444 项**（428 + 16 契约自检）：

| 套件 | 项数 | 本版变化 |
|---|---|---|
| `smoke` | **45** | +6：三条不变量（每个工具都有「典型调用」/ 全库无 `which` / `summaryOnly` 真省且不丢数字，后两条各对 3 个 op 断言）+ **版本号三处一致 + 安装示例同步** |
| `deploy-test` | 30 | — |
| `probe-deploy-test` | 16 | — |
| `lualint-test` | 32 | — |
| `shot-test` | 103 | — |
| `playtest-test` | 63 | — |
| `giaruns-test` | 25 | — |
| `leveldata-test` | 44 | — |
| `metrics-test` | 39 | — |
| `client-render-test` | 31 | — |
| 契约自检 `selftest.mjs` | 16 | — |

> 那三条不变量的日志是照抄可查的：
> `✓ 每个工具的 description 都带「典型调用」（8 个）` /
> `✓ 没有 which 参数了` /
> `✓ op=levels 的 summaryOnly 真的省上下文 → 全量 18917B → 摘要 3126B（17%）…且摘要里的数字都在`

---

## [0.0.8] — 2026-09-23

**P2-7 截图连拍**。纯 AI 侧，没动面板。

### 新增 · `miliastra_shot op=burst`

```jsonc
// ① 立刻连拍 5 张
{ "op": "burst" }
// ② 「等试玩开跑 → 再等 3 秒 → 连拍 5 张」——**一次调用完成**
{ "op": "burst", "awaitPlaytest": true, "afterSec": 3, "count": 5 }
// ③ 先看要花多久、拍几张（一张都不拍）
{ "op": "burst", "count": 5, "dryRun": true }
```

为什么做成**一次调用**：拆成「先 `playtest op=wait` 再逐个 `capture`」两次调用时，
两次之间的往返延迟（1~3 秒）会直接毁掉时间精度 —— 而连拍的全部意义就在时间上。
判据与 `miliastra_playtest op=wait` **共用同一份实现**（`waitForPlaytestStart`），
两条路各写一套早晚会漂移，而漂移过的判据比没有判据更坏。

细节：`count` 夹在 1~20；间隔地板 800ms（小于它会被夹住并在回执里标 `clamped`）；
**某一张失败就停手**（不白耗剩下的时间），回执里给 `abortedAt`；
缩略图在**同一次进程**里顺带生成（省一次起进程）；回执**不带图片二进制**，只给
`GET /miliastra/shot?name=<file>`（原图）与 `&thumb=1`（小图）两个入口。

### 重要发现 · `burstMs` **不是**「每 N 毫秒一张」

真机第一次连拍就暴露了：要 `burstMs=800`，实测帧距是 **2600ms**。

原因是 `burstMs` 只是**两张之间的额外等待**，没算上单张自身的耗时 ——
本机（起 PowerShell + `PrintWindow` + 顺带出缩略图）**约 2.6 秒/张**。

**参数看起来是那个意思、其实不是，这种最坑**，所以：

- 回执里恒定给 **`measuredIntervalMs`（实测帧距）**与 `timing.note`，把「单张耗时才是真实上限」写在脸上；
- 实测帧距比请求值大 1.5 倍以上时，`note` 里直接加一句「⚠️ 差额是**单张自身耗时**，不是没生效」；
- 参数描述里也写明它只是「额外等待」。

### 真机证据（一次性，已跑）

```
op=burst count=3
ok=true  count=3  okCount=3  suspect=0  span=5201ms  measuredIntervalMs=2600
  #1 game-burst-verify-1-…png   0ms  1456×939  2.4 MB  suspect=false
  #2 game-burst-verify-2-…png  2640ms  1456×939  2.4 MB  suspect=false
  #3 game-burst-verify-3-…png  5201ms  1456×939  2.4 MB  suspect=false
op=burst count=2 → measuredIntervalMs=2510（第二次复现，稳定在 2.5~2.7 秒）
```

⚠️ **由此定一条边界**：单帧寿命 **<1 秒**的特效，这个速度**注定只能抓到 1~2 帧** ——
是引擎/进程启动的地板，不是参数没调好。要看快特效得改脚本（把过程画在控件上、或分多局抓）。

### 已验证 · 测试

`npm test` 全绿，合计 **438 项**（422 + 16 契约自检）：

| 套件 | 项数 | 本版变化 |
|---|---|---|
| `shot-test` | **103** | +17：`clampBurstMs` / `planBurst`（偏移、总时长、上下夹、被夹标记）/ `burstSummary`（成功失败可疑计数、真实 elapsedMs、**实测帧距**、超请求间隔的警告、失败帧带 error、**回执不含图片二进制**、单张与空集） |
| `smoke` | 39 | — |
| `deploy-test` | 30 | — |
| `probe-deploy-test` | 16 | — |
| `lualint-test` | 32 | — |
| `playtest-test` | 63 | — |
| `giaruns-test` | 25 | — |
| `leveldata-test` | 44 | — |
| `metrics-test` | 39 | — |
| `client-render-test` | 31 | — |
| 契约自检 `selftest.mjs` | 16 | — |

> 自动化测试里**故意不真拍**：每张约 2.6 秒、还会往用户的截图目录里写 2.4MB ——
> 测试不该占用户的磁盘。真机连拍留一次性证据（上面那段）。

### 顺带 · 重跑 `miliastra_playtest op=wait` 的回归

`op=wait` 的判据刚被抽成共用的 `waitForPlaytestStart`，所以这一版跑了一次真机回归：
`op=wait timeoutSec=5` 仍能被打断并如实回 `hit:false`（smoke 里那条断言覆盖）。

---

## [0.0.7] — 2026-09-23

**P1-5 指标约定**：让脚本能「吐指标」，工具能「看指标」。纯 AI 侧，没动面板。

### 起因

那一晚救命的**不是** 36 条日志正文，而是作者自己加的那一行：

```
[yuan-code] 落出边界 -> 重生（第 3 关，摔死处 x=814，最后站立=#2 移动）
```

有了它「卡在哪一段」一目了然；而这类埋点**每次都要重新发明一遍格式**。

### 新增 · `miliastra_log op=metrics`

**两层，都不猜语义：**

**① 严格约定**（可选，用上就给更完整的汇总）—— `[MIL]` 开头 + `k=v`：

```
[MIL] evt=death lv=3 x=814 stand=2
[MIL] evt=clear lv=3 ms=8420
[MIL] death lv=3 x=814              ← 事件名不带 evt= 也行（取第一个裸词）
```

| 键 | 含义 |
|---|---|
| `evt` / `event` / `e` | 事件名（缺省取第一个裸词） |
| `lv` | 关卡号（也认 `第 3 关`） |
| `x` / `y` | 位置 |
| `ms` | 耗时毫秒 |
| `n` | 计数 |
| 其它 | **原样保留**，工具不解释 |

汇总成「每个事件：次数 / **分关次数** / 数值字段的分布」+ 一条时间线。

**② 宽松抽取**（**不用改脚本**）—— 任何日志行里出现 `k=<数字>` 就收，配上关卡线索
（`第 N 关` / `lv=N`）给出**按键**的分布与集中区。这条拿**现在的日志**就能跑。

### 关键设计：两个「集中」指标，因为它们回答不同问题

| 字段 | 含义 | 抗离群值 |
|---|---|---|
| `core` | **四分位距 p25~p75**（中间 50% 落在哪） | ✅ 是 —— 「x 集中在 700~860」用的是**这个** |
| `hotBin` | 直方图命中最多的那一箱（看形状） | ❌ 会被离群值摊薄 |

实测这组数（12 个 x 跨 460~1086，其中 9 个挤在 694~860）：`hotBin` 最多只装到 **3 个**，
而 `core` 给出 **706~825** —— 一眼就能说「集中在 700~800 这一段」。

> 这条是**做的时候被测试逼出来的**：我第一版只报了直方图热区，
> 断言按「热区应该覆盖 700~860」写，结果实测热区是 `647.8~710.4`（只 3 个）——
> 不是代码错，是**指标本身不抗离群值**。补 `core` 之后两边的解释力才够。

### 真机跑出来的结果（不用改脚本）

```
2026-09-23_21-46-05_157.gia：30 条记录，宽松命中 17 条（MIL 0 条，忽略 13 条）
  键 x    ：10 次，458~1011，中位数 623，**集中区 502.5~703**，热区 458~513.3
    第 3 关：10 次
  键 玩家x： 6 次，386~679，中位数 454.5，**集中区 454~455**，热区 444.6~473.9
  关卡分布：{"3":10,"(未标注)":7}
```

（顺带看到：这一局的第 3 关摔死 x 是 **502~703**，而 21:29 那局是 **694~1086** ——
落点在改几何之后**明显前移**了。这是**数字**，不是判定。）

### 安全与纪律

- **只读** `.gia`，一个字节都不写；没有指标格式的行**一律静默忽略**（`ignored` 计数如实报出）。
- 输出里**没有** verdict/pass 这类判决字段（有测试守着）：只说「N 次里有 M 次落在 a~b」，不说「这关有问题」。
- `[MIL]` 行**不进**宽松集合（不重复计数）。

### 已验证 · 测试

`npm test` 全绿，合计 **421 项**（405 + 16 契约自检）：

| 套件 | 项数 | 本版变化 |
|---|---|---|
| `metrics-test` | **39**（新） | 解析（含中文键、引号、`#2` 不当数字）/ **静默忽略** / 分布与分位数 / 热区 vs 集中区 / 分关统计 / 时间线 / **不许出现判决字段** / 真机宽松抽取 |
| `smoke` | **39** | +1：`log op=metrics` 只读用例 |
| `deploy-test` | 30 | — |
| `probe-deploy-test` | 16 | — |
| `lualint-test` | 32 | — |
| `shot-test` | 86 | — |
| `playtest-test` | 63 | — |
| `giaruns-test` | 25 | — |
| `leveldata-test` | 44 | — |
| `client-render-test` | 31 | — |
| 契约自检 `selftest.mjs` | 16 | — |

### 已知边界

- `core` / `hotBin` 都需要**足够样本**才有意义；样本少时如实给出 `n`，别过度解读。
- 宽松抽取是**按键**统计，它**不知道** `x` 是「摔死位置」—— 键名是脚本自己起的，工具只统计。
- 想让工具认得事件名与关卡，**采用 `[MIL]` 约定**（`op=metrics` 回执里带 `convention.how` 与字段表，可照抄）。

---

## [0.0.6] — 2026-09-23

**P0-1 的 A 方案**（作者定）：把关卡表变成**机器读得动**的，插件只做「读 → 算数字 → 报出来」，
**判决留在玩法那一侧**。仍然是纯 AI 侧，没动面板。

### 起因为什么要这个

那一晚白跑了两局：按设计文档的 ASCII 图**脑算**排坐标，结果 A 的横向占位 `[400,740]` 与 B 的 `[720,1060]`
**水平投影重叠 20px**、A 最高时玩家头顶离 B 底面只剩 **6px**（原话：「碰到空气墙的感觉，被踢回来」）。
这两个数字**本来算得出来** —— 缺的只是「让工具读得到那张关卡表」。

### 新增 · `miliastra_code op=levels`

从**活文件**里抽出 `local LEVELS = { … }`，给出每关的**几何事实**：

| 字段 | 是什么 |
|---|---|
| `plats` | 每块平台 `{ n, x, y, w, h, kind, kindName, mv, iso }`（`n` = 它在表里的 1-based 序号） |
| `facts.adjacent` | **相邻声明对**：`dxOverlap`（水平投影重叠多少 px）/ `dxGap`（缝多少 px）/ `dyRise`（后者高多少）/ `pattern` |
| `facts.risingOverlaps` | 其中形状是「**向上 + 水平重叠**」的那几对（**那次白跑就是这个形状**） |
| `facts.nearMiss` | 两两之间「水平投影重叠 且 垂直净空 ≤ `nearPx`」→ `clearance` 就是「还剩几 px」 |
| `facts.overlaps` | 两块矩形**真的相交** |
| `facts.kinds` / `bbox` / `spanX` / `spanY` | 分类计数与包围盒 |

**纪律（有测试专门守这条）**：输出里**不出现** `pass` / `reachable` / `verdict` 这类字段 ——
「跳得过去吗」取决于跳跃初速、重力、移动平台相位，那是**玩法**，是作者定的。
`nearPx` 是**筛选阈值**不是判定；`adjacent` 按**声明顺序**（脚本注释说那是通关路径顺序）。

**解析边界**：只认**纯字面量表** —— 数字 / 字符串 / `true` / `false` / `nil` / 常量名（含
`local A, B, C = 1, 2, 3` 这种**多变量一行**）/ 嵌套表 / 命名段 / 尾随逗号 / 行与长注释。
表里一旦出现**算出来的值**（算术、拼接、函数调用、变量引用），**报行号 + 那一行原文**，
绝不猜、也不静默少算一块。

实测本机活文件 `双相.lua` v9.2 的表**全部落在「认」的一侧**：
`local K_PLAIN, K_ICE, … = 0, 1, 2, 3, 4, 5`、`{ 1380, 420, 40, 140, K_ICEWALL, nil, true }`、
中文都在字符串里；画布常量叫 `DESIGN_W, DESIGN_H`（不是 `CANVAS_*`，候选名单里都认）。

### 真机跑出来的结果（只报数字）

```
双相.lua：3 关，画布 1600×1000
  第1关 教学    ：平台 15（iso 1）bbox 1600×880  相邻重叠 1 处（其中**向上重叠 0**）真相交 0  净空≤48px 0
  第2关 长链    ：平台 30（iso 0）bbox 1600×550  相邻重叠 1 处（其中**向上重叠 0**）真相交 0  净空≤48px 0
  第3关 移动平台：平台 5 （iso 0）bbox 1600×560  相邻重叠 0 处                    真相交 0  净空≤48px 0
```

那两处「相邻重叠」都查了原始坐标：一处是 `x[1240..1580] y[560..650]` → `x[1280..1600] y[700..790]`
（**向下**走 140px），另一处是第 2 关两行之间的**向下**换行 460px —— 都是关卡里正常的行间过渡。
**「向上重叠」三关都是 0**，也就是 v9.2 那次「相邻平台水平投影不再重叠」的修复**确实生效了**。

### 修复 · 序号口径混用（自己踩的，写下来）

第一次把结果打出来时，`adjacent.i/j` 报的是「**非 iso 平台里的第几个**」，
而卡片里 `plats` 是**含 iso 的全表** —— 拿前者索引后者，就会指到另一块平台
（实测指到了第 1 关那块 `iso` 冰墙，差点得出「冰墙和地面重叠 300px」的错误结论）。

现在 **`i/j` 一律是「表里的 1-based 序号」**，并且每条里**内联 `aBox` / `bBox` 坐标** ——
不看序号也能直接读。序号口径这种事**必须只有一种**。

### 已验证 · 测试

`npm test` 全绿，合计 **381 项**（365 + 16 契约自检）：

| 套件 | 项数 | 本版变化 |
|---|---|---|
| `leveldata-test` | **44**（新） | 去注释 / 常量表 / 抽表 / **认不出时如实报行号**（算术、函数调用、没闭合）/ 几何事实 / 形状分类 / 序号口径 / **「不许出现判决字段」** + 真机抽表 |
| `smoke` | **38** | +1：`code op=levels` 只读用例 |
| `deploy-test` | 30 | — |
| `probe-deploy-test` | 16 | — |
| `lualint-test` | 32 | — |
| `shot-test` | 86 | — |
| `playtest-test` | 63 | — |
| `giaruns-test` | 25 | — |
| `client-render-test` | 31 | — |
| 契约自检 `selftest.mjs` | 16 | — |

### 已知边界

- 只**读**活文件，不写、不改；解析失败时返回 `ok:false` + `line` + `lineText`，由人决定怎么办。
- 坐标**按表里怎么写就怎么报**（该表约定设计坐标 y 从顶向下）；脚本转控件坐标时会翻 y
  （实测 `canvasH / 2 - dy * sy`）—— 回执里带了 `coordinateNote` 提醒，**别直接和控件坐标比**。
- 移动平台只报 `mv = { range, period }`，**不推算行程包络**（相位算法是玩法的）。

---

## [0.0.5] — 2026-09-23

按作者定的两条：**① 关卡体检走「A 方案」**（另开一轮）；**② 新能力优先做 AI 侧，GUI 面板不同步实现**。
所以这一版是**纯工具层**的四件事 + 一个顺手修掉的脆弱测试。

它们都来自同一份实战清单（`docs/dsh-miliastra_优化想法_2026-09-23.md`），逐条都先核过证据 ——
清单里 3 处硬伤（跨两张图比 `.gil` 大小、误诊「两个工具口径不一致」、`.gia` 局中持续写）
在这个版本里按实测改写了，不照抄。

### 新增 · `miliastra_code op=fixbom`（活文件带 BOM 时只去那 3 个字节）

BOM **不是本工具加的**（部署路径会拒绝带 BOM 的源文件）—— 实测它来自**新建关卡时编辑器自己写的活文件**：
本机备份里确实有一份 `EF BB BF` 开头的 **22449** 字节版，同一份内容无 BOM 是 **22446** 字节，**正好差 3 字节**。

安全顺序与部署同源：

| 情况 | 行为 |
|---|---|
| 本来就没有 BOM | **什么都不做**（不为「修一下」去动不需要动的文件，连 mtime 都不碰） |
| 备份失败 | **立即中止**，一个字节都不动 |
| 正常 | 备份 → 原子写 → 校验（**逐字节比对：除了那 3 字节，其它必须完全相同** + 无 BOM + 仍是合法 UTF-8） |
| 校验不过 | **自动回滚**回带 BOM 的那一版 |

⚠️ 回执里会**提前写明副作用**：`<原名>.bak` 这时指向「带 BOM 的那一版」，
而 `op=restore` 会**故意拒绝**还原它（那种文件还原回去 Lua 照样报错）。

### 新增 · 部署指纹（发现「活文件被静默写回旧版」）

`op=deploy` 成功后会在备份目录写一份 `.miliastra-deploy.json`（SHA / 字节 / 行数 / 来源 / 时刻）。
之后 `op=inspect` 每次都比对：

- 一致 → `sameAsDeploy:true`；
- **不一致** → `changedSinceDeploy:true` + 字节差 / 行数差 + 一句「**多半是编辑器把脚本面板里的内存版存回了磁盘**」。

为什么值得做：实测**编辑器保存时会把脚本面板里的内存版写回活文件**（那次保存让 `.gil` 从 54.5KB 涨到 65.9KB，
差值 ≈ 那次部署的脚本增量）。也就是说**部署成功 ≠ 编辑器拿的是新版**，而以前这种情况**没有任何迹象**。

> 写指纹失败**不影响部署成败**，只降级成一条 warning。

### 新增 · `ErrorLog.txt` 巡检（`miliastra_health` 与 `op=inspect`）

官方文档写得清楚：**循环调用 / 挂载失败这类错不进 `.gia`**，只在客户端脚本同目录写 `ErrorLog.txt`。
所以「`.gia` 里干干净净」**不能**单独当成「脚本没出事」的证据。
现在两个入口都顺手扫一眼：有 → 报大小 / 行数 / 首几行 + 提醒；**没有也如实显示「没有」**（省一次人工翻目录）。

### 新增 · 部署后自动对账（回执里直接给结论）

部署完立刻用 `.gil` 里嵌的脚本源码快照与刚投进去的活文件比 SHA，把结论写进回执：

- 一致 → `nextStep`：「停掉当前试玩 → 重新试玩一局」；
- 不一致 → `nextStep`：「**先在编辑器里存盘**（地图里嵌的还不是这一版）→ 再重新试玩一局」；
- 没有 `.gil` / 地图里没有脚本映射 → **如实说对不了账**，且 `nextStep` 里**不许出现「可以试玩了」**。

（起因：实测真有「部署完没重开局、白等 8 分钟」这一次。）

### 新增 · `miliastra_log op=runs`（按「局」切分 + 局间 diff）

一个 `.gia` 里可以装多局（实测 `21-24-16_155` 装了两段完整生命周期）。以前只有整文件倒序的 `op=tail`，
读的人得**人肉**把 30 多条倒过来、再分清哪段属于哪局。

现在 `op=runs` 按 `instance` 第一段切局，每局一行摘要：
`kind`（`47504` 试玩 / `90003` 编辑器主屏）、`epochSec`（**开跑时刻，与 `miliastra_playtest` 报的是同一个值**）、
`startedAt`、记录数、**疑似异常次数**、**错误样式数** + 首末行 + 就绪横幅，
并给最近两局**局间 diff**（新增/消失的错误样式 + 各项 delta + 一句结论）。

配套加了 `run=<epoch 秒或 instance 片段>` 过滤：`op=tail run=1790171162` 就**只看那一局**。

> `faultCount` / `errorSample` 是按**通用词**（重生/死亡/失败/`nil value`…）归的「**疑似**」计数，
> 不是平台给的分类 —— 回执里带 `caveat` 说明，具体含义以脚本里那行 `print` 自己的文案为准。

### 修复

- **截图列表在 mtime 并列时顺序不确定**：连着截两张常常落在同一毫秒里，
  只按 `mtimeMs` 排就退化成「`readdir` 给什么顺序就是什么顺序」，面板上同一批图每次刷新顺序都可能变。
  实测这条让一个测试**偶发失败**（断言「新的在前」时拿到 `a,b` 而不是 `b,a`）。
  现在按名字倒序兜底（文件名里带时间戳，≈ 按时间倒序），测试里也显式把两份的 mtime 拉开。

### 已验证 · 测试

`npm test` 全绿，合计 **336 项**（320 + 16 契约自检）：

| 套件 | 项数 | 本版变化 |
|---|---|---|
| `smoke` | **37** | +3：真机上 `health.errorLog` / `code.deploy` 指纹的形状、`log op=runs` 只读用例 |
| `deploy-test` | **30** | +2：指纹比对（一致 / 被改写 / 没指纹）、`fixbom` 的零改动与「备份失败即中止」 |
| `probe-deploy-test` | **16** | +6：工具层端到端的 `op=inspect` / `op=deploy` / `op=fixbom`、ErrorLog 巡检、对账失败路径 |
| `lualint-test` | 32 | — |
| `shot-test` | 86 | 修掉 1 条偶发（mtime 并列） |
| `playtest-test` | 63 | — |
| `giaruns-test` | **25**（新） | 分组 / 排序 / 计数去重 / 局间 diff；**真机回归**：`155` 必须切成 2 局试玩 |
| `client-render-test` | 31 | — |
| 契约自检 `selftest.mjs` | 16 | — |

### 文档

- README 与工作区 `AGENTS.md` 补上这四件事的用法与理由。
- `AGENTS.md` 记下作者定的开发优先级：**新能力优先做 AI 侧（工具 / 纯函数 / 回执），GUI 面板不同步实现**。

---

## [0.0.4] — 2026-09-23

补上**「试玩开跑」这个时刻本身** —— 之前所有能力都只能等这一局**结束**。

作者的原话是：「**能不能监听游戏开始玩，就能让 AI 实现游戏开始玩几秒后截图或者看日志**」。
这句话逼出一个之前一直没问对的问题：**「开跑」到底有没有可观测的信号？**

### 已验证 · 「开跑」有实时信号（本机实测）

先说结论能不能用：**能**，延迟 0.07~0.18 秒。

| | |
|---|---|
| 信号来源 | `%USERPROFILE%\AppData\LocalLow\miHoYo\<品牌>\output_log.txt`（游戏客户端自己写的 Unity 日志，**每行带毫秒时间戳、持续追加**） |
| 开跑 | `Genshin Loading Log: BeyondLevelPlayModule SetCurLevelData guid: 0 serverVersion: 0 isTrial:True` |
| 结束 | `Genshin Loading Log: StartQuickSwitchSceneAction … reason:QuickSwitchToBeyondSettleSceneNormally` |
| 实测延迟 | 2026-09-23 21:46 那局：开跑行自己写的是 `21:46:02.420`，**21:46:02.600 就读到了（Δ +0.18s）**；结束行 `21:46:58.960` → `21:46:59.033`（**Δ +0.07s**） |
| 附带好处 | 这是**平台级**标记 —— 脚本一行都不 print、磁盘上没有 `.gia` 的局，它照样记（本机 20:36 / 21:11 两局就是这样） |
| 对号 | 它带的 `NowTimeStamp:<epoch 秒>` **等于**那一局 `.gia` 里 instance 的第三段（`47504-<账号>-<这个>-<毫秒>`），所以「实时开跑」和「事后日志」能对上 |

### 修正 · 关于 `.gia` 的一条老结论

README / AGENTS 之前写过「`.gia` 文件还在变大 → 在试玩」——**这条是错的**。实测：

| 文件 | 名字时刻 | **落盘时刻（CreationTime = LastWriteTime）** | 该局结束 |
|---|---|---|---|
| `…21-46-05_157` | 21:46:05 | **21:47:07** | 21:46:58 |
| `…21-29-40_156` | 21:29:40 | **21:31:40** | 21:31:33 |
| `…21-24-16_155` | 21:24:16 | **21:26:14** | 21:26:06 |

本机 22 个 `.gia` **全部** `CreationTime == LastWriteTime`，且都晚于文件名时刻。
→ **局在跑的时候磁盘上根本没有这个文件**；「盯 `.gia`」只能等这一局结束。
（`2026-09-23_21-46-05` 这个文件名里的时刻 = 该局首次 print 的时刻，不是落盘时刻。）

### 新增

- **`miliastra_playtest`（第 8 个工具）** —— 试玩开跑/结束的实时侦测。
  - `op=status`：现在在不在试玩 / 开跑时刻 / 已跑多少秒 / 本局编号（epoch）/ 最近 8 局（起止 + 时长）/ 信号文件路径。
  - `op=wait`：**等下一次开跑**。`timeoutSec`（默认 90，上限 300）到点就回 `hit:false`，**不报错、不挂死**；
    `backSec` 可回扫刚过去的那一局（人点了试玩再叫 AI 的场景）；
    `afterSec` 命中后再等 N 秒才返回 —— 这就是「开跑 N 秒后」。
  - 典型用法：`miliastra_playtest op=wait afterSec=3` → 命中 → `miliastra_shot op=capture target=game`
    = **游戏开跑约 3 秒后的画面**。
- **`lib/playtest.mjs`** —— 解析 / 分类 / 归约 / 命中判定全是**纯函数**（可单测），I/O 无状态。
  处理了三件容易漏的事：① 日志行被拆到两次读取之间（carry）；② 游戏重启导致 `output_log.txt` **换代**（size 回退）；
  ③ 连续两次开跑没看到结束（**隐式收尾**，不把两局并成一局）。
- **面板「试玩开跑」卡片**（③ 日志与画面 栏最上面）：状态行（🟢 试玩中 · 已跑 N 秒 / ⚪ 未在试玩）、
  本局编号、最近几局胶囊、信号来源与**实测延迟**，以及
  **「开跑自动截图：开/关」+ 3/5/10 秒**（默认**关** —— 磁盘是用户的，没人点过就不该自己往里写图片）。
- **`tools/playtest-watch.mjs`** —— 只读侦测器：增量 tail `output_log.txt`，对每条命中行打印
  **Δ = 读到的时刻 − 它自己写的时刻**。这条数据就是上面那张表。
- **`tools/gia-timeline.mjs`** —— 按 `instance` 把 `.gia` 切成局，并列每局的 `level2` 区间。

### 安全

- 自动截图**默认关**；开了也只**新增 PNG 到插件数据目录**，不删、不改、不碰活文件与 `.gia`。
- 侦测只**读**游戏自己写的日志文件；不读内存、不连游戏端口、不冒充编辑器。
- `op=wait` 的时间参数全部夹在区间内（`timeoutSec` 5~300 / `afterSec` 0~120 / `backSec` 0~3600 / `pollMs` 100~5000），
  非法值给默认、不静默接受离谱值。

### 文档

- 记录一条**反例**：`.gia` 记录里的 `level2` 字段**不是局内秒表**（151 那局 25 条全是 1、155 第 1 局从 34 起、
  `90003` 那一路能到 842）→ 语义仍 `unknown`，**要时间轴只认日志行自己的毫秒时间戳**。
  （这条是自己在做本版时先信了、被实测推翻的，写下来免得后人再踩。）

### 已验证 · 测试

`npm test` 全绿，合计 **300 项**（含 16 项契约自检）：

| 套件 | 项数 |
|---|---|
| `smoke` | **34**（+3：`op=status` 形状、`op=wait` 超时可打断、试玩状态只读） |
| `deploy-test` | 28 |
| `probe-deploy-test` | 10 |
| `lualint-test` | 32 |
| `shot-test` | 86 |
| `client-render-test` | **31**（+2：开跑触发判据纯函数、试玩卡片文案） |
| `playtest-test` | **63**（新） |
| 契约自检 `selftest.mjs` | 16 |

---

## [0.0.3] — 2026-09-23

按作者反馈做减法 + 补上截图真正缺的那一环。

两条反馈直接定了这一版：

1. 「**最近一局是 2 小时前写的（18:44:57）做个是什么鬼东西 下面一大段一直在**」
   —— 那是 0.0.1 加的「试玩体检」结论条。它**常驻**：只要最近一局是几小时前写的，
   红框 + 「可能原因」+「下一步」就永远挂在日志卡片最上面。作者当然知道自己没在试玩。
   → **整块删掉**（前端 + 工具 op + 判据模块 + 测试）。
2. 「**图片最好有缩略图预览**」→ 补上，并且**不让页面去拉原图**。

同一天里还有一条硬要求：**删除试玩自动化相关的越界线索**（详见「安全」）。

---

### 变更

#### 移除「试玩体检」（`miliastra_log op=diagnose` + 面板结论条）

删掉的东西（不留半截）：

| 位置 | 删了什么 |
|---|---|
| `miliastra_log` 工具 | `op=diagnose` 整个分支；`enum` 从 5 个变 4 个（`sessions` / `tail` / `grep` / `tags`） |
| `lib/logdiag.mjs` | 整个文件（判据纯函数） |
| `tests/logdiag-test.mjs` | 整个文件（9 项），并从 `npm test` 里摘掉 |
| 面板 | 结论条（红框 + 可能原因 + 下一步）、「试玩体检」按钮、「打开面板/切关卡自动跑一次」的 effect、`__logDiag` 测试钩子 |
| 渲染测试 | 两条针对它的断言块 |

**保留**：「试玩完自动取」—— 它只是守望 `.gia` 文件、试玩结束后把那一局日志填好，
是纯读取，与「替用户下结论」无关。

「最近一局是什么时候写的」这个**事实**本来就在日志卡片的 KV 行里，看一眼就够。
工具描述里也换成一句无结论的提醒：`.gia` 里只有脚本自己 `print` 的东西，
最坑的一次是**压根忘了开试玩**（游戏客户端开着 ≠ 在试玩）。

#### 截图：缩略图预览

面板上能看到缩略图，但**页面不会去拉原图** —— 一张原图 2.4 MB，列十几张就是 30 MB，
每次刷新都要重下。所以：

- **截图时就顺手生成预览**（`capture-window.ps1 -ThumbOut`）—— 用**同一个 bitmap** 缩小，
  **不额外起 PowerShell**（起一个进程约 1 秒，而面板一开就要十几张）
- 预览放 `<shots>\_thumbs\`，与原图**同名**；`listShots` 只收顶层文件，所以预览不会被当成截图
- `op=clean` 删截图时**连预览一起删**（否则 `_thumbs` 会攒一堆孤儿）
- 老的截图没有预览 → `GET /miliastra/shot?...&thumb=1` 会**按需生成**，`lib/thumbnail.ps1` 兜底
- 缩略图比原图旧就重做（改了图不会一直显示旧预览）
- 面板：最近一张**放大显示**（「我刚才截的对不对」是第一眼要看的），下面 6 张网格（可展开到 24），
  全部 `loading="lazy"`，点开在新标签页看原图

#### 新增只读图片路由 `GET /miliastra/shot?name=<文件名>[&thumb=1]`

插件里**唯一**返回非 JSON 的路由，所以守卫写死在这里：

| 检查 | 结果 |
|---|---|
| `name` 为空 / 含 `/` 或 `\` / 是 `.` 或 `..` | **400**（`../`、子目录、绝对路径一律拒绝） |
| 不是 `.png` | 400 |
| 解析后的父目录 ≠ 截图目录 | 400（越界） |
| 文件不存在 | 404 |
| 通过 | 200 + `image/png`，`Cache-Control: private, max-age=31536000, immutable`（文件名带时间戳，内容不会再变） |

判据抽在 `lib/shot.mjs` 的 `resolveShotFile()`（纯路径运算，**13 项测试**盯它）。

---

### 修复

#### 截图选错了窗口 —— 抓回一张 160×28 的标题栏碎片

**复现**：点「截编辑器」得到一张 **160×28** 的 PNG（1536 字节），内容是一小条菜单栏，而回执是 `ok:true`。

**根因**：进程 ≠ 一个窗口。本机实测 `BeyondEditor.exe` (5608) 同时有
**900×800 的日志窗**和 **160×28 的最小化残片**，而 `Process.MainWindowHandle` 指向了**后者**。
原实现 `foreach ($p in $procs) { if ($p.MainWindowHandle -ne 0) { ...; break } }`
就是「第一个有主窗口的进程」，于是挑中了碎片。

**修法**（`lib/capture-window.ps1` 重写选窗部分）：

1. 用 `EnumWindows` **枚举该进程的全部可见顶层窗口**（不再只看 `MainWindowHandle`）；
2. 按**面积从大到小**取第一个，同等大小优先没最小化的；
3. 最大的仍小于 **200×150** → **拒绝**（报错 + 列出所有候选），不往截图目录写垃圾；
4. 新增 `window=<标题子串>` 参数手动指定（例如 `window=日志`）；
5. 回执带 `candidates[]`（`title/w/h/area/minimized`），**选窗依据看得见**。

**顺带补的第三个可信度指标 —— `uniformRatio`**：
原来只量黑像素。但 **PrintWindow 返回全白**时黑比是 0，照样判「可信」。
现在同时量「**单一颜色占比**」> 98.5% 也判 `suspect`。
另外把「尺寸小得离谱」也纳入 `judgeCapture`（作为第二道防线）。

---

### 安全

- **不留越界线索**：上一版把「编辑器↔客户端存在一个私有本地通道、要驱动它就得冒充编辑器」
  连同端口号一起写进了文档。作者的要求是**全删，只留一句「没有自动化通道，不做」** ——
  已从 README / CHANGELOG / AGENTS.md 一并清掉，避免以后（人或 AI）照着去试。
- **只读图片路由的路径守卫**：见上。删截图时连预览一起删，不留孤儿文件。

---

### 已验证

| 层 | 命令 | 项数 |
|---|---|---|
| L1 工具层 | `node tests/smoke.mjs` | 30 |
| L1 部署与备份安全 | `node tests/deploy-test.mjs` | 28 |
| L1 探针部署 / 多活文件 / 两种关卡布局 | `node tests/probe-deploy-test.mjs` | 10 |
| L1 Lua 结构校验器 | `node tests/lualint-test.mjs` | 32 |
| L1 **截图（含缩略图路径、路径守卫、可信度四查）** | `node tests/shot-test.mjs` | **86** |
| L3 真实 React 渲染（含缩略图走小图路由） | `node tests/client-render-test.mjs` | 29 |
| L1 技能契约自检 | — | 16 |
| L4 真机 | `node tests/live-check.mjs` | 9 |

**合计 231 项**（215 + 自检 16）。

**本版真机验到的**（`observed`）：

- 选窗修复：`editor` 目标现在挑中 **900×800 的日志窗**（回执 `title=日志`，候选里能看到那个
  160×28 的碎片被标了 `minimized:true`）。**打开 PNG 看过**，确实是日志窗。
- `capture` 一次同时产出原图（92.7 KB）与预览（39.2 KB）——**打开预览看过**，是同一张图。
- 游戏目标回归正常：`YuanShen` → 1456×939、`printwindow`、黑比 0.0312。
- 活文件**未被本轮改动触碰**。

**仍未真机验的**（`pending`）：`api-check` 探针、`Tween`/`EaseType` 那套动画 API、
`game.IsTestPlay()` 的返回值、`.gia` 的落盘时机（承接前两版，仍标「尚未完全实测」）。

---

## [0.0.2] — 2026-09-23

补上工具链里一直缺的一环：**画面取证**。

前三件工具回答的是「代码对不对」（活文件 / 地图 / 日志），但**回答不了「画面对不对」** ——
控件到底挂上去了没、位置歪没歪、颜色对不对。之前这一环只能靠人截图再手动贴回来，现在做成工具；
同时把作者提的两件要求落进设计里：**它得跟日志一样主动告诉用户东西存在哪，而且要提示清理。**

本版**没有做任何侵入行为**：不碰游戏进程、不碰地图、不碰存档；唯一的写操作是往插件自己的数据目录写 PNG。
顺带把「试玩按钮能不能自动化」查清了，结论是**不能**（见「已知边界」）。

---

### 新增

#### `miliastra_shot` —— 第 7 个 Host 工具

| op | 干什么 |
|---|---|
| `capture`（默认） | 立刻截一张。`target=game`（`YuanShen.exe`，默认）/ `editor`（`BeyondEditor.exe`），也可 `process=` 指定任意进程名 |
| `list` | 截到哪去了、有多少张、占多大、最近一张 |
| `clean` | 清理。**默认 `dryRun`（只报告）**；真删要 `dryRun:false` + `confirm:true` 双钥匙 |
| `targets` | 可截目标 + 它们此刻在不在跑（复用 `lib/proc.mjs` 的 10 秒缓存） |

**回执恒带窗口身份**：`process` / `pid` / `title` / `mode` / `blackRatio`。
这不是装饰 —— 第一版抓回来的其实是**恰好压在前面的浏览器**，而当时回执只有 `{ok:true,path}`，
**光看返回值完全无法发现**，是靠人眼看 PNG 才暴露的。

#### `lib/shot.mjs` —— 判据与 IO 分开

里面有**删除判据**，所以按纯函数写、可单独测：

| 导出 | 性质 | 说明 |
|---|---|---|
| `shotFileName` / `sanitizeLabel` / `stampOf` / `nextFreeName` | 纯 | `game-<标签>-20260923-204915.png`；**允许中文**（只清 Windows 非法字符与控制字符）；同秒重复自动顺延 `-2` |
| `planClean` | 纯 | **删除判据**：`all` 或 `olderThanDays` 必须显式给；`keepLast` 是任何模式下都生效的保护网；**既不给条件就一张都不删**并说明原因 |
| `judgeCapture` | 纯 | **可信度判据**：`screen` 路线且不在前台 → `suspect:true`；黑像素 > 98.5% → `suspect:true` |
| `listShots` / `removeShots` | IO | 只收 `.png` **文件**（同名目录不算）；删除失败进 `failed[]`，**不抛** |
| `dataRoot` / `shotsDir` | IO | `MILIASTRA_DATA_DIR` > `DSH_HOME/miliastra` > `~/.dsh/miliastra`，再下 `shots\` |

#### `lib/capture-window.ps1` —— 窗口截图助手

Node 在 Windows 上没有内建窗口截图能力，所以这一段是 PowerShell。**源码刻意保持纯 ASCII**
（PowerShell 5.1 在没有 BOM 时按 ANSI 解码 `.ps1`，任何中文都会变乱码并可能打断解析）。

两条路线按可信度排序，不靠运气：

| 顺序 | 路线 | 何时用 | 可信度 |
|---|---|---|---|
| 1 | `PrintWindow(hwnd, dc, PW_RENDERFULLCONTENT=2)` | 总是先试 | **不需要窗口在前台**，被遮挡也行 |
| 2 | 屏幕抓取（`CopyFromScreen` + `AttachThreadInput`/`SetForegroundWindow` 抢前台） | PrintWindow 失败，**或第 1 步的黑像素 > 98.5%** | 只有目标窗口真的在前台才对 → 回执里标 `front` |

黑比是**量出来的**，不是猜的：Unity/D3D 的 swap chain 有时会让 PrintWindow 返回全黑，
所以不能「返回成功就当作成功」；`judgeCapture` 会把这种情况标成 `suspect`。

同时做了 **DPI 感知**（`SetProcessDPIAware`）—— 不声明的话，缩放不是 100% 时窗口矩形与像素对不上，图会被裁。

#### 面板 · 「游戏截图」卡片（第 ③ 栏改名 **「③ 日志与画面」**）

作者的要求原话：「**做游戏截图希望有这个功能跟日志一样要提示用户，是存到插件运行目录要提示用户清理**」。
所以卡片的设计目标**不是「能截到图」**（工具已经截出来了），而是**让用户始终知道东西落在哪**：

| 卡片上永远可见 | 为什么 |
|---|---|
| **存放目录**（绝对路径，随 `status` 一起来，不用点按钮才看得到） | 不然用户根本不知道图去哪了 |
| **现有 N 张 · 占多大** + 最近一张文件名 | 不然不知道要不要清 |
| **「不会自动删。清理要你自己点：先看『会删哪些』，确认了才真删。」** | 用户会默认工具会自己收拾 —— 这句必须写在卡片上，不能藏在文档里 |
| 截图回执：截到的是「原神」pid=3284 · 1456×939 · 窗口自绘 · 黑比 0.0312 | **自证**：不用相信工具，一眼看出它截的是不是那个窗口 |

按钮：`截取游戏画面` / `截编辑器` / `列目录` / `清理…`。清理是**两步**：
未规划时**不给**「确认删除」按钮（测试守住这条），规划出来后列出将删清单 + 确认 + 取消。
`suspect=true` 时用醒目样式（`-err`）弹出 `warning`。

#### `tools/shot-live.mjs`（开发工具，不随包发布）

真机验收用：截图这件事的正确性**只有看图才知道**，单元测试能测的只有命名 / 清理规划 / 目录解析。
`node tools/shot-live.mjs game` 会真截一张并把路径打出来让人去看。

---

### 变更

#### 探针描述的模板清单改为**生成**，不再硬编码

`miliastra_probe` 的工具描述原本写死「**四个模板** `ping` / `tree` / `instantiate` / `api-surface`」——
第 5 个 `api-check` 加进去之后**描述就悄悄过期了**（真机上一查才发现：描述里只有 4 个，`enum` 里却是 5 个）。
现在数量与清单都从 `PROBE_TEMPLATES` / `PROBE_INFO` 生成，**加模板不可能再漏**。
测试名与注释里同样过期的「四个模板」一并修掉。

---

### 修复

- **PowerShell 5.1 重定向 stdout 走的是控制台代码页（本机 936/GBK），不是 UTF-8**。
  窗口标题「原神」出来是 `d4ad c9f1`，Node 按 utf8 解码成 `ԭ��`。
  确认方式：`execFileSync` 拿原始 bytes 看 hex 尾巴。修法：脚本把**所有非 ASCII 转义成 `\uXXXX`**，
  整行输出纯 ASCII，任何解码器都不会错（已复验：`title` 解析回 `原神`）。
- **`package.json` 的 `files` 只有 `lib/**/*.js` 和 `lib/**/*.mjs`，漏了 `lib/**/*.ps1`** ——
  **发布到 npm 会缺掉 `capture-window.ps1`，别人装上截图直接坏**。已补。
  （这类「本地测全绿、装给别人就坏」的问题只有对着发布清单逐条核才看得见。）

---

### 安全

截图模块按**「磁盘是用户的」**这个前提设计，和备份同一套原则：

| 约定 | 为什么 |
|---|---|
| **绝不自动删** | 删除不可恢复。`clean` 只按**显式条件**干活（`all` 或 `olderThanDays`） |
| 没给条件 → **一张都不删**并说明原因 | 「默认帮你清一清」是最糟的行为 |
| 真删要 **`dryRun:false` + `confirm:true` 双钥匙** | 和 `noBackup` 双钥匙同源：不给随手绕过 |
| `keepLast` 在**任何模式**下都生效 | 保护网：哪怕 `all:true` 也至少留最新 N 张 |
| 落盘**不放两个地方** | ① 不放 `external_lua_file\`（米哈游存档，会污染「活文件 = 目录里的 .lua」这类判断）；② 不放 `node_modules\dsh-miliastra\`（**插件升级/重装会整个替换掉，用户的图会凭空消失**）。所以放第三个地方：插件自己的数据目录 |
| 工具回执里**永远报绝对路径 + 张数 + 体积** | 用户要能自己决定清不清 |

**本版没有做任何侵入行为**：不读游戏内存、不连游戏端口、不改地图、不动存档。

---

### 文档

- 本文件新增 `[0.0.2]` 段；`README.md` 工具表加 `miliastra_shot`、新增「截图」小节与「试玩按钮为什么不能自动化」；
  版本号 `0.0.1` → `0.0.2`。
- 工作区 `AGENTS.md` §9 工具表加 `miliastra_shot`、测试项数更新、新增**画面取证纪律**与
  **试玩按钮通道对照表**（三个通道逐条给证据）。

---

### 已验证

| 层 | 命令 | 项数 |
|---|---|---|
| L1 工具层 | `node tests/smoke.mjs` | 30 |
| L1 部署与备份安全 | `node tests/deploy-test.mjs` | 28 |
| L1 探针部署 / 多活文件 / 两种关卡布局 | `node tests/probe-deploy-test.mjs` | 10 |
| L1 Lua 结构校验器 | `node tests/lualint-test.mjs` | 32 |
| L1 试玩体检判据 | `node tests/logdiag-test.mjs` | 9 |
| L1 **截图：命名 / 清理规划 / 目录解析 / 可信度判据 / 真删双钥匙** | `node tests/shot-test.mjs` | **59** |
| L3 真实 React 渲染（+ 截图卡片、两步清理） | `node tests/client-render-test.mjs` | 30 |
| L1 技能契约自检 | — | 16 |
| **L4 真机** | `node tests/live-check.mjs` | 9 |

**合计 214 项**（含自检 16 项）。

**本版真机验到的**（`observed`）：

- 截图抓的是**原神窗口本体**：`process=YuanShen pid=3284 title=原神`，`mode=printwindow`、
  `blackRatio=0.0312`、1456×939；**打开 PNG 看过**，是游戏画面（UID 201170108），不是浏览器。
- `PrintWindow` 路线**确实不需要窗口在前台**（回执 `front:false` 而图是对的）。
- 端到端：`op=targets` / `op=list` / `op=capture` 走通，落盘 `~/.dsh/miliastra/shots/`。
- 活文件**未被本轮改动触碰**：`双相.lua` = `97266B79…E028`、22446 字节（写操作只发生在插件数据目录）。

**仍未真机验的**（`pending`）：`api-check` 探针、`Tween`/`EaseType` 那套动画 API、
`game.IsTestPlay()` 的返回值、`.gia` 的落盘时机（维持 0.0.1 的结论：尚未完全实测）。

---

### 已知边界

- **「试玩」按钮没有自动化通道 —— 不做。** 编辑器没有官方 CLI、没有插件/本地 RPC 通道，
  主界面是 **Unity 自绘**的（UIA 子孙节点 = 0），没有可驱动的原生控件。
  试玩由人在编辑器里点；插件负责把人的动作接住（试玩完自动取回最新一局日志），
  **不做任何越界的事**（不读游戏内存、不连游戏进程的端口、不冒充编辑器）。

- **截图不能按组件取景**：没有蛋仔 `eggy_shot focus=prefix:椅子` 那种「按组件包围盒自动移镜头」，
  因为千星奇域这边**还没有读场景对象的工具**（`.gil` 的 `#7` 场景对象字段已能读，但没做成工具）。
  目前只截整个窗口。
- **`target=editor` 截到的是编辑器的日志/节点图窗口**，不是 3D 编辑视图 —— 3D 视图在**游戏客户端窗口**里
  （所以 `target=game` 才是看画面的那个）。
- **`.gil` 里 30+ 个顶层字段仍未解读**（承接 0.0.1）。

---

## [0.0.1] — 2026-09-23

首个版本。这套工具链不是设计出来的，是**在原神·千星奇域 7.1 正式服上排一次真实的 bug 排出来的** ——
那次「`game.InstantiateClientUIControl` 对任何索引号都返回 `nil`」折腾了好几小时，
根因是**「客户端控件模板库为空」**。踩过的坑都固化成了工具能力与 README 的「关键知识」。

本版开发过程里有相当一部分时间花在**推翻自己**上：先后纠正了 7 条想当然的结论
（见「修复 · 被实测推翻的结论」）。所以下面每条「实测」都尽量带证据、标状态。

---

### 新增

#### Host 半边 · 6 个工具

| 工具 | 能力 |
|---|---|
| **`miliastra_health`** | 环境体检：客户端安装 / 关卡 / 活文件 / 地图 / 日志目录定位。**两种关卡布局都扫**（`<id>\<id>.gil` 与根目录的 `<id>.gil` —— 只扫前者会漏掉大部分图）。另含编辑器 / 游戏**进程状态**（`BeyondEditor.exe` / `YuanShen.exe`，10 秒缓存、best-effort 不抛） |
| **`miliastra_code`** | 活文件 `inspect` / `read` / `deploy` / `backup` / `backups` / `restore`。部署一律 **先备份 → 二进制拷贝 → 比对 SHA-256 → 校验无 UTF-8 BOM** |
| **`miliastra_map`** | 读 `.gil`（protobuf）：`summary` / `clientui` / `script` / `strings`。`clientui` 给出**客户端控件谱系 + 可被脚本创建的模板清单**；`script` 给出**地图快照 vs 活文件的一致性** |
| **`miliastra_log`** | 读 `.gia` 运行时日志：`sessions` / `tail` / `grep` / `tags`，结构化到 `{时间, 账号, 玩家, 关卡, 正文}` |
| **`miliastra_probe`** | 探针模板化（见下），`render` → `deploy` → `collect` |
| **`miliastra_echo`** | 回显参数，确认参数真的传到了 Host |

另外：

- **同源 HTTP 路由** `/miliastra/{status,tools,tool}` —— 面板取数据的唯一合法通道，
  带**本机访问守卫**（非 `127.0.0.1`/`localhost` 一律 403）、未知路由 404、`Cache-Control: no-store`。
- **系统提示段**：让 agent 知道「有这么个插件、什么时候该用它」，而不是去拼 PowerShell。
- **`/status` 的 `clientHalf` 自检**：查询宿主 `dsh-client-modules` 的 boot 图，
  直接回答「面板 bundle 有没有进 `window.__DSH_BOOT__`」——
  把原先「只能靠人眼看面板出没出来」变成可断言的数据（含 `rev` = bundle 内容哈希）。

#### Client 半边 · 侧边栏面板

- 注册到**官方槽位** `sidebar.footer.action`（不是 DOM 注入）；**三栏**布局（① 关卡 / ② 代码 / ③ 日志），每栏独立滚动
- 粉蓝配色；入口图标内联 PNG（像素画，`image-rendering: pixelated`）
- **关卡切换 + 自动跟随**：默认跟着 `miliastra_health` 判定的「当前关卡」走；点某一关切成手动锁定，一键回自动
- **进程状态**：免截图看编辑器 / 游戏在不在跑（实测：编辑器是**独立进程**，不是游戏子进程）
- **多活文件**：一个关卡可以有多个 `.lua`（不同角色各一个），选择器切换，所有操作跟着走
- **活文件体检**：大小 / 行数 / SHA-256 / 是否带 BOM / 编码是否合法 / 有无中文注释
- **脚本一致性**：地图里嵌的脚本快照 vs 本地活文件 —— 回答「跑的是不是本地这版代码」
- **备份清单 + 一键还原**（详见「安全」）；份数过多只提醒，**永不自动删**
- **试玩完自动取回**：守望 `.gia`，试玩结束自动把那一局的日志填好（见「变更」）
- **历史日志**：列出最近 12 局，点某一局只看那一局；TAG 汇总与按 TAG 过滤
- **诊断日志三连**（刻意保留）：`factory 已执行` / `apply 运行中` / `入口已注册` ——
  client 半边失败默认是**全静默**的，这三行让排障一眼定位卡在哪一步

#### 部署前的 Lua 结构校验（`lib/lualint.mjs`）

**一段语法错的 Lua 投进沙箱，试玩会静默不生效** —— 脚本根本没起来，日志里只会是「什么都没有」，
这比报错难查得多。所以 `miliastra_code op=deploy` 与 `miliastra_probe op=deploy` 默认先扫一遍结构：

| `lintMode` | 行为 |
|---|---|
| `strict`（默认） | 缺 `end` / 括号不配平 / 字符串或长注释没闭合 / `repeat` 少 `until` → **拒绝部署，且不碰活文件、不产生备份** |
| `warn` | 照投，但问题进 `warnings[]`，不静默吞掉 |
| `off` | 不校验（逃生舱）；未知取值明确报错，**不静默降级成 off** |

零依赖的结构级启发式（**不是**完整 Lua 解析器）：先严格剥注释与字符串（含 `--[[ ]]` / `[==[ ]==]`），
再统计块开闭与括号配对；报的是**「第 N 行的 function 没有对应的 end」**，不是「文件最后一行」。

#### 5 个探针模板（`lib/probes.mjs`）

| 模板 | 大白话 | 干什么 |
|---|---|---|
| `ping` | 探活 | 确认脚本到底有没有跑起来（日志空着时先跑它） |
| `tree` | 看控件 | 屏幕上挂着哪些客户端控件、父子关系、画布多大 |
| `instantiate` | 试钥匙 | 拿一串索引号去试，看哪个真能被脚本创建 |
| `api-surface` | 翻字典 | 把枚举和成员列出来（比如按键的真名） |
| `api-check` | 核文档 | 官方文档写的接口，真机上到底有没有（见「文档」） |

> 所有模板都内建 `script:EnableUpdate(true)`（漏了它 `OnUpdate` 永不触发，只会打三行空壳），
> 且长输出统一走 `pChunked()` 分片（见「修复」）。

> **⚠️ 下面这一节已在 0.0.3 移除**（作者反馈：那段结论条**常驻**在面板最上面，
> 「最近一局是 2 小时前写的」+ 一大段「可能原因/下一步」对用户没有信息量）。保留原文仅作历史记录。

#### 试玩体检（`miliastra_log op=diagnose`）

背景：作者试玩了一会儿回来说「看看」，**磁盘上却没有新的 `.gia`** —— 而当时的工具只会
安安静静把**上一次的旧日志**端上来。他不知道漏了哪一步，最后自己查出来是**忘了从编辑器开试玩**。
（原话：「是我忘记开了 **这情况要提示用户**」。）

这类「空结果」最坑的地方是：它和「脚本没 print」「日志被配置关掉」「试玩还在进行中」
**长得一模一样**。所以把「人肉判断」抽成可断言的**纯函数** `lib/logdiag.mjs`：

| 结论 | 触发 | 给什么 |
|---|---|---|
| `fresh` | 最近一局 ≤ 120 秒前写的 | 一句话：这应该就是你刚才那局 → 直接取日志 |
| `stale` | 最近一局更旧 | 醒目提示「**你刚才那局没有写出日志**」+ 按证据给可能原因 + 下一步 |
| `no-logs` | 一个 `.gia` 都没有 | 这张图从没试玩过（并点出编辑器 / 游戏在不在跑） |

`stale` 时的可能原因**按「能拿到的事实」排**，不笼统说「不知道」：

1. **其实没点「试玩」**（最常见）—— 实测同一个游戏进程跨了一整天的全部试玩，所以「进程在跑」不能当判据
2. 或者试玩还在进行中 —— ⚠️ `.gia` 落盘时机**尚未完全实测**，这里如实标「有可能只在结束时写」，**不写成定论**
3. 地图刚存过盘（有事实才提；地图比日志旧就不提，免得误导）
4. 编辑器「日志」面板里 `客户端脚本` 没勾选 → 脚本 print 不落盘

面板上放在**日志卡片最上面**（`stale` 用醒目样式、`fresh` 只一句淡色说明），
打开面板 / 切关卡时**自动跑一次**，并留一个「试玩体检」按钮随时再问。
`logdiag-test.mjs` 9 项专门盯这个判据（**判错会把人带偏**：比如游戏都没跑却说「试玩进行中」）。

#### 离线知识库抽取（工作区产物，**不进本仓库**）

内容是官方文档的抽取产物（版权归米哈游），本仓库只放可发布的代码。产物在工作区 `docs/`：

| 产物 | 抽取工具 | 规模 |
|---|---|---|
| `千星奇域_API参考.md` | `tools/extract-api-reference.mjs` | **153 个接口 + 27 张枚举表 / 394 个成员** |
| `千星奇域_按键事件枚举对照表.md` | `tools/extract-key-enums.mjs` | `KeyEventType` 164 / `KeyboardKeyCode` 59 / `ControllerKeyCode` 25 |
| `千星奇域_补间动画.md` | `tools/extract-api-reference.mjs` | `Tween` / `TweenSequence` / **`EaseType` 31 条缓动曲线** |

---

### 变更

#### 探针从面板上撤下来，收进可折叠的「高级诊断」

背景：作者反馈「**折腾探针对人没啥用啊**」。这是对的 —— 探针解决的是「AI 写代码时不知道某件事」，
而创作者要为它付出四次手工操作（部署 → 停试玩 → 重开 → 收回 → 还原）。

- 探针收进右栏「高级诊断」折叠卡片，**默认收起**（收起态不渲染内容，不是藏起来）
- 同一折叠区里放进**「读界面控件」**（只读，从 `.gil` 读，不碰任何文件），
  两块**风险分开标注**：① 只读 ／ ② 会临时覆盖你的脚本

#### 日志从「一坨文本」改成格式化列表

| 改前 | 改后 |
|---|---|
| 时间/标签/正文同色同字号挤成一行 | 时间（暗）+ `[TAG]`（胶囊）+ 正文（自动折行）分列 |
| 出错那行混在几十行里看不出 | 疑似异常 → 左红竖线 + 底色；警告 → 粉线 |
| 超长行把整块撑爆（枚举 dump 上万字符） | 超 400 字符折叠成「共 N 字符，点开看全」 |
| 高度上限 210px | 52vh（约 3.7 倍） |
| 无筛选 | 「只看异常」开关 + `N 行 · 疑似异常 X · 警告 Y` 计数 |

> ⚠️ 异常判色是**关键词启发式**（地图脚本 `print` 不带级别），只用来「显眼」，不用来「判定」。
> 有反例测试守住：`err=nil` 是**正常**输出，不能一见 `nil` 就标红（否则「只看异常」全是噪音）。

#### 试玩结束自动取回最新一局

守望 `.gia` 文件：出现新文件 / 文件变大 → 说明试玩在动；安静 6 秒后自动取回那一局。
判据抽成**纯函数** `autoFollowStep`（定时器不好测，判据好测）：

| 情况 | 行为 |
|---|---|
| 出现新文件 / 文件变大 | 记为「在动」，不取 |
| 还在变大 | 状态显示「这一局还在写（试玩中）」 |
| 安静够 6 秒且「看见动过」 | **自动取回** |
| 打开面板时最新一局是旧的（几小时前） | **不取** —— 别拿旧局面糊用户 |
| 打开面板时最新一局刚写完（30 秒内） | 算「刚玩过」，直接取 |
| 同一版日志（名字 + 大小没变） | 不重复取 |
| 0 字节 | 不取，显示「这一局还没写出内容」 |

#### 备份命名统一：固定名 + 本地时间戳各一份

每次备份写**两份**：固定名 `<原名>.bak`（= 最近一次覆盖前的版本，**还原默认用它**）
+ 历史 `<原名>.<本地时间戳>_备份.lua`（永不自动删，同秒撞名顺延 `-2`/`-3`）。
时间戳从 **UTC 改成本地时间**（原来比本地少 8 小时，看着像错的）。

#### 地图体检扩展（静态读 `.gil`，不用试玩、不碰任何文件）

| 字段 | 读出来是什么 | 实测（本机地图） |
|---|---|---|
| `#29` | 客户端 + 资源版本 | `CNRELWin7.1.0`　资源 `48145775 / 48379043 / 48455349 / 48474090` |
| `#11` | 阵营 / 出生点 / 预设点 | 阵营 3 个（初始玩家/物件/造物阵营 + `UI_MarkPlayer_Faction_*`）、出生点「出生点1」、预设点「新建预设点」 |
| `#7` | 场景对象 | **403 条**（`地形01` …） |

#### 界面文案改成「使用者视角」

背景：作者反馈「**这探针描述很抽象啊 有没有大白话的说明**」「**gui 界面也没看懂探针作用**」。
根因是按**实现者视角**写文案（「只读诊断脚本」「最小连通性」「读画布上所有活控件的控件模板索引并逐个试创建」）。

| 位置 | 改前 | 改后 |
|---|---|---|
| 工具描述 | 「探针（只读诊断脚本）模板化」 | 「探针 —— **「问游戏一句」的工具**」，写明为什么需要它、代价是什么、四步怎么走 |
| 面板小标题 | 只读诊断脚本 | 问一句就答 |
| 模板按钮 | `ping` / `tree` / `instantiate` | **探活（ping）/ 看控件（tree）/ 试钥匙（instantiate）/ 翻字典（api-surface）/ 核文档（api-check）** |
| 选中的模板 | 无说明 | 「做什么 / 什么时候点它」两行展开（来自 Host 的 `PROBE_INFO`，单一口径） |
| 面板顶部 | 无 | 「探针是干嘛的 / 什么时候用 / 要付什么代价」+ 四步流程 |

---

### 修复

#### 契约类（错过一次就够难受的）

- **`exports.inject` 没声明 `slots`** → cordis 的服务代理直接抛
  `cannot get property "slots" without inject`，**整条 loader entry 失败**。
  症状最难看：宿主报 `failed to apply loader entry`，而页面上**一条我们的日志都没有**。
  更坑的是当时把诊断日志写在了 `try` 外面 —— 一行探针把整个插件打死了。
- **Client 的 `callTool` 少剥一层信封** → 面板上「本机存档 —」「关卡数 undefined」全是空的。
  信封是三层 `body{ok,data:{name,ok,data:<业务返回体>}}`，这个错**犯了两次**
  （一次在 `live-check.mjs`、一次在面板里）。现在剥离逻辑抽成独立函数并加了回归测试。

#### 扫描与路径

- **关卡扫描盲区**：`Beyond_Local_Save_Level` 下有两种布局，只扫带文件夹的那种会**漏掉大部分图**
  （实测 8 个只看见 3 个；修复后 18 个）。
- **`MILIASTRA_LOCALLOW` 写错会静默回退到真实存档根** —— 手滑就会去动真文件。
  改成「显式指定就照做，指错就明确报错」。
- **解析 5 字节 varint 只解 4 字节**，漏掉整整一段 ID 区间（工具早期版本）。

#### 备份与还原

- **备份同秒撞名**：时间戳只到秒，同一秒连部署两次会覆盖前一份备份。改为自动顺延 `-2` / `-3`。
- **备份文件的 mtime 沿用源文件**（`copyFileSync` 的行为，已实测）→ 若按 mtime 排序取「最新备份」会**选错**，
  界面上显示的备份时间也是错的。改为：备份 mtime 显式设成**创建时间**，排序**优先用文件名里的时间戳**。
- **`stampOfName` 解析下标算错**（按含 `-` 的字符串算的，实际拼起来时 `-` 已去掉）——
  由新加的断言抓出。

#### 结构校验器

- **换行被丢掉** → 「行尾标识符 + 下一行行首词」粘成一个标识符
  （`local sx, sy` 换行接 `local` → `sylocal`），**行首的 `end` 被吞进上一行尾巴**。
  症状极具迷惑性：**真文件恒报「缺 29 个 end」，而单行小样例全绿**。
  逐行拆 token 才抓到（`L130:sylocal`）。现在换行占位保留，有 4 条回归断言盯它。
- **缺 `end` 时报的是「文件最后一行」**，对排障几乎没用。改为记开块栈，报**最内层没关上的那个块**的行号。

#### 日志与探针

- **探针漏 `script:EnableUpdate(true)`** → `OnUpdate` 永不触发，游戏里只打
  `OnInit`/`OnEnable`/`OnStart` 三行空壳。模板已内建这行，并有断言守住。
- **探针长输出被日志静默截断**（实机跑 `api-surface` 时发现）：
  **单条日志消息上限实测正好 10000 字符**，超出部分不报错、不提示，直接没了。
  `Enum.KeyEventType`（164 项）那份刚好打满，排在字母表后面的
  `KeyboardMoveLeftKeyDown` / `KeyboardJumpKeyDown` 恰好全被切掉 ——
  **看日志的人会以为「枚举里没有这个键」**。现在长输出走 `pChunked()`（3500 字符 / 片，带 `(1/3)` 标记），
  测试守卫「模板必须有分片 helper 且 `CHUNK < 10000`」。
- **`api-surface` 模板里的语法错**（`if … then … end else … end`）—— 由新加的模板结构校验抓出。
  那种错**投进去就是整段静默失效**。

#### 面板

- **漏列 `api-surface`**（硬编码了 3 个模板名）→ 改成**从 Host 的 `op=list` 拿清单**，
  加模板不用改面板；拿不到才用面板内兜底文案，并有测试核对两边不脱节。
- **按钮编号缺「②」**（只有 ① 部署 / ③ 收回，中间那步要人做却没写）→ 补一行显式说明。
- **部署后没有「还原我的脚本」入口** → 新增 **「④ 还原我的脚本」** 一键按钮
  （用部署时自动生成的那份备份；未部署时该按钮不出现）。
- **界面文案里残留 Markdown 记号**（`**粗体**` 与反引号在面板上是**原样显示**的）→ 清掉并加测试守住。
  该测试后来**从 1 种渲染态扩到 3 种**（收起 / 展开 / 展开且带数据），一扩又抓到两处漏网。
- **README 里「客户端排障」整节重复了两遍** —— 复查时发现，删掉残缺的那份、保留完整版。

#### 被实测推翻的结论

写代码与文档时想当然的判断，逐条用真数据推翻。**这些是本版最有价值的部分**：

| # | 原来的说法 | 实测事实 |
|---|---|---|
| 1 | `.gia` 文件名里的数字 = 游戏进程 pid | ❌ **不是**。`YuanShen.exe` 从 12:39 一直开着（pid **1088**），而文件里的号从 **135 逐局涨到 151** —— 它是**每次游戏启动**的会话序号 |
| 2 | 每次试玩 = 一个新 `.gia` 文件 | ❌ **也是错的**。一个 `.gia` 里可以有**多次试玩**：实测 `17-01-26_149` 里出现两遍同一份探针输出（`instance` 不同：17:01:21 / 17:01:58） |
| 3 | 「游戏进程在跑」≈ 在试玩 | ❌ **不能用**。同一个游戏进程跨了当天 13:40~18:44 的**全部**试玩 |
| 4 | `.gia` 里可能有框架级的「开始/结束」标记 | ⚠️ **默认没有**（只有脚本自己 `print` 的内容），但这条**取决于编辑器日志面板的配置** —— 不是平台的硬边界 |
| 5 | `Enum.KeyboardKeyCode` 里有物理键名 | ❌ **没有**。它只有语义键（`JumpKey` / `MoveLeftKey`…共 59 项），所以 `Enum.KeyboardKeyCode.W` **恒为 nil 且不报错**。绑键只能认 `Enum.KeyEventType` |
| 6 | 能从 `.gil` 里直接读出「哪些号能被创建」 | ⚠️ 只能读**候选**（无父节点 + 名字是控件类型）。实测 `1073741867`(文本框) / `1073741868`(图片) 真能创建，而 `1073741863~1866`（画布实例）一律 `nil` |
| 7 | 官方 API 文档里有「479 个函数 / 420 个枚举」 | ❌ **那个数字是错的**（把枚举成员当函数一起数了）。校准后是 **153 个接口 + 27 张枚举表 / 394 个成员**。教训：**报数之前先用正则量一遍** |

> 第 7 条是我在对话里先报错的，后来靠「先校准再报」纠正 —— 已写进工作区 `AGENTS.md` 的教训清单。
> 另外还有一条**抽取工具本身**的坑：官方原文把枚举成员写成「表名直接拼成员名」
> （`Enum.EaseTypeLinear`，不是 `Enum.EaseType.Linear`），
> 且参数里会**嵌套括号**（`onComplete: fun()`）—— 用 `[^)]*` 会整批漏掉 `Tween:SetOnComplete` 这类条目。

---

### 安全

活文件是**唯一副本**：它只活在米哈游的本地存档目录里，没有 git、没有撤销。
按这个前提把「能覆盖 / 能损坏它」的路径逐条审了一遍，**审出 2 个致命 + 4 个高危**并全部修掉：

| # | 级别 | 问题（都实证过） | 修法 |
|---|---|---|---|
| 1 | 🔴 | `deploy` 里**备份失败后没有 return，照样覆盖** —— 磁盘满 / 无权限时会把唯一副本直接抹掉 | **备份失败立即中止**，活文件零改动，并给出「去检查 `_backup` 是否可写 / 磁盘是否已满」 |
| 2 | 🔴 | 写文件**非原子**（`copyFileSync` 直写目标）—— 断电 / 崩溃会留下半截损坏的文件（备份同样） | 新增 `atomicWriteFile`：同目录临时文件 → **fsync** → **rename 原子替换**（实测 `renameSync` 在 Windows 上可覆盖） |
| 3 | 🟠 | 备份 mtime 沿用源文件 → 「最新备份」会选错 | 见「修复 · 备份与还原」 |
| 4 | 🟠 | 备份名时间戳用 UTC | 改本地时间 |
| 5 | 🟠 | `miliastra_probe op=deploy` 把 `_探针_*.lua` 写进**活文件目录**，而「当前文件」按 mtime 取最新 → **探针成了当前文件**，后续不带 `file` 的操作全打错目标 | 探针源码改写到**备份目录**（紧挨着被替换的文件）；另加名字护栏：`pickLuaFile` / `chooseLua` 都会跳过附属文件（`_探针_*` / `*_备份.lua` / `*.bak`）并报告跳过了几个 |
| 6 | 🟡 | `noBackup:true` 可随手传 → 绕过唯一安全网 | 改成**双钥匙**：必须同时传 `allowNoBackup:true`，否则拒绝 |
| 7 | 🟡 | 没有固定名备份，还原得自己翻时间戳挑 | 固定名 `<原名>.bak` + `op=restore` **不传 backup 就用它** |
| 8 | 🟡 | 还原失败只报一句 error | 失败给 `nextSteps`；**校验不过自动回滚**到覆盖前那一版并明确告知；部署同理 |
| 9 | 🟡 | 自覆盖（`source == dest`）没拦 | 拒绝并说明 |

**给人的提示（面板）**：备份卡片显示「位置 / 固定名备份 / 备份写在脚本旁边」、
**「还原到最新备份」一键按钮**（不用挑版本）、列表里固定名那份标 `★`、
覆盖前确认语写明「覆盖谁 + 会先备份 + 校验不过会自动回滚」、失败时把 `nextSteps` 一起显示。

**给 AI 的提示（工具描述）**：单列「安全约定（写活文件的地方都遵守，别绕过）」7 条；
`file` / `backup` / `noBackup` / `allowNoBackup` / `backupDir` 每个参数写清后果；
所有写操作回执 `restoreWith`（照着跑就能还原）。

---

### 文档

- **本仓库**：`README.md`（装、用、关键知识、能力边界、验证阶梯）、`CHANGELOG.md`（本文件）、`LICENSE`。
  README 顶部新增**文档索引**，说明哪些文档在仓库里、哪些在工作区、以及**为什么不进仓库**。
- **工作区 `AGENTS.md`**：本工作区的长期约定。本版新增/重写了
  §8.1 按键事件（含两条静默出错的坑）、§8.2 运行时能枚举什么、§8.3 `.gia` 真实结构（四条纠正）、
  §9 的「活文件安全约定」表与「高级诊断」两块风险说明。
- **工作区 `docs/`**：三本离线参考（API / 按键枚举 / 补间动画），全部由工具抽取、**不要手改**。
- **新增开发工具**（不随包发布）：
  `lint-probes` · `lint-all` · `inspect-gia` · `gil-field` · `gil-info` · `gia-runs` ·
  `render-probe` · `live-render-check` · `watch-playtest` · `dump-panel-text`

> `dump-panel-text` 是这一版**特意做出来的**：作者两次反馈「界面看不懂」，
> 根因都是**没人以使用者视角读过一遍**。现在改文案前先把它打出来自己读。

---

### 已验证

| 层 | 命令 | 项数 |
|---|---|---|
| L1 工具层（lossless JSON / JSON Schema / 只读用例） | `node tests/smoke.mjs` | 22 |
| L1 部署与备份安全（备份失败不覆盖 / 原子写 / 固定名 / 回滚 / 双钥匙 / 自覆盖拦截） | `node tests/deploy-test.mjs` | 28 |
| L1 探针部署 / 多活文件 / 两种关卡布局 / 模板结构 | `node tests/probe-deploy-test.mjs` | 10 |
| L1 Lua 结构校验器（不误报 / 报对行号 / 15 个真文件回归） | `node tests/lualint-test.mjs` | 32 |
| L1 试玩体检判据（四种结论 / 阈值边界 / 拿不到进程也不能乱猜） | `node tests/logdiag-test.mjs` | 9 |
| L3 真实 React 渲染（窄态 / 视觉身份 / 信封剥离 / 日志格式化 / 自动取回判据 / 备份卡片 / 读界面控件 / 试玩体检） | `node tests/client-render-test.mjs` | 28 |
| L1 技能契约自检（`dsh-plugin-win10` 的 `selftest.mjs`，原名 `dsh-plugin-dev`） | — | 16 |
| **L4 真机**（对着运行中的 `dsh web`） | `node tests/live-check.mjs` | 9 |

**合计 154 项**（含自检 16 项）。契约取证自本机 **`dsh 0.1.5-rc.1`**。

两格比数字更值钱的证据：

1. **`clientHalf` 断言**（L4 内）—— 查宿主 `dsh-client-modules` 的 boot 图，
   机器证明「面板 bundle 已进 `window.__DSH_BOOT__`」，并给出 `rev`（bundle 内容哈希）。
   **这一格以前只能靠人眼看面板出没出来。**
2. **枚举数量双向对上** —— 运行时探针数出 `KeyEventType` 164 / `KeyboardKeyCode` 59 / `ControllerKeyCode` 25，
   与**离线文档抽取**逐项一致。文档与真机互相印证，而不是各自声称。

**真机跑过的东西**（`observed`）：`api-surface` 探针（部署 → 试玩 → collect → 还原全程走通）、
`双相_v8_正式服` 脚本、`.gil` 静态读取、备份与还原。

**还没真机跑过的**（`pending`，别当已验）：
`api-check` 探针（只过了结构校验）、文档抽出来的 `Tween` / `TweenSequence` / `EaseType` 那套动画 API、
`game.IsTestPlay()` 的返回值、以及 **`.gia` 到底在试玩开始还是结束时落盘**（体检里如实标了「尚未完全实测」）。

---

### 已知边界

- **编辑器 UI 操作没有自动化通道**：在「客户端控件模板」里点【添加客户端控件】、给容器节点挂脚本、
  建容器节点 —— 必须由人在编辑器里完成。本插件替代的是「人和 AI 之间的来回搬运」，不是编辑器操作本身。
- **不做场景写操作**：工具只读，或只写「活文件」（脚本本身），不碰地图数据。
- **「是否在试玩」没有权威信号**：只能用「`.gia` 还在不在变大」代理，滞后数秒。
  `YuanShen.exe` 在跑**不能**当判据（见「被实测推翻的结论」第 3 条）。
- **负载 / 节点图统计尚未实现**：官方文档确认「负载计算」是编辑器 UI 功能（静态菜单 + 动态试玩报告）；
  「节点图」在 `.gil` 里只找到**复合节点名注册**（`#10`），逻辑本身不在存档里。
- **`.gil` 里 30+ 个顶层字段仍未解读**（本版只读了 `#1 #2 #7 #9 #11 #29 #39 #43 #50`）。
- **`patchReload: live` 不会重新 import Host 模块**（实测：改 `cordis.patch.yml` 触发重载后
  `/miliastra/status` 仍返回旧字段）—— 所以 **Host 改动一定要重启 `dsh web`**，
  只有 `lib/client.js` 刷新页面即可（`rev` 会变，可机器核对）。