# Assets: sources, layout, manifest, credits Owner: `tools/fetch-assets.mjs` and `tools/assets/*`. Research background: `docs/research/07-assets.md`. All art, Spine models and audio are **downloaded at install time**. They are never committed; `public/assets/` is git-ignored. Everything the client needs is listed in **`data/assets.json`**. The client should only request URLs that appear in that manifest. ## Running ```bash 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 two upstream indexes: `audio_data.json` and `models_data.json`. | | `--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. | 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.com` fails, the jsDelivr mirror (`cdn.jsdelivr.net/gh/…`) is tried. - There is no mirror for the ArknightsAssets2 `voice` branch, 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 `downloadErrors` in 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 **242 MiB in about 3,700 files**. It took 134 s on a ~3 MB/s link. A re-run takes about 1 s. Outputs: - `data/assets.json`: the manifest (committed). - `public/assets/**`: art and audio (git-ignored). - `public/fonts/*`: fonts and `fonts.css`. - `.cache/assets-report.json`: misses, fallbacks 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. Upstream indexes are cached under `.cache/`. They are downloaded when missing: - `.cache/gamedata/excel/audio_data.json`, from `Kengxxiao/ArknightsGameData` (zh_CN). - `.cache/ark-models/models_data.json`, from `isHarryh/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` | | Default-skill icons, including backup operators' skills | yuanyan `skill/` | `skill/{iconId sanitized}.png` | | 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` | | 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}.*` | | BGM | AA2 `voice` branch `audio/sound_beta_2/music/**` | `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` | | 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. - **Tokens:** the 20 pool tokens. - **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); - the bosses (`boss_1..10`); - the closure of their summons (`randomEnemyAttribute` spawns and blackboard `enemy_key` references); - every key of `data/enemies.json` (built by `tools/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_entlec` through the talent key `take_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 of `data/tokens.json` that 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 under `enemies` and `chars`. - **Skill icons:** the default skill of every chess (`defaultSkillIndex`), plus each backup operator's `skillIndex`. That makes 144 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: 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. ## Post-processing - **Atlases** (research 07 §5.3): - Insert `size: W,H` right 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 sets `ALPHA_MODES.PMA`. - Page names are sanitized to safe file names. - All of this is idempotent. - **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, `OnAttack` times per animation, and bounds. - Attachment paths are checked against the atlas regions. - Then the animation-role resolver runs. See `tools/assets/anim-roles.mjs` and research 07 §5.4. - **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 `woff2` decoder. - `fonts.css` lists WOFF2 first and falls back to the original file. - Images stay PNG. WebP conversion is not done: it would need a native dependency. ## Manifest schema (`data/assets.json`) All paths are URL paths relative to the site root, for example `/assets/char/avatar/char_002_amiya.png`. **Only entries whose files exist on disk are emitted.** When a key is missing, the client should use its fallback (research 07 §5.6). ```js { 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, ui, sfxUnits }, chars: { [charId]: { avatar, avatarE2?, portrait, portraitE2?, spine: { front: Spine, back?: Spine } } }, enemies: { [enemyId]: { icon, spine?: Spine, spineAliasOf?: enemyId } }, tokens: { [tokenId]: { owner: charId|null, avatar?, spine?: Spine, spineVariant?: string } }, 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 }, ui: { ['group/key']: url }, // e.g. 'hudPanel/icon_hp', 'shopCard/frame_lv1', 'loading/loading_ac_core' prof: { icon: {caster…warrior}, large: {…}, battlecard: {…, token}, sub: {[subProfessionId]: url} }, audio: { bgm: { lobby, prep, combat, boss: { intro?, loop } }, // intro then crossfade to loop (1 s) bossBgm: { [bossId]: { intro?, loop } }, // per-boss track of its R14/R15 level 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, win, lose, killCoin }, units: { [charId|tokenId|enemyId]: { attack?, hit?, skill?, skills?: {[skillIndex]: url}, die?, born? } } } }, // 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_) as fallbacks // (DESIGN §18.4: 纯烬艾雅法拉's S3 impact used to be her `hit`); enemies and tokens take the first attack-like bank fonts: { css: '/fonts/fonts.css', faces: { [name]: { family, weight, woff2?, original } } } } ``` ### The `Spine` object ```js { 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 `Roles` object ```js 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) die: string|null, // null ⇒ use the Front model's Die (Back lacks it) or fade out 0.5 s move: Clip|null, // enemies: Move_Begin|Move_Start + Move_Loop|Move + Move_End → Run_* stun: Clip|null // 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`. 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. - **Attack speed:** set the attack `timeScale` to `duration / attackInterval`. - **Enemy aliases:** `enemies[id].spineAliasOf` means the model belongs to another enemy. Two cases: - `enemy_1305_mhslim` has no Spine anywhere, so it uses the slime model. Tint it. - `_2` boss variants reuse the base model. ### Other fallbacks - **Tokens:** - Without an avatar, use `chars[owner].avatar` with a 召唤物 badge, or `prof.battlecard.token`. - Without a Spine, 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. - **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 runs `SPINE_EVICT_DELAY_MS` (1 s) after a release, the "no scene on screen" budget (0 bytes) applies after `SPINE_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.enemyHit` or a WebAudio blip (research 07 §6.4). - **Bond icons:** if the real glyph ever fails, the stored file is the nation camp logo. ## 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, 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. 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 - **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. - 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](https://github.com/yuanyan3060/ArknightsGameResource) - [fexli/ArknightsResource](https://github.com/fexli/ArknightsResource) (ArkResourceAutoUpdateBot) - [isHarryh/Ark-Models](https://github.com/isHarryh/Ark-Models) (ArkUnpacker). Not for commercial use. - [ArknightsAssets/ArknightsAssets2](https://github.com/ArknightsAssets/ArknightsAssets2) (ArknightsStudio) - [Kengxxiao/ArknightsGameData](https://github.com/Kengxxiao/ArknightsGameData) - **Spine runtime.** pixi-spine is MIT-licensed. It embeds Spine Runtime code under the [Spine Runtimes License](http://esotericsoftware.com/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. - **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.