59 KiB
Assets: sources, layout, manifest, credits
Owner: tools/fetch-assets.mjs and tools/assets/*. Research background: docs/research/07-assets.md.
All official game art, Spine models and audio are downloaded at install time. They are never committed; public/assets/ is git-ignored.
The UI sound audio.sfx.ui.bondUp is the official ON_ACT1AUTOCHESS_ADD_BOND bank's
act1autochess_b_ui_buffup. Live prep/battle layer gains use it as well as bond activation. Its bank settings are
maxSoundAllowed: 1 and popOldest: true: one dedicated SFX channel, replacing the previous sound without an extra
cooldown. It respects the SFX/mute settings; a stale asynchronous decode cannot restart a replaced cue (#482).
All downloaded game assets the client needs are listed in data/assets.json. Game-art requests must use that manifest.
The original project icons below are shipped static files, not downloaded game assets.
Original project application icons (#413)
By the maintainer's revised decision of 2026-10-10, 沿用 0.2.x 的薄荷绿城堡并精修.
public/icons/app.svg retains the existing mint rook's three battlements, inset tower and splayed base.
The selected refinement (B) narrows the tower, balances the battlements, reduces the base thickness and adds breathing
room. On the 512 px canvas the mark spans x=128–384 and y=88–424; the tower is 176 px wide and the base is 32 px thick.
The original colours remain mint #4ed8af and dark #0c0f0e, with no text, gradients, gloss or texture.
Provenance / originality statement (2026-10-10): this is a proportion refinement of this project's existing
public/icons/app.svg from before ba5b998a, expressed as basic rectangles and polygons.
No official logo, artwork, icon or font was used, downloaded, embedded, traced or redrawn for this refinement.
The vector and exports are project-owned work under the repository's GPL-3.0-or-later licence, separate from the
Hypergryph / Yostar game assets below. The castle mark does not assert official branding.
Rebuild offline with Python 3 and Pillow (the same image library used by local-extract):
python3 tools/export-app-icons.py
# Optional review export without replacing shipped files:
python3 tools/export-app-icons.py --out /tmp/stronghold-icons
The script reads only the master SVG's flat rectangle / polygon geometry, renders at 8× and downsamples; unsupported SVG elements fail rather than silently disappearing. It needs no game art, fonts, browser or network. ICO frames use the same native-size renders as the PNG favicons, with explicit 24-bit DIBs and opaque 1-bit masks whose rows are padded to 4 bytes. This avoids malformed transparency masks in Pillow's RGB ICO export.
Files in public/icons/ |
Use |
|---|---|
app-192.png, app-512.png |
Manifest purpose: any, 192 / 512 px |
app-maskable-192.png, app-maskable-512.png |
Manifest purpose: maskable, opaque full-bleed background |
favicon-16.png, favicon-32.png, favicon-48.png, favicon.ico |
Tab / legacy favicon; ICO contains 16 / 32 / 48 px frames |
apple-touch-icon.png |
180 px home-screen icon |
app.svg |
Scalable favicon and editable source |
The maskable mark is scaled to 87.5% around the canvas centre. Its outermost vertex is about 36.1% of the canvas
width from that centre; even antialiased edge pixels stay inside the 40%-radius safe circle specified by
Web Application Manifest §2.3.
The background remains opaque to every edge; launcher masks may remove the background outside the safe zone.
Normal icons and favicons retain the larger mark for readability. public/index.html links the SVG / ICO / PNG
favicons and Apple icon; public/manifest.json lists all four install icons. All these static files must be tracked
and included by the full and lite package allowlist; no changes to data/assets.json are needed.
test/ui/pwa-install-023.test.js checks decoded pixels, mask safety and references; test/package.test.js checks
packaging inclusion. The local handoff claude-review-023/icon-v2/ holds the original and A/B/C SVGs, light/dark
256 px and 16/32/48/96 px comparisons, and phone-layout / mask previews with their render script.
These are offline review renders, not evidence of browser or native OS installation.
Running
npm install # also vendors pixi / pixi-spine / preact / three (tools/vendor.mjs; three.js is fetched by
# browsers only when data/local-assets.json lists the board atlas — the 3D board, DESIGN §15)
npm run assets # = node tools/vendor.mjs && node tools/fetch-assets.mjs
| Option | Effect |
|---|---|
--concurrency=N |
Parallel downloads (default 16). |
--force |
Re-download everything. |
--offline |
No network. Re-runs post-processing (atlas fixes, skeleton parsing, WOFF2) on what is already on disk, then rebuilds data/assets.json. |
--dry-run |
Print the plan (file and model counts, alias notes) and exit. |
--refresh-index |
Re-download the upstream indexes: audio_data.json, charword_table.json (the 干员战斗语音 slots) and models_data.json. |
--voice-lang=cn |
The dub of audio.voice (the 中文 voice setting): cn (default) |
--voice-all |
Plan every official voice slot, including the prep-only lines no battle plays (干员报到 / 编入队伍 / 任命队长 — 360 files, one per operator and slot). Off by default: nothing requests them, so planning them only makes every run download more. |
--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). |
--strict |
Exit 1 when a leaf was dropped for having no file on disk, instead of only reporting it (see "A dropped leaf is reported" below). The same as the environment variable SP_ASSETS_STRICT=1 — for CI and packaging builds. |
--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 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
every other install still has from the committed data/assets.json (a pull request once carried such a manifest, 42
audio entries short — all 42 still resolve upstream; PR #7). When the rebuilt manifest lacks an entry of the current
one, the run keeps the current file, prints the entries it would drop (also in the report: droppedEntries,
manifestWritten: false) and exits 1. Re-run to retry the downloads, or pass --allow-shrink (or --prune) when the
smaller manifest is intended, for example after a mapping change. Build fields (version, hash, generator,
stats), new entries and a changed value are never a drop (tools/assets/manifest.mjs droppedEntries). A run whose
plan legitimately narrows — like the 干员战斗语音 default, which no longer plans the three prep-only slots (360 entries,
DESIGN §21.30) — reports exactly those entries and needs --allow-shrink once; the list it prints is the check.
A dropped leaf is reported, never silent. resolveTemplate leaves out every entry none of whose alternative files
is on disk — for a leaf that is an entry the client loses outright: a unit whose attack sound was never downloaded has
no audio.sfx.units[charId].attack at all, so the sound is simply missing (nothing falls back; there is no URL to
retry). That is a different thing from an entry whose fallback was used. The run therefore prints a summary naming at most eight such leaves
and records the complete list as droppedLeaves (a subset of misses, which also lists the
unresolved Spine models):
[assets] 1 leaf dropped: no alternative on disk (audio.sfx.units.some_char.attack)
A run with nothing dropped prints [assets] no leaf dropped: every planned leaf has a file on disk. This is only
observability: which files are planned, downloaded and written does not change. --strict (or SP_ASSETS_STRICT=1)
turns those drops into a failure (exit 1, the leaves named) so a CI or packaging run cannot ship a manifest with such
holes — the committed manifest's shrink guard cannot see them once the entry is already gone. The manifest may
already have been written before this check returns failure; strict mode does not roll it back.
The script is idempotent. A file on disk is kept, not re-downloaded, when any one of these holds:
- its size matches the ledger entry from a previous download (
.cache/assets-ledger.json); - its size matches the byte count recorded in research;
- no size is known, but the file passes format validation. Validation checks the PNG signature and
IEND, the MP3 header, the sfnt header, the atlas page line, and that a skel is not an HTML error page.
Every download is written to a temp file, then renamed into place. A partial download therefore never reaches its final path.
How downloads are fetched:
- 16 connections at a time.
- Each source gets 3 attempts with exponential backoff.
- If
raw.githubusercontent.comfails, the jsDelivr mirror (cdn.jsdelivr.net/gh/…) is tried. - There is no mirror for the ArknightsAssets2
voicebranch, because jsDelivr returns 404 for it. - A manifest entry with fallbacks (for example an enemy icon that falls back to its base enemy's icon) only moves on to the next alternative after a definitive 404. When the primary fails transiently (network error, 5xx or an invalid payload after all retries), no fallback is fetched. The path is listed under
downloadErrorsin the report, and the next run retries the primary. - A skeleton that fails to parse is deleted and removed from the ledger, so the next online run downloads it again.
The first run downloads about 548 MiB in 10,643 files (stats of data/assets.json in 0.2.2: 574,449,900 bytes). The two 干员战斗语音 dubs are 2,674 files each, the twelve slots a battle plays for 191 operators: the Chinese one 65.6 MiB, the Japanese one 85.2 MiB (89,388,041 bytes; adding it took 107 s at about 1 MB/s from raw.githubusercontent.com). A re-run takes about 1 s. The three prep-only slots the official client uses elsewhere (干员报到 / 编入队伍 / 任命队长, 360 more files per dub, 18.4 MiB of them Chinese) are left out unless --voice-all is passed.
Outputs:
data/assets.json: the manifest (committed).public/assets/**: art and audio (git-ignored).public/fonts/*: fonts andfonts.css..cache/assets-report.json: misses, fallbacks,droppedLeaves(the leaves left out of the manifest for having no file on disk) and notes from the last run..cache/spine-info.json: skeleton parse cache.
The run exits with code 1 if any pool operator is missing its avatar, its portrait or its Front Spine, and — with
--strict / SP_ASSETS_STRICT=1 — if any leaf was dropped for having no file on disk.
Upstream indexes are cached under .cache/. They are downloaded when missing:
.cache/gamedata/excel/audio_data.json, fromKengxxiao/ArknightsGameData(zh_CN)..cache/ark-models/models_data.json, fromisHarryh/Ark-Models.
The research JSONs in docs/research/ (03, 05, 07) define which ids are needed.
What is downloaded
| Class | Source | Local path (under public/assets/) |
|---|---|---|
| Operator avatars, 180×180. Base, plus E2 when it exists. | yuanyan3060/ArknightsGameResource avatar/ |
char/avatar/{charId}.png, char/avatar/{charId}_2.png |
| Operator half-body portraits, 180×360 | yuanyan portrait/ |
char/portrait/{charId}_1.png, _2.png |
| Skill icons: every skill index of every planned operator (DESIGN §16 loadouts, 自选 picks) | yuanyan skill/ |
skill/{iconId sanitized}.png |
Module type icons: the typeIcon of every module of data/chess.json and data/backups.json (the 干员调配 / 自选 module tiles when the local-client art lacks them) |
AA2 cn arts/ui/uniequiptype/{typeIcon}.png (else its lower-case name: WAH-Y → wah-y.png) |
module/{typeIcon in lower case}.png — one file per type: the official data spells one DEC X module dec-X and the others dec-x, and paths that differ only in case are one file on Windows / macOS and in a release zip |
| Enemy icons | yuanyan enemy/. Fallbacks: the handbook id, then the base id. |
enemy/icon/{enemyId}.png |
| Token avatars | yuanyan avatar/ |
token/avatar/{tokenId}.png |
| Bond icons (the real autochess glyphs) | ArknightsAssets2 cn ui/autochess/[uc]autochesscommon/arts/bondicon/. Fallback: the camp logo. |
bond/{bondId}.png |
| Shop item icons | AA2 …/arts/shopitemicon/ |
item/{trapId}.png |
| Band (strategy) icons | AA2 …/arts/bandicon/ |
band/{bandId}.png |
| Profession, sub-profession and battle-card icons | AA2 arts/profession_hub, arts/ui/subprofessionicon, ui_battle_new/battlecard |
prof/icon_{p}.png, prof/large_{p}.png, prof/battlecard_{p}.png, prof/sub/{subProfessionId}.png |
| UI sprites (see below) | AA2 ui/autochess/**, arts/**, activity/[uc]act2autochess/**, battle/[pack]common/sprites |
ui/{group}/{key}.png |
The 36 battle emotes and the 19 玩法说明 (tutorial) pages, which tools/local-extract also extracts (GitHub issue #42: without the local client a server showed default emote icons) |
AA2 cn ui/emoticon/theme/[uc]{themeId}/icon/{picId}.png (shared/constants.js EMOTE_CATALOG) and arts/guidebookpages/[pack]autochess/{key}.png (1024², shown at 16:9 like the local copies) |
ui/emoticon/{dir}/{picId}.png, ui/guide/{key}.png |
| Operator battle Spine (Front, Back) | fexli/ArknightsResource spine/{id}/{id}/{Front,Back}/ |
spine/op/{charId}/{front,back}/{stem}.{skel,atlas,png} |
| 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) |
| Original token Spine overlays (40 summons: most 自选 summons, 凯瑟琳's 爬行号·防护单元, 凛御银灰's 风雪之眼, 香槟炸弹) | local-client extraction (tools/local-extract/extract.py TOKEN_SPINES, optional); the original overlay is preferred, with any listed web model retained as fallback |
local/spine/token/{tokenId}/{stem}.* (listed in data/local-assets.json) |
| Official battlefield scenes (one per battlefield: meshes, lightmaps, albedo textures; DESIGN §15, DESIGN §30.21) | the scene bundles scenes/activities/<season>/level_<stage>/… (tools/local-extract/extract.py --only scene, optional). The client carries them only while the season runs; off season tools/local-extract/fetch_scenes.py downloads the bundles of the in-season version 26-04-13-12-31-42_e9b560 from the official CDN into .cache/official-scenes/ (33 bundles, 22,801,308 bytes compressed on the Android list, md5-checked), and the materials / textures still come from the local client's theme bundles. The 0.2.5 full release zip includes the extracted files under public/assets/local/scene/ when the maintainer extracted them before packaging; a lite zip and a source install may fetch and extract them (node tools/setup.mjs --scenes). The repository never stores the bundles or the extracted files. Without a scene the 3D board draws its procedural metal tiles |
local/scene/{stageId}/scene.json, scene.bin, lm{i}.*; local/scene/tex/{texture}.* (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 (Chinese, audio.voice) and voice/{charId}/cn_nn.mp3 (Japanese, audio.voiceJp: the same file names) — the lines charword_table.json lists (placeType = when the game plays one, voiceAsset = the path); `--voice-lang=jp |
en |
| Fonts: Bender Regular and Light, Novecento Wide | TimWangZi/The-font-of-Arknights | public/fonts/*.{otf,ttf,woff2}, public/fonts/fonts.css |
The stem of a Spine model is the upstream file name. Two examples: char_107_liskam has the stem char_107_liskarm, and enemy_9032_aclionk uses enemy_1559_vtlionk. The skel and atlas of a model always share one stem. pixi-spine locates the atlas by swapping the extension, so this matters.
Id scope
- Operators: all 138 pool charIds from
activity_table(charShopChessDatas[*].charId ∪ backupCharId), including hidden chess and backup operators; plus every unit ofdata/backups.jsonresearch 07 does not list — the 72 自选 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; 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 (from05-maps.json; the tutorial is excluded); - the bosses (
boss_1..10); - the closure of their summons (
randomEnemyAttributespawns and blackboardenemy_keyreferences); - every key of
data/enemies.json(built bytools/build-data.mjs, when present), which adds for example the enemies swapped in by 机变 effects; - the bosses' handbook/model ids, such as
enemy_1559_vtlionk; - enemy units spawned by operator kits (research 03 skills/talents). For example, 隐德来希's default S3 summons 心烛
enemy_5601_entlecthrough the talent keytake_extra_enemy_key. 心烛 has no icon and no Spine in any dump, so it has no manifest entry: it is reported as a miss, and the client must draw a glyph.
- Extra tokens: any
token_*key ofdata/tokens.jsonthat research does not list gets the default avatar and Spine locations.- The non-token summons in that file (
enemy_9012_acloon炎佑,char_605_cmedic,char_613_acmedc) are found underenemiesandchars.
- The non-token summons in that file (
- Skill icons: every skill index of every planned operator (the default one first; DESIGN §16 loadouts, the 自选 picks' three skills): 522 icons.
- UI:
- every group from
07-assets.json → autochessUi: rarity, elite and chess-level sprites, the shop panel and cards, HUD, bond board, equip slot, round dialog, band choose, settlement, prepare backdrop; arts(rarity stars, elite icons, the camp logos of pool nations, the loading illustrations used by the act2 modes, battle common sprites, act2 entry backdrops and season logo, item rarity frames);- extras (
tools/assets/plan.mjs UI_EXTRAS): mode choice art, battle-ready backdrops, battle UI (speed, pause, HP slider, attack range, boss avatar frame, skill ready),empty_skill, the 机变 panel and cards, the equip-replace dialog, the bond detail dialog, the prep-ready panel, stage-info titles, the 36 battle emotes (emoticon/{dir}/{picId}) and the 19 玩法说明 pages (guide/{key}) — these two keyed by the group and name ofdata/local-assets.json.
- every group from
Post-processing
- Atlases (research 07 §5.3):
- Insert
size: W,Hright after each page name. fexli atlases omit it; the value is read from the PNG header. An existing size that disagrees with the PNG is corrected. - Ark-Models enemy atlases get
pma: true, because their textures are premultiplied. pixi-spine reads this and setsALPHA_MODES.PMA. - Page names are sanitized to safe file names.
- All of this is idempotent.
- Insert
- Skeletons: every
.skel(Spine 3.8.99 binary) is parsed in Node with@pixi-spine/runtime-3.8, the parser the client ships.- The parse extracts animation names and durations, event names,
OnAttacktimes per animation, and bounds. - Attachment paths are checked against the atlas regions.
- Then the animation-role resolver runs. See
tools/assets/anim-roles.mjsand research 07 §5.4. - A web enemy model whose clip names mislead the resolver gets the roles of its official battle prefab
(
tools/assets/spine.mjs PREFAB_SPINE_ROLES, read from the local client'sbattle/enm_pfb_*.ab: the prefab's anim key → clip table and the clip its Spine starts on): 普通囚犯 / 老练囚犯 start on their grey*3set (【禁锢】), not the unnumbered red one (【解放】) the names point to.
- The parse extracts animation names and durations, event names,
- Fonts: OTF/TTF files are converted to WOFF2 by a built-in encoder (
tools/assets/woff2.mjs: Brotli with null transforms).- Its output was verified lossless against Google's reference
woff2decoder. fonts.csslists WOFF2 first and falls back to the original file.
- Its output was verified lossless against Google's reference
- Images stay PNG. WebP conversion is not done: it would need a native dependency. The local-client board textures are the exception:
tools/local-extract/extract.py(Python, where Pillow is already a dependency) writes WebP copies of the 12 textures the 3D board downloads (WEBP: colour maps lossy at quality 95 with the alpha and the RGB under transparent texels kept, normal and data maps lossless) anddata/local-assets.jsonlists the copies; the PNGs stay beside them fortools/crop-board-atlas.mjs. The 19 local 玩法说明 pages get a WebP copy too (GUIDE_WEBP_MODE, lossy quality 90 since the maintainer's side-by-side look of 2026-10-12: 25.6 MB → 4.2 MB on the CN client's pages; lossless would be 17.4 MB), listed in place of the PNG, which stays beside it; the mirror copies underui/guide/stay PNG.extract.py --webpadds the copies to an existing extraction without the client.
Manifest schema (data/assets.json)
All paths are URL paths relative to the site root, for example /assets/char/avatar/char_002_amiya.png — in the committed file; a server hands them out with a content-hash segment ("The committed manifest and the served manifests" below). Only entries whose files exist on disk are emitted. When a key is missing, the client should use its fallback (research 07 §5.6).
{
version: 1, // schema version
hash: 'a1b2c3d4e5f6', // content hash (cache busting)
generator: 'tools/fetch-assets.mjs',
stats: { files, bytes, chars, charsWithBack, enemies, enemiesWithSpine, tokens, tokensWithSpine,
spineModels, bonds, items, bands, skills, modules, ui, sfxUnits, voiceChars, voiceJpChars },
chars: { [charId]: { avatar, avatarE2?, portrait, portraitE2?, spine: { front: Spine, back?: Spine } } },
enemies: { [enemyId]: { icon, spine?: Spine, spineAliasOf?: enemyId,
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,
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 },
skills: { [iconId]: url }, // iconId = skill_table iconId ?? skillId
skillsById: { [skillId]: iconId },
modules: { [typeIcon]: url }, // module type icon (uniequip typeIcon, e.g. 'sol-x'): public/js/screens/loadout.js
// moduleIconOf after the local-client art
ui: { ['group/key']: url }, // e.g. 'hudPanel/icon_hp', 'shopCard/frame_lv1', 'loading/loading_ac_core';
// 'emoticon/basic/pic_happy_battle', 'guide/autochess_home_1': the data/local-assets.json
// group + name of the same picture (the client takes the local one first)
prof: { icon: {caster…warrior}, large: {…}, battlecard: {…, token}, sub: {[subProfessionId]: url} },
audio: {
bgm: { lobby, prep, combat, combatAlts?: [{ intro?, loop }, …], unite?: { intro?, loop }, boss: { intro?, loop } },
// intro then crossfade to loop (1 s); combatAlts = the 开战 tracks of the mode's own act, chosen by the
// round, not drawn: [0] = m_bat_kazimierz2_1 骑士之日 (rounds 8–13), [1] = m_bat_kazimierz2_2 无畏者
// (rounds 1–7) — audio.js combatTrackFor / bgmKeyFor 'combat:<i>', the same on every client;
// `unite` = 联防's own track — the official escaped_single / escaped_multi levels declare
// `bgmEvent = corrosion` (卡西米尔 act13d5d0), so the rescue phase does not reuse the 作战's track
// (audio.js bgmKeyFor 'unite', falling back to `bgm.combat` for a manifest that lacks it)
bossBgm: { [bossId]: { intro?, loop } }, // per-boss track of its R14/R15 level
voice: { [charId]: { start, faceEnemy, select, place, skill1…skill4,
resultFour, resultThree, resultTwo, resultLose } },
// 干员战斗语音: an operator's official battle lines (charword_table.json placeType → slot,
// tools/assets/audio.mjs VOICE_SLOTS); a slot with several lines is an array and the client
// draws one (public/js/audio.js voice); a slot whose lines are all missing on disk is left
// out (an operator with none has no entry), never an empty array. Only these twelve ever play (DESIGN §21.30): the
// prep-only slots 干员报到 / 编入队伍 / 任命队长 are left out of the plan by default —
// nothing requests them and they cost 360 files (19.3 MB) per run — and `--voice-all` adds
// them (audio.mjs VOICE_PREP_SLOTS) for the complete official set
voiceJp: { [charId]: { …the slots of `voice` } },
// the Japanese dub (0.2.2, the owner's request 「全套的日配语音」): the same slots, lines and file
// names as `voice`, under /assets/audio/voice/jp/ (AA2 `voice/`). The client plays it when the
// player picks 日本語 (settings 语音语言, not tied to the interface language) and falls back to
// `voice` for a slot it lacks or a file the host does not have (public/js/audio.js voiceLine);
// the full zip ships it unless tools/package.mjs FULL_ZIP_JP_VOICE is off
sfx: {
ui: { click, back, confirm, tab, pick, drop, error, buy, sell, income, refresh, freeze, levelup,
merge, equip, itemMerge, bondUp, artPlace, ready, timer, draft, yourTurn, yourTurnCircle,
target, broadcast, danger, emote, roundStart, rest, battleStart, battleStartBoss,
bossRoundTeam, bossRoundSingle, bossRoundSecret, killBoss, killBossAll, killBossNormal,
defenceStart, defenceUnite, battleOverReduce, battleOverNoReduce, battleOverNormal, goFirst,
disconnect, settlementSucceed, settlementFail, settlementTeam, settlementBossSign,
goodEvaluation, load, start, matchSucceed, matchFail, matchCancel, joinRoom },
battle: { deploy, tokenDeploy, charDie, enemyDie, enemyDieHeavy, enemyHit, heal, leak, win, lose, killCoin },
units: { [charId|tokenId|enemyId]: { attack?, hit?, skill?, skills?: {[skillIndex]: url}, attacks?: {[skillIndex]: url}, hits?: {[skillIndex]: url}, die?, born?,
attackMix?: {[skillIndex]: {p?, vol?}}, hitMix?: {[skillIndex]: {p?, vol?}},
mix?: { [attack|hit|die|born]: { p?, vol? } } } }
}
},
// units' mix (tools/assets/audio.mjs bankMix; community report #30): the official bank of a role's sound — `p` = the weight
// of its sounds that have a file over all weights (an empty asset is a chance of silence: 猎狗pro / 深池侦察犬 0.2), `vol` =
// the played file's volume (妖怪 0.7); only values other than 1. public/js/audio.js plays the role with chance p at its
// base gain × min(1, vol)
// units' attack / hit (tools/assets/audio.mjs pickUnitSfx): operators get normal-mode banks only — the plain
// `attack` / `combat` ability first, never a bank holding a skill-mode file (`_d` / `_h` / `_s`; the normal attack's end
// in `_n`) — with their own projectile banks (ON_PROJECTILE_BORN / _HIT.projectile_chr_<name>) as fallbacks
// (DESIGN §18.4: 纯烬艾雅法拉's S3 impact used to be her `hit`); an operator with such a plain bank (its default mode is
// the unsuffixed ability) never takes a numbered variant `attack.N` — a skill mode's, whatever its file name (银灰's S3
// swing p_atk_silver_n, community report of 2026-10-06); one that numbers its default mode (`attack.0` …) keeps the
// numbered order; enemies and tokens take the first attack-like bank
fonts: { css: '/fonts/fonts.css', faces: { [name]: { family, weight, woff2?, original } } }
}
The Spine object
{
skel, atlas, textures: [url], // load with PIXI.Assets.load(skel); atlas/png sit next to it
pma: boolean, // true for enemies (atlas already carries `pma: true`)
anims: Roles, // resolved roles, below
animations: { [name]: seconds }, // every animation with its duration
events: [name], // e.g. ['OnAttack', 'OnStart']
hits: { [animName]: [seconds] }, // OnAttack event times (apply damage / spawn projectile)
bounds: { x, y, width, height } | null // skeleton AABB (setup pose, skeleton units)
}
The enemies' attack clip lengths are also a data input: tools/build-data.mjs copies each enemy model's
anims.attack.loop length and first hits time into data/enemies.json attackAnim (the sim stands an unblocked
ranged enemy for that clip, GitHub #58, and strikes every enemy attack at that frame, 0.2.0; docs/DATA.md §9) — rebuild the data after a manifest change that touches them.
The Roles object
Clip = { begin: string|null, loop: string, end: string|null, via?: 'combat'|'attackAny'|'skill'|'idle'|'attack' }
SkillClip = Clip & { index: number /* 0-based skill index */, idle: string|null /* Skill_n_Idle */ }
Roles = {
idle: string, deploy: string,
attack: Clip, // play begin once, loop per attack, end when stopping; `via:'idle'` ⇒ add a flash
attackDown: Clip|null, // _Down variants (target below the unit)
skill: SkillClip|null, // for the chess's default skill (primary index)
skills?: { [index]: SkillClip }, // when the char is used with several default skills (backups); an enemy: every
// numbered skill clip (Skill_01..04 of 盐风主教昆图斯 — the slot a sim `cast` event names, PR #275)
die: string|null, // null ⇒ a Back model gives way to the Front model's Die (DESIGN §22.1); any other
// skeleton holds its idle's first frame while it fades out
move: Clip|null, // enemies: Move_Begin|Move_Start + Move_Loop|Move + Move_End → Run_*
run?: Clip, // the model's own Run cycle (Run_Begin/Loop/End): an enemy faster than moveSpeed 1 moves on it
stun: Clip|null // Stun | Stun_1 | Dizzy_Loop (+ *_Begin / *_End); null ⇒ freeze the track (timeScale 0)
}
via marks a fallback:
via |
The attack is actually… |
|---|---|
combat |
Combat |
attackAny |
a numbered attack such as Attack_01, possibly framed by Attack_Begin/Attack_End (char_1045_svash2) |
skill |
Skill_1_Loop (pure supporters) |
idle |
Idle (no attack animation at all) |
On a skill clip, via: 'attack' means the model has no skill animation, so the attack clip is reused.
A skill clip may also come from directional-only animations when a model has no undirected ones: for example Skill_Right_Loop (char_279_excu) or Skill_Loop_Up (the Back model of char_431_ashlok).
The resolver's full precedence list is in the header of tools/assets/anim-roles.mjs.
The manifest roles describe a unit's first form. Units whose skeleton holds another form's clip set get it from
public/js/render/units.js FORMS (keyed by Spine id, switched by the form of the sim's 'phase' / 'ember' / 'revive'
/ 'telegraph' / 'stone' / 'liberate' / 'substitute' / 'swap' / 'dollEnd' fx — shared/protocol.js fxForm; no client stage drops these fx: the runner keeps them through
catch-up frames and hidden tabs (keepsState), the game screen's pre-entry buffer (keepEarly) and the render engine's
event queue (render/interp.js isCosmeticEvent) too — or, for a view built mid-battle, UnitInfo form, which render/app/info.js renderInfo passes to the view; a
change clip plays once first, an end clip is timed from the fx's dur to finish as that state ends, keeping the
current form's death clip until the next form's fx). A blocked or revealed 隐匿 enemy is drawn solid: the sim sends the
stealth bit only while its 隐匿 is on — and 假想敌:骨刺 changes model with that bit, not with an fx (STEALTH_FORMS, GitHub
#296, PR #365): the manifest's *_A (the three-headed snake) while the bit is on, its *_B set (form revealed) the
moment it is blocked or revealed, *_A again when its 隐匿 is back; no change clip, a running attack, stun or freeze
pose carried over (render/spine.js syncFormPose). The forms:
- 掠海漂移体's crawl (
Change, then*_02); - 假想敌:骨刺 (
*_A/*_B, above); - 转译基底·α's three forms (
A_Die_B/_C/_D, 2 s each, thenB_*寻仇者,C_*幽灵,D_*特战术师); - the 深池逐火 embers (
Die, thenIdle_2/Move_2/Die_2;Reviveends as it stands up) and 假想敌:再生's puppet (A_Die, thenB_*;B_Revive); - the leaders' 重生: 锏 (
Revive1,Revive2held,Revive3, thenB_*), 扎罗 (A_revive_1/_2/_3, thenB_*), “复仇者” (Revive_Begin/_Loop/_End), 杰斯顿 (C1_Die, thenC2_*); - 守墓石像 (the statue on
Sleep[ASSUMED by name], then the flyer's*_2). - the 孤岛风云 prisoners, as their official prefabs' modes: confined on the manifest's grey set,
warning(mode R, the warning before the last confined attack) on the blinking orange set,liberty(【解放】) on the red set — 普通囚犯 / 老练囚犯Idle3→Idle2→Idle, 强壮囚犯Idle→Idle2→Idle3, 拳师囚犯 / 重犯 / 传奇重犯*_grey→*_orange→*_red(Move / Attack / Die alike; no change clip). - the 傀儡师 operators' <替身> (form
dollof the sim's 'substitute' / 'swap' / 'dollEnd' fx, DESIGN §22.11; the*_Bclips draw the 替身's own slots and hide the 本体's): 归溟幽灵鲨Start_B(it fades in),Idle_B,Die_Bover its last second (it breaks apart and fades),Die_B_2when it is knocked out (it collapses), and the 本体 back onStart_2(a form'sleaveclip); 风丸Start_B,Idle_B/Attack_B/Die_B, the 本体 back onStart. Facing up, 归溟幽灵鲨's Back skeleton has onlyIdle_BandStart_2, 风丸'sStart_B,Idle_BandAttack_B: the missing clips are skipped. Neither Back skeleton has the 替身's death clip, so a 替身 knocked out lies on the Front model (like every knocked-out operator facing up, DESIGN §22.1) with itsDie_B_2/Die_B: the view keeps the form it died in for that model and for one rebuilt while it lies down, although the sim resets the form right after the knock-out.
Not mapped (clip names ambiguous): “自在”, “巨大的丑东西”, 主角阵营角色 and “余音” (*_A / *_B: which of its two forms is A
is not known) keep their manifest clips.
Other renderer rules from research 07 §5.4–5.5:
- Choosing the model: Front when the unit faces right or down; Front mirrored when facing left; Back when facing up — while it stands: a dead or knocked-out operator falls and lies with the Front model unless its Back skeleton has a Die clip of its own (131 of the 135 have none; DESIGN §22.1, GitHub issue #25).
- Attack speed: set the attack
timeScaletoduration / attackInterval. - Model size: every skeleton is drawn at one
UNIT.modelScale(render/style.js, 320 skeleton units per tile), which stands for the official standard. The official client also scales each enemy model in its battle prefab: the Graphic / FaceSwitcher / Spine transforms multiply to 0.27 for most enemies and for the operators' battle skins, but not for all of them. For example, 威龙 is 0.16, 妖怪 0.20 and 青铜镜 0.6. The skeletons themselves carry no such scale, because every enemy SkeletonDataAsset uses 0.01. So an enemy is drawn × data/enemies.jsonmodelScale(its prefab's product ÷ 0.27, see docs/DATA.md; user playtest #6: 威龙 used to be drawn 1.35× a 妖怪 instead of 1.08×), and its HP bar sits on that model: at its setup-pose bounds' height × the same factors, or, for a skeleton without bounds, at the chibi headroom ×modelScale(bosses 2.2 tiles).tools/local-extract/enemy_scales.pyreads the products from a local client, andtools/build-data.mjs MODEL_SCALESkeeps them. Two prefab quirks on top (PR #211 by @xcdoge; the owner's decision of 2026-10-06; docs/research/12 §3.1): the two 帝国炮火先兆者 are stretched vertically (modelScaleY1.263: their Graphic scale is (0.19, 0.24, 0.24)) and 木制瑞印 is mirrored (mirrorX: a negative Graphic X scale) —tools/local-extract/enemy_model_offsets.pyreads them,tools/build-data.mjs MODEL_STRETCH_Y/MIRRORED_PREFABSkeep them. - Flying units hover
FLY_HOVER= 1.3 tiles up (render/units.js; the client's single fly offset 0.35 in its character space, whose unit is the standard prefab scale 0.27 — docs/research/12) — an enemy flyer above the road whatever tile it crosses (a high-ground or forbidden block under it is no step, GitHub #277), an operator or summon above its tile: the body, its HP bar, damage numbers and hits ride the lift, the shadow stays on the ground tile (the block top under an enemy flyer crossing one), on the 2D and the 3D board alike (one camera drives both). - Enemy aliases:
enemies[id].spineAliasOfmeans the model belongs to another enemy. Two cases:_2variants whose official prefab is the base one (鸭爵, 高普尼克, 流泪小子, 圆仔, 假想敌:胄, 假想敌:铳): the base model, as in the game.- an enemy whose own model no dump carries: 灼热源石虫 / 炽焰源石虫 (
enemy_1305_mhslim/_2) use the plain 源石虫 on the web (plan.mjs ENEMY_SPINE_ALIAS). The reason is upstream: isHarryh/Ark-Models indexes1305_mhslim/1305_mhslim_2but with an emptyassetList— registered, never uploaded — soarkModel()finds no files for them and the alias chain drops toenemy_1007_slime, a different enemy rather than a variant of it, which is why the renderer tints that alias toward the slug's own colours. Their official skeletons only exist in the client's enemy art bundles (refs/arts/enm_art_*.ab); the mobile build also ships them asenemy_spine/<enemyId>/<enemyId>.{skel,atlas,png}(straight-alpha pages: nopma: trueline), but its only public mirror is a community wiki rather than a GitHub dump, so it is deliberately not an asset source — a third-party site is not added tosources.mjsfor two models, and the tinted alias stays the web model until a GitHub dump carries them. The same official model from the local client is the optional overlay,enemies[id].spineLocal={ group, skel, atlas, textures, pma, anims, animations, events, hits, bounds }(file names in thedata/local-assets.jsongroupspine/enemy/{enemyId}; the rest as aspineentry):tools/local-extract/extract.pywrites the model topublic/assets/local/spine/enemy/{enemyId}/(page textures with their[alpha]texture merged in: premultiplied RGB + A like Ark-Models; the atlas getssize:andpma: true) and lists its files indata/local-assets.json.- The metadata comes from the committed
tools/assets/local-enemy-spines.json(fetch-assets --local-spinesparses the extracted models into it), never from the disk:data/assets.jsonis byte-identical with or without the extraction, it has no/assets/local/URL, and setup / doctor / the manifest tests never miss these files. - The client (
assets.js spineEntry, withassets.local(), whichcreateFieldViewawaits with the manifest) draws the official model when the local manifest lists every file of it; otherwise, or when it fails to load (UnitView: the entry'sfallback), the web alias, tinted toward the slug's own lava colours (render/units.js ALIAS_TINT: 灼热 orange, 炽焰 red-orange; research 07 §5.6 "a hue shift", [ASSUMED] look) so a source install without the extraction still tells them from the plain 源石虫. A release bundle carries the models only when it is zipped from a checkout where the extraction ran with thespine/enemyjob (an extraction made with 0.1.0 lacks it:node tools/setup.mjs --localagain, then check thatdata/local-assets.jsonlistsspine/enemy/enemy_1305_mhslimandspine/enemy/enemy_1305_mhslim_2). User feedback after 0.1.0 (D3: "所有特殊源石虫的模型全表现为普通源石虫"): the ELEMENT faction spawns up to ten of them a round. A 2026-10-03 audit of every enemy ofdata/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 itsFullskin (five passengers) in the game and is drawn with thedefaultone.
- 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 lacksbtl_pfb_tokens_0and has ASTC pages), the same overlay as the enemies above:tools/local-extract/extract.py TOKEN_SPINES(40 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 topublic/assets/local/spine/token/{tokenId}/like an enemy model (skeleton, sizedpma: trueatlas, 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 committedtools/assets/local-token-spines.json(fetch-assets --local-spines), never from the disk;assets.js spineEntrydraws the model whendata/local-assets.jsonlists every file of it, else (or when it fails to load) the avatar diamond as before. The models are drawn like the web tokens: oneUNIT.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 sC_Defaultpose), 酒神's 本能的召唤 (Loop/End) and 白铁's 多功能平台 (Endas it goes) [ASSUMED: by the clip names], and 凯尔希·思衡托's 战术锚点, whose Start / Idle / Die clips hide its only attachment: it stays on itsDefaultpose (a white anchor mark) [ASSUMED]. - Not extracted: the tokens whose prefab draws nothing (an
EmptyAnimatorinstead 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: agit pulland 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 40 token models above) is not downloaded: the client looks it up indata/local-assets.jsononly (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].avatarwith a 召唤物 badge, orprof.battlecard.token— except 圣聆初雪's 保护目标(冻结状态) (PRTS 无头像; the frozen gate), drawn as a procedural ice diamond (render/units.js ICE_TOKENS). - Without a Spine (and without its local-client model, "Token models from the local client"), draw the avatar sprite with a bob tween.
spineVariantnames the skin-variant model that stands in for the missing default model.
- Without an avatar, use
- Enemies without a spine (for example
enemy_9016_acstmr): drawiconin 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.abholds per-weapon projectile sprites (projectile_arrow(_new),projectile_crossbow(_new),projectile_yuki,img_fx_light_01/02,trail_11), and the per-characterbattle/prefabs/effects/*.abare particle systems whose textures live in other bundles — but none of it is extracted: the sim'sarrowalso covers gun snipers, and friends joining a game may not have the local art. - Spine memory (
public/js/assets.js): skeletons are refcounted in an LRU with an idle budget (SPINE_IDLE_BYTES, 48 MB) and a 15 s grace; eviction runsSPINE_EVICT_DELAY_MS(1 s) after a release, the "no scene on screen" budget (0 bytes) applies afterSPINE_QUIET_DELAY_MS(3 s) with nothing referenced, and a skeleton whose unload is still in flight is never handed out again — a new load waits for the unload (DESIGN §17.1; it used to leave operators invisible after a battle → prep switch).
Looking up data/chess.json asset ids
data/chess.json stores asset ids in chess[*].assets (docs/DATA.md). They map onto this manifest as follows (checked for all 258 chess):
assets field |
Example id | Manifest URL |
|---|---|---|
avatar |
char_498_inside / char_498_inside_2 |
chars[id].avatar / chars[id minus "_2"].avatarE2 |
portrait |
char_498_inside_1 / char_498_inside_2 |
chars[id minus "_1"].portrait / chars[id minus "_2"].portraitE2 |
spine |
char_498_inside |
chars[id].spine.front (and .back) |
skillIcon |
skchr_inside_2 |
skills[id] (the id is the skill's iconId) |
subProfIcon |
sub_fastshot_icon |
prof.sub[id minus "sub_" and "_icon"] |
- Missing unit SFX: use
audio.sfx.battle.enemyHitor a WebAudio blip (research 07 §6.4). - Bond icons: if the real glyph ever fails, the stored file is the nation camp logo.
The committed manifest and the served manifests
data/assets.json in git (and as tools/fetch-assets.mjs writes it) and data/local-assets.json (as
tools/local-extract writes it) hold plain URLs. Since 0.2.6 (host bandwidth, layer 1) the server hands out a view of
each (server/http/assetIndex.js, server/http/assetViews.js):
- Shortly after the start (1 s) the server hashes every file under
public/assets/andpublic/fonts/in the background: SHA-256, the first 16 hex digits. The hashes are kept in.cache/asset-hashes.jsonby path + size + mtime, so a restart only stats the files and reads none of the unchanged ones (12,691 files / 724 MB on the maintainer's machine: 1.3 s for the first build, 0.25 s after that). A file whose size or mtime changed is hashed again; a re-extraction,setup --forceor a Docker rebuild then costs the players only the files whose bytes changed. GET /data/assets.jsonandGET /data/local-assets.jsonanswer with every art URL carrying its file's hash as the first segment:/assets/char/avatar/char_003_kalts.png→/assets/~398f32e2ff78d9e6/char/avatar/char_003_kalts.png,/fonts/~<hash>/…. Audio keeps its/assets/~<hash>/audio/….mp3form;public/js/media.jsasks for it as/media/~<hash>/…(the download-manager route,shared/media.js).- A Spine model's skeleton, atlas and pages carry one hash, the model's (SHA-256 of the files' hashes): pixi-spine finds
the atlas by the skeleton's name and the pages next to the atlas, so the URLs share a folder. In
data/assets.jsonevery{ skel, atlas, textures }object is a model; indata/local-assets.jsoneveryspine/…group (the files aspineLocalrecord names). - The server strips the segment and serves the file at the plain path. When the segment names the file's current bytes
(its own hash, or its model's) the answer is
Cache-Control: public, max-age=31536000, immutable; any other segment — a page loaded before an update, a URL derived from another file's — gets the current file withno-cache, so an old page keeps working. The ETag of every indexed file is its content hash ("398f32e2ff78d9e6", plus-br/-gzwhen compressed), on the plain URL too. - A path segment, never
?v=:assets.js validSpinematches/\.skel$/,audio.js SKILL_MODE_FILEthe.mp3ending, pixi-spine takes the atlas name from the skeleton's basename and resolves the pages against the atlas's folder — a query string survives none of that. - A URL the index has no hash for (the file is missing, or not hashed yet) stays plain; plain URLs work as before
(
public, max-age=86400, revalidated after a day). - Until the index is ready, with
SP_ASSET_INDEX=0, or on a static host without this server, the manifests go out as committed. The views are rebuilt in memory when a manifest or the hashes change; neither file is ever written by the server, and thehashfield is the committed one. The index lives in.cache/only and is never packaged.
Verification
node --test test/assets.test.js covers the pure helpers: the resolver, the atlas normalizer, the format sniffers, WOFF2, audio banks, the plan id sets, the downloader against a fake network, and the self-heal of corrupt skeletons.
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, pluspma: truefor enemies. - Every Spine model loads the way the client loads it — the local-client enemy and token models too, when
data/local-assets.jsonlists them. That means pixi-spine's atlas reader with real page sizes, where a region outside its page throws, thenSkeletonBinarywithAtlasAttachmentLoader, 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.
- Audio: all 467 MP3s decode with ffmpeg without errors.
- Browser: headless Chrome loads and animates all 529 Spine models with the vendored PixiJS 7.4.2 and pixi-spine 4.0.6, with no console errors. Chrome's font sanitizer also accepts the three WOFF2 files.
- Official data: 771 provenance checks against the official data (
activity_table,skill_table,models_data.json) all match: avatars, portraits, E2 art, bond, band and item icons, and enemy skeleton files.
Licensing and credits
The project's code is GPL-3.0-or-later (LICENSE); none of the items below is covered by it. Details: NOTICE.md (scope, non-commercial terms) and THIRD-PARTY-NOTICES.md (libraries, fonts, licence texts).
- Game assets. All images, Spine models, audio and game data are © Hypergryph (上海鹰角网络). The overseas publisher is Yostar.
- This is an unofficial, non-commercial fan project: no ads, donations or paywall.
- Assets are fetched from public community dumps at install time and are not redistributed in this repository. The plug-and-play bundle attached to a GitHub release does carry them (with the local-client art) under the same non-commercial terms, with
NOTICE.mdinside. - Assets will be removed on request from the rights holders.
- The client must show a credits screen: "Arknights © Hypergryph / Yostar. This is an unofficial fan project; all game assets belong to their owners."
- Asset dumps and tools. Credit to:
- yuanyan3060/ArknightsGameResource
- fexli/ArknightsResource (ArkResourceAutoUpdateBot)
- isHarryh/Ark-Models (ArkUnpacker). Not for commercial use.
- ArknightsAssets/ArknightsAssets2 (ArknightsStudio)
- Kengxxiao/ArknightsGameData
- Spine runtime. pixi-spine is MIT-licensed. It embeds Spine Runtime code under the Spine Runtimes License, which formally expects a Spine Editor licence. This is commonly tolerated for non-commercial fan tools, and the Spine Runtimes License must be credited (
THIRD-PARTY-NOTICES.md). The project's GPL carries an additional permission (section 7) to combine it with the Spine Runtimes (NOTICE.md). - Fonts.
- Bender: © Jovanny Lemonad (Oleg Zhuravlev, Ivan Gladkikh). Free for personal and commercial use.
- Novecento Wide: © Jan Tonellato / Synthview. Free licence.
- Both are mirrored from TimWangZi/The-font-of-Arknights.
- Noto Sans SC and Noto Serif SC (SIL OFL) are loaded from Google Fonts, not self-hosted.
Skill attack banks (0.2.3, PR #410)
pickModeAttacks / pickModeHits select a uniform d/h/s bank from the official audio table (attack/combat abilities
only). A skill's activation sound supplies its mode letter; without one, d/h/s = slots 1/2/3 is [ASSUMED]. Competing
banks prefer START over ON, then plain/numeric order; this is a deterministic approximation, not proof of every
operator's runtime ability graph. Mixed-letter banks are skipped. Preserve mixOf for each selected bank separately;
attacks/hits and attackMix/hitMix use matching skill keys. Regenerate the current manifest instead of importing
an older PR's full JSON, preserving JP voices and current operators. See DESIGN §28.17.
The 40th local token model is the original champagne bomb (token_10031_swire2_gdtrap, §29.5), extracted from the official btl_pfb_tokens_0.ab through TOKEN_SPINES. Its web fallback is retained. Failed board-pack/image requests can retry after connectivity/visibility recovers; a failed three.js module gets at most two fresh-URL retries per page because browser module failures are cached. Successful resources remain cached.