Files
sganggs f3658a0dd4 0.2.3: prepare follow-up fixes and client features (#407, #408, #410, #413, #431, #435, #436, #440, #444)
Add special-target activation without bypassing attack cadence, cooperative
card preferences and emotes, skill sound banks, per-operator voice choices,
text sizing, install prompts, closed-tab recovery and unanimous setup rerolls.
Retain late deployment elapsed time and resolve echo/counter pulses without
recursive hook overflow. Independent final review remains pending.

Golden: roster-016, roster-035, roster-036, roster-045;
boss-boss_1-pair, boss-boss_3-pair, boss-boss_5-pair, boss-boss_7-pair,
hidden-boss_9-pair; solo-HARD-9-boosted: Lemuen attack cadence.
diy-132: Necras extra activation targets.
coop2-NORMAL-14-standins: cooperative purchase preference and simulation fixes.
hidden-boss_10-solo: echo pulse FIFO resolution.
See DESIGN 28.13-28.24 for assumptions, sources and contributor credit.
2026-10-09 22:55:24 +08:00

698 lines
42 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Builds the asset plan: which files the game needs, where they come from and
// where they go, as a *manifest template* that mirrors data/assets.json.
//
// Template leaves:
// { alts: [{ rel, urls[], kind, bytes? }, …] } one file; the first alternative
// that ends up on disk wins (later alts are fallbacks: other URLs for the
// same file, or other sounds of the same bank);
// { model: '<key>' } a Spine model (skel + atlas + page PNGs) from plan.models;
// literal(value) a value emitted as it is (no files: enemies[id] / tokens[id].spineLocal).
// Inputs are the research JSONs (docs/research/03, 05, 07), the official
// audio_data.json and Ark-Models' models_data.json.
//
// Scope (research 07 §1, DESIGN §0): all 138 pool charIds (incl. backup
// operators), the 自选 owned-6★ picks of data/backups.json (`extraOperators`:
// research 07 does not list them — their URLs follow its patterns) and their
// summons, the module type icons of every module the data offers
// (`moduleTypes`), the 20 pool tokens, every enemy that can appear in an
// act2autochess match (07 enemy list ∪ act1autochess wave/boss levels used by
// act2 modes ∪ bosses ∪ their summons ∪ enemy units spawned by operator kits),
// the 23 bonds, 59 shop items, 40 bands,
// default-skill icons, profession icons, autochess UI sprites (the 36 battle
// emotes and the 19 玩法说明 pages included: UI_EXTRAS), BGM and SFX.
import { RAW, joinUrl, safeName, urlBase, urlDir } from './sources.mjs';
import { kindOf } from './formats.mjs';
import { pickUnitSfx, pickModeAttacks, pickModeHits, skillModeLetter, SLOT_MODE_LETTER, UI_SFX, BATTLE_SFX, resolveSpec, indexVoice, VOICE_DIRS, VOICE_BATTLE_SLOTS } from './audio.mjs';
import { literal } from './manifest.mjs';
import { EMOTE_CATALOG } from '../../shared/constants.js';
/**
* The voice slots are read from the zh_CN charword table, whose voiceIds are always `CN_*`: the other dubs
* (`--voice-lang=jp|en|kr`, the JP tree `audio.voiceJp`) share the very same slot numbering and file names, only the
* dump folder differs.
*/
const VOICE_ID_LANG = 'CN';
/**
* The second voice tree, `audio.voiceJp`: the Japanese dub (ArknightsAssets2 `voice/`, audio.mjs VOICE_DIRS.jp) of the
* same slots and lines as `audio.voice`, under `audio/voice/jp/`. The client plays it when the player picks 日本語
* (settings 语音语言) and falls back to `audio.voice` for a line it lacks (public/js/audio.js voiceLine). The owner's
* request of 2026-10-08 「全套的日配语音」: the JP dub of every line the game plays, beside the Chinese one.
*/
export const VOICE_JP_LANG = 'jp';
/**
* Enemies whose Spine no community dump carries: the web model is another enemy's (research 07 §5.6). Their official
* models come from the local client only (tools/local-extract ENEMY_SPINES): `localEnemySpines` adds them as the
* optional `spineLocal` overlay, which the client draws when data/local-assets.json lists its files (user feedback
* after 0.1.0, D3: 灼热源石虫 / 炽焰源石虫 were drawn as the plain 源石虫 everywhere).
*
* Why no dump carries them: isHarryh/Ark-Models *indexes* `1305_mhslim` / `1305_mhslim_2` but with an EMPTY
* `assetList` — registered, never uploaded — so `arkModel()` returns nothing for them and the alias chain below falls
* back to `enemy_1007_slime`: a different enemy rather than a variant of it, which is why the renderer tints it
* (render/units.js ALIAS_TINT). The *mobile* build does ship their own model
* (`enemy_spine/<enemyId>/<enemyId>.{skel,atlas,png}`), but the only public mirror of that build is a community wiki,
* not a GitHub dump, so downloading from it would add a source the project deliberately does not use (docs/ASSETS.md
* "Enemy aliases"). The tinted alias therefore stays the web model until a GitHub dump carries these two.
*/
export const ENEMY_SPINE_ALIAS = Object.freeze({
enemy_1305_mhslim: 'enemy_1007_slime',
enemy_1305_mhslim_2: 'enemy_1007_slime',
});
/** Loading illustrations referenced by act2autochess modeDataDict (non-training). */
const LOADING_USED = new Set(['loading_ac_core', 'loading_ac_prototype', 'loading_ac_hard', 'loading_ac_abyss']);
const PROFESSIONS = ['caster', 'medic', 'pioneer', 'sniper', 'special', 'support', 'tank', 'warrior'];
/**
* The 19 official 玩法说明 (tutorial) pages, in the reading order of public/js/ui/guide.js GUIDE_CHAPTERS (a test keeps
* the two identical): 基础规则 home 1–9, 调度手册 shop 1–6, 进阶图鉴 handbook 1–4.
*/
export const GUIDE_PAGES = Object.freeze([
...[1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => `autochess_home_${i}`),
...[1, 2, 3, 4, 5, 6].map((i) => `autochess_shop_${i}`),
...[1, 2, 3, 4].map((i) => `autochess_handbook_${i}`),
]);
/**
* Extra UI sprites (not in 07-assets.json groups): [group, key, path under ArknightsAssets2 cn assets/dyn].
* The last two blocks are art that used to come from the local client only (tools/local-extract, DESIGN §13) and that
* the mirror carries too (GitHub issue #42: a server without the client showed default emote icons): the 36 battle
* emotes (shared/constants.js EMOTE_CATALOG; the bundle ui/emoticon/theme/[uc]<themeId>.ab → its icon/<picId>.png) and
* the 19 玩法说明 pages (arts/guidebookpages/[pack]autochess.ab, 1024² like the local copies: displayed at 16:9). Their
* manifest keys are the data/local-assets.json group and name — ui['emoticon/<dir>/<picId>'], ui['guide/<key>'] — so
* the client looks both up by the same names (public/js/data.js artUrls: the local file first, then this copy).
*/
export const UI_EXTRAS = (() => {
const L = [];
const mc = 'ui/autochess/[uc]autochessouter/modechoice/auto_chess_mode_choice_state/';
for (const m of ['normal', 'hard', 'abyss', 'funny']) L.push(['modeChoice', `${m}_rhodes_island`, `${mc}${m}_rhodes_island.png`]);
const br = 'ui/autochess/[uc]autochessouter/battleready/auto_chess_battle_ready_state/';
for (const k of ['bg_mountain1', 'bg_mountain2', 'bg_terrain', 'bg_ring', 'rhodes', 'rhodes_white', 'rhodes_outline']) L.push(['battleReady', k, `${br}${k}.png`]);
const bn = 'arts/ui/[uc]battlecommon/ui_battle_new/';
for (const k of ['btn_speed_1x', 'btn_speed_2x', 'btn_pause', 'slider_hp_back', 'slider_hp_fill', 'attack_range_attack', 'attack_range_stand']) L.push(['battleUi', k, `${bn}${k}.png`]);
L.push(['battleUi', 'boss_avatar_bg', `${bn}enemybossinfo/sprite_enemy_boss_avatar_bg.png`]);
L.push(['battleUi', 'skill_ready', 'arts/ui/[uc]battlecommon/ui_battle/sprite_skill_ready.png']);
L.push(['skillIcon', 'empty', 'arts/ui/[uc]charcommon/skills/empty_skill.png']);
L.push(['skillIcon', 'empty_large', 'arts/ui/[uc]charcommon/skills/empty_skill_large.png']);
L.push(['entry', 'season_logo_settle', 'activity/[uc]act2autochess/arts/seasonlogo/season_logo_settle_game.png']);
const ec = 'ui/autochess/[uc]autochessbattle/effectchoose/';
for (const k of ['bg_circle', 'bg_grad', 'bottom', 'deco_glow_top', 'plus', 'square_1', 'square_2', 'square_fill_1', 'square_fill_2', 'tip_glow', 'tip_wait', 'title']) {
L.push(['effectChoose', k, `${ec}autochess_battle_effect_choose_panel/${k}.png`]);
}
for (const k of ['bg_normal', 'bg_selected', 'btn_bg', 'btn_icon', 'select_frame', 'select_glow']) L.push(['effectChoose', `card_${k}`, `${ec}card_small_item/${k}.png`]);
for (const k of ['head_bg', 'head_outline']) L.push(['effectChoose', `avatar_${k}`, `${ec}card_avatar_item/${k}.png`]);
const er = 'ui/autochess/[uc]autochessbattle/equipreplace/equip_replace_dialog/equip_replace_';
for (const k of ['bg', 'arrow_head', 'arrow_line', 'avatart_bg', 'avatart_frame', 'close', 'replace_icon', 'opiton_bg']) L.push(['equipReplace', k, `${er}${k}.png`]);
const bd = 'ui/autochess/[uc]autochessbattle/hud/dialog/autochess_hud_bond_detail_dialog/';
for (const k of ['active_icon', 'active_stack_bg', 'arrow', 'detail_bg', 'matte_circle', 'title_char_icon']) L.push(['bondDetail', k, `${bd}${k}.png`]);
const pr = 'ui/autochess/[uc]autochessbattle/prepready/';
for (const k of ['cancel_bg', 'cancel_icon', 'player_bg', 'player_frame', 'player_ready', 'ready_bg', 'ready_frame', 'ready_icon']) L.push(['prepReady', k, `${pr}panel_prep_ready/${k}.png`]);
L.push(['prepReady', 'countdown_arrow', `${pr}countdown_arrow.png`]);
const si = 'ui/autochess/[uc]autochessouter/stageinfo/auto_chess_stage_info_state/';
for (const k of ['img_title_mode_abyss', 'img_title_mode_funny', 'img_title_mode_hard', 'img_title_mode_normal', 'btn_confirm', 'btn_confirmed']) L.push(['stageInfo', k, `${si}${k}.png`]);
for (const e of EMOTE_CATALOG) L.push([`emoticon/${e.dir}`, e.picId, `ui/emoticon/theme/[uc]${e.themeId}/icon/${e.picId}.png`]);
for (const k of GUIDE_PAGES) L.push(['guide', k, `arts/guidebookpages/[pack]autochess/${k}.png`]);
return Object.freeze(L.map((x) => Object.freeze(x)));
})();
/** Renames of research 07 `arts` groups to manifest UI groups. */
const ARTS_GROUPS = Object.freeze({
rarityStars: 'rarity', rarityStarsYellow: 'rarityYellow', eliteIcon: 'elite', eliteIconLarge: 'eliteLarge',
campLogo: 'campLogo', loadingIllust: 'loading', battleCommon: 'battle', act2Entry: 'entry', itemRarityBg: 'itemRarity',
});
// ---------------------------------------------------------------------------
// helpers
/**
* One download alternative.
* @param {string} rel path under public/assets
* @param {string|string[]} urls
* @param {number} [bytes] expected size
*/
function alt(rel, urls, bytes) {
const a = { rel, urls: [].concat(urls).filter((u) => typeof u === 'string' && u), kind: kindOf(rel) };
if (Number.isInteger(bytes) && bytes > 0) a.bytes = bytes;
return a;
}
/** A leaf with the given alternatives (null alternatives are dropped). */
function leaf(...alts) {
const list = alts.flat().filter((a) => a && a.urls && a.urls.length);
return list.length ? { alts: list } : null;
}
/** One operator voice line: charword voiceAsset ('char_263_skadi/CN_023') → `audio/voice/<lang>/<charId>/cn_023.mp3`. */
function voiceAlt(asset, lang) {
const [charId, voiceId] = String(asset).split('/');
if (!charId || !voiceId || !/^[a-z0-9_]+$/i.test(charId) || !/^[a-z]{2}_\d+$/i.test(voiceId)) return null;
const file = `${charId}/${voiceId.toLowerCase()}.mp3`;
return alt(`audio/voice/${lang}/${file}`, joinUrl(RAW.aa2voice, `${VOICE_DIRS[lang]}/${file}`));
}
/** Sound path under sound_beta_2 → alternative under public/assets/audio/<sub>. */
function soundAlt(path, sub = 'sfx') {
const rel = `audio/${sub}/` + path.split('/').map(safeName).join('/');
return alt(rel, joinUrl(RAW.aa2voice, path));
}
function soundLeaf(paths, sub = 'sfx', max = 4) {
return leaf((paths || []).slice(0, max).map((p) => soundAlt(p, sub)));
}
/**
* A unit's SFX roles (pickUnitSfx) → { roles: { attack?, hit?, die?, born? } sound leaves, mix: { [role]: { p?, vol? } }
* | null } — the official play chance / volume of each role's bank (audio.mjs bankMix; community report #30: 猎狗's
* attack bank is 80 % silence). The caller stores `mix` last in the unit's entry (sfx.units[id].mix).
*/
function unitSounds(audio, sfx) {
const roles = {};
let mix = null;
for (const r of ['attack', 'hit', 'die', 'born']) {
if (!sfx[r]) continue;
roles[r] = soundLeaf(sfx[r]);
const m = typeof audio.mixOf === 'function' ? audio.mixOf(sfx[r]) : null;
if (m) (mix || (mix = {}))[r] = m;
}
return { roles, mix };
}
/** Expected bytes for a 07 {url, bytes} record when it matches `url`. */
function bytesOf(rec, url) {
return rec && typeof rec === 'object' && rec.url === url && Number.isInteger(rec.bytes) ? rec.bytes : undefined;
}
/** Local file stem for a skeleton URL or file name ('…/char_102_texas.skel' → 'char_102_texas'). */
function skelStem(urlOrName) {
return safeName(urlBase(urlOrName).replace(/\.skel$/i, ''));
}
/** Collect every enemy key referenced under a wave/branch structure. */
function walkKeys(node, add) {
if (Array.isArray(node)) { for (const x of node) walkKeys(x, add); return; }
if (node && typeof node === 'object') {
if (typeof node.key === 'string') add(node.key);
for (const v of Object.values(node)) walkKeys(v, add);
}
}
/**
* A research-07-shaped operator record of a character research 07 does not list (the 自选 owned-6★ picks): every URL
* from the patterns of 07-assets.json `meta.patterns` — avatar / portrait (E0–E1 and E2), the default-skin battle Spine
* Front / Back, the skill icons, the sub-profession icon. No expected byte counts (the downloader validates the files);
* a file the mirrors lack is a miss the client falls back from (the E2 art to the E0–E1 one, a missing Back to Front).
* @param {string} id charId
* @param {{ subProfessionId?: string|null, nationId?: string|null, skills?: Array<{ index: number, skillId: string, iconId?: string|null }> }} x
*/
export function patternOperator(id, x = {}) {
const sp = (side) => {
const b = `${RAW.fexli}spine/${id}/${id}/${side}/${id}`;
return { skel: `${b}.skel`, atlas: `${b}.atlas`, png: `${b}.png` };
};
return {
name: x.name ?? null, subProfessionId: x.subProfessionId ?? null, nationId: x.nationId ?? null, chess: [],
avatar: { e0e1: { url: `${RAW.yuanyan}avatar/${id}.png` }, e2: { url: `${RAW.yuanyan}avatar/${id}_2.png` } },
portrait: { e0e1: { url: `${RAW.yuanyan}portrait/${id}_1.png` }, e2: { url: `${RAW.yuanyan}portrait/${id}_2.png` } },
skills: (x.skills || []).map((s) => ({
index: s.index, skillId: s.skillId, iconId: s.iconId || s.skillId,
icon: { url: `${RAW.yuanyan}skill/skill_icon_${encodeURIComponent(s.iconId || s.skillId)}.png` },
})),
battleSpine: { front: sp('Front'), back: sp('Back'), note: null },
subProfessionIcon: x.subProfessionId ? joinUrl(RAW.aa2, `arts/ui/subprofessionicon/sub_${x.subProfessionId}_icon.png`) : null,
};
}
// ---------------------------------------------------------------------------
// id sets
/**
* Per-charId skill indices used by the pool (0-based; primary first).
* @param {any} ops03 docs/research/03-operators.json
* @returns {Map<string, number[]>}
*/
export function skillIndicesByChar(ops03) {
const primary = new Map();
const all = new Map();
const add = (id, idx, isPrimary) => {
if (typeof id !== 'string' || !Number.isInteger(idx) || idx < 0) return;
if (!all.has(id)) all.set(id, new Set());
all.get(id).add(idx);
if (isPrimary && !primary.has(id)) primary.set(id, idx);
};
const chess = Array.isArray(ops03?.chess) ? ops03.chess : [];
for (const c of chess) if (c.charId) add(c.charId, c.defaultSkillIndex, !c.isGolden);
for (const c of chess) if (c.backup?.charId) add(c.backup.charId, c.backup.skillIndex, true);
const out = new Map();
for (const [id, set] of all) {
const p = primary.has(id) ? primary.get(id) : Math.min(...set);
out.set(id, [p, ...[...set].filter((i) => i !== p).sort((a, b) => a - b)]);
}
return out;
}
/**
* Every enemy id that can appear in an act2autochess match.
* @param {{ assets07:any, enemies05:any, maps05:any, ops03?:any }} r
* ops03 (optional): enemy units spawned by operator kits (e.g. 隐德来希's S3 summons
* `enemy_5601_entlec` 心烛 via a talent blackboard `take_extra_enemy_key`)
* @returns {string[]} sorted ids
*/
export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
const known = (id) => !!(enemies05?.enemies?.[id] || assets07?.enemies?.[id]);
const set = new Set(Object.keys(assets07?.enemies || {}));
const add = (k) => { if (typeof k === 'string' && /^enemy_\d+_[a-z0-9_]+$/i.test(k)) set.add(k); };
for (const c of Array.isArray(ops03?.chess) ? ops03.chess : []) {
const kit = JSON.stringify([c?.skill ?? null, c?.talents ?? null, c?.tokens ?? null]);
for (const m of kit.match(/enemy_\d+_[a-z0-9_]+/gi) || []) add(m);
}
for (const lv of Object.values(maps05?.roundLevels || {})) {
const usedBy = Array.isArray(lv?.usedBy) ? lv.usedBy : [];
if (usedBy.length && usedBy.every((u) => String(u).startsWith('mode_training'))) continue; // tutorial is out of scope
for (const ref of lv?.enemyDbRefs || []) add(Array.isArray(ref) ? ref[0] : ref);
walkKeys(lv?.waves, add);
walkKeys(lv?.branches, add);
}
for (const b of Object.values(enemies05?.bosses || {})) { add(b?.enemyId); for (const s of b?.spawns || []) add(s); }
// Closure over summons: randomEnemyAttribute spawns + `enemy_key` blackboard references.
let changed = true;
while (changed) {
changed = false;
for (const id of [...set]) {
const e = enemies05?.enemies?.[id];
if (!e) continue;
const refs = new Set(e.ac?.rand?.spawns || []);
for (const m of JSON.stringify([e.skills ?? null, e.talent ?? null]).match(/enemy_\d+_[a-z0-9_]+/gi) || []) refs.add(m);
for (const r of refs) if (!set.has(r) && known(r)) { set.add(r); changed = true; }
}
}
return [...set].sort();
}
// ---------------------------------------------------------------------------
// plan
/**
* Build the asset plan.
* @param {object} p
* @param {any} p.assets07 docs/research/07-assets.json
* @param {any} p.ops03 docs/research/03-operators.json
* @param {any} p.enemies05 docs/research/05-enemies.json
* @param {any} p.maps05 docs/research/05-maps.json
* @param {ReturnType<import('./audio.mjs').indexAudio>} p.audio indexed audio_data.json
* @param {any} p.modelsData Ark-Models models_data.json
* @param {any} [p.charword] parsed excel/charword_table.json — the operators' official voice slots (voice, voiceJp)
* @param {string} [p.voiceLang] voice dump of `audio.voice` (the 中文 setting): cn (default) | jp | en | kr — `audio.voiceJp`
* is the JP dub (`voice/`) whatever it is
* @param {boolean} [p.voiceJp] plan the JP tree `audio.voiceJp` (default true)
* @param {Iterable<string>|null} [p.voiceSlots] which voice slots to plan: VOICE_BATTLE_SLOTS (default) plans only the
* lines a battle can play, null plans every slot of audio.mjs VOICE_SLOTS (`--voice-all`). The prep-only slots
* (干员报到 / 编入队伍 / 任命队长) are never requested by the client and cost 360 files / 19.3 MB of downloads.
* @param {string[]} [p.extraEnemyIds] more enemy ids that can spawn (e.g. keys of data/enemies.json)
* @param {string[]} [p.extraTokenIds] more token ids (e.g. token_* keys of data/tokens.json)
* @param {Record<string,string>} [p.extraHandbook] enemyId → handbook/model id (e.g. from data/bosses.json)
* @param {Record<string, import('./spine.mjs').LocalSpineMeta>} [p.localEnemySpines] metadata of the enemy models the
* local client has (the committed tools/assets/local-enemy-spines.json, never the disk): each planned enemy listed
* gets `spineLocal` = { group: 'spine/enemy/<id>', ...meta } beside its web `spine`
* @param {Record<string, import('./spine.mjs').LocalSpineMeta>} [p.localTokenSpines] the same for the token (summon)
* models (tools/assets/local-token-spines.json): each planned token listed gets `spineLocal` = { group:
* 'spine/token/<id>', ...meta } beside its web `spine`, if any (most have none: the client drew the avatar)
* @param {Record<string, any>} [p.extraOperators] charId → { subProfessionId, nationId, skills: [{ index, skillId, iconId }] }
* of characters research 07 does not list (the 自选 owned-6★ picks, data/backups.json `units`): planned like the pool
* operators from patternOperator
* @param {string[]} [p.moduleTypes] module type icon ids (`typeIcon`, e.g. 'sol-x') → manifest `modules[typeIcon]`, the
* official type icon (arts/ui/uniequiptype) the 干员调配 / 自选 module tiles draw when the local-client art lacks it
* @returns {{ template: any, models: Map<string, any>, notes: string[] }}
*/
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, charword = null, voiceLang = 'cn',
voiceJp = true, voiceSlots = VOICE_BATTLE_SLOTS,
extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {}, localTokenSpines = {}, extraOperators = {},
moduleTypes = [] }) {
const notes = [];
/** @type {Map<string, any>} */
const models = new Map();
const skillIdx = skillIndicesByChar(ops03);
// --- Spine model helpers -------------------------------------------------
const addModel = (key, def) => { if (!models.has(key)) models.set(key, { key, ...def }); return { model: key }; };
const fexliModel = (key, kind, dir, rec, skillIndices) => {
// rec: 07 { skel:{url,bytes}|url|url[], atlas, png } — arrays are candidate folders (same order for all three)
const us = (x) => (Array.isArray(x) ? x : [typeof x === 'string' ? x : x?.url]).filter((v) => typeof v === 'string' && v);
const b = (x) => (x && typeof x === 'object' && !Array.isArray(x) ? x.bytes : undefined);
if (!us(rec?.skel).length || !us(rec?.atlas).length || !us(rec?.png).length) return null;
const stem = skelStem(us(rec.skel)[0]);
return addModel(key, {
kind, dir, pma: false, skillIndices,
baseUrl: urlDir(us(rec.atlas)[0]),
skel: alt(`${dir}${stem}.skel`, us(rec.skel), b(rec.skel)),
// pixi-spine finds the atlas by swapping the .skel extension: keep the same stem.
atlas: { ...alt(`${dir}${stem}.atlas`, us(rec.atlas), b(rec.atlas)), mutable: true },
pngs: [alt(dir + safeName(urlBase(us(rec.png)[0])), us(rec.png), b(rec.png))],
});
};
// --- operators -----------------------------------------------------------
const chars = {};
const skills = {};
const skillsById = {};
const unitsSfx = {};
const known = assets07?.operators || {};
const extraOps = {};
for (const [id, x] of Object.entries(extraOperators || {})) if (/^char_\d+_[a-z0-9]+$/i.test(id) && !known[id]) extraOps[id] = patternOperator(id, x);
const operators = { ...known, ...extraOps };
const charIds = Object.keys(operators).sort();
for (const id of charIds) {
const o = operators[id];
// DESIGN §16 operator loadouts: any skill of the character can be equipped — the icons, skill SFX and Spine skill
// clips of every skill index (the pool's primary index first, as before)
const idx0 = skillIdx.get(id) || [0];
const idx = [...idx0, ...(o.skills || []).map((k) => k.index).filter((i) => Number.isInteger(i) && i >= 0 && !idx0.includes(i)).sort((a, b) => a - b)];
const c = {};
c.avatar = leaf(alt(`char/avatar/${id}.png`, o.avatar?.e0e1?.url, o.avatar?.e0e1?.bytes));
if (o.avatar?.e2?.url) c.avatarE2 = leaf(alt(`char/avatar/${id}_2.png`, o.avatar.e2.url, o.avatar.e2.bytes));
c.portrait = leaf(alt(`char/portrait/${id}_1.png`, o.portrait?.e0e1?.url, o.portrait?.e0e1?.bytes));
if (o.portrait?.e2?.url) c.portraitE2 = leaf(alt(`char/portrait/${id}_2.png`, o.portrait.e2.url, o.portrait.e2.bytes));
c.spine = {};
const front = fexliModel(`op:${id}:front`, 'op', `spine/op/${id}/front/`, o.battleSpine?.front, idx);
if (front) c.spine.front = front; else notes.push(`${id}: no Front battle Spine in research data`);
const back = fexliModel(`op:${id}:back`, 'op', `spine/op/${id}/back/`, o.battleSpine?.back, idx);
if (back) c.spine.back = back;
chars[id] = c;
// skill icons (+ skill SFX) of every skill index; normal-mode attack / impact sounds only (a ranged operator's own
// projectile banks projectile_chr_<name> as fallbacks — user playtest #4 item 6, audio.mjs pickUnitSfx)
const short = id.replace(/^char_\d+_/, '');
const sfx = pickUnitSfx(audio.unitBanks.get(id), { operator: true, projectile: {
born: audio.bank(`battle.ON_PROJECTILE_BORN.projectile_chr_${short}`), hit: audio.bank(`battle.ON_PROJECTILE_HIT.projectile_chr_${short}`) } });
const { roles: u, mix } = unitSounds(audio, sfx);
const skillSfx = {};
// Per-skill ATTACK / IMPACT sound (audio.mjs pickModeAttacks / pickModeHits): a skill mode has its own swing and its
// own hit sound, and neither may play as the unit's normal attack / impact (银灰, 纯烬艾雅法拉). An operator whose
// every attack belongs to a skill mode has no normal bank at all (司霆惊蛰 — 解放者, she only attacks while a skill
// runs — the report 「三技能攻击没有音效」); 赤刃明霄陈's S3 slashes swing `p_atk_hljdswd_s` and land `p_imp_hljdswd_s`
// where her normal attack uses the `_n` files (the report 「斩击音效应该和普通攻击不一样」). The mode letter of a skill
// index is read from that skill's own activation sound (`p_skill_lzxqlkl_s` → s = 技能3); without one (most operators
// carry no activation sound) we infer 技能1 / 2 / 3 = d / h / s ([ASSUMED], below). The client plays
// them while that skill is active (public/js/audio.js unit()).
const modeAttacks = pickModeAttacks(audio.unitBanks.get(id));
const modeHits = pickModeHits(audio.unitBanks.get(id));
const attacks = {};
const hits = {};
const attackMix = {}, hitMix = {};
for (const i of idx) {
const s = (o.skills || []).find((k) => k.index === i);
if (!s) { notes.push(`${id}: skill index ${i} missing in research data`); continue; }
const iconId = s.iconId || s.skillId;
if (s.icon?.url && !skills[iconId]) skills[iconId] = leaf(alt(`skill/${safeName(iconId)}.png`, s.icon.url, s.icon.bytes));
if (s.skillId) skillsById[s.skillId] = iconId;
const ss = audio.skillBanks.get(s.skillId)?.get('ON_SKILL_START');
if (ss?.length) skillSfx[String(i)] = soundLeaf(ss);
// [ASSUMED] Without a named activation mode, infer d/h/s from slot 1/2/3; see DESIGN §28.17.
const letter = skillModeLetter(ss?.[0]) ?? SLOT_MODE_LETTER[i];
if (letter && modeAttacks[letter]) {
attacks[String(i)] = soundLeaf(modeAttacks[letter]);
const m = audio.mixOf?.(modeAttacks[letter]);
if (m) attackMix[String(i)] = m;
}
if (letter && modeHits[letter]) {
hits[String(i)] = soundLeaf(modeHits[letter]);
const m = audio.mixOf?.(modeHits[letter]);
if (m) hitMix[String(i)] = m;
}
}
const primarySkill = skillSfx[String(idx[0])];
if (primarySkill) u.skill = primarySkill;
if (Object.keys(skillSfx).length > 1) u.skills = skillSfx;
if (Object.keys(attacks).length) u.attacks = attacks;
if (Object.keys(hits).length) u.hits = hits;
if (Object.keys(attackMix).length) u.attackMix = attackMix;
if (Object.keys(hitMix).length) u.hitMix = hitMix;
if (mix) u.mix = mix;
if (Object.keys(u).length) unitsSfx[id] = u;
}
// --- tokens --------------------------------------------------------------
const chessById = new Map((ops03?.chess || []).map((c) => [c.chessId, c]));
const tokens = {};
const tokenIds = new Set(Object.keys(assets07?.tokens || {}));
for (const id of extraTokenIds) if (typeof id === 'string' && /^token_\d+_[a-z0-9_]+$/i.test(id)) tokenIds.add(id);
for (const id of [...tokenIds].sort()) {
// Tokens unknown to research 07: default avatar/Spine locations (misses are tolerated).
const t = assets07?.tokens?.[id] ?? {
avatar: { url: `${RAW.yuanyan}avatar/${id}.png` },
battleSpineDefault: null,
battleSpineSkinVariantsOnly: [id],
};
const usedBy = ops03?.tokensUsedByPool?.[id]?.usedByChess || [];
const ownerChess = usedBy.map((cid) => chessById.get(cid)).find(Boolean);
const entry = { owner: ownerChess?.charId || ownerChess?.backup?.charId || null };
entry.avatar = t.avatar?.url ? leaf(alt(`token/avatar/${id}.png`, t.avatar.url, t.avatar.bytes)) : null;
let model = null;
if (t.battleSpineDefault) {
model = fexliModel(`token:${id}`, 'token', `spine/token/${id}/`, t.battleSpineDefault, [0]);
} else if (Array.isArray(t.battleSpineSkinVariantsOnly) && t.battleSpineSkinVariantsOnly.length) {
// Skin-variant models live in a `Spine/` folder, or `Front/` for directional ones.
const v = t.battleSpineSkinVariantsOnly[0];
const bases = ['Spine', 'Front'].map((f) => `${RAW.fexli}spine/${encodeURIComponent(id)}/${encodeURIComponent(v)}/${f}/${encodeURIComponent(v)}`);
model = fexliModel(`token:${id}`, 'token', `spine/token/${id}/`, {
skel: bases.map((b) => b + '.skel'), atlas: bases.map((b) => b + '.atlas'), png: bases.map((b) => b + '.png'),
}, [0]);
if (model) entry.spineVariant = v;
}
entry.spine = model;
// the official model from the local client, drawn instead of the avatar (or of `spine`) when extracted (optional)
const loc = localTokenSpines && Object.hasOwn(localTokenSpines, id) ? localTokenSpines[id] : null;
if (loc && typeof loc === 'object') {
entry.spineLocal = literal({ group: `spine/token/${id}`, ...loc });
notes.push(`${id}: official Spine from the local client when extracted (spineLocal)`);
}
tokens[id] = entry;
const { roles: u, mix } = unitSounds(audio, pickUnitSfx(audio.unitBanks.get(id)));
if (mix) u.mix = mix;
if (Object.keys(u).length) unitsSfx[id] = u;
}
// --- enemies -------------------------------------------------------------
const handbookOf = new Map();
for (const b of Object.values(enemies05?.bosses || {})) if (b?.enemyId && b.handbookId && b.handbookId !== b.enemyId) handbookOf.set(b.enemyId, b.handbookId);
for (const [eid, hb] of Object.entries(extraHandbook || {})) if (typeof hb === 'string' && hb !== eid && !handbookOf.has(eid)) handbookOf.set(eid, hb);
const mdData = modelsData?.data || {};
const enemyDir = modelsData?.storageDirectory?.Enemy || 'models_enemies';
const pick = (v) => (Array.isArray(v) ? (v.find((x) => typeof x === 'string' && !x.includes('$')) ?? v[0]) : v);
const arkModel = (eid) => {
const key = eid.replace(/^enemy_/, '');
const al = mdData[key]?.assetList;
const skel = pick(al?.['.skel']); const atlas = pick(al?.['.atlas']); const png = pick(al?.['.png']);
if (![skel, atlas, png].every((x) => typeof x === 'string' && x)) return null;
const base = `${RAW.arkModels}${enemyDir}/${encodeURIComponent(key)}/`;
const rec07 = assets07?.enemies?.[eid]?.battleSpine;
const dir = `spine/enemy/${eid}/`;
const stem = skelStem(skel);
const mk = (file, r, rel) => alt(rel ?? dir + safeName(file), base + encodeURIComponent(file), bytesOf(r, base + encodeURIComponent(file)));
return addModel(`enemy:${eid}`, {
kind: 'enemy', dir, pma: true, skillIndices: [0], baseUrl: base,
skel: mk(skel, rec07?.skel, `${dir}${stem}.skel`),
atlas: { ...mk(atlas, rec07?.atlas, `${dir}${stem}.atlas`), mutable: true },
pngs: [mk(png, rec07?.png)],
});
};
const baseIdOf = (eid) => { const m = /^(enemy_\d+_[a-z0-9]+?)_\d+$/i.exec(eid); return m ? m[1] : null; };
const enemyIdSet = new Set(collectEnemyIds({ assets07, enemies05, maps05, ops03 }));
for (const id of extraEnemyIds) if (typeof id === 'string' && /^enemy_\d+_[a-z0-9_]+$/i.test(id)) enemyIdSet.add(id);
// Boss handbook ids (their own icon/model ids) are addressable too.
for (const hb of handbookOf.values()) if (/^enemy_\d+_[a-z0-9_]+$/i.test(hb)) enemyIdSet.add(hb);
const enemyIds = [...enemyIdSet].sort();
const enemies = {};
for (const id of enemyIds) {
const e = {};
const icon07 = assets07?.enemies?.[id]?.icon;
const iconAlts = [alt(`enemy/icon/${id}.png`, `${RAW.yuanyan}enemy/${id}.png`, bytesOf(icon07, `${RAW.yuanyan}enemy/${id}.png`))];
for (const other of [handbookOf.get(id), baseIdOf(id)]) if (other) iconAlts.push(alt(`enemy/icon/${id}.png`, `${RAW.yuanyan}enemy/${other}.png`));
e.icon = leaf(iconAlts);
// Spine: own model, else alias chain (research 07 §5.6).
let spine = arkModel(id);
if (!spine) {
const seen = new Set([id]);
const queue = [ENEMY_SPINE_ALIAS[id], baseIdOf(id), handbookOf.get(id)].filter(Boolean);
while (!spine && queue.length) {
const cand = queue.shift();
if (seen.has(cand)) continue;
seen.add(cand);
spine = arkModel(cand);
if (spine) e.spineAliasOf = cand;
else queue.push(...[ENEMY_SPINE_ALIAS[cand], baseIdOf(cand), handbookOf.get(cand)].filter(Boolean));
}
if (!spine) notes.push(`${id}: no enemy Spine upstream (client draws the icon, if any, or a glyph)`);
else notes.push(`${id}: Spine aliased to ${e.spineAliasOf}`);
}
e.spine = spine;
// the official model from the local client, drawn instead of `spine` when the extraction is installed (optional)
const loc = localEnemySpines && Object.hasOwn(localEnemySpines, id) ? localEnemySpines[id] : null;
if (loc && typeof loc === 'object') {
e.spineLocal = literal({ group: `spine/enemy/${id}`, ...loc });
notes.push(`${id}: official Spine from the local client when extracted (spineLocal)`);
}
enemies[id] = e;
let banks = audio.unitBanks.get(id);
for (const other of [handbookOf.get(id), e.spineAliasOf, baseIdOf(id)]) {
if (banks?.size) break;
if (other) banks = audio.unitBanks.get(other);
}
const { roles: u, mix } = unitSounds(audio, pickUnitSfx(banks));
if (mix) u.mix = mix;
if (Object.keys(u).length) unitsSfx[id] = u;
}
// --- bonds / items / bands -------------------------------------------------
const bonds = {};
for (const [bondId, b] of Object.entries(assets07?.bonds || {})) {
bonds[bondId] = leaf(alt(`bond/${bondId}.png`, b.icon?.url, b.icon?.bytes), b.fallbackCampLogo ? alt(`bond/${bondId}.png`, b.fallbackCampLogo) : null);
}
const items = {};
for (const [trapId, it] of Object.entries(assets07?.items || {})) items[trapId] = leaf(alt(`item/${trapId}.png`, it.icon?.url, it.icon?.bytes));
const bands = {};
for (const [bandId, b] of Object.entries(assets07?.bands || {})) bands[bandId] = leaf(alt(`band/${bandId}.png`, b.icon?.url, b.icon?.bytes));
// --- professions ----------------------------------------------------------
const prof = { icon: {}, large: {}, battlecard: {}, sub: {} };
for (const p of PROFESSIONS) {
const i = assets07?.arts?.professionIcon?.[p];
if (i) prof.icon[p] = leaf(alt(`prof/icon_${p}.png`, i));
const l = assets07?.arts?.professionIconLargeWhite?.[p];
if (l) prof.large[p] = leaf(alt(`prof/large_${p}.png`, l));
}
for (const p of [...PROFESSIONS, 'token']) {
prof.battlecard[p] = leaf(alt(`prof/battlecard_${p}.png`, joinUrl(RAW.aa2, `arts/ui/[uc]battlecommon/ui_battle_new/battlecard/icon_profession_${p}.png`)));
}
for (const o of Object.values(operators)) {
const sub = o.subProfessionId;
if (typeof sub === 'string' && sub && !prof.sub[sub] && o.subProfessionIcon) prof.sub[sub] = leaf(alt(`prof/sub/${safeName(sub)}.png`, o.subProfessionIcon));
}
// --- UI ------------------------------------------------------------------
const ui = {};
const addUi = (group, key, url) => {
const k = safeName(key);
const name = `${group}/${k}`;
if (!ui[name] && url) ui[name] = leaf(alt(`ui/${group}/${k}.png`, url));
};
for (const [group, entries] of Object.entries(assets07?.autochessUi || {})) {
for (const [key, url] of Object.entries(entries || {})) addUi(group, key, url);
}
const nations = new Set(Object.values(operators).map((o) => o.nationId).filter(Boolean));
const logos = new Set(['logo_rhodes', ...[...nations].map((n) => `logo_${n}`)]);
for (const b of Object.values(assets07?.bonds || {})) if (b.fallbackCampLogo) logos.add(urlBase(b.fallbackCampLogo).replace(/\.png$/i, ''));
for (const [src, group] of Object.entries(ARTS_GROUPS)) {
for (const [key, url] of Object.entries(assets07?.arts?.[src] || {})) {
if (src === 'campLogo' && !logos.has(key)) continue;
if (src === 'loadingIllust' && !LOADING_USED.has(key)) continue;
addUi(group, key, url);
}
}
for (const [group, key, path] of UI_EXTRAS) addUi(group, key, joinUrl(RAW.aa2, path));
// --- audio ----------------------------------------------------------------
const bgmLeaf = (bankName) => {
const b = audio.bgm(bankName);
if (!b?.loop) { notes.push(`BGM bank ${bankName} not found`); return null; }
const out = { loop: soundLeaf([b.loop], 'bgm', 1) };
if (b.intro) out.intro = soundLeaf([b.intro], 'bgm', 1);
return out;
};
// BGM files are flattened to audio/bgm/<basename>.
const flatBgm = (node) => {
if (!node) return node;
for (const k of Object.keys(node)) {
const l = node[k];
if (l?.alts) for (const a of l.alts) a.rel = 'audio/bgm/' + safeName(a.rel.split('/').pop());
}
return node;
};
// 联防 BGM: the official 联防 levels (`escaped_single` / `escaped_multi`) declare `bgmEvent = corrosion` — a
// 卡西米尔 act13d5d0 battle track — so the rescue phase does NOT reuse the 作战's track. Kept out of `bgm` when the
// bank is missing (the client then falls back to `bgm.combat`; public/js/audio.js resolveBgm), so the manifest stays
// valid for an older audio_data.
const unite = flatBgm(bgmLeaf('battle.ON_GAME_READY.corrosion'));
// 开战 BGM: the two official battle tracks of the mode's own 卡西米尔 act (music_act13side_1 骑士之日 /
// music_act13side_2 无畏者, 塞壬唱片). The client does not draw them: the round decides (audio.js combatTrackFor —
// 无畏者 for rounds 1–7, 骑士之日 from round 8 on), so index 0 must stay `bat_kazimierz2_1` and index 1
// `bat_kazimierz2_2` (test/ui/audio.test.js pins both the order and the round table).
// Kept out of `bgm` below when the index has neither bank, so the manifest stays valid for an older audio_data.
const combatAlts = ['battle.ON_GAME_READY.bat_kazimierz2_1', 'battle.ON_GAME_READY.bat_kazimierz2_2']
.map((n) => flatBgm(bgmLeaf(n))).filter(Boolean);
const bgm = {
lobby: flatBgm(bgmLeaf('sys.ON_ACTIVITY_LOADED.act2autochess')),
prep: flatBgm(bgmLeaf('battle.ON_GAME_READY.act1autochess_shop')),
combat: flatBgm(bgmLeaf('battle.ON_GAME_READY.act1autochess_shop')),
boss: flatBgm(bgmLeaf('battle.ON_GAME_READY.rglk1phantomcastle')),
...(combatAlts.length ? { combatAlts } : {}),
...(unite ? { unite } : {}),
};
const bossBgm = {};
for (const lv of Object.values(maps05?.roundLevels || {})) {
if (typeof lv?.bgm !== 'string') continue;
for (const u of lv.usedBy || []) {
const m = /\(boss:(boss_\d+)\)/.exec(String(u));
if (m && !bossBgm[m[1]] && !String(u).startsWith('mode_training')) bossBgm[m[1]] = flatBgm(bgmLeaf(`battle.ON_GAME_READY.${lv.bgm}`));
}
}
const sfxUi = {};
for (const [name, spec] of Object.entries(UI_SFX)) { const l = soundLeaf(resolveSpec(spec, audio.bank)); if (l) sfxUi[name] = l; else notes.push(`UI SFX ${name}: no sound`); }
const sfxBattle = {};
for (const [name, spec] of Object.entries(BATTLE_SFX)) { const l = soundLeaf(resolveSpec(spec, audio.bank)); if (l) sfxBattle[name] = l; else notes.push(`battle SFX ${name}: no sound`); }
// --- module type icons ----------------------------------------------------
const modules = {};
for (const t of [...new Set(moduleTypes || [])].filter((x) => typeof x === 'string' && /^[a-z0-9-]+$/i.test(x)).sort()) {
// the client's file name, else its lower-case form (the type id of a few modules is mixed case: WAH-Y → wah-y.png).
// One lower-case file per type: the official data spells one DEC X module 'dec-X' (uniequip_003_aglina) and the
// others 'dec-x'; two paths that differ only in case are one file on Windows / macOS (and in a release zip built or
// extracted there), so a case-sensitive server would miss one of them.
modules[t] = leaf(alt(`module/${safeName(t).toLowerCase()}.png`, [...new Set([t, t.toLowerCase()])].map((n) => joinUrl(RAW.aa2, `arts/ui/uniequiptype/${n}.png`))));
}
// --- 干员战斗语音 (excel/charword_table.json → audio.voice, audio.voiceJp) ---------------------------------
// The official lines of every operator the mode can field, for the slots the client can actually play: 行动出发 start /
// 行动开始 faceEnemy / 选中干员 select / 部署 place / 作战中1-4 skillN / 结算 result* (charword `placeType`,
// audio.mjs VOICE_BATTLE_SLOTS). A slot with several lines stays an array — the client draws one at random
// (public/js/audio.js voice). Operators without official battle voice keep no entry at all: the 17 预备干员
// (char_60x_c*, char_617_sharp2) and the mode's own 盟约·辅助干员 (char_616_pithst).
// The three prep-only slots (干员报到 gacha / 编入队伍 squad / 任命队长 squadFirst) are NOT planned by default: the
// client never requests them, and downloading them adds 360 files / 19.3 MB to every `npm run assets` — pass --voice-all for
// the complete official set (`voiceSlots: null`, reviewer note on the voice PR).
// `audio.voiceJp` (VOICE_JP_LANG): the same slots and lines in the Japanese dub, a second tree beside the Chinese one
// (the owner's request of 2026-10-08) — same file names, `audio/voice/jp/`.
const voiceSlotsByChar = indexVoice(charword, VOICE_ID_LANG, voiceSlots);
const voiceTree = (lang) => {
const tree = {};
for (const [charId, slots] of voiceSlotsByChar) {
if (!chars[charId]) continue; // only the operators this game can field (`chars`: the 138 pool charIds and the 自选 picks)
const v = {};
for (const [slot, assets] of Object.entries(slots)) {
// one leaf per line (部署1 / 部署2 …): an array stays an array so the client can draw one — chaining them as
// alternatives of a single leaf would keep only the first line that landed on disk.
const lines = assets.map((a) => leaf(voiceAlt(a, lang))).filter(Boolean);
if (!lines.length) continue;
v[slot] = lines.length === 1 ? lines[0] : lines;
}
if (Object.keys(v).length) tree[charId] = v;
}
return tree;
};
const voice = voiceTree(voiceLang);
if (!Object.keys(voice).length) notes.push('battle voice: charword_table.json has no slots (index missing?)');
const template = {
chars, enemies, tokens, bonds, items, bands, skills, skillsById, modules, ui, prof,
audio: {
bgm,
bossBgm: Object.fromEntries(Object.entries(bossBgm).sort(([a], [b]) => a.localeCompare(b, 'en', { numeric: true }))),
voice,
...(voiceJp ? { voiceJp: voiceTree(VOICE_JP_LANG) } : {}),
sfx: { ui: sfxUi, battle: sfxBattle, units: unitsSfx },
},
};
return { template, models, notes };
}