feat: TS 构建管线、Spine 抓取器与 wallpapers/ 唯一真相来源

把项目从「手写 dist/」改成「wallpapers/ 是唯一真相来源,dist/ 由 pnpm build 生成」,
并补上配套的类型、门禁与抓取器。一次提交落地整条管线,因为拆开会留下不能构建的中间态。

- src/:运行时与模拟器源码(TS,strict),编译到 build/ 再拷进各分发
- tools/:build / dev / check-{syntax,paths,dist},以及抓取器与回归门禁 tools/checks/
  (.scratch/ 下那批一次性脚本移入 tools/checks/ 并入库为长期门禁)
- wallpapers/:七档壁纸的源数据 + README.md(id/音频/预设的完整规范)
- docs/adr/0005-0008:构建管线与分发拓扑、模拟器契约、自包含 sim、每骨架资源布局
- .gitignore:排除 .scratch/ 的参考资料副本(上游 spine 整仓克隆 ~1.2 GB、
  抓取侦查数据 ~680 MB)与调试转储;这些是本地调查材料,补偿会让仓库无法克隆
- 归一化 .gitignore/CONTEXT.md 行尾(工作区 CRLF、索引 LF 造成的整文件假 diff)

同时修掉三档卡住构建的未完工壁纸:
- kv45 的 meta.json 里 id 还是抓取期场景名 scene_main,经 downloader promote 正名为 kv45
- shajin / zhigengniao_juheye 的 meta.json 误用了骨架描述文件(name/spine/animations/pages)、
  且都缺 preset.template.json;现按规范重建:骨架沉到 spines/<名>/(spine-ts 按 atlas 所在
  目录解析贴图页)、补上元数据与单骨架预设,并清掉 zhigengniao 骨架里指向作者机的绝对路径
- 顺带 promote 已在 sources.yml 里的 kv46(月升之前,与兽共舞)

pnpm check 五道门全绿:10 个分发 / 7 档壁纸 / 141 处引用自包含。
This commit is contained in:
Shuery committed 2026-10-02 01:27:02 +08:00
1 parent b8eee05d78
commit 3f11426964
297 files changed
+216627 -1926

No files matched your search

