# `tools/downloader/` — 米哈游活动页 Spine 抓取器 从 `wallpapers/sources.yml` 列出的活动页里把 **Spine 骨架 + 贴图 + 场景装配信息** 抓下来, 落到 staging(`_out/`)。**每个有内容的场景各产出一个「可直接搬走的壁纸目录」**——用户挑中的那份 整个移到 `wallpapers/<游戏id>/` 就能用(也可以让 `promote` 代劳)。 它不是构建输入:`wallpapers/` 只放**最终要发布**的资源,抓取脚本与临时下载都留在 `tools/`。 ```bash python -m tools.downloader fetch --page kv45 # 抓一页(结尾自动 verify,有硬伤就非 0 退出) python -m tools.downloader fetch # 抓 sources.yml 里的全部页面 python -m tools.downloader fetch --interactive # 逐页问:这一页推荐哪个场景(不影响落盘范围) python -m tools.downloader select --page nico-tea # 只做选择,写 selection.yml python -m tools.downloader verify # 只自检产物 python -m tools.downloader fetch --offline # 只用 _cache/ + 已落盘的产物,不发任何请求(重跑验证) python -m tools.downloader promote --page kv45/scene_ava --game hsr --id scene_ava \ --name 挥掷千星的筹码 --title '【崩坏:星穹铁道】挥掷千星的筹码' --description '[b]挥掷千星的筹码[/b]' node tools/checks/check-downloader.mts # 门:编译 + 夹具绿 + 四处破坏必红 ``` 依赖:Python 3.10+ 与 **PyYAML**(`requirements.txt`)。不需要浏览器——这些页面免登录即可拿到 全量入口 bundle,骨架数据就在里面。 --- ## 一、产物形状 ``` tools/downloader/ ├── _cache/<页面>/<脚本名> # 抓下来的 bundle 原文(文本调试缓存,可重跑零请求) ├── _out/<游戏>/<页面>/ │ ├── page.json # 页面级溯源:来源 URL / 入口脚本 / 场景清单 / 每个场景目录的摘要 │ └── <场景id>/ # ← 一个场景 = 一个可直接搬走的壁纸目录 │ ├── meta.json # id / name / title / description / game / page / scene / source / audio │ ├── preset.template.json # 运行时配置(背景图 + sceneConfig.parts) │ ├── scene.json # 场景侧车:part 列表 + 每个 part 的世界变换 │ ├── spines/<骨架名>/ │ │ ├── <骨架名>.json # 骨架(skeleton.images 已归一化为空串) │ │ ├── <骨架名>.atlas # 第一行 = 贴图页名,与落盘文件名逐字一致 │ │ ├── <贴图页…> # 与 atlas **同居**(布局 A,见 ADR 0008) │ │ └── meta.json # spine 版本 / 动画 / 皮肤 / 来源 URL │ ├── scene/<场景图> # 几何平面(geometry + material.diffuse)贴的图 │ └── audios/ # 音源(当前恒为空:页面的 BGM 还没抓) └── selection.yml # 机器所有的「哪一页推荐哪个场景」(**入库**) ``` 场景目录名 = 场景 id 转义成合法壁纸 id(只允许 `[a-z0-9_-]`,首字符必须是字母数字): `scene_main` / `P1` / `loading` 原样,中文场景 id(back-moon 的 `动画预览`)折成 `scene`。 **真实 id 不丢**——它在同目录的 `meta.json` 与 `scene.json` 里。 `_cache/` 与 `_out/` 在 `.gitignore` 里;`selection.yml` 入库。 ### 场景内去重、跨场景不去重 * **场景内按 id 去重**:同一个场景里同一具骨架被引用多次只存一份(`ctc_rewards` 在 back-moon 的 `scene_content` 里出现 4 次、`gc_win` 在 `scene_gacha` 里 6 次,都只落一份)。 `scene.json` 里那些 part 仍然一条不少——只有**文件**去重。 * **跨场景不去重**:`scene_main/` 与 `scene_ava/` 会各存一份用到的骨架。`_out/` 是可随时 重生成的 staging,体积换"每个目录都能单独搬走"。总体积记在 `page.json` 的 `totalBytes` 里。 ### `usable`:不是每个场景目录都能搬走 各页的 `scene_ui` 是一层**场景渲染目标**:它的平面用 `drawScene` modifier 把别的场景渲染成纹理 (diffuse 名就是场景 id),本身没有任何资源文件。这种场景照样落盘("页面上有几个场景"这件事在 产物里是完整的),但它的预设 `parts` 是空的: ```json { "usable": false, "reason": "场景里只有运行时纹理(drawScene / 贴图缓冲),没有可搬走的资源" } ``` `promote` 拒绝搬这种目录;`page.json` 的 `sceneDirs[]` 里逐条标了 `usable`。 ## 二、场景选择:只决定"推荐哪个" `fetch` **落盘全部有内容的场景**(至少有一具骨架或一块贴图平面)。纯色平面组成不了壁纸 (`sceneConfig.parts` 会是空的),完全没有 part 的场景也没有内容——这两类不落盘,原因记在 `page.json` 的 `skippedScenes` 里。 推荐场景由 `selection.yml` / `sources.yml` / 默认值算出(优先级写死,避免两份文件打架): 1. `wallpapers/sources.yml` 里这一页写了 `scene:` / `spines:` → 用它(**人的意志最高**) 2. `tools/downloader/selection.yml` 里有这一页的记录 → 用它(上次交互的结果) 3. 都没有 → **骨架最多的那个场景**(`--interactive` 时才问人) 默认规则与旧项目的判断一致(get-memory 会推荐 `P1`、back-moon 会推荐 `scene_main`)。 推荐值写进 `page.json` 的 `chosenScene` / `chosenDir`,是 `promote` 不给 `--scene` 时的默认值。 > **这份选择不再裁剪产物。** 以前它决定"只落哪几个骨架",现在产物是"一个场景一个自包含的 > 壁纸目录"——砍掉几具骨架会做出一个缺件的坏场景。要精简就在场景目录里的 > `preset.template.json` 上改(那是纯数据,构建期才读)。 > `sources.yml` 的 `spines:` 字段同理,只剩记录语义。 ## 三、`scene.json` 里有什么 页面用自研引擎(three.js 系)描述场景:`sceneList` 是场景数组,每个场景是一棵树,节点有三类负载: | kind | 含义 | 需要资源? | | --- | --- | --- | | `spine` | `spine:{id:"main_nike"}`,一具骨架 = 一个视觉元件 | 是(骨架 + atlas + 贴图页) | | `image` | `geometry` + `material.uniforms.diffuse`,贴图平面 | 是(`scene/<名>.`) | | `solid` | `defines.USE_TEXTURE == 0` 的**纯色平面**(diffuse 常写 `DEFAULT`) | 否 | 每个 part 带 `position` / `scale`(**世界变换**)与 `localPosition` / `localScale`,合成语义照抄引擎: ``` world_position = parent_position + parent_scale ⊙ local_position world_scale = parent_scale ⊙ local_scale ``` 旋转暂不参与合成(旧项目同样如此),只在 `stats.rotatedNodes` 里报出数量——不假装算了。 绘制层级 = 树序遍历序(`order`)+ 节点自身的 `renderOrder`。 带 `"runtime": true` 的 `image` part 是**没有独立文件**的平面:`drawScene` 的场景渲染目标、 `cacheContainer` 的贴图缓冲,或 diffuse 指向同场景骨架缓存。它们不进预设,也不算缺资源。 **用户口中的 "scene / geometric" 就是这里的两类节点**:`sceneList` 的场景容器与 `geometry` 平面。 Spine 官方没有这两个数据概念(官方 7 种附件类型里没有 geometric;`scene` 在 spine-webgl 里指的是 `SceneRenderer` 这个渲染器)。所以"支持场景动画"= 运行期把多具骨架 + 若干贴图平面按这套摆放合成。 ## 四、`preset.template.json` 怎么生成的 * `sceneConfig.parts`:`scene.json` 里每个 `spine` / `image` part 一条;纯色平面与运行时纹理不写 (运行时画不了它们,写进去只是死配置)。路径一律写成 `./spines/<名>/…`、`./scene/<名>.`—— **正斜杠**,因为预设会被整体搬走,`str(Path)` 的反斜杠在别的机器上是错的。 * `backgroundImage`:构建期它是**必填且必须真实存在**的。自动挑法 = 场景里**面积最大**的贴图平面 (它决定 `document.body` 的底图与取景用的宽高比,挑到一块小按钮会让整幅画的比例全错); 一块贴图平面都没有时退到第一具骨架的 atlas 第一行声明的贴图页。 * 写法与校验在同一个模块 `layout.py` 里(`build_preset` / `missing_preset_paths`): 产物形状一旦改,校验规则必须同时改,分成两个文件迟早漂移。写完之后**当场**把每条路径在磁盘上 核一遍,不留到构建期才炸。 ## 五、页面侧的坑(都踩过,别再踩) - **描述表的 `src` 表达式不能用一条 `.+?` 通吃**:kv45 的 bundle 里有个编译后的模板片段写着 `{src:e.activeIcon,alt:""}})`,`\{src:(.+?),id:"…",type:"image"\}` 会从那里一路吃 **8.8 万字符** 去够后面的 `,id:"…",type:"image"}`,把夹在中间的真表项(`{src:$w,id:"loading_dt1",…}`)整个吞掉。 现在分成两种形状各匹配各的:`_DESCRIPTOR_INLINE`(`Object.values(Object.assign({…}))[0]`)与 `_DESCRIPTOR_SIMPLE`(不含 `,{}` 的表达式)。 - **资源引用有四种写法**,缺一种就会把真资源当"不存在": 1. `X = a.p + "images/x.png"`(`_ASSET_LITERAL`) 2. `{src:a(38458),id:"x"}`:模块直接导出字符串——小图被 webpack 内联成 `e.exports="data:image/png;base64,…"`(`_MODULE_STRING`)。get-memory 的 `loading_moutain_a`、 `a01_lizi`、start-ndkl 的 `loading_start_1` 都栽在这里(少了它们,平面会报"资源表里没有 URL")。 3. `{src:$w,id:"x"}`:`$w="data:image/png;base64,…"`(`_STRING_ASSIGN`,kv45 的 `loading_dt1`)。 4. `Object.values(Object.assign({"<源路径>":"data:…"}))[0]`(`_DATA_IN_ASSIGN`)。 - **骨架数据一律内联在 bundle 里**,6 个页面里 `.atlas` / `.skel` 的网络请求数是 **0**。 只有 hsr 的骨架 json 走网络(页面根目录 `.json`),而且它在 **webpack 异步 chunk** 里(`258.ccb0954b.js`)——入口 HTML 根本没列它,得读 `.u=` 的 chunk 名映射再排队抓。 这也是 hsr 的 `scene_ava` **首次离线抓不到**的原因:那两个 json 从没进过 `_cache/`, 得先联网抓一次(见第七节的离线边界)。 - **三种内联家族**都要认:A `Object.values(Object.assign({"…/spine/.json":{…}}))[0]`(JS 对象字面量, 键不带引号,用 `jslit` 规范化);B 匿名模块 + 配对表 `{atlas:fn(id),json:fn(id)}`, 骨架是 `JSON.parse('…')`(**单引号** JS 字符串,要先按 JS 语义还原);C hsr 的 `spineSetting`。 - **公共路径变量名逐页不同**:`n.p` / `a.p` / `t.p`。写死 `n.p` 会让半个页面的资源表全空。 - **同一逻辑名可能有两个候选**(引擎的桌面/移动两套预载表)。判据:数组式描述表是基准集, 字典式表是移动端覆盖(引擎里是 `desktop() || base.forEach(e => override[e.id] && …)`),桌面取基准集。 - **atlas 有两种书写风格**:`size:498,330` 与 `size: 256, 256`,解析器两种都要吃。 - **`.atlas` 的页名要读 atlas 自己声明的**(`atlas.py` 解析出来的第一行/页行),不要按 `_N` 猜: 多页是 `_2.png`,也有完全不同的名字。落盘用逻辑名,atlas 第一行不用改。 - **`skeleton.images` 要归一化**:作者目录(`../images/`)搬进分发后一定指错,写空串即可 (页名相对 atlas 所在目录解析),原值记在 `meta.json` 的 `originalImages` 里。 - **跨平台路径**:别拿 `str(Path)` 当映射键(Windows 反斜杠 vs 预设里的正斜杠),一律 `Path.as_posix()`。 ## 六、多版本 Spine `meta.json` 逐具骨架记录 `skeleton.spine` 原文。事实基线(详见 `.scratch/spine-versions/REPORT.md`): - 版本串是**编辑器版本**;`4.0-from-4.1-from-4.2-from-4.3.23` 这类 `-from-` 串表示 **数据是 4.0 格式**(降级导出),运行库只认开头那个 `major.minor`。 - 运行库**不校验**这个串;spine-ts 4.2 能读 4.0/4.1/4.2 的数据。**4.3 数据会被静默丢掉全部约束** (4.3 把约束并进 `root.constraints`),所以 `verify` 对 `≥4.3` 的骨架直接报红。 - 目前 7 页共 170+ 具骨架全是 ≤4.2 格式,**一套 4.2 运行库就够**;真出现 4.3 数据再谈多套 UMD 共存 (官方没有 `spine-version` 属性,只能各包一层别名函数避免 `window.spine` 互相覆盖)。 - 重分发运行库要带 Spine Runtimes License Agreement(Exhibit A)+ 版权声明,不得删各文件头。 ## 七、`verify` 检查什么 逐场景目录核一遍:`meta.json` / `preset.template.json` / `scene.json` 都在;`meta.json` 的 `id` 等于目录名、字符集合法、`name`/`title`/`description` 非空、`audio.choices` 指向的文件存在; 预设里每条 `./…` 路径在磁盘上找得到;侧车引用的骨架有目录与文件、atlas 声明的贴图页逐字落盘、 几何平面的图存在;纯色与运行时纹理不计入缺失;骨架版本在运行库可读范围内。 页面级还会抓"旧形状的残留"(页面根的 `scene.json` / `spine/` / `scene/`)与"不是场景目录的目录"。 **退出码非 0 = 有硬伤**。 它不评判画面对不对——那是 `tools/checks/verify-scene-player.mts` 的事(多骨架 + 贴图平面真的合成出来)。 ### 离线能跑到哪一步 `fetch --offline` 只读 `_cache/`(bundle 文本)与**已经落盘的 `_out/`**。所以: * **重跑**永远是安全的:产物存在就跳过,不发任何请求。 * **首次**抓一个"从没抓过的场景"需要联网——它的骨架 json 与贴图页都还没有本地副本。 hsr 的 `scene_ava`(`zhigengniao_juheye` / `shajin`)就是这样:先 `fetch --page kv45`(联网) 一次,之后再 `--offline` 就完全绿。缺什么会**逐条报出来**(不会甩一条 traceback 就走)。 ## 八、离线预览(`_out/` 能看,`_cache/` 不能) | 目录 | 内容 | 能否预览 | | --- | --- | --- | | `_cache/<页面>/` | 原始 bundle **文本**(引用仍是线上绝对地址、贴图不在里面) | ❌ 按设计就是文本调试缓存 | | `_out/<游戏>/<页面>/<场景>/` | 真实文件树(骨架 / atlas / 贴图页 / 场景图 / 侧车 / 预设) | ✅ **可离线预览** | ```bash node tools/preview.mts # 列出 _out 里可预览的场景 node tools/preview.mts ys/nico-tea # 页面 = 取它的推荐场景 node tools/preview.mts ys/nico-tea/scene_main # 点名场景 node tools/preview.mts hsr/kv45/scene_ava --port 8199 node tools/preview.mts ys/nico-tea --no-serve # 只组装到 tools/.cache/preview/ ``` `tools/preview.mts` 把「构建出的运行时 + staging 的资产 + 由 `scene.json` 生成的预设」组装成 一个独立目录再起静态服务;**页面只读本地文件**。页面里的调试面是 `window.__sceneDebug` (`parts` / `loaded` / `errors` / `framing` / `rotatedSkipped`),排查"少加载了一件"直接看它。 两点说明: - **要预览"原页面"而不是我们的产物**,得走整站镜像(`.scratch/page-mirror/spec.md`,尚未实现); `_cache/` 不能满足这个需求——它只有文本,没有资产树,也没有改写引用。 - `tools/checks/verify-scene-player.mts` 里另有一份"从 scene.json 生成预设"的代码, **故意不与 preview 共用**:门必须独立于被验证对象,共用一份就变成自己验自己。 ## 九、`promote`:搬进 `wallpapers/` `fetch` 已经产出终态,所以 promote 退化成 **拷贝 + 校验 + 写 meta**: ```bash python -m tools.downloader promote --page kv45/scene_ava --game hsr --id scene_ava \ --name '挥掷千星的筹码' --title '【崩坏:星穹铁道】挥掷千星的筹码' --description '[b]挥掷千星的筹码[/b]' ``` * `--page` 认三种写法:页面 id(`kv45`)、`页面/场景`(`kv45/scene_ava`)、场景目录的路径。 给页面 id 时用 `--scene` 点名,或让它取 `page.json` 的 `chosenScene`。 * `--name` / `--title` / `--description` 可选:不给就沿用场景目录 `meta.json` 里的 (那里已经是"页面名(场景id)"的形状,够用但通常要改成正式文案)。 * `--cover` 给逻辑名时覆盖 `backgroundImage`。 * **目标目录里的 `meta.json` 的 `id` 会被改写成 `--id`**——所以壁纸 id 可以跟场景目录名不同, 但目录名与 `meta.json.id` 必须一致(构建期会校验)。 * 搬之前会再核一遍预设里的每条路径在**目标目录**上存在;搬完不用手工改任何路径。 也可以完全不用 promote:**整个场景目录拷到 `wallpapers/<游戏id>/<壁纸id>/` 就完事** (前提是目录名 = `meta.json.id`,且那个目录 `usable`)。两种做法等价,promote 只是顺手改名与校验。 ## 十、还没做的 - **音源**:页面 BGM 是另一条链,`audios/` 现在是空的,`meta.json` 的 `audio.choices` 也是空的。 - **预览图**:没有抓,也没有生成。 - **`scene_ui` 这类纯渲染目标场景**:落盘但不可搬走(运行时不会 `drawScene`)。