feedback5 WX: the 39 summon models no web dump carries come from the local client (extract.py TOKEN_SPINES) and are drawn instead of the avatar diamond when data/local-assets.json lists them

- tools/local-extract/extract.py TOKEN_SPINES (`--only spine/token`): each token's battle prefab in the
  battle token packs (pkgrps/btl_pfb_tokens_*.ab; the Windows build has all 39, the iOS build lacks
  btl_pfb_tokens_0), the skeleton of its Front (or only) Spine renderer, written to
  public/assets/local/spine/token/<id>/ like the enemy models (sized pma atlas, premultiplied pages;
  the newer RGBA pages keep their own alpha). The enemy writer is shared (write_spine; the two enemy
  models re-extract byte for byte). The skinpack job of 乌尔比安's block is gone: it extracted
  nothing, and that token draws no model in the game (nor do 圣聆初雪's, 酒神's, 贝洛内's and
  予愿安洁莉娜's hidden tokens: no Spine renderer, no avatar in either install).
- tools/assets/local-token-spines.json (committed, `fetch-assets --local-spines`) → data/assets.json
  tokens[id].spineLocal for 35 自选 summons, 凯瑟琳's 爬行号·防护单元 and 凛御银灰's three 风雪之眼; the
  manifest is the same with or without the extraction (JSON diff: only the 39 spineLocal entries and
  the hash). spine.mjs LOCAL_SPINE_ROLES maps clips the resolver cannot read (电弧's 戴乌 C_Skill1_*,
  酒神's 本能的召唤 Loop / End, 白铁's platforms End, 战术锚点 on its Default pose) [ASSUMED where named].
- assets.js spineEntry: a token resolves its spineLocal like an enemy (no web model behind it: the
  diamond stays when it is not listed or fails to load). Scale as the web tokens (UNIT.modelScale).
- setup reports an extraction without the summon models; docs: ASSETS.md "Token models from the local
  client", DEPLOY.md §6, DESIGN §13 (design/client.md), README.
- Tests: test/local-token-models.test.js (new), test/local-extract.test.js (job table, prefab walk,
  alpha merge), test/assets.test.js (the local token models load like the client does),
  test/package.test.js (the full zip ships them), test/static-local-art.test.js. Golden unchanged.
This commit is contained in:
sganggs committed 2026-10-06 20:16:20 +08:00
1 parent 83d76e507d
commit 575333137c
17 files changed
+2738 -111

No files matched your search

+1 -1
View File
@@ -96,7 +96,7 @@ npm start # 启动服务器:http://localhost:3000
也可以直接运行启动脚本(Windows `scripts\start-windows.bat`,macOS / Linux `scripts/start.sh`):首次会自动安装依赖、下载素材,然后启动服务器并打开浏览器。
- **本地客户端素材(可选)**:官方 3D 棋盘、部分官方界面图标(交流按钮与表情面板的边框、模组类型图标等)和灼热 / 炽焰源石虫的官方模型需要从本机的《明日方舟》PC 客户端提取(Windows 原生客户端、macOS 的 CrossOver 或 PlayCover)。`npm run setup` 检测到客户端时会询问是否提取(需要 Python 3.8+,依赖装在项目内的 `.venv-extract`,不影响系统);之后可以用 `node tools/setup.mjs --local` 重新提取,或用 `--game "<…/StreamingAssets/AB/Windows>"` 指定路径。没有客户端时游戏照常运行,这几样换成替代样式:2D 棋盘、样式相近的图标、染色的普通源石虫。表情和「玩法说明」的教程图随上面的素材一起从公开镜像下载,不需要客户端。没有客户端的服务器(例如 Linux VPS)也可以从**同一版本**的整合包(完整包;精简包没有)里复制 `public/assets/local/` 和 `data/local-assets.json`,见 [docs/DEPLOY.md](docs/DEPLOY.md) 的「本地客户端素材」。
- **本地客户端素材(可选)**:官方 3D 棋盘、部分官方界面图标(交流按钮与表情面板的边框、模组类型图标等)、灼热 / 炽焰源石虫和 39 个召唤物(多数自选召唤物)的官方模型需要从本机的《明日方舟》PC 客户端提取(Windows 原生客户端、macOS 的 CrossOver 或 PlayCover)。`npm run setup` 检测到客户端时会询问是否提取(需要 Python 3.8+,依赖装在项目内的 `.venv-extract`,不影响系统);之后可以用 `node tools/setup.mjs --local` 重新提取,或用 `--game "<…/StreamingAssets/AB/Windows>"` 指定路径。没有客户端时游戏照常运行,这几样换成替代样式:2D 棋盘、样式相近的图标、染色的普通源石虫、召唤物头像。表情和「玩法说明」的教程图随上面的素材一起从公开镜像下载,不需要客户端。没有客户端的服务器(例如 Linux VPS)也可以从**同一版本**的整合包(完整包;精简包没有)里复制 `public/assets/local/` 和 `data/local-assets.json`,见 [docs/DEPLOY.md](docs/DEPLOY.md) 的「本地客户端素材」。
- 素材下载优先使用 GitHub,失败时自动改用 jsDelivr 镜像。
- `npm run doctor`(即 `node tools/doctor.mjs`)可以随时诊断:Node 版本、素材是否完整、端口占用、局域网地址和防火墙。
+1 -1
View File
@@ -1 +1 @@
{"version":1,"hash":"ffa64aa22a1e","generator":"tools/fetch-assets.mjs","stats":{"files":7972,"bytes":485093753,"chars":209,"charsWithBack":206,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":671,"bonds":23,"items":59,"bands":40,"skills":522,"modules":131,"ui":602,"sfxUnits":483,"voiceChars":191},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/front/char_017_huang.skelLine truncated
{"version":1,"hash":"026eaf9ff08e","generator":"tools/fetch-assets.mjs","stats":{"files":7972,"bytes":485093753,"chars":209,"charsWithBack":206,"enemies":252,"enemiesWithSpine":251,"tokens":58,"tokensWithSpine":13,"spineModels":671,"bonds":23,"items":59,"bands":40,"skills":522,"modules":131,"ui":602,"sfxUnits":483,"voiceChars":191},"chars":{"char_003_kalts":{"avatar":"/assets/char/avatar/char_003_kalts.png","avatarE2":"/assets/char/avatar/char_003_kalts_2.png","portrait":"/assets/char/portrait/char_003_kalts_1.png","portraitE2":"/assets/char/portrait/char_003_kalts_2.png","spine":{"front":{"skel":"/assets/spine/op/char_003_kalts/front/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/front/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/front/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Die":1,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":-227.579,"y":-138.244,"width":532.04,"height":551.479}},"back":{"skel":"/assets/spine/op/char_003_kalts/back/char_003_kalts.skel","atlas":"/assets/spine/op/char_003_kalts/back/char_003_kalts.atlas","textures":["/assets/spine/op/char_003_kalts/back/char_003_kalts.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":0,"idle":null},"1":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Attack","end":null,"via":"attack","index":2,"idle":null}}},"animations":{"Attack":1,"Default":0,"Idle":9,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433]},"bounds":{"x":410.785,"y":-2.26,"width":531.785,"height":416.492}}}},"char_010_chen":{"avatar":"/assets/char/avatar/char_010_chen.png","avatarE2":"/assets/char/avatar/char_010_chen_2.png","portrait":"/assets/char/portrait/char_010_chen_1.png","portraitE2":"/assets/char/portrait/char_010_chen_2.png","spine":{"front":{"skel":"/assets/spine/op/char_010_chen/front/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/front/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/front/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill_2","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Die":1,"Idle":2,"Skill":1,"Skill_2":1.167,"Skill_3":2.933,"Skill_End":0.6,"Skill_End_2":0.6,"Skill_End_3":0.3,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467],"Skill_2":[0.467],"Skill_3":[0.6,0.8,1,1.2,1.4,1.6,1.8,2,2.2,2.833,2.933]},"bounds":{"x":-322.335,"y":-109.274,"width":663.549,"height":524.806}},"back":{"skel":"/assets/spine/op/char_010_chen/back/char_010_chen.skel","atlas":"/assets/spine/op/char_010_chen/back/char_010_chen.atlas","textures":["/assets/spine/op/char_010_chen/back/char_010_chen.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":null,"loop":"Skill","end":"Skill_End","index":0,"idle":null},"1":{"begin":null,"loop":"Skill","end":"Skill_End","index":1,"idle":null},"2":{"begin":null,"loop":"Skill","end":"Skill_End","index":2,"idle":null}}},"animations":{"Attack":1.5,"Attack_End":0.6,"Attack_Pre":0.6,"Default":0,"Idle":2,"Skill":1,"Skill_End":0.6,"Start":0.833},"events":["OnAttack","OnStart"],"hits":{"Attack":[0.433,1],"Skill":[0.467]},"bounds":{"x":-173.082,"y":-13.829,"width":315.887,"height":404.623}}}},"char_017_huang":{"avatar":"/assets/char/avatar/char_017_huang.png","avatarE2":"/assets/char/avatar/char_017_huang_2.png","portrait":"/assets/char/portrait/char_017_huang_1.png","portraitE2":"/assets/char/portrait/char_017_huang_2.png","spine":{"front":{"skel":"/assets/spine/op/char_017_huang/front/char_017_huang.skelLine truncated
+35 -6
View File
@@ -25,7 +25,7 @@ npm run assets # = node tools/vendor.mjs && node tools/fetch-assets.mjs
| `--prune` | Delete files under `public/assets/` that the manifest no longer references, for example after a mapping change. Without this flag they are only listed in the report. `public/assets/local/` (written by `tools/local-extract`) is never pruned. Implies `--allow-shrink`. |
| `--allow-shrink` | Write `data/assets.json` even when it loses entries the current one has (see "The manifest never shrinks by accident" below). |
| `--add-only` | For a checkout whose `public/assets/` and `public/fonts/` are shared with another one (a git worktree with symlinked asset folders): download only the files missing on disk and never re-download, rewrite or delete an existing file — atlases already on disk are left as they are, the fonts are not rebuilt (the manifest keeps its current `fonts`). Not with `--prune` / `--force`. |
| `--local-spines` | Rewrite `tools/assets/local-enemy-spines.json` (the metadata of the enemy models only the local client has, see "Enemy aliases") from the models `tools/local-extract/extract.py` extracted to `public/assets/local/spine/enemy/`. Run it after a game update changed them; without it the committed file is used and a differing extraction only gets a warning. |
| `--local-spines` | Rewrite `tools/assets/local-enemy-spines.json` and `tools/assets/local-token-spines.json` (the metadata of the enemy and token models only the local client has, see "Enemy aliases" and "Token models from the local client") from the models `tools/local-extract/extract.py` extracted to `public/assets/local/spine/enemy/` and `public/assets/local/spine/token/`. Run it after a game update changed them; without it the committed files are used and a differing extraction only gets a warning. |
**The manifest never shrinks by accident.** An entry whose files are missing on this machine is left out of a rebuilt
manifest, so a run where some downloads failed (or whose upstream audio / model index lost them) would drop entries that
@@ -90,6 +90,7 @@ The research JSONs in `docs/research/` (03, 05, 07) define **which** ids are nee
| Token Spine | fexli: the default model, or else the first skin variant (`spine/{tokenId}/{variant}/Spine/`) | `spine/token/{tokenId}/{stem}.*` |
| Enemy Spine (PC build, premultiplied alpha) | isHarryh/Ark-Models `models_enemies/{key}/`, file names from `models_data.json` | `spine/enemy/{enemyId}/{stem}.*` |
| Enemy Spine that no dump carries (灼热源石虫 / 炽焰源石虫) | the local client only (`tools/local-extract/extract.py ENEMY_SPINES`, optional); never downloaded and never required: an overlay of the web alias (`enemies[id].spineLocal`) | `local/spine/enemy/{enemyId}/{stem}.*` (listed in `data/local-assets.json`) |
| Token Spine that no dump carries (39 summons: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼) | the local client only (`tools/local-extract/extract.py TOKEN_SPINES`, optional); never downloaded and never required: an overlay (`tokens[id].spineLocal`; without it the avatar diamond) | `local/spine/token/{tokenId}/{stem}.*` (listed in `data/local-assets.json`) |
| BGM | AA2 `voice` branch `audio/sound_beta_2/music/**` (大厅/休整期 `act1autochess`, 开战 `act13side/m_bat_kazimierz2_{1,2}` — 骑士之日 / 无畏者; the 开战 track follows the round: `_2` 无畏者 rounds 1–7, `_1` 骑士之日 from round 8) | `audio/bgm/{file}.mp3` |
| SFX (UI, battle, per unit) | AA2 `voice` `audio/sound_beta_2/**`, mapped from `audio_data.json` banks | `audio/sfx/{same sub-path}.mp3` |
| 干员战斗语音 | AA2 `voice` `audio/sound_beta_2/voice_cn/{charId}/cn_nn.mp3` — the lines `charword_table.json` lists (`placeType` = when the game plays one, `voiceAsset` = the path); `--voice-lang=jp|en|kr` takes the same file names from `voice/`, `voice_en/`, `voice_kr/` | `audio/voice/{lang}/{charId}/{cn_nn}.mp3` |
@@ -100,7 +101,7 @@ The `stem` of a Spine model is the upstream file name. Two examples: `char_107_l
### Id scope
- **Operators:** all 138 pool charIds from `activity_table` (`charShopChessDatas[*].charId ∪ backupCharId`), including hidden chess and backup operators; plus every unit of `data/backups.json` research 07 does not list — the 71 自选 owned-6★ picks (`diy.ownedPool`, DATA.md §18; the collab operators are not in the data, and 焰狐龙梓兰's entries left the manifest when she left the pool in 0.2.0) — planned from 07's URL patterns (`tools/assets/plan.mjs patternOperator`, `tools/fetch-assets.mjs dataExtras`): avatar and portrait (E0–E1 and E2), the default-skin battle Spine Front / Back, the icon and skill sound of each skill, the sub-profession icon. 209 operators in all (206 with a Back model).
- **Tokens:** the 20 pool tokens, and the 38 summons of the 自选 picks (`data/backups.json tokens`) as extra tokens (below): their avatars; their battle Spine exists upstream only as skin variants, which the default locations miss — no Spine yet (the client draws the avatar).
- **Tokens:** the 20 pool tokens, and the 38 summons of the 自选 picks (`data/backups.json tokens`) as extra tokens (below): their avatars; no dump carries the battle Spine of the 自选 summons (upstream has at most skin variants, which the default locations miss), so the web manifest has no model for them — 35 of them, and 4 pool summons, have the official model as an optional local-client overlay ("Token models from the local client"); 3 have no model in the game at all.
- **Enemies:** 253 ids planned, 252 in the manifest (心烛 has no assets). The set is the union of:
- the 07 enemy list;
- every enemy in the `act1autochess_*` wave, boss and 联防 levels that act2 modes use (from `05-maps.json`; the tutorial is excluded);
@@ -149,7 +150,9 @@ All paths are URL paths relative to the site root, for example `/assets/char/ava
spineLocal?: { group, skel, atlas, textures, …Spine } } },
// spineLocal: an optional local-client model; file names in a data/local-assets.json group,
// not URLs, and always emitted (independent of the disk) — "Enemy aliases" below
tokens: { [tokenId]: { owner: charId|null, avatar?, spine?: Spine, spineVariant?: string } },
tokens: { [tokenId]: { owner: charId|null, avatar?, spine?: Spine, spineVariant?: string,
spineLocal?: { group, skel, atlas, textures, …Spine } } },
// spineLocal: as for enemies — "Token models from the local client" below
bonds: { [bondId]: url }, // white glyphs; tint in CSS/canvas
items: { [trapId]: url },
bands: { [bandId]: url },
@@ -317,13 +320,37 @@ Other renderer rules from research 07 §5.4–5.5:
round. A 2026-10-03 audit of every enemy of `data/enemies.json` (249) against the client's battle prefabs (the
skeleton each prefab's Spine renderer draws) found no other enemy drawn with another enemy's model; 伊利昂的木驮兽
(`enemy_10159_mntrjn`) starts on its `Full` skin (five passengers) in the game and is drawn with the `default` one.
- **Token models from the local client** (0.2.0): no dump carries the battle Spine of most 自选 summons (fetch-assets:
"missing skel"), nor of 凯瑟琳's 爬行号·防护单元 and 凛御银灰's 风雪之眼, so they were drawn as the avatar diamond. The
local client has them in its battle token packs (`pkgrps/btl_pfb_tokens_*.ab`, the Windows build carries all of them;
the iOS build lacks `btl_pfb_tokens_0` and has ASTC pages), the same overlay as the enemies above:
- `tools/local-extract/extract.py TOKEN_SPINES` (39 ids; `--only spine/token`) reads each token's battle prefab
(`dyn/battle/prefabs/[uc]tokens/<id>.prefab`), takes the skeleton of its Front renderer — a directional token has
Front / Back (/ Down) renderers, each with its own skeleton of the same name; the web tokens use Front too — and
writes it to `public/assets/local/spine/token/{tokenId}/` like an enemy model (skeleton, sized `pma: true` atlas,
premultiplied pages: the older tokens' `[alpha]` texture merged in, the newer RGBA pages kept with their own alpha).
- `tokens[id].spineLocal` = `{ group: 'spine/token/{tokenId}', skel, atlas, textures, pma, anims, … }` from the
committed `tools/assets/local-token-spines.json` (`fetch-assets --local-spines`), never from the disk;
`assets.js spineEntry` draws the model when `data/local-assets.json` lists every file of it, else (or when it fails
to load) the avatar diamond as before. The models are drawn like the web tokens: one `UNIT.modelScale`, no
per-prefab factor (the official prefabs scale most tokens by the standard 0.27; W's 此面向敌 0.4, 令's “清平” 0.25 and
“弦惊” 0.3, 傀影's 镜中虚影 0.26, 风雪之眼 and 淬羽赫默's 夜灯 0.25 — like the web tokens' 医疗探机 / 诅咒娃娃 0.4 and
香槟炸弹 0.25).
- Clip names the resolver cannot read are mapped in `tools/assets/spine.mjs LOCAL_SPINE_ROLES`: 电弧's 戴乌
(`C_Skill1_*`, beside a 0 s `C_Default` pose), 酒神's 本能的召唤 (`Loop` / `End`) and 白铁's 多功能平台 (`End` as it
goes) [ASSUMED: by the clip names], and 凯尔希·思衡托's 战术锚点, whose Start / Idle / Die clips hide its only
attachment: it stays on its `Default` pose (a white anchor mark) [ASSUMED].
- Not extracted: the tokens whose prefab draws nothing (an `EmptyAnimator` instead of a Spine renderer; no avatar in
either install's asset index either): 乌尔比安's 从不混淆的方向, 圣聆初雪's 保护目标(冻结状态), 酒神's 迷狂牢笼, 贝洛内's
牵绊 and 予愿安洁莉娜's “一会儿见!” — the last three keep the token fallback picture (`tokenAvatarUrl`: no avatar, no
owner in the manifest → the 召唤物 battle-card icon).
### Other fallbacks
- **Emotes and 玩法说明 pages** (`public/js/data.js artUrls / nextArtUrl`, `ui/guide.js guideStage`): the local-client picture (`data/local-assets.json`) first, then the mirror copy (`ui['emoticon/…']`, `ui['guide/…']`), each tried in turn when one fails to load; when none is left — none listed, or every copy failed (for example data/assets.json lists the downloaded pages but the files are not on disk yet: a `git pull` and restart without setup) — the neutral emote glyph, and for a page the official tips text (`config.tips`). The rest of the local-client art (the 3D board, the official HUD sprites, module type icons, the two enemy models above) is not downloaded: the client looks it up in `data/local-assets.json` only (most of the HUD sprites are on the mirror too, DESIGN §22.5); docs/DEPLOY.md §6 lists what falls back without it.
- **Emotes and 玩法说明 pages** (`public/js/data.js artUrls / nextArtUrl`, `ui/guide.js guideStage`): the local-client picture (`data/local-assets.json`) first, then the mirror copy (`ui['emoticon/…']`, `ui['guide/…']`), each tried in turn when one fails to load; when none is left — none listed, or every copy failed (for example data/assets.json lists the downloaded pages but the files are not on disk yet: a `git pull` and restart without setup) — the neutral emote glyph, and for a page the official tips text (`config.tips`). The rest of the local-client art (the 3D board, the official HUD sprites, module type icons, the two enemy models and the 39 token models above) is not downloaded: the client looks it up in `data/local-assets.json` only (most of the HUD sprites are on the mirror too, DESIGN §22.5); docs/DEPLOY.md §6 lists what falls back without it.
- **Tokens:**
- Without an avatar, use `chars[owner].avatar` with a 召唤物 badge, or `prof.battlecard.token` — except 圣聆初雪's 保护目标(冻结状态) (PRTS 无头像; the frozen gate), drawn as a procedural ice diamond (`render/units.js ICE_TOKENS`).
- Without a Spine, draw the avatar sprite with a bob tween.
- Without a Spine (and without its local-client model, "Token models from the local client"), draw the avatar sprite with a bob tween.
- `spineVariant` names the skin-variant model that stands in for the missing default model.
- **Enemies without a spine** (for example `enemy_9016_acstmr`): draw `icon` in a diamond. Enemies with no manifest entry at all (`enemy_5601_entlec` 心烛): draw a procedural glyph.
- **Battle effects** (projectiles per kind, hit sparks and slashes, skill bursts and auras, 蕾缪安's lock reticles and shells, 回环射手 boomerangs — DESIGN §17.3) are procedural: the FX atlas is drawn at run time (`public/js/render/textures.js`), so they need no downloaded or local art. The local client does have battle effect art — `battle/[pack]common.ab` holds per-weapon projectile sprites (`projectile_arrow(_new)`, `projectile_crossbow(_new)`, `projectile_yuki`, `img_fx_light_01/02`, `trail_11`), and the per-character `battle/prefabs/effects/*.ab` are particle systems whose textures live in other bundles — but none of it is extracted: the sim's `arrow` also covers gun snipers, and friends joining a game may not have the local art.
@@ -350,7 +377,9 @@ When `public/assets` exists, the same file also checks the generated output:
- Every manifest path exists on disk.
- Every pool operator has an avatar, a portrait and a Front model.
- Every atlas has `size:` lines, plus `pma: true` for enemies.
- Every Spine model loads the way the client loads it. That means pixi-spine's atlas reader with real page sizes, where a region outside its page throws, then `SkeletonBinary` with `AtlasAttachmentLoader`, where a missing region throws. Every resolved role is then posed.
- Every Spine model loads the way the client loads it — the local-client enemy and token models too, when `data/local-assets.json` lists them. That means pixi-spine's atlas reader with real page sizes, where a region outside its page throws, then `SkeletonBinary` with `AtlasAttachmentLoader`, where a missing region throws. Every resolved role is then posed.
`test/feedback1d-models.test.js` (enemies) and `test/local-token-models.test.js` (tokens) check the local-client overlays: the plan, the committed metadata, the manifest without `/assets/local/` URLs, the client's choice of model; `test/local-extract.test.js` the extractor's job table and helpers.
The 2026-09-27 verification pass also checked:
- **PNG:** all 2,211 PNGs pass a full CRC and inflate check.
+4 -1
View File
@@ -280,10 +280,13 @@ services:
| 官方 3D 棋盘(贴图、模型、地图特效) | 2D 棋盘,地块由程序绘制 |
| 部分官方界面图标与底板:交流按钮和表情面板的边框、暂停面板、装备替换窗口、干员调配界面、队友状态与漏怪标记、模组类型图标等 | 样式相近的替代图形、图标或文字 |
| 灼热 / 炽焰源石虫的官方模型 | 染成橙色 / 红橙色的普通源石虫 |
| 39 个召唤物的官方模型(多数自选召唤物,以及凯瑟琳的爬行号·防护单元、凛御银灰的风雪之眼;公开镜像没有) | 召唤物头像(菱形底板) |
表情(6 套 × 6 个)和「玩法说明」的 19 页教程图公开镜像也有:`node tools/setup.mjs` 会和其他素材一起下载(约 21 MB),不需要客户端;有本地素材时优先显示本地的。
**没有客户端的服务器**想要上表中的官方素材:从**同一版本**的完整包([Releases](https://github.com/sganggs/Stronghold-Protocol/releases))里,把 `public/assets/local/` 文件夹和 `data/local-assets.json` 复制到服务器项目目录下的相同位置。服务器每次请求都会重新读取这两处,不必重启,玩家刷新页面即可。一定要用与服务器代码相同版本的完整包:各版本提取的内容和清单可能不同(例如灼热 / 炽焰源石虫的模型是 0.1.0 之后才加入的),混用其他版本的文件会缺图或用错图。复制后 `node tools/doctor.mjs` 会显示本地素材的条目数和「3D 棋盘可用」。
**没有客户端的服务器**想要上表中的官方素材:从**同一版本**的完整包([Releases](https://github.com/sganggs/Stronghold-Protocol/releases))里,把 `public/assets/local/` 文件夹和 `data/local-assets.json` 复制到服务器项目目录下的相同位置。服务器每次请求都会重新读取这两处,不必重启,玩家刷新页面即可。一定要用与服务器代码相同版本的完整包:各版本提取的内容和清单可能不同(例如灼热 / 炽焰源石虫的模型是 0.1.0 之后才加入的,召唤物模型是 0.2.0 加入的),混用其他版本的文件会缺图或用错图。复制后 `node tools/doctor.mjs` 会显示本地素材的条目数和「3D 棋盘可用」。
**0.2.0 之前提取过的**:召唤物模型是 0.2.0 新增的提取项,旧的提取结果里没有(`node tools/setup.mjs` 会提示「缺少新版的自选召唤物模型」)。有客户端的电脑运行 `node tools/setup.mjs --local` 重新提取即可,只想补这一项也可以在提取用的 Python 环境里运行 `tools/local-extract/extract.py --only spine/token`(新文件写入 `public/assets/local/spine/token/`,清单里其他条目保持不变)。
**3D 棋盘贴图的下载量**:每位玩家进入对局时都要从开服的电脑下载 3D 棋盘的 12 张贴图。提取时会给这 12 张各写一份 WebP(颜色贴图有损、质量 95,法线和数据贴图无损),清单里列的是 WebP,同名 PNG 留在旁边给裁切工具和 setup 用。这部分下载量从约 6.7 MB 降到约 2 MB,网速慢的远程联机最明显。只有 PNG 的本地素材(例如在这一改动之前提取的)可以用提取时的 Python 环境运行 `tools/local-extract/extract.py --webp` 就地补上,只需要 Pillow,不需要客户端。
+1
View File
@@ -58,6 +58,7 @@ Audio: autochess BGM per phase, UI SFX (buy/sell/refresh/level/merge/ready/timer
- `guide/autochess_{home,shop,handbook}_N.png` — the 19 official tutorial pages; stored squashed to 1024² — **display at 16:9** (the mirror copies too, §22.5).
- `projectiles/*` — battle projectile sprites.
- `spine/enemy/<id>/` — enemy models no web dump carries (灼热 / 炽焰源石虫, `extract.py ENEMY_SPINES`): manifest `enemies[id].spineLocal` (metadata from the committed `tools/assets/local-enemy-spines.json`); drawn instead of the tinted web alias when every file is listed (§21.14).
- `spine/token/<id>/` — token (summon) models no web dump carries (39: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼; `extract.py TOKEN_SPINES`, the Front renderer's skeleton of each battle token prefab): manifest `tokens[id].spineLocal` (metadata from the committed `tools/assets/local-token-spines.json`); drawn instead of the avatar diamond when every file is listed (docs/ASSETS.md "Token models from the local client").
- `module/<TYPE>` — the official module (uniequip) TYPE icons (white glyphs; keys are the client's mixed-case file names, e.g. `PRI-X`, `mar-x`, `isw-a`), matched **case-insensitively** against a ModuleRecord's `typeName` by `assetUrls.moduleTypeIconUrl(local, typeName)` (Greek type letters map to the file's Latin letter: `ISW-α` → `isw-a`) — shown on the 干员调配 module cards / module info, the detail panel's 模组 row and the shop card's module tag; lettered tiles / type text without them.
---
+10 -5
View File
@@ -35,7 +35,8 @@
// await assets.local(); // optional local-client art manifest (data/local-assets.json,
// assets.localUrl('map/autochess', 'TX_autochessi_D') DESIGN §13) → URL or null (never required)
// assets.spineEntry('enemy_1305_mhslim') // its official model from the local client once local() listed
// // it (manifest spineLocal), else the web model; `.fallback`
// // it (manifest spineLocal; enemies and tokens), else the web
// // model; `.fallback`
//
// Every URL helper is also exported as a pure function taking the manifest first (`avatarUrl(manifest, …)`),
// so it can be unit tested without a browser. Helpers never throw on unknown ids — they return null and the
@@ -141,9 +142,10 @@ export function subProfIconUrl(m, sub) {
* Spine manifest entry for a unit asset id (operator charId, token id, enemy id). `opts.back` asks for the Back
* model (operators only; falls back to Front). Enemy aliases are resolved transparently. Returns the Spine
* object of docs/ASSETS.md or null.
* `opts.local` (the data/local-assets.json manifest): an enemy whose official model only the local client has
* (`spineLocal`, e.g. 灼热源石虫 — user feedback after 0.1.0, D3) gets that model when the manifest lists every one of
* its files; the returned entry's `fallback` is the web model (aliased), for a load failure (DESIGN §13: local art is
* `opts.local` (the data/local-assets.json manifest): an enemy or token whose official model only the local client has
* (`spineLocal`, e.g. 灼热源石虫 — user feedback after 0.1.0, D3 — and most 自选 summons) gets that model when the
* manifest lists every one of its files; the returned entry's `fallback` is the web model (an enemy's alias; a token's
* web model, mostly none — its view then keeps the avatar diamond), for a load failure (DESIGN §13: local art is
* optional, everything works without it).
*/
export function spineEntry(m, id, opts) {
@@ -155,7 +157,10 @@ export function spineEntry(m, id, opts) {
return validSpine(sp) ? sp : null;
}
const tk = get(get(m, 'tokens'), s);
if (tk) return validSpine(tk.spine) ? tk.spine : null;
if (tk) {
const web = validSpine(tk.spine) ? tk.spine : null;
return (opts && opts.local && localSpineEntry(tk.spineLocal, opts.local, web)) || web;
}
const en = get(get(m, 'enemies'), s);
if (en) {
let web = null;
+3 -3
View File
@@ -2,7 +2,7 @@
// data/assets.json. The pure helpers (animation-role resolver, atlas
// normalizer, PNG/WOFF2/audio helpers, plan id sets) are always tested; the
// on-disk checks run only when public/assets exists (it is git-ignored and
// produced by `npm run assets`); the optional local-client enemy models
// produced by `npm run assets`); the optional local-client enemy and token models
// (spineLocal, tools/local-extract) only when data/local-assets.json lists them.
import { test, describe } from 'node:test';
@@ -661,10 +661,10 @@ describe('generated manifest data/assets.json', () => {
for (const e of Object.values(manifest.enemies)) if (e.spine) models.set(e.spine.skel, e.spine);
for (const t of Object.values(manifest.tokens)) if (t.spine) models.set(t.spine.skel, t.spine);
assert.ok(models.size > 400);
// the official enemy models of the local client, as the client resolves them (DESIGN §13: only when listed)
// the official enemy and token models of the local client, as the client resolves them (DESIGN §13: only when listed)
const localPath = join(ROOT, 'data', 'local-assets.json');
const local = existsSync(localPath) ? JSON.parse(readFileSync(localPath, 'utf8')) : null;
for (const id of Object.keys(manifest.enemies)) {
for (const id of [...Object.keys(manifest.enemies), ...Object.keys(manifest.tokens)]) {
const s = local ? spineEntry(manifest, id, { local }) : null;
if (s?.local && [s.skel, s.atlas, ...s.textures].every((u) => existsSync(join(PUBLIC, u)))) models.set(s.skel, s);
}
+98 -2
View File
@@ -1,7 +1,9 @@
// tools/local-extract/extract.py (DESIGN §13 / §15): the job table, the manifest merge of --only, and the helpers that
// summarise Materials (map/<theme>/materials.json) and GameObjects (mesh/<bundle>/prefab.json) for the official 3D
// board, and enemy_scales.py's table of the official enemy model sizes (build-data MODEL_SCALES). The Python helpers
// run without UnityPy (duck-typed fakes); the real-output checks skip when nothing was extracted on this machine.
// board, the token Spine models (TOKEN_SPINES: the Front renderer of each battle token prefab, the merged pages), and
// enemy_scales.py's table of the official enemy model sizes (build-data MODEL_SCALES). The Python helpers run without
// UnityPy (duck-typed fakes; merge_alpha needs Pillow); the real-output checks skip when nothing was extracted on this
// machine. The token models' manifest side: test/local-token-models.test.js.
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
@@ -39,6 +41,100 @@ describe('extract.py helpers (no UnityPy needed)', { skip: !PY && 'no python3' }
assert.equal(new Set(jobs.map((j) => j.sub)).size, jobs.length, 'one output dir per job');
});
test('job table: the token Spine models (TOKEN_SPINES) come from the battle token packs into spine/token/<id>; no plain job writes under spine/', () => {
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
const { jobs, tokenSpines, enemySpines } = JSON.parse(r.stdout);
assert.equal(tokenSpines.bundles, 'pkgrps/btl_pfb_tokens_*.ab');
assert.equal(tokenSpines.sub, 'spine/token');
assert.equal(enemySpines.sub, 'spine/enemy');
// every listed id is a token of the game data without a web model (data/assets.json tokens[id].spine), sorted, unique
const ids = tokenSpines.ids;
assert.equal(ids.length, 39);
assert.deepEqual([...ids].sort(), ids);
assert.equal(new Set(ids).size, ids.length);
const tokens = JSON.parse(readFileSync(path.join(ROOT, 'data/tokens.json'), 'utf8'));
const backups = JSON.parse(readFileSync(path.join(ROOT, 'data/backups.json'), 'utf8'));
const web = JSON.parse(readFileSync(path.join(ROOT, 'data/assets.json'), 'utf8')).tokens;
for (const id of ids) {
assert.ok(tokens[id] || backups.tokens[id], `${id}: a token of the game data`);
assert.equal(web[id]?.spine, undefined, `${id}: no web model`);
}
// the 自选 summons whose prefab draws nothing (an EmptyAnimator: displayType HIDDEN / an effect) are left out
for (const id of ['token_10039_ulpia_block', 'token_10055_phatm2_mndclv', 'token_10058_sbell2_icetgt', 'token_10065_demetr_dmtpos',
'token_10071_aglna2_agairp']) assert.ok(!ids.includes(id), id);
assert.ok(!jobs.some((j) => j.sub.startsWith('spine/')), 'the old skinpack job of 乌尔比安\'s block extracted nothing (its skin prefab holds no art)');
});
test('token helpers: prefab container ids, --only selection of model ids, the Front renderer, atlas page names', () => {
const out = py(`
W = {'token_10009_weedy_cannon', 'token_10002_kalts_mon3tr'}
ids = ['token_a', 'token_b']
nodes = [('Back', True, {'m_PathID': 2}), ('Front', True, {'m_PathID': 1}), ('Down', True, {'m_PathID': 3})]
print(json.dumps({
'ids': [e.token_prefab_id(c, W) for c in ['dyn/battle/prefabs/[uc]tokens/token_10009_weedy_cannon.prefab',
'battle/prefabs/tokens/token_10002_kalts_mon3tr.prefab',
'dyn/battle/prefabs/skins/character/token_10002_kalts_mon3tr/token_10002_kalts_mon3tr_boc#6.prefab',
'dyn/battle/prefabs/[uc]tokens/token_10000_silent_healrb.prefab', None]],
'only': [e.spine_ids(o, 'spine/token', ids) for o in ([], ['spine'], ['spine/token'], ['spine/token/token_b'], ['spine/enemy'], ['map'])],
'pick': [e.pick_spine_node(nodes)[0], e.pick_spine_node([('Spine', True, {}), ('X', True, {})])[0],
e.pick_spine_node([('Front', False, {}), ('Other', True, {})])[0], e.pick_spine_node([('A', False, {})])[0], e.pick_spine_node([])],
'pages': e.atlas_pages('\\ntoken_x.png\\nsize: 64,64\\nformat: RGBA8888\\nC_Body\\n rotate: false\\n xy: 2, 2\\n\\ntoken_x2.png\\nsize: 32,32\\nC_Leg\\n xy: 0, 0\\n'),
}))`);
assert.deepEqual(out.ids, ['token_10009_weedy_cannon', 'token_10002_kalts_mon3tr', null, null, null]);
assert.deepEqual(out.only, [['token_a', 'token_b'], ['token_a', 'token_b'], ['token_a', 'token_b'], ['token_b'], [], []]);
assert.deepEqual(out.pick, ['Front', 'Spine', 'Other', 'A', null]);
assert.deepEqual(out.pages, ['token_x.png', 'token_x2.png']);
});
test('prefab_spine_nodes: the SkeletonAnimation renderers of a prefab tree, depth first, inactive branches marked', () => {
// a directional token prefab: Graphic / DefaultSkin / FaceSwitcher / {Front, Back} (each with its own skeleton), an
// inactive Spare renderer, a component in another bundle (m_FileID ≠ 0) and a MonoBehaviour without a type tree
const out = py(`
class O:
def __init__(self, kind, tree=None, err=False): self.type, self._t, self._e = NS(name=kind), tree, err
def read_typetree(self):
if self._e: raise ValueError('no type tree')
return self._t
objs = {}
def go(pid, name, kids=(), sda=None, active=1):
tr = 100 + pid
comps = [{'component': {'m_FileID': 0, 'm_PathID': tr}}]
objs[tr] = O('Transform', {'m_Children': [{'m_FileID': 0, 'm_PathID': 100 + k} for k in kids], 'm_GameObject': {'m_FileID': 0, 'm_PathID': pid}})
if sda is not None:
objs[200 + pid] = O('MonoBehaviour', {'skeletonDataAsset': {'m_FileID': 0, 'm_PathID': sda}})
comps.append({'component': {'m_FileID': 0, 'm_PathID': 200 + pid}})
objs[pid] = O('GameObject', {'m_Name': name, 'm_IsActive': active, 'm_Component': comps})
go(1, 'token_x', kids=(2, 7))
go(2, 'Graphic', kids=(3,))
go(3, 'FaceSwitcher', kids=(4, 5, 6))
go(4, 'Front', sda=41)
go(5, 'Back', sda=51)
go(6, 'Spare', sda=61, active=0)
go(7, 'Modes')
objs[107]._t['m_Children'] = []
objs[1]._t['m_Component'] += [{'component': {'m_FileID': 3, 'm_PathID': 999}}, {'component': {'m_FileID': 0, 'm_PathID': 300}}]
objs[300] = O('MonoBehaviour', err=True)
nodes = e.prefab_spine_nodes(objs[1].read_typetree(), objs)
print(json.dumps({'nodes': [[n, a, r['m_PathID']] for n, a, r in nodes], 'pick': e.pick_spine_node(nodes)[2]['m_PathID']}))`);
assert.deepEqual(out.nodes, [['Front', true, 41], ['Back', true, 51], ['Spare', false, 61]]);
assert.equal(out.pick, 41, 'the Front skeleton (the fetched tokens use Front / Spine too)');
});
test('merge_alpha: the [alpha] texture as A, else the page\'s own alpha; RGB clamped to A (premultiplied input), multiplied for straight input', { skip: spawnSync(PY, ['-c', 'import PIL']).status !== 0 && 'no Pillow' }, () => {
const out = py(`
from PIL import Image
rgb = Image.new('RGB', (2, 1)); rgb.putdata([(200, 100, 50), (90, 90, 90)])
alpha = Image.new('RGB', (2, 1)); alpha.putdata([(128, 128, 128), (255, 255, 255)])
rgba = Image.new('RGBA', (2, 1)); rgba.putdata([(60, 200, 10, 100), (0, 0, 0, 0)])
print(json.dumps({'split': list(e.merge_alpha(rgb, alpha).getdata()), 'own': list(e.merge_alpha(rgba, None).getdata()),
'opaque': list(e.merge_alpha(rgb, None).getdata()), 'straight': list(e.merge_alpha(rgba, None, True).getdata())}))`);
assert.deepEqual(out.split, [[128, 100, 50, 128], [90, 90, 90, 255]], 'A from the [alpha] texture, RGB ≤ A');
assert.deepEqual(out.own, [[60, 100, 10, 100], [0, 0, 0, 0]], 'an RGBA page keeps its alpha (the newer tokens: _UseAlphaTex 0)');
assert.deepEqual(out.opaque, [[200, 100, 50, 255], [90, 90, 90, 255]], 'no alpha anywhere: opaque');
assert.deepEqual(out.straight, [[23, 78, 3, 100], [0, 0, 0, 0]], '_StraightAlphaInput 1: RGB × A / 255 (Pillow truncates)');
});
test('material_info: textures by name with tiling, floats, colours, sorted keywords; unresolvable refs → null', () => {
const out = py(`
class P:
+217
View File
@@ -0,0 +1,217 @@
// test/local-token-models.test.js — the token (summon) models only the local client has (0.2.0): 39 summons — most of the
// 自选 picks' (data/backups.json `tokens`), 凯瑟琳's 爬行号·防护单元 and 凛御银灰's 风雪之眼 — have no battle Spine in
// any community dump (fetch-assets reported "missing skel"), so the client drew their avatar diamond. Their official
// skeletons come from the battle token packs of the local client (tools/local-extract/extract.py TOKEN_SPINES →
// public/assets/local/spine/token/<id>/, optional and git-ignored) and are an OVERLAY like the enemy models of
// test/feedback1d-models.test.js: data/assets.json tokens[id].spineLocal names the files of the data/local-assets.json
// group `spine/token/<id>` with the metadata of the committed tools/assets/local-token-spines.json — never read from the
// disk, so the manifest is the same with or without the extraction. The client (assets.js spineEntry) draws the model
// when data/local-assets.json lists every one of its files; without it (or when it fails to load) the diamond stays.
// Tokens whose prefab draws nothing (乌尔比安's 从不混淆的方向, 酒神's 迷狂牢笼, …) are not in the list (extract.py).
// The extractor side (job table, prefab walk, alpha merge): test/local-extract.test.js.
import { test, describe, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { buildPlan } from '../tools/assets/plan.mjs';
import { resolveTemplate, collectLeaves } from '../tools/assets/manifest.mjs';
import {
findLocalTokenModels, localSpineMeta, loadLocalSpines, LOCAL_TOKEN_SPINES_FILE, localTokenSpineGroup, LOCAL_SPINE_ROLES, applyRoleFix,
} from '../tools/assets/spine.mjs';
import { roleAnimationNames } from '../tools/assets/anim-roles.mjs';
import { indexAudio } from '../tools/assets/audio.mjs';
import { createAssets, spineEntry, validSpine } from '../public/js/assets.js';
import { installFakePixi, fakeViewCtx } from './render/fakepixi.js';
import { presetCamera } from '../public/js/render/projection.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ASSETS = path.join(ROOT, 'public/assets');
const readJson = (rel) => JSON.parse(readFileSync(path.join(ROOT, rel), 'utf8'));
const COMMITTED = readJson(LOCAL_TOKEN_SPINES_FILE).models;
const MANIFEST = readJson('data/assets.json');
const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0);
/** extract.py TOKEN_SPINES (--print-jobs needs no dependencies). */
const TOKEN_SPINES = (() => {
if (!PY) return null;
const r = spawnSync(PY, [path.join(ROOT, 'tools/local-extract/extract.py'), '--print-jobs'], { encoding: 'utf8', env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
return r.status === 0 ? JSON.parse(r.stdout).tokenSpines.ids : null;
})();
const IDS = Object.keys(COMMITTED).sort();
/** A data/local-assets.json listing the extracted files of `ids` (extract.py's group layout), minus `drop`. */
function localManifest(ids, drop = null) {
const groups = {};
for (const id of ids) {
const g = groups[localTokenSpineGroup(id)] = {};
const m = COMMITTED[id];
for (const f of [m.skel, m.atlas, ...m.textures]) if (f !== drop) g[f] = { path: `/assets/local/spine/token/${id}/${f}`, kind: f.endsWith('.png') ? 'Texture2D' : 'TextAsset' };
}
return { version: 1, source: 'local-client', groups };
}
describe('the 自选 summon models from the local client are an optional overlay', () => {
test('the committed metadata: the TOKEN_SPINES of extract.py, each a skeleton + same-stem atlas + pages, its roles on its own clips', () => {
assert.equal(IDS.length, 39);
if (TOKEN_SPINES) assert.deepEqual(IDS, [...TOKEN_SPINES].sort(), 'one entry per extract.py TOKEN_SPINES id');
for (const id of IDS) {
const m = COMMITTED[id];
assert.match(m.skel, /\.skel$/, id);
assert.equal(m.atlas, m.skel.replace(/\.skel$/, '.atlas'), `${id}: next to the skeleton (pixi-spine finds the atlas by its name)`);
assert.ok(m.textures.length >= 1 && m.textures.every((t) => t.endsWith('.png')), id);
assert.equal(m.pma, true, `${id}: premultiplied pages (extract.py merge_alpha)`);
const names = Object.keys(m.animations);
assert.ok(names.includes(m.anims.idle), `${id} idle ${m.anims.idle}`);
assert.ok(m.anims.attack && names.includes(m.anims.attack.loop), `${id} attack`);
for (const n of roleAnimationNames(m.anims)) assert.ok(names.includes(n), `${id} role clip ${n}`);
}
// 凛御银灰's three 风雪之眼 share one skeleton (one prefab each)
assert.deepEqual([...new Set(['1', '2', '3'].map((n) => COMMITTED[`token_10057_svash2_eagle${n}`].skel))], ['token_10057_svash2_eagle.skel']);
});
test('role fixes: clips the resolver cannot read by name are mapped (电弧\'s 戴乌, 酒神\'s 本能的召唤, 白铁\'s platforms, the anchor)', () => {
for (const [id, fix] of Object.entries(LOCAL_SPINE_ROLES)) {
assert.ok(COMMITTED[id], `${id} is a local model`);
for (const [role, v] of Object.entries(fix)) assert.deepEqual(COMMITTED[id].anims[role], v, `${id} ${role}`);
}
const tower = COMMITTED.token_10051_radian_tower1.anims;
assert.deepEqual([tower.idle, tower.deploy, tower.attack.loop, tower.die], ['C_Skill1_Idle', 'C_Skill1_Start', 'C_Skill1_Attack', 'C_Skill1_Die'],
'戴乌: its C_Skill1_* set, not the 0 s C_Default pose');
assert.equal(COMMITTED.token_10054_phatm2_encdool.anims.idle, 'Loop');
assert.equal(COMMITTED.token_10027_ironmn_pile1.anims.die, 'End');
assert.equal(COMMITTED.token_10068_kalts2_mtship.anims.idle, 'Default', '战术锚点: its Idle clip hides its only attachment');
// a fix naming a clip the skeleton lacks is not applied
const roles = { idle: 'Idle', die: null };
assert.deepEqual(applyRoleFix(roles, { idle: 'Nope', die: 'End' }, { Idle: 1, End: 1 }), { roles: { idle: 'Idle', die: 'End' }, missing: ['idle'] });
assert.deepEqual(applyRoleFix(roles, undefined, {}), { roles, missing: [] });
});
test('plan: the token gets spineLocal from the committed metadata as it is; no metadata → no overlay; nothing to download', () => {
const plan = (localTokenSpines) => buildPlan({
assets07: readJson('docs/research/07-assets.json'), ops03: readJson('docs/research/03-operators.json'),
enemies05: readJson('docs/research/05-enemies.json'), maps05: readJson('docs/research/05-maps.json'),
audio: indexAudio({}), modelsData: { data: {} }, extraTokenIds: IDS, localTokenSpines,
});
const before = plan(undefined);
for (const id of IDS) assert.equal(before.template.tokens[id].spineLocal, undefined, `${id}: no overlay without metadata`);
const p = plan(COMMITTED);
const tmp = mkdtempSync(path.join(tmpdir(), 'sp-tokplan-'));
try {
const { value } = resolveTemplate({ tokens: Object.fromEntries(IDS.map((id) => [id, p.template.tokens[id]])) }, { root: tmp, spine: new Map() });
for (const id of IDS) assert.deepEqual(value.tokens[id].spineLocal, { group: `spine/token/${id}`, ...COMMITTED[id] }, id);
} finally { rmSync(tmp, { recursive: true, force: true }); }
assert.ok(!collectLeaves(p.template).some((l) => /spineLocal/.test(l.path)), 'the overlay is no file to download');
assert.ok(p.notes.includes('token_10002_kalts_mon3tr: official Spine from the local client when extracted (spineLocal)'));
});
test('data/assets.json: tokens[id].spineLocal = the committed metadata for exactly these tokens; no /assets/local/ URL; no web model changed', () => {
const urls = [];
const walk = (n) => { if (typeof n === 'string') { if (n.includes('/assets/local/')) urls.push(n); } else if (n && typeof n === 'object') Object.values(n).forEach(walk); };
walk(MANIFEST);
assert.deepEqual(urls, [], 'setup / doctor / the manifest tests count every /assets/ URL as required');
const withLocal = Object.keys(MANIFEST.tokens).filter((id) => MANIFEST.tokens[id].spineLocal).sort();
assert.deepEqual(withLocal, IDS);
for (const id of IDS) {
assert.deepEqual(MANIFEST.tokens[id].spineLocal, { group: `spine/token/${id}`, ...COMMITTED[id] }, `${id}: the committed metadata`);
assert.equal(MANIFEST.tokens[id].spine, undefined, `${id}: no web model (it was the avatar diamond)`);
assert.equal(spineEntry(MANIFEST, id), null, `${id}: without the local art the client keeps the diamond`);
}
// the tokens that always had a web model keep it, without an overlay
for (const [id, t] of Object.entries(MANIFEST.tokens)) if (t.spine) { assert.ok(validSpine(t.spine), id); assert.equal(t.spineLocal, undefined, id); }
});
test('the committed metadata is what the extracted models parse to', { skip: !IDS.every((id) => existsSync(path.join(ASSETS, 'local/spine/token', id))) && 'token models not extracted (tools/local-extract)' }, async () => {
const found = await findLocalTokenModels(ASSETS);
const { meta, problems } = await localSpineMeta(ASSETS, Object.fromEntries(IDS.map((id) => [id, found[id]])));
assert.deepEqual(problems, []);
for (const id of IDS) assert.deepEqual(meta[id], COMMITTED[id], `${id}: re-run node tools/fetch-assets.mjs --local-spines`);
// and the shared data/local-assets.json lists every file of each (the client draws the model only then)
const local = JSON.parse(readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8'));
for (const id of IDS) assert.ok(spineEntry(MANIFEST, id, { local })?.local, `${id}: listed in data/local-assets.json`);
});
test('findLocalTokenModels: skeleton + same-stem atlas + page PNGs under local/spine/token/<id>/; loadLocalSpines', async () => {
const dir = mkdtempSync(path.join(tmpdir(), 'sp-toklocal-'));
try {
const put = (rel, body = 'x') => { mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); writeFileSync(path.join(dir, rel), body); };
put('local/spine/token/token_10057_svash2_eagle1/token_10057_svash2_eagle.skel');
put('local/spine/token/token_10057_svash2_eagle1/token_10057_svash2_eagle.atlas');
put('local/spine/token/token_10057_svash2_eagle1/token_10057_svash2_eagle.png');
put('local/spine/token/token_x_noatlas/token_x_noatlas.skel');
put('local/spine/token/enemy_1305_mhslim/enemy_1305_mhslim.skel');
put('local/spine/token/enemy_1305_mhslim/enemy_1305_mhslim.atlas');
put('local/spine/token/enemy_1305_mhslim/enemy_1305_mhslim.png');
const found = await findLocalTokenModels(dir);
assert.deepEqual(Object.keys(found), ['token_10057_svash2_eagle1'], 'one stem per folder; only token ids');
assert.equal(found.token_10057_svash2_eagle1.atlas, 'local/spine/token/token_10057_svash2_eagle1/token_10057_svash2_eagle.atlas');
assert.deepEqual(await findLocalTokenModels(path.join(dir, 'missing')), {});
assert.deepEqual(await loadLocalSpines(path.join(ROOT, LOCAL_TOKEN_SPINES_FILE)), COMMITTED);
assert.deepEqual(await loadLocalSpines(path.join(dir, 'none.json')), {});
} finally { rmSync(dir, { recursive: true, force: true }); }
});
});
describe('client: the official summon model only when data/local-assets.json lists it', () => {
test('assets.js spineEntry: every file listed → the official model; anything missing → none (the diamond); web models unchanged', () => {
const local = localManifest(IDS);
for (const id of IDS) {
const m = COMMITTED[id];
const e = spineEntry(MANIFEST, id, { local });
assert.equal(e.skel, `/assets/local/spine/token/${id}/${m.skel}`);
assert.equal(e.atlas, `/assets/local/spine/token/${id}/${m.atlas}`);
assert.deepEqual(e.textures, m.textures.map((t) => `/assets/local/spine/token/${id}/${t}`));
assert.equal(e.pma, true);
assert.deepEqual(e.anims, m.anims);
assert.equal(e.local, true);
assert.equal(e.fallback, null, 'no web model to fall back to');
assert.equal(spineEntry(MANIFEST, id, { local }), e, 'one entry object per manifest pair');
assert.equal(spineEntry(MANIFEST, id, { local: localManifest([]) }), null, 'not extracted → no model');
assert.equal(spineEntry(MANIFEST, id, { local: localManifest([id], m.textures[0]) }), null, 'a page missing → no model');
}
assert.equal(spineEntry(MANIFEST, 'token_10028_vigil_wolf', { local }), MANIFEST.tokens.token_10028_vigil_wolf.spine, 'a web token unchanged');
const store = createAssets({ manifest: MANIFEST, localManifest: local });
assert.equal(store.spineEntry('token_10002_kalts_mon3tr').skel, '/assets/local/spine/token/token_10002_kalts_mon3tr/token_10002_kalts_mon3tr.skel');
assert.equal(createAssets({ manifest: MANIFEST }).spineEntry('token_10002_kalts_mon3tr'), null, 'before / without local(): the diamond');
});
describe('UnitView', () => {
let fake, UnitView;
before(async () => {
fake = installFakePixi();
({ UnitView } = await import('../public/js/render/units.js'));
});
after(() => fake.restore());
const tick = () => new Promise((r) => setImmediate(r));
const cam = () => presetCamera('normal', { width: 1280, height: 720 });
const info = (id) => ({ id: 7, side: 'ally', kind: 'token', defId: id, spine: id, avatar: id, tier: 1, x: 4, y: 10, maxHp: 1000, dir: 'RIGHT' });
test('a summon view draws its official model with its own clips; a failed load keeps the diamond and is retried', async () => {
const local = localManifest(IDS);
const asked = [], released = [];
const store = (fail) => ({
picture: () => null, image: async () => null,
spineEntry: (id) => spineEntry(MANIFEST, id, { local }),
spine: {
acquire: async (e) => { asked.push(e.skel); if (fail(e)) throw new Error('404'); return { animations: Object.keys(e.animations).map((name) => ({ name })) }; },
release: (e) => released.push(e.skel),
},
});
const id = 'token_10051_radian_tower1';
const ok = new UnitView(fakeViewCtx(fake.P, { assets: store(() => false), cam }), info(id));
await tick(); await tick();
assert.equal(ok.actor?.entry.skel, `/assets/local/spine/token/${id}/${COMMITTED[id].skel}`, 'the official model');
assert.equal(ok.actor.entry.anims.idle, 'C_Skill1_Idle', 'with the mapped clips');
asked.length = 0;
const bad = new UnitView(fakeViewCtx(fake.P, { assets: store(() => true), cam }), info(id));
await tick(); await tick(); await tick();
assert.deepEqual(asked, [`/assets/local/spine/token/${id}/${COMMITTED[id].skel}`], 'tried once, no web model behind it');
assert.ok(released.includes(asked[0]), 'the failed acquire is released');
assert.equal(bad.actor, null, 'the avatar diamond stays');
assert.ok(bad._retryAt > 0, 'and the model is loaded again later (SPINE_RETRY_MS)');
for (const v of [ok, bad]) v.destroy?.();
});
});
});
+9 -5
View File
@@ -27,7 +27,8 @@ test('selection: runtime files in; tests, maintainer tools, other docs, dev page
const { keep, drop } = selectTracked([
'server/index.js', 'server/sim/rng.js', 'shared/constants.js', 'public/index.html', 'public/js/main.js', 'public/i18n/en.json',
'data/chess.json', 'data/i18n/en.json', 'tools/setup.mjs', 'tools/vendor.mjs', 'tools/fetch-assets.mjs', 'tools/doctor.mjs',
'tools/crop-board-atlas.mjs', 'tools/assets/plan.mjs', 'tools/assets/local-enemy-spines.json', 'tools/local-extract/extract.py',
'tools/crop-board-atlas.mjs', 'tools/assets/plan.mjs', 'tools/assets/local-enemy-spines.json', 'tools/assets/local-token-spines.json',
'tools/local-extract/extract.py',
'tools/local-extract/LICENSE-Ark-Unpacker.txt', 'scripts/start.sh', 'scripts/start-windows.bat', 'scripts/launch.mjs',
'scripts/install-service-windows.ps1', 'docs/PLAYING.md', 'docs/DEPLOY.md', ...RUNTIME_RESEARCH, 'package.json',
'package-lock.json', 'LICENSE', 'NOTICE.md', 'THIRD-PARTY-NOTICES.md', 'README.md', 'CHANGELOG.md',
@@ -42,10 +43,11 @@ test('selection: runtime files in; tests, maintainer tools, other docs, dev page
'public/.DS_Store', 'tools/local-extract/.venv-extract/x.py', 'node_modules/ws/index.js',
]);
for (const f of ['server/index.js', 'public/i18n/en.json', 'data/i18n/en.json', 'tools/assets/local-enemy-spines.json',
'tools/local-extract/LICENSE-Ark-Unpacker.txt', 'scripts/install-service-windows.ps1', 'docs/research/07-assets.json', 'CHANGELOG.md']) {
'tools/assets/local-token-spines.json', 'tools/local-extract/LICENSE-Ark-Unpacker.txt', 'scripts/install-service-windows.ps1',
'docs/research/07-assets.json', 'CHANGELOG.md']) {
assert.ok(keep.includes(f), `keeps ${f}`);
}
assert.equal(keep.length, 34, keep.join(' '));
assert.equal(keep.length, 35, keep.join(' '));
assert.equal(drop.length, 40, drop.join(' '));
assert.ok(!keep.some((f) => drop.includes(f)));
});
@@ -149,10 +151,12 @@ function fakeCheckout() {
put('public/assets/char/char_1048_orchd2.png', PNG);
put('public/assets/local/g/x.webp', PNG);
put('public/assets/local/g/x.png', PNG);
put('public/assets/local/spine/token/token_x/token_x.skel', 'skel'); // a token model only the local client has (0.2.0)
put('public/assets/.DS_Store', 'junk');
put('public/fonts/fonts.css', '@font-face{}\n');
put('public/fonts/x.woff2', PNG);
put('data/local-assets.json', JSON.stringify({ version: 1, groups: { g: { x: { path: '/assets/local/g/x.webp' } } } }));
put('data/local-assets.json', JSON.stringify({ version: 1, groups: { g: { x: { path: '/assets/local/g/x.webp' } },
'spine/token/token_x': { 'token_x.skel': { path: '/assets/local/spine/token/token_x/token_x.skel' } } } }));
put('.cache/assets-report.json', '{}');
put('pv/clip.txt', 'promo');
const git = (...args) => spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
@@ -170,7 +174,7 @@ const SHIPPED_TRACKED = ['CHANGELOG.md', 'LICENSE', 'NOTICE.md', 'README.md', 'T
'tools/assets/network.mjs', 'tools/crop-board-atlas.mjs', 'tools/doctor.mjs', 'tools/fetch-assets.mjs', 'tools/local-extract/extract.py',
'tools/setup.mjs', 'tools/vendor.mjs'].sort();
const ART = ['data/local-assets.json', 'public/assets/char/a.png', 'public/assets/local/g/x.png', 'public/assets/local/g/x.webp',
'public/assets/ui/b c.png', 'public/fonts/fonts.css', 'public/fonts/x.woff2'];
'public/assets/local/spine/token/token_x/token_x.skel', 'public/assets/ui/b c.png', 'public/fonts/fonts.css', 'public/fonts/x.woff2'];
test('dry-run on a temporary checkout: the full and the lite file lists, the summary, nothing written', () => {
const { dir } = fakeCheckout();
+11
View File
@@ -88,7 +88,18 @@ test('docs and messages say what falls back without the local art and how a serv
const s6 = deploy.slice(deploy.indexOf('## 6. 本地客户端素材'));
assert.ok(deploy.includes('## 6. 本地客户端素材') && s6.length > 200, 'DEPLOY §6');
for (const re of [/同一版本/, /public\/assets\/local\//, /data\/local-assets\.json/, /3D 棋盘/, /源石虫/, /表情/, /玩法说明/]) assert.match(s6, re);
// 0.2.0: the summon models of the local client (extract.py TOKEN_SPINES) — named in the fallbacks, DEPLOY §6 and README,
// and an extraction made before them is reported by setup as lacking them (re-extract with --local)
assert.match(LOCAL_ART_FALLBACK, /召唤物/);
assert.match(s6, /召唤物/);
assert.match(s6, /--only spine\/token/);
const { localGaps } = await import('../tools/setup.mjs');
assert.equal(localGaps({ enemySpines: true, tokenSpines: true }), '');
assert.equal(localGaps({ enemySpines: true, tokenSpines: false }), ',缺少新版的自选召唤物模型');
assert.equal(localGaps({ enemySpines: false, tokenSpines: false }), ',缺少新版的灼热/炽焰源石虫模型和自选召唤物模型');
assert.match(read('tools/setup.mjs'), /\$\{localGaps\(local\)\}(重新提取:--local)/);
const readme = read('README.md');
assert.match(readme, /召唤物/);
assert.match(readme, /表情和「玩法说明」的教程图随上面的素材一起从公开镜像下载/);
assert.match(readme, /\*\*同一版本\*\*的整合包/);
assert.ok(!/需本地提取/.test(read('docs/PLAYING.md')), 'PLAYING: the 玩法说明 pages come with the download');
File diff suppressed because it is too large. Load diff
+12 -2
View File
@@ -6,7 +6,7 @@
// that ends up on disk wins (later alts are fallbacks: other URLs for the
// same file, or other sounds of the same bank);
// { model: '<key>' } a Spine model (skel + atlas + page PNGs) from plan.models;
// literal(value) a value emitted as it is (no files: enemies[id].spineLocal).
// literal(value) a value emitted as it is (no files: enemies[id] / tokens[id].spineLocal).
// Inputs are the research JSONs (docs/research/03, 05, 07), the official
// audio_data.json and Ark-Models' models_data.json.
//
@@ -305,6 +305,9 @@ export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
* @param {Record<string, import('./spine.mjs').LocalSpineMeta>} [p.localEnemySpines] metadata of the enemy models the
* local client has (the committed tools/assets/local-enemy-spines.json, never the disk): each planned enemy listed
* gets `spineLocal` = { group: 'spine/enemy/<id>', ...meta } beside its web `spine`
* @param {Record<string, import('./spine.mjs').LocalSpineMeta>} [p.localTokenSpines] the same for the token (summon)
* models (tools/assets/local-token-spines.json): each planned token listed gets `spineLocal` = { group:
* 'spine/token/<id>', ...meta } beside its web `spine`, if any (most have none: the client drew the avatar)
* @param {Record<string, any>} [p.extraOperators] charId → { subProfessionId, nationId, skills: [{ index, skillId, iconId }] }
* of characters research 07 does not list (the 自选 owned-6★ picks, data/backups.json `units`): planned like the pool
* operators from patternOperator
@@ -314,7 +317,8 @@ export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
*/
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, charword = null, voiceLang = 'cn',
voiceSlots = VOICE_BATTLE_SLOTS,
extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {}, extraOperators = {}, moduleTypes = [] }) {
extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {}, localTokenSpines = {}, extraOperators = {},
moduleTypes = [] }) {
const notes = [];
/** @type {Map<string, any>} */
const models = new Map();
@@ -417,6 +421,12 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
if (model) entry.spineVariant = v;
}
entry.spine = model;
// the official model from the local client, drawn instead of the avatar (or of `spine`) when extracted (optional)
const loc = localTokenSpines && Object.hasOwn(localTokenSpines, id) ? localTokenSpines[id] : null;
if (loc && typeof loc === 'object') {
entry.spineLocal = literal({ group: `spine/token/${id}`, ...loc });
notes.push(`${id}: official Spine from the local client when extracted (spineLocal)`);
}
tokens[id] = entry;
const { roles: u, mix } = unitSounds(audio, pickUnitSfx(audio.unitBanks.get(id)));
if (mix) u.mix = mix;
+104 -21
View File
@@ -2,13 +2,15 @@
// normalize atlases (size:/pma:), parse skeletons and resolve animation roles.
// Produces the per-model `spine` entries of data/assets.json.
//
// Enemy models that no dump carries but the local client has (tools/local-extract/
// extract.py ENEMY_SPINES → public/assets/local/spine/enemy/<id>/, optional and
// git-ignored) are an overlay, never the manifest's `spine`: their metadata lives
// in the committed tools/assets/local-enemy-spines.json (loadLocalEnemySpines),
// Enemy and token models that no dump carries but the local client has (tools/local-extract/
// extract.py ENEMY_SPINES → public/assets/local/spine/enemy/<id>/, TOKEN_SPINES →
// public/assets/local/spine/token/<id>/; optional and git-ignored) are an overlay, never
// the manifest's `spine`: their metadata lives in the committed
// tools/assets/local-enemy-spines.json / local-token-spines.json (loadLocalSpines),
// refreshed from the extracted files by `fetch-assets --local-spines`
// (findLocalEnemyModels + localEnemySpineMeta, read only), so data/assets.json
// is the same with or without the extraction (ASSETS.md "Enemy aliases").
// (findLocalEnemyModels / findLocalTokenModels + localSpineMeta, read only), so
// data/assets.json is the same with or without the extraction (ASSETS.md "Enemy aliases",
// "Token models from the local client").
import { readFile, readdir, writeFile, stat, rename, mkdir, unlink } from 'node:fs/promises';
import { dirname, join } from 'node:path';
@@ -24,20 +26,26 @@ async function fileStat(p) { try { const s = await stat(p); return s.isFile() ?
export const LOCAL_ENEMY_SPINE_DIR = 'local/spine/enemy/';
/** Its data/local-assets.json group of an enemy (`spine/enemy/<id>`; the client resolves the file names through it). */
export const localEnemySpineGroup = (id) => `spine/enemy/${id}`;
/** Where tools/local-extract/extract.py writes token (summon) Spine models, under public/assets. */
export const LOCAL_TOKEN_SPINE_DIR = 'local/spine/token/';
/** Its data/local-assets.json group of a token (`spine/token/<id>`). */
export const localTokenSpineGroup = (id) => `spine/token/${id}`;
/**
* Enemy Spine models extracted from the local client (tools/local-extract/extract.py ENEMY_SPINES): every
* `local/spine/enemy/<enemyId>/` holding a skeleton, the atlas of the same stem and at least one page PNG.
* Spine models extracted from the local client under `base` (a LOCAL_*_SPINE_DIR): every `<base><id>/` whose id matches
* `idRe` holding a skeleton, the atlas of the same stem and at least one page PNG.
* @param {string} root absolute public/assets directory
* @param {string} base model folder under root, with a trailing slash
* @param {RegExp} idRe the ids to take
* @returns {Promise<Record<string, { dir: string, skel: string, atlas: string, pngs: string[] }>>} paths relative to root
*/
export async function findLocalEnemyModels(root) {
async function findLocalModels(root, base, idRe) {
const out = {};
let ids = [];
try { ids = (await readdir(join(root, LOCAL_ENEMY_SPINE_DIR), { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name); } catch { return out; }
try { ids = (await readdir(join(root, base), { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name); } catch { return out; }
for (const id of ids.sort()) {
if (!/^enemy_\d+_[a-z0-9_]+$/i.test(id)) continue;
const dir = `${LOCAL_ENEMY_SPINE_DIR}${id}/`;
if (!idRe.test(id)) continue;
const dir = `${base}${id}/`;
let files = [];
try { files = (await readdir(join(root, dir))).sort(); } catch { continue; }
const skel = files.find((f) => f.endsWith('.skel'));
@@ -50,19 +58,85 @@ export async function findLocalEnemyModels(root) {
}
/**
* @typedef {{ skel: string, atlas: string, textures: string[], pma: boolean, anims: any, animations: Record<string, number>,
* events: string[], hits: Record<string, number[]>, bounds: any }} LocalSpineMeta file names relative to the model's
* data/local-assets.json group (localEnemySpineGroup), the rest as a manifest SpineEntry
* Enemy Spine models extracted from the local client (tools/local-extract/extract.py ENEMY_SPINES): every
* `local/spine/enemy/<enemyId>/` holding a skeleton, the atlas of the same stem and at least one page PNG.
* @param {string} root absolute public/assets directory
*/
export const findLocalEnemyModels = (root) => findLocalModels(root, LOCAL_ENEMY_SPINE_DIR, /^enemy_\d+_[a-z0-9_]+$/i);
/**
* Token Spine models extracted from the local client (tools/local-extract/extract.py TOKEN_SPINES): every
* `local/spine/token/<tokenId>/` holding a skeleton, the atlas of the same stem and at least one page PNG.
* @param {string} root absolute public/assets directory
*/
export const findLocalTokenModels = (root) => findLocalModels(root, LOCAL_TOKEN_SPINE_DIR, /^token_\d+_[a-z0-9_]+$/i);
/**
* Spine metadata of the extracted enemy models (findLocalEnemyModels), parsed like processModels does — but read only:
* the atlas is sized / pma-normalized in memory (extract.py already writes it so), nothing on disk changes.
* @typedef {{ skel: string, atlas: string, textures: string[], pma: boolean, anims: any, animations: Record<string, number>,
* events: string[], hits: Record<string, number[]>, bounds: any }} LocalSpineMeta file names relative to the model's
* data/local-assets.json group (localEnemySpineGroup / localTokenSpineGroup), the rest as a manifest SpineEntry
*/
const clipOf = (loop, via) => (via ? { begin: null, loop, end: null, via } : { begin: null, loop, end: null });
/**
* Role fixes of local models whose clip names the resolver (tools/assets/anim-roles.mjs, by name) cannot read: model id →
* roles replacing the resolved ones (the others are kept); localSpineMeta applies them, so the committed metadata carries
* them. A fix naming a clip the skeleton lacks is dropped and reported.
* - 电弧's 戴乌 (token_10051_radian_tower1): its clips are C_Skill1_Start / _Idle / _Attack / _Die beside a 0 s C_Default
* pose, which the resolver took for its idle, deploy and attack;
* - 酒神's 本能的召唤 (token_10054_phatm2_encdool): Start, Loop (5 s), End and a 0 s Default — idle on the Loop and the
* End as it goes [ASSUMED: by the clip names];
* - 白铁's 白铁™多功能平台 (token_10027_ironmn_pile1 / pile2): Start, Idle, End — the End as it goes [ASSUMED: by the
* clip name; the resolver finds no Die clip];
* - 凯尔希·思衡托's 战术锚点 (token_10068_kalts2_mtship): its Start / Idle / Die clips hide its one attachment (the official
* battle shows the anchor through other means), so it would be an empty tile; it stays on the 0 s Default pose, the
* skeleton's own white anchor mark, and fades out as it goes [ASSUMED].
*/
export const LOCAL_SPINE_ROLES = Object.freeze({
token_10051_radian_tower1: {
idle: 'C_Skill1_Idle', deploy: 'C_Skill1_Start', attack: clipOf('C_Skill1_Attack'),
skill: { ...clipOf('C_Skill1_Attack', 'attack'), index: 0, idle: null }, die: 'C_Skill1_Die',
},
token_10054_phatm2_encdool: {
idle: 'Loop', attack: clipOf('Loop', 'idle'), skill: { ...clipOf('Loop', 'attack'), index: 0, idle: null }, die: 'End',
},
token_10027_ironmn_pile1: { die: 'End' },
token_10027_ironmn_pile2: { die: 'End' },
token_10068_kalts2_mtship: {
idle: 'Default', deploy: 'Default', attack: clipOf('Default', 'idle'), skill: { ...clipOf('Default', 'attack'), index: 0, idle: null },
die: null,
},
});
/**
* `roles` with the fix of LOCAL_SPINE_ROLES applied: each fixed role whose clips all exist in `durations` replaces the
* resolved one; `missing` lists the roles left as resolved because a clip is missing.
* @param {any} roles resolveRoles output
* @param {Record<string, any>|undefined} fix
* @param {Record<string, number>} durations the skeleton's clips
* @returns {{ roles: any, missing: string[] }}
*/
export function applyRoleFix(roles, fix, durations) {
if (!fix) return { roles, missing: [] };
const out = { ...roles };
const missing = [];
const has = (n) => n == null || Object.hasOwn(durations || {}, n);
for (const [role, v] of Object.entries(fix)) {
const names = typeof v === 'string' ? [v] : v && typeof v === 'object' ? [v.begin, v.loop, v.end, v.idle] : [null];
if (names.every(has)) out[role] = v;
else missing.push(role);
}
return { roles: out, missing };
}
/**
* Spine metadata of extracted models (findLocalEnemyModels / findLocalTokenModels), parsed like processModels does — but
* read only: the atlas is sized / pma-normalized in memory (extract.py already writes it so), nothing on disk changes.
* Roles are resolved for skill index 0, like the web enemy and token models, then LOCAL_SPINE_ROLES applied.
* @param {string} root absolute public/assets directory
* @param {Record<string, { dir: string, skel: string, atlas: string, pngs: string[] }>} found
* @returns {Promise<{ meta: Record<string, LocalSpineMeta>, problems: string[] }>}
*/
export async function localEnemySpineMeta(root, found) {
export async function localSpineMeta(root, found) {
const meta = {};
const problems = [];
const base = (rel) => rel.slice(rel.lastIndexOf('/') + 1);
@@ -83,9 +157,11 @@ export async function localEnemySpineMeta(root, found) {
const sk = parseSkel(await readFile(join(root, m.skel)), info.regions);
if (!sk.animations.length) throw new Error('skeleton has no animations');
if (sk.missingRegions?.length) problems.push(`${id}: ${sk.missingRegions.length} attachment(s) not in atlas (e.g. ${sk.missingRegions[0]})`);
const fixed = applyRoleFix(resolveRoles(sk.animations, { skillIndices: [0], durations: sk.durations }), LOCAL_SPINE_ROLES[id], sk.durations);
if (fixed.missing.length) problems.push(`${id}: role fix not applied (clip missing) for ${fixed.missing.join(', ')}`);
meta[id] = {
skel: base(m.skel), atlas: base(m.atlas), textures: info.pages, pma: true,
anims: resolveRoles(sk.animations, { skillIndices: [0], durations: sk.durations }),
anims: fixed.roles,
animations: sk.durations, events: sk.events, hits: sk.hits, bounds: sk.bounds,
};
} catch (e) {
@@ -94,21 +170,28 @@ export async function localEnemySpineMeta(root, found) {
}
return { meta, problems };
}
/** The enemy models' name for localSpineMeta (one parse for every local model). */
export const localEnemySpineMeta = localSpineMeta;
/** Committed metadata of the local-client enemy models: tools/assets/local-enemy-spines.json. */
export const LOCAL_ENEMY_SPINES_FILE = 'tools/assets/local-enemy-spines.json';
/** Committed metadata of the local-client token models: tools/assets/local-token-spines.json. */
export const LOCAL_TOKEN_SPINES_FILE = 'tools/assets/local-token-spines.json';
/**
* The `models` of tools/assets/local-enemy-spines.json (enemyId → LocalSpineMeta); {} when the file is missing or bad.
* The `models` of a committed local-model metadata file (LOCAL_ENEMY_SPINES_FILE / LOCAL_TOKEN_SPINES_FILE: id →
* LocalSpineMeta); {} when the file is missing or bad.
* @param {string} path absolute path of the file
* @returns {Promise<Record<string, LocalSpineMeta>>}
*/
export async function loadLocalEnemySpines(path) {
export async function loadLocalSpines(path) {
try {
const j = JSON.parse(await readFile(path, 'utf8'));
return j && typeof j.models === 'object' && j.models && !Array.isArray(j.models) ? j.models : {};
} catch { return {}; }
}
/** loadLocalSpines of tools/assets/local-enemy-spines.json (the enemies' name for it). */
export const loadLocalEnemySpines = loadLocalSpines;
/**
* @typedef {{ skel: string, atlas: string, textures: string[], pma: boolean, anims: any,
+49 -24
View File
@@ -9,8 +9,11 @@
// aliases"); the official ones the local client has (tools/local-extract/
// extract.py ENEMY_SPINES, optional) are added as `spineLocal` from the
// committed tools/assets/local-enemy-spines.json — never from the disk, so the
// manifest is the same with or without the extraction. --local-spines rewrites
// that file from the extracted models (after a game update).
// manifest is the same with or without the extraction. The token (summon)
// models no dump carries (extract.py TOKEN_SPINES) likewise, from
// tools/assets/local-token-spines.json (ASSETS.md "Token models from the local
// client"). --local-spines rewrites both files from the extracted models (after
// a game update).
//
// Idempotent: existing files with the right size are skipped, so re-running is
// cheap. Downloads use ~16 parallel connections, 3 retries per direct source,
@@ -51,7 +54,10 @@ import { normalizeProxyPrefix } from './assets/sources.mjs';
import { loadIndexes } from './assets/cache.mjs';
import { indexAudio, VOICE_DIRS } from './assets/audio.mjs';
import { buildPlan } from './assets/plan.mjs';
import { processModels, findLocalEnemyModels, localEnemySpineMeta, loadLocalEnemySpines, LOCAL_ENEMY_SPINES_FILE } from './assets/spine.mjs';
import {
processModels, findLocalEnemyModels, findLocalTokenModels, localSpineMeta, loadLocalSpines, LOCAL_ENEMY_SPINES_FILE,
LOCAL_TOKEN_SPINES_FILE, LOCAL_ENEMY_SPINE_DIR, LOCAL_TOKEN_SPINE_DIR,
} from './assets/spine.mjs';
import { collectLeaves, downloadLeaves, resolveTemplate, totalBytes, contentHash, droppedEntries, MANIFEST_VERSION } from './assets/manifest.mjs';
import { fontJobs, buildFonts } from './assets/fonts.mjs';
import { skelParserAvailable } from './assets/skel.mjs';
@@ -62,7 +68,22 @@ const FONTS = join(ROOT, 'public', 'fonts');
const CACHE = join(ROOT, '.cache');
const MANIFEST = join(ROOT, 'data', 'assets.json');
const REPORT = join(CACHE, 'assets-report.json');
const LOCAL_SPINES = join(ROOT, LOCAL_ENEMY_SPINES_FILE);
/**
* The local-client model overlays (spineLocal): the committed metadata file, where extract.py writes the models (under
* public/assets), how to find them, and the file's `about` line.
*/
const LOCAL_SPINE_KINDS = {
enemy: {
file: LOCAL_ENEMY_SPINES_FILE, dir: LOCAL_ENEMY_SPINE_DIR, find: findLocalEnemyModels, what: 'enemy',
about: 'Spine metadata of the enemy models only the local client has (tools/local-extract/extract.py ENEMY_SPINES); '
+ 'data/assets.json enemies[id].spineLocal. Written by node tools/fetch-assets.mjs --local-spines (docs/ASSETS.md "Enemy aliases").',
},
token: {
file: LOCAL_TOKEN_SPINES_FILE, dir: LOCAL_TOKEN_SPINE_DIR, find: findLocalTokenModels, what: 'token',
about: 'Spine metadata of the token (summon) models only the local client has (tools/local-extract/extract.py TOKEN_SPINES); '
+ 'data/assets.json tokens[id].spineLocal. Written by node tools/fetch-assets.mjs --local-spines (docs/ASSETS.md "Token models from the local client").',
},
};
const HELP = `Usage: node tools/fetch-assets.mjs [options]
--concurrency=N parallel downloads (default 16)
@@ -80,8 +101,9 @@ const HELP = `Usage: node tools/fetch-assets.mjs [options]
(without it such a run keeps the current manifest, lists the entries and exits 1)
--add-only download only files missing on disk; never re-download, rewrite or delete an existing
file, no font rebuild (a worktree sharing public/assets and public/fonts)
--local-spines rewrite ${LOCAL_ENEMY_SPINES_FILE} from the enemy models extracted
by tools/local-extract/extract.py (public/assets/local/spine/enemy/)
--local-spines rewrite ${LOCAL_ENEMY_SPINES_FILE} and ${LOCAL_TOKEN_SPINES_FILE} from the
enemy and token models extracted by tools/local-extract/extract.py
(public/assets/local/spine/enemy/, public/assets/local/spine/token/)
--help this text
Environment: SP_ASSET_SOURCE sets the default source; SP_GITHUB_PROXY sets the
HTTPS mirror prefix (default https://gh-proxy.com/; empty disables the proxy).
@@ -247,33 +269,32 @@ function requiredMisses(m, charIds) {
}
/**
* Metadata of the local-client enemy models (the committed LOCAL_SPINES). With --local-spines it is rewritten from the
* models extracted under public/assets/local/spine/enemy/ (read only); otherwise extracted models whose metadata differs
* from the committed one only get a warning — the manifest never depends on what this machine extracted.
* Metadata of the local-client models of one kind (LOCAL_SPINE_KINDS: the committed file). With --local-spines it is
* rewritten from the models extracted under public/assets/local/spine/<kind>/ (read only); otherwise extracted models
* whose metadata differs from the committed one only get a warning — the manifest never depends on what this machine
* extracted.
* @param {{ localSpines: boolean, dryRun: boolean }} opts
* @param {'enemy'|'token'} kind
*/
async function syncLocalEnemySpines(opts) {
const committed = await loadLocalEnemySpines(LOCAL_SPINES);
const found = await findLocalEnemyModels(ASSETS);
async function syncLocalSpines(opts, kind) {
const k = LOCAL_SPINE_KINDS[kind];
const committed = await loadLocalSpines(join(ROOT, k.file));
const found = await k.find(ASSETS);
if (!Object.keys(found).length) {
if (opts.localSpines) log(`[local-spines] no extracted enemy model under public/assets/local/spine/enemy/ — ${LOCAL_ENEMY_SPINES_FILE} kept`);
if (opts.localSpines) log(`[local-spines] no extracted ${k.what} model under public/assets/${k.dir} — ${k.file} kept`);
return committed;
}
const { meta, problems } = await localEnemySpineMeta(ASSETS, found);
const { meta, problems } = await localSpineMeta(ASSETS, found);
for (const p of problems) log(`[local-spines] ${p}`);
if (opts.localSpines && !opts.dryRun) {
const models = { ...committed, ...meta };
const sorted = Object.fromEntries(Object.keys(models).sort().map((k) => [k, models[k]]));
const doc = {
about: 'Spine metadata of the enemy models only the local client has (tools/local-extract/extract.py ENEMY_SPINES); '
+ 'data/assets.json enemies[id].spineLocal. Written by node tools/fetch-assets.mjs --local-spines (docs/ASSETS.md "Enemy aliases").',
models: sorted,
};
await writeJsonAtomic(LOCAL_SPINES, doc, 2);
log(`[local-spines] ${Object.keys(meta).length} model(s) → ${LOCAL_ENEMY_SPINES_FILE}`);
const sorted = Object.fromEntries(Object.keys(models).sort().map((id) => [id, models[id]]));
await writeJsonAtomic(join(ROOT, k.file), { about: k.about, models: sorted }, 2);
log(`[local-spines] ${Object.keys(meta).length} ${k.what} model(s) → ${k.file}`);
return sorted;
}
for (const [id, m] of Object.entries(meta)) {
if (JSON.stringify(m) !== JSON.stringify(committed[id])) log(`[local-spines] ${id}: the extracted model differs from ${LOCAL_ENEMY_SPINES_FILE} (re-run with --local-spines to update it)`);
if (JSON.stringify(m) !== JSON.stringify(committed[id])) log(`[local-spines] ${id}: the extracted model differs from ${k.file} (re-run with --local-spines to update it)`);
}
return committed;
}
@@ -304,7 +325,8 @@ async function main() {
const extras = dataExtras(dataBackups, dataChess);
const extraHandbook = {};
for (const b of Object.values(dataBosses || {})) if (b?.enemyKey && typeof b.handbookId === 'string') extraHandbook[b.enemyKey] = b.handbookId;
const localEnemySpines = await syncLocalEnemySpines(opts);
const localEnemySpines = await syncLocalSpines(opts, 'enemy');
const localTokenSpines = await syncLocalSpines(opts, 'token');
const plan = buildPlan({
assets07, ops03, enemies05, maps05, audio, modelsData, charword, voiceLang: opts.voiceLang,
// default: only the slots a battle can play (plan.mjs VOICE_BATTLE_SLOTS); --voice-all takes the whole official set
@@ -313,6 +335,7 @@ async function main() {
extraTokenIds: [...Object.keys(dataTokens || {}), ...extras.tokenIds],
extraHandbook,
localEnemySpines,
localTokenSpines,
extraOperators: extras.extraOperators,
moduleTypes: extras.moduleTypes,
});
@@ -409,6 +432,8 @@ async function main() {
log(`on disk (manifest) : ${mb(s.bytes)} in ${s.files} files`);
log(`chars ${s.chars} (Back model ${s.charsWithBack}) · enemies ${s.enemies} (Spine ${s.enemiesWithSpine}) · tokens ${s.tokens} (Spine ${s.tokensWithSpine}) · Spine models ${s.spineModels}`);
log(`bonds ${s.bonds} · items ${s.items} · bands ${s.bands} · skill icons ${s.skills} · UI ${s.ui} · units with SFX ${s.sfxUnits}`);
const overlays = (o) => Object.values(o || {}).filter((e) => e?.spineLocal).length;
log(`local-client models (spineLocal, drawn when extracted): enemies ${overlays(manifest.enemies)} · tokens ${overlays(manifest.tokens)}`);
log(`operator battle voice: ${s.voiceChars} charIds (--voice-lang=${opts.voiceLang})`);
log(`fonts: ${Object.values(fonts.files).map((f) => f.woff2 || f.original).join(', ') || 'none'}`);
if (resolved.fallbacks.length) { log(`fallbacks used (${resolved.fallbacks.length}):`); for (const f of resolved.fallbacks.slice(0, 20)) log(` ${f}`); }
+213 -35
View File
@@ -7,7 +7,7 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
- every Sprite of the three autochess UI bundles (HUD frames, badges, banners, icons)
- the 6 in-match emoticon themes of 盟约 (activity_table autoChessData.enabledEmoticonThemeIdList; only the
*_battle sprites, keyed by display_meta_table picId: emoticon/<dir>/<picId>.png, see data/emotes.json)
- the autochess guidebook pages, battle projectile sprites and a few token/skin Spine models missing upstream
- the autochess guidebook pages and battle projectile sprites
(the emotes and the guidebook pages are on the public mirror too: tools/fetch-assets.mjs downloads them, and the
client uses these local copies first — GitHub issue #42)
- the enemy battle Spine models no community dump carries (ENEMY_SPINES: 灼热源石虫 / 炽焰源石虫), from the enemy art
@@ -15,6 +15,11 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
merged in (premultiplied RGB + A, the Ark-Models format; the atlas gets `size:` / `pma: true` like the fetched
enemies); the client draws them instead of the web alias once this manifest lists them (data/assets.json
enemies[id].spineLocal, docs/ASSETS.md "Enemy aliases")
- the token (summon) battle Spine models no community dump carries (TOKEN_SPINES: most 自选 summons, 凯瑟琳's
爬行号·防护单元, 凛御银灰's 风雪之眼), from the battle token prefabs (pkgrps/btl_pfb_tokens_*.ab: the skeleton
the prefab's Front / only Spine renderer draws) → spine/token/<tokenId>/<stem>.skel|.atlas + merged page PNGs, the
same format; drawn instead of the avatar diamond once this manifest lists them (data/assets.json
tokens[id].spineLocal)
- for the official 3D board (DESIGN §15): the map theme's Material parameters (map/<theme>/materials.json; shader
names resolved through the shaders/*.ab bundles), the background / device meshes as Wavefront OBJ
(mesh/<bundle>/<mesh>.obj; UnityPy's exporter, X mirrored into a right-handed frame) and their GameObject
@@ -94,7 +99,6 @@ JOBS = [
('battle/prefabs/[uc]projectiles.ab', 'projectiles', {'Sprite', 'Texture2D'}),
# official module (uniequip) type icons, keyed by lower-case type name (e.g. 'mar-x')
('spritepack/ui_equip_type_hub_h2_0.ab', 'module', {'Sprite'}),
('skinpack/token_10039_ulpia_block.ab', 'spine/token_10039_ulpia_block', {'TextAsset', 'Texture2D'}),
*[(rel, f'mesh/{sub}', {'Mesh', 'GameObject', 'Material', 'Texture2D'}) for rel, sub in MESH_BUNDLES],
('arts/effects/[pack]map.ab', 'map/fx', {'Mesh', 'GameObject', 'Material', 'Texture2D'}),
('arts/maps/common/res.ab', 'map/common', {'Material', 'Texture2D'}, r'^(TX|MT)_wind_device$'),
@@ -114,6 +118,35 @@ ENEMY_SPINES = ['enemy_1305_mhslim', 'enemy_1305_mhslim_2']
ENEMY_ART = 'refs/arts/enm_art_*.ab'
ENEMY_SPINE_SUB = 'spine/enemy'
# Token (summon) battle Spine models that no community dump carries: fexli/ArknightsResource has most of the 自选
# summons (data/backups.json `tokens`) only as skin variants or not at all, so tools/fetch-assets.mjs reports "missing
# skel" for them and the client drew the avatar diamond. Every token of data/tokens.json and data/backups.json whose
# battle prefab draws a Spine model and that has no web model (audited 2026-10-06 against both local installs). Read from
# the battle token packs (dyn/battle/prefabs/[uc]tokens/<id>.prefab): the SkeletonDataAsset of the prefab's Spine
# renderer — of a directional token (FaceSwitcher with Front / Back / Down renderers, each its own skeleton under the
# same name) the Front one, the model the fetched tokens use too (plan.mjs: Spine/ or Front/) — with its skeleton,
# atlas and page textures as in ENEMY_SPINES. The Windows build carries every one (pkgrps/btl_pfb_tokens_0/4/5.ab; the
# iOS build lacks btl_pfb_tokens_0, and its pages are ASTC rather than BC7). Output: spine/token/<id>/ (manifest group
# spine/token/<id>); the client draws the model when the group lists every file of data/assets.json
# tokens[id].spineLocal (metadata in tools/assets/local-token-spines.json, `node tools/fetch-assets.mjs --local-spines`).
# Not here: the tokens whose prefab draws nothing (an EmptyAnimator in place of the Spine renderer, displayType HIDDEN
# or an effect only, and no avatar in either install's asset index) — 乌尔比安's 从不混淆的方向, 圣聆初雪's frozen
# protection point, 酒神's 迷狂牢笼, 贝洛内's 牵绊, 予愿安洁莉娜's “一会儿见!” (token_10039 / 10058 / 10055 / 10065 / 10071).
TOKEN_SPINES = [
'token_10002_kalts_mon3tr', 'token_10003_cgbird_bird', 'token_10005_mgllan_drone1', 'token_10005_mgllan_drone2',
'token_10005_mgllan_drone3', 'token_10007_phatom_twin', 'token_10008_cqbw_box', 'token_10009_weedy_cannon',
'token_10020_ling_soul1', 'token_10020_ling_soul2', 'token_10020_ling_soul3', 'token_10024_ebnhlz_rcube',
'token_10025_doroth_recttp', 'token_10026_bgsnow_subbow', 'token_10027_ironmn_pile1', 'token_10027_ironmn_pile2',
'token_10027_ironmn_pile3', 'token_10029_slent2_protrb', 'token_10032_jesca2_jckshd', 'token_10034_ray_sndbst',
'token_10035_wisdel_wward', 'token_10041_cathy_catsld', 'token_10043_necras_skeltn', 'token_10050_monstr_prosts',
'token_10051_radian_tower1', 'token_10052_radian_tower2', 'token_10053_radian_tower3', 'token_10054_phatm2_encdool',
'token_10057_svash2_eagle1', 'token_10057_svash2_eagle2', 'token_10057_svash2_eagle3', 'token_10059_nasti_nstdef',
'token_10060_nasti_nstchr', 'token_10061_nasti_nstbld', 'token_10064_wang_stone1', 'token_10066_closur_ourbase',
'token_10068_kalts2_mtship', 'token_10069_mcnist_mcgraf', 'token_10070_aphris_pc',
]
TOKEN_PACKS = 'pkgrps/btl_pfb_tokens_*.ab'
TOKEN_SPINE_SUB = 'spine/token'
# Derived three.js maps: (output subdir, source texture, kind, output name). 'normal_rg' rebuilds Z of a two-channel
# (BC5) normal map into an RGB tangent-space map; 'rough_from_gloss' turns Unity's metallic/gloss map (smoothness in
# A) into a roughness (G) / metalness (B) map. Written after the job that exported the source texture.
@@ -192,6 +225,73 @@ def enemy_spine_id(container, wanted):
return m.group(1) if m and m.group(1) in wanted else None
def token_prefab_id(container, wanted):
"""The token id of a battle token prefab container path (dyn/battle/prefabs/[uc]tokens/<id>.prefab) when it is one
of `wanted`, else None (a skin's prefab lives under battle/prefabs/skins/character/…)."""
m = re.search(r'(?:^|/)battle/prefabs/(?:\[uc\])?tokens/([^/]+)\.prefab$', container or '', re.I)
return m.group(1) if m and m.group(1) in wanted else None
def spine_ids(only, sub, ids):
"""The ids of a Spine model list (ENEMY_SPINES / TOKEN_SPINES, written to `<sub>/<id>`) that run under --only: all of
them with no prefix, `sub` or a parent of it; else those whose output dir is a prefix or below one."""
if not wants_sub(only, sub):
return []
if not only or any(sub == p.rstrip('/') or sub.startswith(p.rstrip('/') + '/') for p in only):
return list(ids)
return [i for i in ids if any(f'{sub}/{i}' == p.rstrip('/') or f'{sub}/{i}'.startswith(p.rstrip('/') + '/')
for p in only)]
def prefab_spine_nodes(root, objects):
"""The Spine renderers of a prefab, depth first: [(GameObject name, active, skeletonDataAsset PPtr)] for every
MonoBehaviour holding a `skeletonDataAsset` (Spine's SkeletonAnimation). `root` is the root GameObject's type tree,
`objects` the bundle's {path id: object}; references into other bundles are skipped."""
out = []
def local(pptr):
return objects.get(pptr.get('m_PathID')) if pptr and not pptr.get('m_FileID') else None
def walk(go, active, depth):
tr, refs = None, []
for c in go.get('m_Component') or []:
o = local(c.get('component'))
if o is None:
continue
try:
if o.type.name == 'Transform':
tr = o.read_typetree()
elif o.type.name == 'MonoBehaviour':
sda = o.read_typetree().get('skeletonDataAsset')
if sda and sda.get('m_PathID'):
refs.append(sda)
except Exception: # a script without a readable type tree is no Spine renderer we could use
continue
active = active and bool(go.get('m_IsActive', 1))
out.extend((go.get('m_Name') or '', active, ref) for ref in refs)
if tr is None or depth >= 12:
return
for ch in tr.get('m_Children') or []:
t = local(ch)
g = local(t.read_typetree().get('m_GameObject')) if t is not None else None
if g is not None:
walk(g.read_typetree(), active, depth + 1)
walk(root, True, 0)
return out
def pick_spine_node(nodes):
"""The renderer the remake draws among prefab_spine_nodes: of a directional token (a FaceSwitcher with Front / Back /
Down renderers) the Front one, else one named Spine, else the first — active renderers first; None when empty."""
live = [n for n in nodes if n[1]] or list(nodes)
for want in ('Front', 'Spine'):
hit = next((n for n in live if n[0] == want), None)
if hit:
return hit
return live[0] if live else None
def _num(v, nd=6):
"""JSON-safe rounded float (NaN / inf → 0)."""
try:
@@ -494,13 +594,34 @@ def _text_bytes(text_asset):
return raw.encode('utf-8', 'surrogateescape') if isinstance(raw, str) else bytes(raw)
def merge_alpha(rgb, alpha):
"""RGBA page of a split texture: RGB of the main texture (premultiplied: the client's Spine material has
_StraightAlphaInput 0) and A from its [alpha] texture; RGB is clamped to A (block-compression bleed)."""
def merge_alpha(rgb, alpha, straight=False):
"""Premultiplied RGBA page of a Spine atlas texture: RGB of the main texture and A from its [alpha] texture (the
split pages: _UseAlphaTex 1), else the main texture's own alpha (the newer RGBA pages: _UseAlphaTex 0); RGB is
clamped to A (block-compression bleed) — the client's Spine material reads premultiplied input
(_StraightAlphaInput 0) — or, for a material with _StraightAlphaInput 1 (`straight`), multiplied by A."""
from PIL import Image, ImageChops
r, g, b = rgb.convert('RGB').split()
a = alpha.convert('L').resize(rgb.size) if alpha is not None else Image.new('L', rgb.size, 255)
return Image.merge('RGBA', tuple(ImageChops.darker(ch, a) for ch in (r, g, b)) + (a,))
if alpha is not None:
a = alpha.convert('L').resize(rgb.size)
elif rgb.mode in ('RGBA', 'LA', 'PA') or 'transparency' in rgb.info:
a = rgb.convert('RGBA').getchannel('A')
else:
a = Image.new('L', rgb.size, 255)
op = ImageChops.multiply if straight else ImageChops.darker
return Image.merge('RGBA', tuple(op(ch, a) for ch in (r, g, b)) + (a,))
def _is_page_line(lines, i):
"""Whether line i of a Spine atlas starts a page: a page name is an unindented line without ':' that comes first
or after a blank line (a region name follows its page's or the previous region's fields)."""
line = lines[i]
return bool(line.strip()) and ':' not in line and not line[:1].isspace() and (i == 0 or not lines[i - 1].strip())
def atlas_pages(text):
"""The page names (image files) of a Spine atlas text, in order."""
lines = text.replace('\r\n', '\n').replace('\r', '\n').lstrip('\ufeff').split('\n')
return [lines[i].strip() for i in range(len(lines)) if _is_page_line(lines, i)]
def normalize_atlas(text, sizes):
@@ -512,8 +633,7 @@ def normalize_atlas(text, sizes):
out, i, n = [], 0, len(lines)
while i < n:
line = lines[i]
starts_page = line.strip() and ':' not in line and not line[:1].isspace() and (i == 0 or not lines[i - 1].strip())
if not starts_page:
if not _is_page_line(lines, i):
out.append(line)
i += 1
continue
@@ -538,21 +658,21 @@ def normalize_atlas(text, sizes):
return '\n'.join(out)
def write_enemy_spine(objects, sda, eid, out_root, manifest, log):
"""Write one enemy model (skeleton, atlas, merged pages) of a SkeletonDataAsset typetree; returns the file count."""
def write_spine(objects, sda, sub, name, out_root, manifest, log):
"""Write one Spine model of a SkeletonDataAsset type tree — its skeleton TextAsset, its atlas TextAsset(s) (sized,
`pma: true`: normalize_atlas) and the page textures of the atlas materials (_MainTex with its _AlphaTex merged in,
merge_alpha) — to <out_root>/<sub>/ as <stem>.skel, <stem>.atlas (one stem: pixi-spine finds the atlas by the
skeleton's name) and <page>.png, and list them in the manifest group `sub`. Returns the file count; 0 when a part
is missing or the textures do not match the atlas pages (nothing is written then). `name` labels the log lines and
names a skeleton without a name."""
get = lambda pptr: objects.get(pptr['m_PathID']) if pptr and not pptr.get('m_FileID') else None # noqa: E731
sub = f'{ENEMY_SPINE_SUB}/{eid}'
out_dir = Path(out_root) / sub
out_dir.mkdir(parents=True, exist_ok=True)
files = {}
skel = get(sda.get('skeletonJSON'))
if skel is None:
log(f' warn {eid}: skeleton TextAsset not in this bundle')
log(f' warn {name}: skeleton TextAsset not in this bundle')
return 0
ta = skel.read()
stem = safe_name(getattr(ta, 'm_Name', '') or eid).removesuffix('.skel')
files[f'{stem}.skel'] = _text_bytes(ta)
pages = {}
stem = safe_name(getattr(ta, 'm_Name', '') or name).removesuffix('.skel')
texts, pages = [], {}
for aref in sda.get('atlasAssets') or []:
aa = get(aref)
if aa is None:
@@ -560,22 +680,34 @@ def write_enemy_spine(objects, sda, eid, out_root, manifest, log):
at = aa.read_typetree()
af = get(at.get('atlasFile'))
if af is not None:
files[f'{stem}.atlas'] = _text_bytes(af.read())
texts.append(_text_bytes(af.read()).decode('utf-8', 'replace'))
for mref in at.get('materials') or []:
mo = get(mref)
if mo is None:
continue
texs = dict(mo.read_typetree()['m_SavedProperties']['m_TexEnvs'])
props = mo.read_typetree()['m_SavedProperties']
texs = dict(props['m_TexEnvs'])
straight = dict(props.get('m_Floats') or []).get('_StraightAlphaInput') == 1
main, alpha = get(texs.get('_MainTex', {}).get('m_Texture')), get(texs.get('_AlphaTex', {}).get('m_Texture'))
if main is None:
continue
mt = main.read()
pages[safe_name(mt.m_Name) + '.png'] = merge_alpha(mt.image, alpha.read().image if alpha is not None else None)
if f'{stem}.atlas' not in files or not pages:
log(f' warn {eid}: atlas or page textures missing')
pages[safe_name(mt.m_Name) + '.png'] = merge_alpha(mt.image, alpha.read().image if alpha is not None else None,
straight)
if not texts or not pages:
log(f' warn {name}: atlas or page textures missing')
return 0
sizes = {name: img.size for name, img in pages.items()}
files[f'{stem}.atlas'] = normalize_atlas(files[f'{stem}.atlas'].decode('utf-8'), sizes).encode('utf-8')
atlas = texts[0] if len(texts) == 1 else '\n\n'.join(t.strip('\n') for t in texts) + '\n'
names = atlas_pages(atlas)
if set(names) != set(pages):
if len(names) != 1 or len(pages) != 1:
log(f' warn {name}: atlas pages {names} do not match the textures {sorted(pages)}')
return 0
pages = {names[0]: next(iter(pages.values()))} # one page under another name: saved as the atlas names it
files = {f'{stem}.skel': _text_bytes(ta),
f'{stem}.atlas': normalize_atlas(atlas, {p: img.size for p, img in pages.items()}).encode('utf-8')}
out_dir = Path(out_root) / sub
out_dir.mkdir(parents=True, exist_ok=True)
for fname, blob in files.items():
(out_dir / fname).write_bytes(blob)
manifest.setdefault(sub, {})[fname] = {'path': f'/assets/local/{sub}/{fname}', 'kind': 'TextAsset'}
@@ -587,10 +719,11 @@ def write_enemy_spine(objects, sda, eid, out_root, manifest, log):
def export_enemy_spines(ab_root, out_root, manifest, log, ids=None):
"""ENEMY_SPINES from the enemy art bundles (each bundle loaded on its own; stops once every model was found)."""
"""ENEMY_SPINES (or `ids`) from the enemy art bundles (each bundle loaded on its own; stops once every model was
found)."""
import aklz4 # noqa: F401 (registers the LZ4AK decoder)
import UnityPy
wanted, n = set(ids or ENEMY_SPINES), 0
wanted, n = set(ENEMY_SPINES if ids is None else ids), 0
for f in sorted(Path(ab_root).glob(ENEMY_ART)):
if not wanted:
break
@@ -605,7 +738,7 @@ def export_enemy_spines(ab_root, out_root, manifest, log, ids=None):
if not eid:
continue
try:
k = write_enemy_spine(objects, ref.read_typetree(), eid, out_root, manifest, log)
k = write_spine(objects, ref.read_typetree(), f'{ENEMY_SPINE_SUB}/{eid}', eid, out_root, manifest, log)
except Exception as e:
log(f' warn {eid}: {e}')
continue
@@ -618,6 +751,46 @@ def export_enemy_spines(ab_root, out_root, manifest, log, ids=None):
return n
def export_token_spines(ab_root, out_root, manifest, log, ids=None):
"""TOKEN_SPINES (or `ids`) from the battle token packs: each token's prefab → the SkeletonDataAsset its Front / only
Spine renderer draws (pick_spine_node) → write_spine. Each pack is loaded on its own; stops once every model was
found."""
import aklz4 # noqa: F401 (registers the LZ4AK decoder)
import UnityPy
wanted, n = set(TOKEN_SPINES if ids is None else ids), 0
for f in sorted(Path(ab_root).glob(TOKEN_PACKS)):
if not wanted:
break
try:
env = UnityPy.load(str(f))
except Exception as e: # corrupt or unsupported bundle: report and continue
log(f'FAIL load {f.name}: {e}')
continue
objects = {o.path_id: o for o in env.objects}
for name, ref in env.container.items():
tid = token_prefab_id(name, wanted)
if not tid:
continue
sub = f'{TOKEN_SPINE_SUB}/{tid}'
try:
node = pick_spine_node(prefab_spine_nodes(ref.read_typetree(), objects))
sda = objects.get(node[2]['m_PathID']) if node and not node[2].get('m_FileID') else None
if sda is None:
log(f' warn {tid}: its prefab has no Spine renderer in this bundle')
continue
k = write_spine(objects, sda.read_typetree(), sub, tid, out_root, manifest, log)
except Exception as e:
log(f' warn {tid}: {e}')
continue
if k:
wanted.discard(tid)
n += k
log(f'{f.relative_to(ab_root).as_posix()}: {tid} ({node[0]}) -> {sub} ({k} files)')
for tid in sorted(wanted):
log(f'skip (not found) token Spine {tid}')
return n
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--game', help='AssetBundle root (…/StreamingAssets/AB/Windows or …/Documents/Bundles)')
@@ -637,8 +810,10 @@ def main():
derived = [{'sub': sub, 'from': src, 'derive': kind, 'name': name} for sub, src, kind, name in DERIVED]
webp = [{'sub': sub, 'name': name, 'mode': mode} for sub, name, mode in WEBP]
print(json.dumps({'emoteThemes': [{'themeId': t, 'dir': d} for t, d in EMOTE_THEMES], 'jobs': jobs,
'derived': derived, 'webp': webp, 'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB,
'ids': ENEMY_SPINES}}, ensure_ascii=False))
'derived': derived, 'webp': webp,
'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB, 'ids': ENEMY_SPINES},
'tokenSpines': {'bundles': TOKEN_PACKS, 'sub': TOKEN_SPINE_SUB, 'ids': TOKEN_SPINES}},
ensure_ascii=False))
return 0
if args.webp:
return webp_only(Path(args.out), Path(args.manifest), print)
@@ -648,8 +823,9 @@ def main():
print('No Arknights install found. Pass --game <AssetBundle root>.', file=sys.stderr)
return 2
jobs = select_jobs(args.only)
enemy_spines = wants_sub(args.only, ENEMY_SPINE_SUB)
if not jobs and not enemy_spines:
enemy_ids = spine_ids(args.only, ENEMY_SPINE_SUB, ENEMY_SPINES)
token_ids = spine_ids(args.only, TOKEN_SPINE_SUB, TOKEN_SPINES)
if not jobs and not enemy_ids and not token_ids:
print(f'--only {args.only}: no job matches', file=sys.stderr)
return 2
out_root = Path(args.out)
@@ -658,8 +834,10 @@ def main():
print(f'AB root: {ab_root}')
for job in jobs:
total += export_bundle(ab_root, job, out_root, manifest, print)
if enemy_spines:
total += export_enemy_spines(ab_root, out_root, manifest, print)
if enemy_ids:
total += export_enemy_spines(ab_root, out_root, manifest, print, enemy_ids)
if token_ids:
total += export_token_spines(ab_root, out_root, manifest, print, token_ids)
old = {}
if args.only and Path(args.manifest).exists():
try:
+17 -5
View File
@@ -198,20 +198,32 @@ export function checkAssets() {
* the 玩法说明 pages are not in the list: step 5 downloads them from the public mirror with the other assets (GitHub
* issue #42). Shown by setup and doctor.
*/
export const LOCAL_ART_FALLBACK = '3D 棋盘改用 2D,部分官方界面图标和灼热/炽焰源石虫模型用替代样式';
export const LOCAL_ART_FALLBACK = '3D 棋盘改用 2D,部分官方界面图标、灼热/炽焰源石虫模型和多数自选召唤物模型用替代样式';
/** Where a machine without the client gets the local art (docs/DEPLOY.md §6「本地客户端素材」); shown by doctor (setup's row,
* printed on every start by scripts/launch.mjs, only points to that section). */
export const LOCAL_ART_COPY_HINT = '没有客户端的服务器可以从同一版本的整合包(完整包)复制 public/assets/local 和 data/local-assets.json';
/**
* Local-client art (optional): manifest entry count, whether the 3D board atlas is on disk and whether the extraction
* has the enemy models only the client has (`spine/enemy/*` groups, extract.py ENEMY_SPINES — added after 0.1.0).
* has the enemy and token models only the client has (`spine/enemy/*` groups, extract.py ENEMY_SPINES — added after
* 0.1.0; `spine/token/*` groups, TOKEN_SPINES — added in 0.2.0).
*/
export function checkLocal() {
const m = readJson(LOCAL_MANIFEST);
const count = m && m.groups ? Object.values(m.groups).reduce((n, g) => n + Object.keys(g || {}).length, 0) : 0;
const enemySpines = !!(m && m.groups && Object.keys(m.groups).some((g) => g.startsWith('spine/enemy/')));
return { manifest: !!m, count, board3d: exists(LOCAL_BOARD_ATLAS), tiles: exists(LOCAL_BOARD_TILES), enemySpines, dirPresent: exists(path.join(ROOT, 'public', 'assets', 'local')) };
const has = (prefix) => !!(m && m.groups && Object.keys(m.groups).some((g) => g.startsWith(prefix)));
return {
manifest: !!m, count, board3d: exists(LOCAL_BOARD_ATLAS), tiles: exists(LOCAL_BOARD_TILES), enemySpines: has('spine/enemy/'),
tokenSpines: has('spine/token/'), dirPresent: exists(path.join(ROOT, 'public', 'assets', 'local')),
};
}
/** What an extraction made by an older extract.py lacks (setup's 本地客户端美术 row; re-extract with --local). */
export function localGaps(local) {
const gaps = [];
if (local && !local.enemySpines) gaps.push('灼热/炽焰源石虫模型');
if (local && !local.tokenSpines) gaps.push('自选召唤物模型');
return gaps.length ? `,缺少新版的${gaps.join('和')}` : '';
}
/**
@@ -445,7 +457,7 @@ async function main() {
add(already ? 'ok' : 'warn', '本地客户端美术(可选)', `${client.kind} 客户端缺少卫戍协议资源(请在游戏内下载全部资源):${client.path}`);
} else if (already && opts.local !== 'force') {
if (local.board3d && !local.tiles && !opts.check) cropBoardTiles(log);
add('ok', '本地客户端美术(可选)', `已提取 ${local.count} 项${local.board3d ? ',3D 棋盘可用' : ''}${local.enemySpines ? '' : ',缺少新版的灼热/炽焰源石虫模型'}(重新提取:--local)`);
add('ok', '本地客户端美术(可选)', `已提取 ${local.count} 项${local.board3d ? ',3D 棋盘可用' : ''}${localGaps(local)}(重新提取:--local)`);
} else if (opts.check) {
add('skip', '本地客户端美术(可选)', `检测到 ${client.kind} 客户端,可运行 node tools/setup.mjs --local 提取`);
} else {