+1 -1
View File
@@ -4,4 +4,4 @@
关键取舍是**目录名用显示名(含 `「」` 等符号),而代码里的键用 ASCII 的预设 id**。反过来做(目录名用 id)会让目录结构在 WE 的文件夹里毫无可读性;统一用显示名做键则会在改标题时打断老用户已保存的预设选择。所以两者各司其职:显示名服务于人,id 服务于 WE 属性与用户设置,二者用 `preset.js` 里的 `id` 字段绑定。
保留了 `effects/`、`images/`、`audios/` 这三层同名子目录,是为了让 Spine 骨架 JSON 里的 `"images": "../images/"` 相对路径一字不改。
保留了 `effects/`、`images/`、`audios/` 这三层同名子目录,是为了让 Spine 骨架 JSON 里的 `"images": "../images/"` 相对路径一字不改。(**已被 ADR 0008 取代**:现在是 `spines/<骨架名>/` + `scene/`,骨架 JSON 那个前缀归零。)
+11
View File
@@ -5,3 +5,14 @@
理由是资源路径的自包含性:模块内用 `import.meta.url` 推导 `./images/...` 的绝对 URL,于是整个壁纸目录可以被复制、改名、搬家而不需要改任何一行路径。若用 JSON,路径只能以**页面**为基准写死,一旦目录改名(本项目的目录名就是显示名,改标题=改目录)就会全站 404,且每档壁纸都必须先异步取回 JSON 才能建播放器,给启动时序又加一个竞态点。
代价:配置文件不再是纯数据,不能直接 `JSON.parse` 消费。本项目接受这一点——预设数据量极小,且始终由代码持有。
## 修订(2026-09,随 TS 构建管线)
**结论不变:产物仍然是 `preset.js`,仍然用 `import.meta.url` 推导路径。** 但当初拒绝 JSON 的两条理由里有一条已经不成立,需要更正:
- ~~"目录名就是显示名,改标题=改目录"~~ —— 目录名已改为**稳定的 id**(见 ADR 0005),显示名只存在于元数据与 `project.json` 里,改标题不再需要碰目录。这条理由作废。
- "以页面为基准写死路径会在改名后 404" —— 仍然成立,且仍然是不用 `fetch` + JSON 的理由:JSON 里的路径只能相对**页面**解析,而 `preset.js` 能相对**自己**解析,后者才能在合集分发里按深度现算(单档 `./audios/`、游戏合集 `../audios/`、全部合集 `../../audios/`)。
- "异步取 JSON 会给启动时序再加一个竞态点" —— 仍然成立。
**新增的写法约定**:`preset.js` 不再手写,而是由 `pnpm build` 从 `preset.template.json`(**JSON,路径相对壁纸目录**)生成。也就是说"用 JSON 写、生成成 ES module"——两条路的好处各取一半:编辑期是纯数据(可 diff、可校验、好工具化),运行期仍是自包含的 ES module。
@@ -0,0 +1,100 @@
# 构建管线与分发拓扑:id 化目录、无链接、合集层级
## 背景
`dist/` 原先手工维护:`assets/{images,effects,audios}/` 平铺、一个 `dist/` 就是"全部壁纸",`project.json`、`preset.js` 也都是手写的。要加第三档壁纸、要按"单档/合集"两种形态发布、还要有个本地调试服,这套手工结构撑不住了。本次把它换成 `pnpm build` 驱动的构建管线。
## 决策
### 1. 一份代码,四种分发:`dist/releases/<类型>-<id>/`
| 分发 | 目录名 | 含壁纸 | 结构 |
| --- | --- | --- | --- |
> 类型只有两种:**单档**与**合集**(`type: "single" | "collection"`)。下表后两行都是合集,
> 区别在收档范围 `scope: "game" | "all"`——「全部合集」不是第三种类型。
| 单档 | `single-<游戏id>-<壁纸id>` | 1 | `<根>/preset.js`、`<根>/audios/` |
| 游戏合集 | `collection-<游戏id>` | 该游戏全部 | `<根>/<壁纸id>/preset.js`、`<根>/audios/<壁纸id>/` |
| 全部合集 | `collection-all` | 全部 | `<根>/<游戏id>/<壁纸id>/preset.js`、`<根>/audios/<壁纸id>/` |
`pnpm build name`、`pnpm build single`、`pnpm build collect` 是**选择构建哪些分发**的三个入口,不是三种不同的构建逻辑——同一份 `wallpapers/` 源数据,按需生成不同的子集。
具体范围(实现见 `tools/build.ts` 的 `parseArgs`):
| 输入 | 范围 |
| --- | --- |
| `pnpm build` | 全编:全部单档 + 每个游戏合集 + 全部合集 |
| `pnpm build <壁纸id> […]` | 指定壁纸(可多个) |
| `pnpm build single [壁纸id …]` | 只编单档;不给 id = 全部单档 |
| `pnpm build collect [游戏id …]` | 只编合集;不给参数 = 全部合集 |
子命令与壁纸 id 共用同一段位置,靠**词表**区分:`single`/`collect`/`sim` 是保留字,其余裸词都是壁纸 id。注意 `pnpm build collect`(无参,全部合集)与 `--collect all`(只编「全部壁纸合集」)语义不同,两者都保留。
### 2. 目录名用 id,不用显示名(**修订 ADR 0001**)
ADR 0001 决定"目录名用显示名、键用 id"。本次把**目录名也改成 id**:
- 分发目录要能直接上传、要避免中文路径在上传与跨平台解压时的编码问题;
- `dist/releases/` 下要能一眼看出哪档是"合集"、哪档是"单档",所以加 `<类型>-` 前缀;
- 显示名进 `meta.json`,改标题不再需要 `git mv` 一堆目录,也不再让"改标题"变成一次全站路径风险。
代价:`wallpapers/` 与产物目录的可读性变差。用 `meta.json` 里的 `name` 与 `dist-map.json` 的 `displayName` 补回来(调试服索引页就是按它渲染的)。
ADR 0001 里"保留 `effects/`、`images/`、`audios/` 三层同名目录"这一条**不变**:Spine 骨架 JSON 硬编码了 `"images": "../images/"`,改了就得动骨架文件。(**已被 ADR 0008 取代**——贴图页并到 atlas 同目录后,骨架 JSON 里那个前缀归零了。)
### 3. 不产生任何链接,只做拷贝
早期的方案考虑过 NTFS 硬链接来省磁盘(全量构建约 290 MB)。**否决**:链接会把"产物自包含"这条基本性质废掉——拷贝到别的机器、上传、或者只拷一个分发目录时,链接目标不在,产物就残了。
所以构建只做普通拷贝,每个分发目录都是完整的字节副本。磁盘代价用"只构建需要的那几个分发"(`pnpm build name` / `collect`)来换,而不是用链接。
### 4. 分发内不出现绝对链接与跨目录相对链接
一个分发目录必须能被整体拷到别的机器上传,因此产物里:
- 不出现 `/xxx` 这类根绝对路径(脱离分发根就没有意义);
- 不出现 `../` 越出分发根的引用;
- 不出现 `file:///` 与 `//`。
例外:合集分发里壁纸的 `preset.js` 会用 `../audios/<壁纸id>/` 指到**同一分发内**的合集根,这不越界。`pnpm check:dist` 把这几条做成硬门禁。
### 5. 路径用 `import.meta.url` 现算,按分发深度生成
`preset.js` 里的每个路径都是 `asset("./images/x.webp")` 这种相对**自身**的形式(见 ADR 0002)。同一档壁纸在不同分发里深度不同,所以每个分发目录的 `preset.js` 都是**现算现写**的,不能一份源码拷到多处:
```
单档 ./audios/<壁纸id>/<文件>
游戏合集 ../audios/<壁纸id>/<文件>
全部合集 ../../audios/<壁纸id>/<文件>
```
这条路径必须与"音频实际搬到哪儿"严格一致。为此构建**先算落点、再生成 `preset.js`**,并由 `check:dist` 在产物上反查每个 `source:` 指向的文件是否真的存在——"语法正确、路径指向空气"的产物会一路通过类型检查与引用扫描,然后在 WE 里静音(本次重构真的写出过一次)。
### 6. 音频集中到合集根,按壁纸分目录
合集分发的 `bgm` 下拉要能选到分发内任一壁纸的音源,所以音频统一集中到合集根的 `audios/`,不再逐壁纸重复一份:
```
audios/<壁纸id>/<文件> 各壁纸自己的音源(互相隔离)
audios/<文件> 游戏级共享音频(wallpapers/<游戏id>/audios/)
```
按壁纸 id 分一层,是为了让"同一首歌被两档壁纸各自引用"和"两档壁纸各有一首同名但不同的曲子"都变成可表达的状态——扁平布局下后者只能报错。共享层保持扁平,重名即歧义,直接构建失败。
单档分发没有合集根,音频留在壁纸目录内的 `audios/`(**非合集分发不出现共享音频目录**)。
### 7. `VERSION` 是版本号的唯一写者
`VERSION` 文件 → `project.json.version`(**数字**,WE 的格式)。构建时会断言"全部合集"这一档的 `project.json` 与线上已发布版本**逐字段一致**——重构不该悄悄改变已发布产物的语义。已知的唯一偏差是格式化:WE 自己的美化器输出 `"key" : value`(冒号前有空格)并展开嵌套,我们用 `JSON.stringify(…, null, "\t")`。字段名、键序、取值全部对齐。
### 8. 产物 JS 的语法目标由编译器兜底
运行时脚本经 `tsc` 编译,跑在 WE 自带的 CEF 里——那不是一个能随 Node 升级的引擎。因此 `tsconfig` 里显式关掉 `useDefineForClassFields`,让类字段降级成构造函数赋值;否则 `x = 1` / `x;` 这类声明式字段会原样输出,实测会让整个 `index.js` 直接 SyntaxError 白屏。
反过来,`presets.js` 与 `preset.js` 是**生成器拼出来的裸 JS 文本**,编译器完全不看它们。所以 `check:dist` 会让 V8 真的解析一遍每个产物模块(`vm.SourceTextModule`,只解析不执行)——这条门禁抓到过一次真事故(数组字面量里写了对象字面量的计算属性名)。
## 未采纳的方案
- **硬链接/符号链接省磁盘**:见第 3 条。
- **一个 `dist/` 装所有分发**:无法整体上传,且本地调试时"越界引用"会被别的分发意外命中,反而掩盖问题。
- **目录名继续用显示名**:见第 2 条。
+72
View File
@@ -0,0 +1,72 @@
# 模拟器的契约:还原 WE 的 API 面,不自检,替身显式化
## 背景
本地调试壁纸需要在没有 Wallpaper Engine 的情况下跑起来,因此有一个"WE 模拟器"。旧实现(`tools/wallpaper-engine-simulator.js`,1589 行)在这个位置上出过**事故级**的错:它靠 `typeof window.wallpaperRegisterListener === "function"` 来判断"我是不是已经在真实 WE 里",而这个 API **在真实 WE 里不存在**——判据恒为 false,于是模拟器在真实 WE 里也会自我激活并覆盖官方 API。
WE 实际注入的网页壁纸 API 只有 `window.wallpaperPropertyListener`(外加属性/通用属性/暂停/用户目录文件变更这几个回调)。`window.wallpaperRegisterListener` 与 `window.wallpaperEngine` 都不存在。
## 决策
### 1. 不做环境自检;由注入决定它是否存在
模拟器**没有**任何"我在哪"的判断。它只在有人把一个 `<script>` 注入页面时才存在——注入是事实,不是猜测,不存在误判空间。旧实现反过来做(靠 `typeof window.wallpaperRegisterListener === "function"` 自检),而那个 API 在真实 WE 里不存在,判据恒为 false,于是它在真实 WE 里也自我激活并覆盖官方 API。事故级。
**注入点有两个,都合法,但用途不同:**
| 场景 | 谁注入 | 模拟器本体从哪来 | 产物 |
| --- | --- | --- | --- |
| `pnpm dev` | 调试服按路由注入 | 调试服自己的 `/simulator/wallpaper-engine.js` | 分发目录**保持干净** |
| 静态托管(GitHub Pages) | 构建期写进 `index.html` | 分发自带的 `./scripts/wallpaper-engine.js` | `pnpm build --with-sim` |
| 上传 Wallpaper Engine | 没有人注入 | 不存在 | `pnpm build`(默认) |
默认的发布产物里**没有**模拟器文件,也**没有**任何注入痕迹——它必须与"用户从创意工坊下载到的东西"完全一致。
#### 两条供给路径各自踩过的坑
**调试服路径**:驱动原先把动态 `import()` 指向 `/release/<dir>/scripts/wallpaper-engine.js`,而那个文件只有 `--with-sim` 才会被复制进分发——于是默认构建下 `pnpm dev` 必然 404、**面板静默消失**。404 只留在控制台里,看起来像"面板没渲染",很难联想到是构建产物缺了文件。现在调试服从自己的路由提供,与分发目录里有没有它无关。
**静态托管路径**:地址必须是**相对**的 `./scripts/wallpaper-engine.js`。GitHub Pages 把站点放在 `/<repo>/` 子路径下,根绝对路径 `/scripts/…` 会 404。这一点由 `tools/checks/verify-static-preview.mts` 把关——它刻意把分发挂在 `/repo/` 前缀后面服务,挂在根上测不出这个错。
两条路径会在 `--with-sim` 构建 + 调试服同时出现时重叠,所以驱动带幂等闸(`window.__weSimDriver`)。没有它模拟器会被 mount 两遍,页面上出现两个面板。
#### 为什么不让 `pnpm dev` 自动带上 `--with-sim`
那样能一行修好调试路径,但代价是调试时看到的分发目录多了一个文件,不再是你要上传的那份。"调试对象 == 发布对象"这条不变量比少写一行更重要。
### 2. 公开面严格等于 WE 的公开面
模拟器只提供 WE 真正有的东西,一个不多:
- `window.wallpaperPropertyListener`(**等壁纸自己注册**,绝不代注册);
- 不提供 `window.wallpaperEngine`、`window.wallpaperRegisterListener`、`wallpaperGetUserDataPath` 等 WE 没有的全局。
理由:一个凭空承诺的 API 比没有 API 更危险——壁纸会依赖它,然后在真实环境里崩。冒烟测试里有两项断言就是专门盯这个:`typeof window.wallpaperEngine === "undefined"`。
### 3. 属性下发语义
- 初始属性**一次整批**交给 `applyUserProperties`,不逐条发(逐条会让壁纸的 `render()` 被调用 N 次,掩盖真实的性能与竞态)。
- 顺序:先 `applyGeneralProperties`(fps),再 `applyUserProperties`。
- **下发前必须等到 listener 就位**。`?__propsAt=dom` 复现的正是"属性早于壁纸模块到达"这条竞态;此时 listener 还是 undefined,直接下发会被静默吞掉、页面停在默认预设上。真实 WE 不会丢(属性在消息队列里),所以模拟器等,且**只等不代注册**——代注册会掩盖"壁纸忘了在模块顶层注册 listener"这个真错误。
- 壁纸必须在模块顶层注册 listener(见 `src/runtime/index.ts`),否则属性在 `load` 之前到达时收不到。
### 4. 替身必须显式化
浏览器不是 CEF,有几件事物理上做不到。它们不被伪装成"和 WE 一样",而是列入 `window.__weSim.substituted`,并在面板顶部有一条**常驻**的「⚠ 模拟环境」横幅:
| 能力 | WE 的行为 | 模拟器的替身 |
| --- | --- | --- |
| `file` / `texture` / `directory` 选择器 | 弹原生对话框,回传 `file:///` 绝对路径 | `<input type=file>` + `URL.createObjectURL`(回传 `blob:`) |
| 用户数据目录绝对路径 | `wallpaperGetUserDataPath` 返回真实路径 | 虚拟路径 |
| `document.cookie` | CEF 里被隔离 | 浏览器里可用(未隔离) |
### 5. 装载时序
模拟器源码是 ES module(要能正常用 `import`/`export` 写类型),但**不能用 `<script type="module">` 装载**:module 脚本会被延迟到解析完成后执行,那样它就不可能早于壁纸模块就位。驱动脚本因此是一个**经典** `<script>`,内部用动态 `import()` 装载模拟器。两者都由 `tools/lib/drivers.ts` 生成,调试服与对拍测试台共用同一份。
## 未采纳的方案
- **靠某个全局变量的存在自检**:这正是旧实现的事故成因,见第 1 条。
- **提供 `wallpaperEngine` 之类的便利全局**:见第 2 条。
- **替模拟器实现真实文件路径**:浏览器做不到,伪装只会让壁纸在真机上表现不同。
- **用 `<script type="module">` 直接加载模拟器**:时序不成立,见第 5 条。
+93
View File
@@ -0,0 +1,93 @@
# 自包含模拟包:把 `file://` 下的不可能变成可能
## 背景
`pnpm dev` 起本地服务、在浏览器里调壁纸,这条路径很好用,但它有一个硬前提:**有一个 HTTP 服务在跑**。要给别人看一眼效果、要在没装依赖的机器上确认"这档壁纸到底长什么样"、要把某一版效果截图存档,都得先把服务起起来。于是有了"自包含包"这个需求:一个 `sim/index.html`,双击就能开,不依赖 `pnpm dev`、不依赖任何服务器、也不依赖网络。
一开始以为这只是"把资源内联进 HTML"。实际做下来,`file://` 这个环境把四件平时根本不会注意到的事变成了拦路虎,每一件都值得记下来。
## 决策
### 1. 产物位置:`<分发根>/sim/index.html`,不往 `sim/` 里复制任何资源
自包含包是**分发目录的一部分**,不是一份独立产物:
```
dist/releases/single-hsr-kv37/
index.html ← 发布用(ES module,需要 http)
project.json
preset.js
images/ effects/ audios/ ← 布局见 ADR 0008:现在是 spines/<骨架名>/ scene/ audios/
sim/index.html ← 自包含(经典脚本 + 全内联,双击可开)
```
好处是它天然跟着分发走:拷走整个分发目录,发布用的那份和调试用的那份一起走。代价是页面不在分发根下,所以**资源根**和**模块根**要分开算——这是本 ADR 第 4 条,也是这次最费时间的一个坑。
资源一律不复制进 `sim/`。复制会让"分发目录里多出一份重复的 2 MB 贴图",且一旦漏拷一个就是白屏;全部内联则"要么整页可用,要么构建期就报错",没有中间状态。
### 2. 一切内联:模块、资源、音频都变成页面里的字节
`file://` 下浏览器把页面当成 `origin: null`,于是:
| 做法 | `file://` 下 | 结论 |
| --- | --- | --- |
| `<script type="module">` | 拒绝(CORS) | 不能用 |
| `import()` / `fetch()` / `XHR` | 拒绝(CORS) | 不能用 |
| 同目录相对 `<img>` / `<audio>` | 可以 | 可用但不必要 |
| 跨目录 file 资源 | 拒绝 | 不能用 |
| `data:` URL | 处处可用 | **选它** |
| 内联 `<script>` | 可用 | **选它** |
所以自包含包 = 一份 HTML,里面依次是:spine-player(内联)→ 属性下发 → 模块注册表 → 模拟器模块 → 驱动脚本 → 壁纸运行时。所有 ES module 被合成**经典脚本**,用一张 `__weModules` 注册表 + `__require(id)` 还原 `import` 语义;所有资源(图片/骨架 JSON/atlas/贴图页/音频)转成 `data:` URL,装在一张以 URL 为键的表里。
`import` 改写是**保声明**的:`export const X = 1` 会变成 `const X = 1` 加末尾一行 `exports.X = X`。早期版本直接改成 `exports.X = 1`,把模块内的局部绑定删掉了——`REFERENCE_ASPECT is not defined`,而且只在第一帧渲染时才炸(默认参数到那时才求值),构建、加载、尺寸检查全部正常。
### 3. 音频内联是可选项(`--no-embed-audio`)
`xilian-src.flac` 是 37.7 MB,base64 后 50.3 MB。默认内联(这样双击就有声音),但提供开关给"只看画面、不在乎体积"的场合。关闭时构建会明确列出哪些音频没内联、以及后果("双击打开时选不到它们"),而不是安静地少一块功能。
### 4. 两个根必须分开算(**本次最大的坑**)
页面的位置和模块的位置**不是同一个基准**:
- **页面根**:自包含页永远在 `<分发根>/sim/index.html`,资源相对**分发根**寻址,所以相对页面是 `"../"`——**与合集深度无关**。
- **模块根**:`preset.js` 在 `<分发根>/<壁纸id>/preset.js`(合集)或 `<分发根>/preset.js`(单档),发布版用 `new URL("./", import.meta.url)` 相对**自己所在目录**推导,所以相对页面是 `"../<壁纸id>/"`。
这两个值曾经都被写成"页面根 × 分发深度",于是:
- `collection-all`(深度 2)的资源根变成 `"../../"`,跑到 `dist/releases/` 去了;
- 合集分发的 `preset.js` 把 `./audios/kv37/x.mp3` 解析到 `<releases>/audios/...`(少一层),音频退回 `file://` 读,报 `ERR_FILE_NOT_FOUND`。
两个错误的现象都长成"表和模块看起来都对,只有某个资源不对",非常容易误判成那个资源自己的内联逻辑有问题。**单档分发两种错误都不会出现**(深度 0、没有 `<壁纸id>/` 这一段),所以只测单档会一路绿灯。
对应地,资源表的建键基准是**分发根**(`assetRelIn`,合集里带 `<壁纸id>/`),而不是 `preset.js` 的相对路径(`assetUrlIn`)。用后者建键会丢掉 `<壁纸id>/` 这一段。
### 5. 表要同时按"相对键"和"绝对键"注册
运行时拿到的 URL 有两种形态:`preset.js` 求值出来的**绝对** URL(`file:///…/images/kv37.webp`),和 spine-player 内部按 `pathPrefix + 相对路径` 拼出来的**相对**串。两者都要能查中,所以每份资源都注册 `key`、`./key`、`/key`、裸名,以及每个键 `new URL(k, base).href` 的绝对形式。
spine-player 的 `rawDataURIs` 尤其要注意:`loadTexture(path)` 用 `path` 查表、但把回调注册在**原来的** `path` 上,而 `loadTextureAtlas` 传下来的 `path` 是 `atlasUrl 的父目录 + 页名`。因此 `config.rawDataURIs` 要合并 `byRelative` 与 `byAbsolute` **两族**键,且 `jsonUrl`/`atlasUrl`/`binaryUrl` 三个值也要各自以绝对键登记。少了任何一族,spine 就安静地退回 XHR,在 `file://` 下被 CORS 拦掉。
这些键一旦对不上,症状是白屏加一条 `net::ERR_FILE_NOT_FOUND`,**看不出是哪个键**。所以 `WrappedSpinePlayer` 里有一道**生产断言**:三个键任何一个不在合并后的表里就立刻抛错,并打印缺的那个键名。先前的三轮错误猜测都是因为探针只打印了"表有多少个键",而不是"运行时到底查了哪个键"。
### 6. 门禁:`check:dist` 检查自包含页的外部依赖
`check:dist` 对每个 `sim/index.html` 做三项断言:没有 `<script type="module">`、标签上没有 `http(s)://`/`file:///`/根绝对路径。
扫描前必须**剥掉内联脚本的内容**——spine-player 是整份内联的,它内部带着一段编辑器示例模板,里面有 `<script src="https://…">` 这样的字符串,直接对整页扫标签会把它们当依赖,四个分发全报假阳性。
剥除时**只删标签之间的内容,保留开标签本身**。第一版整体替换掉了开标签,于是所有 `<script src=…>` 都不再被检查,门禁看着在跑、实际只能查到 `<link>`。是"往页面里注入一个外链看它拦不拦得住"这个测试把这个漏洞暴露出来的——**一个从不失败的检查等于没有检查**,所以 `tools/checks/test-sim-gate.mts` 会注入四种外部依赖(外链 script、`file:///`、根绝对路径、`type=module`)确认每种都被拦下。
## 未采纳的方案
- **用 Service Worker 拦截 `file://` 请求**:`file://` 下不能注册 Service Worker。
- **让用户起一个 `file://` 代理或用 `--allow-file-access-from-files`**:那就又回到"需要额外操作"了,自包含的意义没了。
- **把资源复制进 `sim/` 而不内联**:跨目录 `file://` 资源读不到,同目录的也要靠相对路径,且会把分发体积翻倍。
- **只支持单档壁纸的自包含包**:合集分发的调试需求同样真实(要试预设切换、共享音频),而且正是它暴露了第 4 条那两个基准错误。
- **在页面上做环境自检来决定走哪条路**:与 ADR 0006 第 1 条同因——判据会恒为假。自包含与否由**构建产物形态**决定,不由运行时探测决定。
## 影响
- `pnpm build sim` 产出自包含包;`pnpm build sim <壁纸id>` / `sim collect` 收窄范围。
- 构建时间从 0.8 s 增至约 5 s(base64 编码 + 语法自检);单页 15 MB(kv37)到 129 MB(内联 flac 的合集)。
- 验收靠 `tools/checks/verify-sim-page.mts`:用 CDP 以 `file://` 打开,断言画布有实际像素、0 异常、0 控制台报错、无 `http(s)` 请求。**像素采样必须在 `requestAnimationFrame` 内跨帧取最大值**——`preserveDrawingBuffer: false` 下在 rAF 外读 `gl.readPixels` 永远读到清空后的缓冲,会得到"0% 非透明"的假阴性。
@@ -0,0 +1,58 @@
# 资源布局:一具骨架一组,而不是按文件类型分组
> **取代** ADR 0001 第 7 行「保留 `effects/`/`images/`/`audios/` 三层同名子目录」与 ADR 0005 第 43 行
> 「这一条不变」。那两条的前提是"Spine 骨架 JSON 硬编码了 `"images": "../images/"`,改了就得动骨架文件";
> 本决策把贴图页并到 atlas 同目录、把骨架里的 `images` 归零之后,这个前提本身消失了。
## 背景
`wallpapers/<游戏id>/<壁纸id>/` 原来是按**文件类型**分的:骨架数据在 `effects/<名>.json`,
atlas 与贴图页在 `images/`(或 `images/<名>/`),场景图在 `images/scene/`。于是**一具骨架自己的
东西被拆到两个目录**,找一样东西要跳两次;`effects/` 这个名字还有歧义(它装的是骨架数据,
不是"特效",而真正的场景图在别处)。
查过官方文档:**Spine 对 HTML 项目的目录结构没有任何约定**。[导出文档](https://en.esotericsoftware.com/spine-export)
只规定"每个骨架一个数据文件、文件名用骨架名",外加导出时可选 pack 出 atlas。所以这不是"遵循规范"的问题,
而是我们自己的取舍。
真正约束布局的只有一条**运行时的解析规则**(读 vendored spine-ts 源码得到):
```js
page.setTexture(assetManager.get(pathPrefix + page.name)); // AssetManager.loadTextureAtlas
```
即 atlas 第一行是页名、**页相对 atlas 所在目录解析**;`skeleton.images` 只是可选前缀。
所以「页必须与 atlas 同目录」是硬约束,其余随便放。
来源页面(米哈游活动页)自己也是按类型分的(`images/effect/spine/*.json|.atlas`、
`images/effect/scene/*`、`audio/*`),它能这么干是因为它的引擎有**全局图片表**、按逻辑名查页;
我们用的是 spine-ts,做不到。
## 决策
**按"一件东西一组"分,而不是按后缀分**:
```
wallpapers/<游戏id>/<壁纸id>/
├── spines/<骨架名>/<名>.json | <名>.atlas | 贴图页… ← 一具骨架一组
├── scene/ ← 场景图与背景图(非骨架的图)
├── audios/ ← 音源
├── meta.json preset.template.json preview.gif
```
- 页跟着 atlas 走,天然满足运行时规则(不再需要 `skeleton.images` 前缀)。
- 去掉了有歧义的 `effects/`:骨架就是骨架。
- 一档壁纸目前就是**一个场景**(`sceneConfig` 就是那一个),所以不再多套一层 `scenes/<场景id>/`;
真出现"一档多场景"时再加,而不是现在提前分层。
- **例外**:`backgroundImage` 可以指向某个骨架的贴图页(kv37 就是这样),那种情况就写
`spines/<名>/<页>`,不强行搬进 `scene/`——**判据是 atlas 自己声明的页名**,不做名字启发式。
## 后果
- 构建侧要同步的一处硬编码:`tools/lib/generate.ts` 的 `copyWallpaperAssets` 只搬
`spines` / `scene` / `audios` 三个目录名;改名必须改它。
- `tools/downloader/promote.py` 产出的新壁纸直接是这套布局(否则存量与增量的形状会分叉)。
- 存量三档壁纸迁移了 86 个文件,预设路径按实际落点重写;
`verify-visual-equivalence` 确认三个比例下**逐像素相同**(布局不影响渲染)。
- 迁移脚本留在 `.scratch/build-pipeline/`(含补救脚本),其中固化一条教训:
跨平台路径映射**别拿 `str(Path)` 当键**(Windows 会给反斜杠,而预设里是正斜杠)。