22 KiB
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
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.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 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 andfonts.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, 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 |
| 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 (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: the default skill of every chess (
defaultSkillIndex), plus each backup operator'sskillIndex. 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.
- 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.
- 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.
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).
{
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_<name>) 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
{
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
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
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. - Enemy aliases:
enemies[id].spineAliasOfmeans the model belongs to another enemy. Two cases:enemy_1305_mhslimhas no Spine anywhere, so it uses the slime model. Tint it._2boss variants reuse the base model.
Other fallbacks
- Tokens:
- Without an avatar, use
chars[owner].avatarwith a 召唤物 badge, orprof.battlecard.token. - Without a Spine, 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.
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. That means pixi-spine's atlas reader with real page sizes, where a region outside its page throws, then
SkeletonBinarywithAtlasAttachmentLoader, 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
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.