diff --git a/.gitignore b/.gitignore index 38f771d..adeea92 100644 --- a/.gitignore +++ b/.gitignore @@ -2,14 +2,37 @@ tools/shots/ tools/.cache/ +# Spine 抓取器(tools/downloader/):文本缓存与 staging 产物都可重跑,不入库。 +# 选择记录 selection.yml 反过来要入库——它是"这一档由哪些 part 组成"的可复现凭据。 +tools/downloader/_cache/ +tools/downloader/_out/ + +# 编译中间产物:tsc 把 src/runtime 与 src/simulator 编到这里,再拷进各分发。 +build/ + # 构建产物:dist/ 完全由 pnpm build 生成(releases/ + dist-map.json)。 -# 它与源码树必然漂移,且是全量拷贝(约 290 MB),因此不入库。 +# 它与源码树必然漂移,且是全量拷贝(约 390 MB),因此不入库。 dist/ -# 重构前的基线快照:只作为"重构等价性"的临时判据,验证完即删,不入库。 -.scratch/dist-baseline/ +# 注意:.scratch/ 本身要入库——issue 与 spec 就住在这里(见 AGENTS.md)。 +# 但它是工作台,下列"参考资料副本"与"调试转储"只服务本地调查,不入库。 +# +# 参考副本:上游第三方的整仓克隆与页面抓取侦查数据。它们是只读的取证材料, +# 不属于本项目产物,体积以 GB 计(spine-versions ≈ 1.2 GB、mhy-recon ≈ 680 MB), +# 提交进来会让仓库永远无法克隆,需要时按各目录 README 重新取回即可。 +.scratch/spine-versions/ +.scratch/mhy-recon/ +.scratch/page-mirror/ +.scratch/downloader-all-scenes/ + +# 调试转储:工具重跑就会再生成,不入库 +.scratch/sim-failed/ +.scratch/sim-scripts/ +.scratch/fileproto/ +.scratch/timing/ # 对拍中间产物:截图、抽取帧、试压的 GIF 都是可重生成的二进制,不入库 +.scratch/shots/ .scratch/*.png .scratch/*.gif @@ -19,3 +42,6 @@ masters/ # 杂项 *.log node_modules/ +__pycache__/ +*.pyc +*.bak diff --git a/.scratch/build-pipeline/fix-preset-paths.py b/.scratch/build-pipeline/fix-preset-paths.py new file mode 100644 index 0000000..2783882 --- /dev/null +++ b/.scratch/build-pipeline/fix-preset-paths.py @@ -0,0 +1,77 @@ +"""补救:按**当前实际布局**重写各档壁纸预设里的资源路径。 + +背景:migrate-walls.py 的映射键在 Windows 下是反斜杠,而预设里是 `./effects/x.json` 这种正斜杠, +查表全落空 → 文件搬了、预设没改。这里不靠任何映射,直接**在壁纸目录里找**该文件现在在哪: + + ./effects/<名>.json → spines/<名>/<名>.json + ./images/<…> → spines/<骨架>/<文件>(若该文件是某 atlas 声明的页或骨架自己的贴图) + 否则 → scene/<文件> + ./images/scene/<文件> → scene/<文件> + +每个改写都要求目标**真实存在**,否则报错而不是写一个坏路径。 +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +WALLS = ROOT / "wallpapers" +APPLY = "--apply" in sys.argv +PAGE_EXT = re.compile(r"\.(png|webp|jpg|jpeg)$", re.IGNORECASE) + + +def atlas_pages(text: str) -> list[str]: + return [l.strip() for l in text.splitlines() if l.strip() and ":" not in l.strip() and PAGE_EXT.search(l.strip())] + + +def owner_of(wall: Path, basename: str) -> str | None: + """这个文件现在归哪个目录(spines/<名> 还是 scene)。""" + for atlas in (wall / "spines").rglob("*.atlas"): + if basename in atlas_pages(atlas.read_text(encoding="utf-8")): + return f"spines/{atlas.parent.name}" + for spine_dir in (wall / "spines").iterdir() if (wall / "spines").is_dir() else []: + if (spine_dir / basename).exists(): + return f"spines/{spine_dir.name}" + if (wall / "scene" / basename).exists(): + return "scene" + return None + + +failed: list[str] = [] +for wall in sorted(p.parent for p in WALLS.rglob("preset.template.json")): + preset_path = wall / "preset.template.json" + preset = json.loads(preset_path.read_text(encoding="utf-8")) + changed: list[str] = [] + + def rewrite(value: object) -> object: + if isinstance(value, str) and value.startswith("./") and "/" in value[2:]: + basename = value.rsplit("/", 1)[-1] + owner = owner_of(wall, basename) + if owner and value != f"./{owner}/{basename}": + changed.append(f"{value} → ./{owner}/{basename}") + return f"./{owner}/{basename}" + if owner is None: + failed.append(f"{wall.relative_to(ROOT)}: {value} 在磁盘上找不到") + if isinstance(value, dict): + return {k: rewrite(v) for k, v in value.items()} + if isinstance(value, list): + return [rewrite(v) for v in value] + return value + + new_preset = rewrite(preset) + print(f"\n== {wall.relative_to(ROOT)}") + for c in changed: + print(" " + c) + if APPLY and changed: + preset_path.write_text(json.dumps(new_preset, ensure_ascii=False, indent="\t") + "\n", encoding="utf-8") + +if failed: + print("\n✗ 以下路径找不到对应文件(未改写):") + for f in failed: + print(" " + f) + sys.exit(1) +print(f"\n{'已重写' if APPLY else '将重写'} {len(changed) if False else ''}{'(dry-run)' if not APPLY else ''}") diff --git a/.scratch/build-pipeline/issues/01-cli-scope-selection.md b/.scratch/build-pipeline/issues/01-cli-scope-selection.md new file mode 100644 index 0000000..09870cc --- /dev/null +++ b/.scratch/build-pipeline/issues/01-cli-scope-selection.md @@ -0,0 +1,31 @@ +# 01 — CLI 范围选择:裸 id、`single`、`collect`、`sim` + +Status: resolved +Type: task + +## 问题 + +ADR 0005 写的是 `pnpm build name` / `pnpm build single` / `pnpm build collect`,但 `tools/build.ts` 只认 `--single` / `--collect`;`package.json` 的 `build:all` 跑的是不存在的 `--all`。 + +## 答案 + +`parseArgs` 改成"先摘子命令、再解析 flag": + +| 输入 | 范围 | +| --- | --- | +| `pnpm build` | 全编(2 单档 + 1 游戏合集 + 全部合集) | +| `pnpm build kv37` / `pnpm build kv37 xilian` | 指定壁纸 | +| `pnpm build single` | 只编全部单档 | +| `pnpm build single kv37` | 指定单档 | +| `pnpm build collect` | 全部合集(`collection-hsr` + `collection-all`) | +| `pnpm build collect hsr` | 指定游戏合集 | +| `pnpm build sim …` | 同 `pnpm build` 的范围,但每档都产出自包含包 | + +子命令与壁纸 id 共用同一段位置,靠词表区分(`single`/`collect`/`sim` 是保留字,其余裸词是 id)。 + +**两个坑**: + +- `pnpm build collect`(无参)不能用 `collect: "all"` 表示——那个值只加「全部壁纸合集」一项,会把 `collection-hsr` 漏掉。改用显式标记 `allCollections`。 +- "不给 id" 与 "没选任何范围" 必须分开:`single` 无参 = 只编单档,无参 = 全编。靠 `onlySingles` / `allCollections` 显式标记,不靠"数组为空"推断。 + +`build:all` 改成 `pnpm build`(它就是全编,不需要额外 flag)。 diff --git a/.scratch/build-pipeline/issues/02-two-roots.md b/.scratch/build-pipeline/issues/02-two-roots.md new file mode 100644 index 0000000..fcd13d9 --- /dev/null +++ b/.scratch/build-pipeline/issues/02-two-roots.md @@ -0,0 +1,30 @@ +# 02 — 两个根:页面根与模块根必须分开算 + +Status: resolved +Type: task + +## 问题 + +合集分发的自包含页音频报 `ERR_FILE_NOT_FOUND`,spine 骨架报 `rawDataURIs 缺键`。单档分发全绿。 + +## 诊断 + +自包含页在 `<分发根>/sim/index.html`,`preset.js` 在 `<分发根>/<壁纸id>/preset.js`。两个位置对应**两个不同的相对基准**: + +- **页面根**:资源相对分发根寻址,相对页面恒为 `"../"`,**与合集深度无关**。 +- **模块根**:`preset.js` 的 `import.meta.url` 是它自己所在目录,相对页面是 `"../<壁纸id>/"`。 + +曾经两者都写成 `"../".repeat(depth)`:`collection-all`(深度 2)资源根变 `"../../"`(跑到 `dist/releases/`),合集 `preset.js` 把 `./audios/…` 少解析一层。 + +同时资源表的建键基准也错了:用 `assetUrlIn`(相对 preset.js)建键会丢掉合集里的 `<壁纸id>/` 这一段,运行时按分发根相对路径查不中。 + +## 答案 + +- `assetRoot` 固定为 `"../"`,不再乘深度。 +- `transformModule` 按 `moduleDir` 把 `new URL("./", __importMetaUrl)` 改写成 `new URL("..//", document.baseURI)`。 +- 新增 `generate.ts` 的 `assetRelIn(release, wallpaper, rel)`(分发根相对),资源表改用它;`assetUrlIn` 保留给发布产物。 +- 音频键用 `urlKey` 剥掉 `../` 前缀即可(`audioPathIn` 已经是分发根相对形式),**不再**拼 `<壁纸id>/`——两个基准不同,别合并。 + +## 教训 + +这两个错误都长成"表和模块看起来都对,只有某个资源不对",极易误判成那个资源自己的内联逻辑。**单档分发两种错误都不出现**,只测单档会一路绿灯——所以验收必须覆盖四档。 diff --git a/.scratch/build-pipeline/issues/03-self-contained-gate.md b/.scratch/build-pipeline/issues/03-self-contained-gate.md new file mode 100644 index 0000000..20a3ac6 --- /dev/null +++ b/.scratch/build-pipeline/issues/03-self-contained-gate.md @@ -0,0 +1,30 @@ +# 03 — 自包含包的门禁必须是会失败的检查 + +Status: resolved +Type: task + +## 问题 + +"自包含"是自包含包的全部意义,不能靠"我记得内联了"。需要在 `check:dist` 里对每个 `sim/index.html` 断言没有外部依赖。 + +## 答案 + +三项断言:没有 `` 块、收尾标签是它自己的,于是干净源码立刻构建失败。守卫只查纯 JS 体(spine-player、模拟器段、运行时段)。 diff --git a/.scratch/build-pipeline/issues/04-project-json-key-order.md b/.scratch/build-pipeline/issues/04-project-json-key-order.md new file mode 100644 index 0000000..c41b318 --- /dev/null +++ b/.scratch/build-pipeline/issues/04-project-json-key-order.md @@ -0,0 +1,23 @@ +# 04 — project.json 键序:与已发布产物逐字节对齐 + +Status: resolved +Type: task + +## 问题 + +重构后 `collection-all/project.json` 字段值完全一致,但顶层键序变了: + +``` +已发布: … general, preview, ratingsex, … visibility, workshopid, workshopurl +本次: … general, ratingsex, … visibility, preview, workshopid, workshopurl +``` + +原因:三个可选字段(`preview`/`workshopid`/`workshopurl`)统一被追加到对象末尾,而线上那份的 `preview` 夹在 `general` 与 `ratingsex` 之间。 + +## 答案 + +`preview` 用条件展开放进对象字面量的正确位置;`workshopid`/`workshopurl` 保持在末尾追加(它们本来就在最后)。 + +**为什么较真**:上传到创意工坊的 `project.json` 要和线上那份对齐,否则每次构建都产生一个"内容相同、文件不同"的 diff,无法判断是真改动还是噪声。 + +**发现方式**:先按字符串比对,只看到"首个差异 @ 353",定位不到键;写成逐键递归比对(`tools/checks/diff-project.mts`)后立刻显示"字段值完全一致、键序不同"。 diff --git a/.scratch/build-pipeline/issues/05-module-synthesis.md b/.scratch/build-pipeline/issues/05-module-synthesis.md new file mode 100644 index 0000000..777dec3 --- /dev/null +++ b/.scratch/build-pipeline/issues/05-module-synthesis.md @@ -0,0 +1,26 @@ +# 05 — 模块合成:保声明的 export 改写 + +Status: resolved +Type: task + +## 问题 + +`file://` 下不能用 ES module,所以发布版的 ES module 要在自包含包里合成经典脚本。第一版把 `export const X = 1` 改写成 `exports.X = 1`,**删掉了模块内的局部绑定**。 + +症状极具误导性:构建、加载、尺寸检查全部正常,只在第一帧渲染时抛 `ReferenceError: REFERENCE_ASPECT is not defined`——因为默认参数 `referenceAspect = REFERENCE_ASPECT` 到那一刻才求值。 + +## 答案 + +改写**保留声明**,把导出追加到模块体末尾: + +| 源码 | 产物 | +| --- | --- | +| `export const X = 1` | `const X = 1` … 末尾 `exports.X = X;` | +| `export function f() {}` | `function f() {}` … 末尾 `exports.f = f;` | +| `export default {…}` | `exports.default = {…}` | + +收尾有一道 `if (/^export\b/m.test(out)) throw` 兜底,任何漏改的 `export` 都会在构建期报错而不是留到浏览器。 + +## 影响 + +`tools/checks/test-transform-module.mts` 里 **9 条断言编码的是旧的错误输出**,必须一并改写。新增两组断言:一组确认本地绑定还活着,一组把改写后的代码**真的跑一遍**(不只是解析),确保产物可执行。 diff --git a/.scratch/build-pipeline/issues/06-pixel-verification.md b/.scratch/build-pipeline/issues/06-pixel-verification.md new file mode 100644 index 0000000..1b03cfc --- /dev/null +++ b/.scratch/build-pipeline/issues/06-pixel-verification.md @@ -0,0 +1,24 @@ +# 06 — 像素验收:必须在 rAF 内跨帧采样 + +Status: resolved +Type: task + +## 问题 + +`verify-sim-page.mts` 报告"非透明占比 0.0%",但页面看上去是画出来的。 + +## 诊断(两个叠加原因) + +1. **取错了 canvas。** 页面里除了 spine 的 canvas,还有一个 300×150 的默认 canvas,检查取的是后者。 +2. **在 `requestAnimationFrame` 之外读像素。** spine 用 `preserveDrawingBuffer: false`,渲染缓冲在合成后被清空;rAF 外调 `gl.readPixels` 永远读到清空后的缓冲。 + +## 答案 + +- 取**面积最大**的 canvas。 +- 注入一个跨 90 帧的 rAF 采样器,累计 `window.__px.best`(各帧最大值),最后读这个值。 + +修好后 kv37 是 100.0%、合集类是 77.9%(骨架没铺满整屏,合理)。 + +## 教训 + +"渲染没发生"和"我没读到"在像素检查里长得一模一样。`preserveDrawingBuffer: false` 这个设置让所有单次采样都恒为 0——看起来像"壁纸白屏",实际是测量方法错了。 diff --git a/.scratch/build-pipeline/issues/07-visual-equivalence.md b/.scratch/build-pipeline/issues/07-visual-equivalence.md new file mode 100644 index 0000000..a963a13 --- /dev/null +++ b/.scratch/build-pipeline/issues/07-visual-equivalence.md @@ -0,0 +1,23 @@ +# 07 — 视觉等价验收:把"画面没变"变成可执行的断言 + +Status: resolved +Type: task + +## 问题 + +`verify-sim-page.mts` 只证明"自包含包能跑",不证明"画面没变"。内联、模块合成、URL 重写这些改动都可能悄悄改掉渲染结果。 + +## 答案 + +`tools/checks/verify-visual-equivalence.mts`:当前 dist 的截图与**重构前**的存档截图(`tools/shots/baseline-pre-refactor/`,6 组)逐像素比对,判据 `mean|diff| = 0 且 max|diff| = 0`。结果:**6 组全部逐像素相同**。 + +配套方法:`node tools/capture.mjs --base ` 抓图,`node tools/diff.mjs a b` 比对。基线帧必须靠 `--base` 指向**正确的分发根**——指向调试服首页会抓到一张空白页(45 KB vs 2165 KB),差异看起来像"整体回归",实际只是抓错了页面。 + +## 一个误读 + +`tools/capture.mjs` 打印的 `spine 层贡献: psnr=18.9 …` 是**同一轮里 ref 帧 vs 主帧**的差("spine 层占了多少像素"),**不是**与基线的比对。我把它读成了后者,一度以为 spine 层与重构前不一致,白查了半天。 + +真正的判定只有 `verify-visual-equivalence.mts`。查证过程留下了两个有用的结论: + +- **捕获是确定性的**:同一份代码两次捕获逐像素相同(`?__freeze=1` 把 `trackTime` 归零并每帧强制,`drawCalls=1437`、`trackTime=0`)。 +- **背景层(ref 帧)逐像素相同**,所以差异(如果有)只可能来自 spine 层。 diff --git a/.scratch/build-pipeline/issues/08-static-preview-and-edge-panel.md b/.scratch/build-pipeline/issues/08-static-preview-and-edge-panel.md new file mode 100644 index 0000000..1080b10 --- /dev/null +++ b/.scratch/build-pipeline/issues/08-static-preview-and-edge-panel.md @@ -0,0 +1,72 @@ +# 08 — 面板改为右边缘滑出,并让它能在静态托管上工作 + +Status: resolved +Type: task + +## 需求(用户原话) + +> 模拟的壁纸测试面板是在鼠标移至页面右侧边缘时弹出的,且并不能仅有调试服提供,未来上传 GitHub 是 pages 预览也需要能够弹出壁纸设置面板 + +两件事: + +1. 面板的打开方式是**鼠标移到页面右侧边缘**——不是常驻按钮。 +2. 面板**不能只有调试服能给**。未来上 GitHub Pages 的预览也要能弹出设置面板。 + +## ① 交互:右边缘滑出 + +原来的实现是右上角一个常驻的「WE 模拟器」按钮。改成 16px 右边缘热区 + 全高抽屉,与 player 配置页同一套约定: + +| 行为 | 结果 | +| --- | --- | +| 指针进入右边缘 16px | 面板滑出 | +| 指针离开面板/边缘 | 自动收回 | +| 点 × 收起 | 收起,且**必须先离开边缘**才会再次自动滑出 | +| 点热区 | 开合(触屏没有 hover,点击是兜底) | + +`#wesim-edge` 里的把手只是"这里能滑出东西"的可见提示,`pointer-events: none`,不参与命中。 + +### 踩的坑:不能用 mouseenter / mouseleave 判定 + +第一版用 `edge.mouseenter` 打开、`root.mouseleave` 收回。测试立刻发现"指针离开 → 自动收回"永远是 `data-open=1`。 + +原因:面板是用 `transform` 从右侧滑进来的,**出现时指针已经在它下面**,浏览器不会为它补发 `mouseenter`——于是 `root` 从来没被标记为 hover,它后面的 `mouseleave` 自然也不会来。 + +改成在 `mousemove` 里按坐标判定,结果只取决于指针位置,与浏览器是否补发事件无关: + +```js +const nearEdge = event.clientX >= window.innerWidth - EDGE_WIDTH; +const overPanel = opened && event.clientX >= window.innerWidth - PANEL_WIDTH; +``` + +`PANEL_WIDTH` / `EDGE_WIDTH` 与 CSS 共用同一组常量(CSS 里用模板插值),避免"改了一处忘了另一处"。 + +`armed` 是"× 收起后需先离开边缘"那条规则的状态位。没有它,点 × 之后指针仍在热区里,下一次 `mousemove` 就把面板弹回来,表现为"× 根本关不掉"。 + +## ② 静态托管:把注入写进 index.html + +| 场景 | 谁注入 | 模拟器本体从哪来 | 产物 | +| --- | --- | --- | --- | +| `pnpm dev` | 调试服按路由注入 | 调试服自己的 `/simulator/wallpaper-engine.js` | 分发目录保持干净 | +| 静态托管(Pages) | 构建期写进 `index.html` | 分发自带的 `./scripts/wallpaper-engine.js` | `pnpm build --with-sim` | +| 上传 Wallpaper Engine | 没有人注入 | 不存在 | `pnpm build`(默认) | + +要点: + +- 静态地址**必须相对**(`./scripts/...`)。Pages 把站点放在 `//` 子路径下,根绝对路径会 404。 +- 驱动带幂等闸 `window.__weSimDriver`:`--with-sim` 构建的分发自带驱动,调试服又会注入一次,没有闸就会 mount 两遍、出现两个面板。 +- 默认产物里**既没有模拟器文件,也没有任何注入痕迹**——它必须与用户从创意工坊下载到的东西完全一致(ADR 0006 第 1 条)。 + +## 验证 + +`tools/checks/verify-static-preview.mts`:**刻意把分发挂在 `/repo/` 前缀后面服务**,而不是挂在根上——挂根上测不出相对路径的错。断言分三组: + +- 静态托管下 `window.__weSim` 存在、`#wesim` 与 `#wesim-edge` 已挂载、没有常驻按钮、横幅在、壁纸本身也渲染 +- 右边缘滑出六态:初始收起 → 滑出 → 离开收回 → 再滑出 → × 收起不被弹回 → 离开边缘后重新可触发 +- 静态服务器 **0 个 404**、0 异常、0 控制台报错 + +四档分发全部通过。另有 `tools/checks/smoke-sim.mts` 的"面板恰好一个"断言守住双重注入。 + +## 顺带发现 + +- 合集那两档的自包含包是 128.7 MB,**超过 GitHub 单文件 100 MB 上限**。所以 Pages 只能走"分发目录 + 构建期注入"这条路,不能直接发布 `sim/index.html`。 +- `src/vendor/spine-player.js.bak` 是内联守卫测试留下的残留,已删。 diff --git a/.scratch/build-pipeline/issues/09-panel-matches-official.md b/.scratch/build-pipeline/issues/09-panel-matches-official.md new file mode 100644 index 0000000..b93be10 --- /dev/null +++ b/.scratch/build-pipeline/issues/09-panel-matches-official.md @@ -0,0 +1,66 @@ +# 09 — 模拟器设置面板还原 Wallpaper Engine 官方面板 + +Status: resolved +Type: task + +## 需求(用户原话) + +> wallpaper engine模拟设置面板严格还原官方面板 + +附官方面板截图(WE 2.8 里本项目这档壁纸的 Wallpaper Settings)。 + +## 官方面板的结构(从截图读出) + +- 标题栏:`Wallpaper Settings` + 右侧 `⟳ 重置` +- 属性一行一个:**图标 + 标签 + 右侧控件** +- `type:"text"` 的属性渲染成**富文本说明块**(`