卫戍协议:盟约 web remake — initial import

Online co-op browser remake of Arknights' Stronghold Protocol: Alliance
(client-simulated combat, official waves/bonds/items/strategies, 3D board,
facing wheel, operator loadout, official emotes, deployment scripts).
This commit is contained in:
sganggs committed 2026-09-29 16:21:31 +08:00
commit 01a44cd288
408 files changed
+308986

No files matched your search

+295
View File
@@ -0,0 +1,295 @@
// Animation-role resolver for Arknights battle Spine models (research 07 §5.4).
//
// Model animation names vary a lot (Attack vs Attack_Begin/Loop/End vs Combat,
// Skill_2_Loop vs Skill2_Loop vs Skill_2, Die missing on Back models, enemies
// with several "forms" such as A_Idle/B_Idle or Idle_grey/Idle_red, …). The
// renderer should not guess at runtime, so tools/fetch-assets.mjs resolves each
// model once and stores the result in data/assets.json.
//
// A "clip" is { begin: string|null, loop: string, end: string|null, via?: string }:
// play `begin` once (if any), then `loop` (once per attack / repeatedly while a
// skill or move lasts), then `end` (if any). `via` is set when the clip is a
// fallback borrowed from another role ('combat', 'attackAny', 'skill', 'idle',
// 'attack'), so the renderer can add e.g. a flash when an attack is just Idle.
//
// Resolution order (names matched exactly first, then case-insensitively; the
// research order comes first, the generic patterns after it are extensions for
// models the research table did not cover):
// idle : Idle → first idle-like (Idle_A, A_Idle, Idle01; not Skill/Stun; animated ones first when
// durations are given) → Default → first animation
// deploy : Start → <form>Start → idle
// attack : Attack → Attack_Start|Attack_Begin + Attack_Loop + Attack_End
// → Attack_Begin + first numbered Attack_A + Attack_End (via attackAny) → Attack_Begin alone
// → Combat → <form>Attack → first non-Down /^Attack/ (Attack_01, Attack_A) (via attackAny)
// → first non-skill …_Attack (B_Attack) (via attackAny)
// → Skill_1_Loop → Skill_Loop → Skill…Attack → Skill…_Loop (via skill) → idle (via idle)
// attackDown: Attack_Down → Attack_Down_Begin/Loop/End → Combat_Down → first /^Attack.*Down/ → null
// skill (0-based skill index i, n = i + 1), for each prefix P in Skill_n, Skilln, Skill_0n, then Skill:
// loop = P_Loop → P_Attack… → P → P_Idle → P_Begin; begin = P_Begin|P_Start; end = P_End;
// idle = P_Idle (replaces idle while the skill is active);
// else the same over directional-only clips (Skill_Right_Loop, Skill_Loop_Up; Right before Up);
// else attack (via attack)
// die : Die → <form>Die → first /^Die/ → first /_Die$/ → null (renderer: Front model's Die, else fade)
// move : Move_Begin|Move_Start + (Move_Loop → Move) + Move_End → <form>Move → Run_Begin/Loop/End
// → Run → first /^Move/ → null
// stun : Stun_Begin + Stun (+ Stun_End) → Stun → null (renderer: freeze the track)
/**
* @typedef {{ begin: string|null, loop: string, end: string|null, via?: string }} Clip
* @typedef {Clip & { index: number, idle: string|null }} SkillClip
* @typedef {{ idle: string|null, deploy: string|null, attack: Clip|null, attackDown: Clip|null,
* skill: SkillClip|null, skills?: Record<string, SkillClip>, die: string|null, move: Clip|null, stun: Clip|null }} Roles
*/
function makeFinder(names) {
const exact = new Set(names);
const lower = new Map();
for (const n of names) {
const k = n.toLowerCase();
if (!lower.has(k)) lower.set(k, n);
}
return (name) => (name && exact.has(name) ? name : lower.get(String(name).toLowerCase()) ?? null);
}
function clip(begin, loop, end, via) {
const c = { begin: begin ?? null, loop, end: end ?? null };
if (via) c.via = via;
return c;
}
const isDown = (n) => /down/i.test(n);
const isEdge = (n) => /_(begin|start|end)$/i.test(n);
/** First name (sorted) matching `re` and passing `ok`. */
function firstLike(names, re, ok = () => true) {
return names.filter((n) => re.test(n) && ok(n)).sort()[0] ?? null;
}
/**
* begin/loop/end triple for a prefix, e.g. prefix 'Move' → Move_Begin|Move_Start, Move_Loop (else the
* plain 'Move'), Move_End. Begin-only models play the begin clip as the action.
*/
function triple(find, prefix) {
const begin = find(`${prefix}_Begin`) ?? find(`${prefix}_Start`);
const loop = find(`${prefix}_Loop`) ?? find(prefix);
const end = find(`${prefix}_End`);
if (loop) return clip(begin, loop, end);
if (begin) return clip(null, begin, end);
return null;
}
/**
* Directional variants as virtual names: 'Skill_Right_Loop' → 'Skill_Loop', 'Skill_Loop_Up' → 'Skill_Loop'
* (Right preferred over Up; `_Down` variants are never aliased). Used only as a last resort for skills
* of models that have no undirected skill clips (char_279_excu, char_431_ashlok Back).
* @param {string[]} names
* @returns {{ names: string[], real: (name:string|null)=>string|null }}
*/
function directionalAliases(names) {
const have = new Set(names.map((n) => n.toLowerCase()));
const alias = new Map();
for (const dir of ['Right', 'Up']) {
const re = new RegExp(`_${dir}(?=_|$)`, 'i');
for (const n of names) {
if (isDown(n) || !re.test(n)) continue;
const k = n.replace(re, '');
const lk = k.toLowerCase();
if (!have.has(lk) && !alias.has(lk)) alias.set(lk, { virtual: k, real: n });
}
}
const virtuals = [...alias.values()].map((a) => a.virtual);
return {
names: [...names, ...virtuals],
real: (name) => (name == null ? null : alias.get(name.toLowerCase())?.real ?? name),
};
}
/** Form tag of multi-form enemies, from the idle name: 'A_Idle' → A_x, 'Idle_grey' → x_grey. */
function formFinder(find, idle) {
if (!idle || /^idle$/i.test(idle)) return () => null;
let m = /^(.+)_idle$/i.exec(idle);
if (m) { const pre = m[1]; return (base) => find(`${pre}_${base}`); }
m = /^idle(_?)(.+)$/i.exec(idle);
if (m) { const sep = m[1]; const suf = m[2]; return (base) => find(`${base}${sep}${suf}`); }
return () => null;
}
function resolveIdle(names, find, durations) {
// Among generic idle-like clips prefer an animated one: 'Idle_A' of enemy_9014_acstma is a 0 s pose.
const idleLike = (animated) => firstLike(names, /(^|_)idle/i,
(n) => !/skill|stun|down/i.test(n) && (!animated || !(Number(durations?.[n]) <= 0)));
return find('Idle')
?? (durations ? idleLike(true) : null)
?? idleLike(false)
?? find('Default')
?? firstLike(names, /^default/i)
?? names[0] ?? null;
}
function resolveAttack(names, find, form, idle) {
const a = find('Attack');
if (a) return clip(null, a, null);
const begin = find('Attack_Begin') ?? find('Attack_Start');
const end = find('Attack_End');
const loop = find('Attack_Loop');
if (loop) return clip(begin, loop, end);
const numbered = firstLike(names, /^attack/i, (n) => !isDown(n) && !isEdge(n));
if (begin) {
// Attack_Begin + Attack_A/B/C + Attack_End (char_1045_svash2): the numbered clips are the attacks.
return numbered ? clip(begin, numbered, end, 'attackAny') : clip(null, begin, end);
}
const c = find('Combat');
if (c) return clip(null, c, null, 'combat');
const fa = form('Attack') ?? form('Combat');
if (fa) return clip(null, fa, null, 'attackAny');
const any = numbered
?? firstLike(names, /(^|_)attack/i, (n) => !isDown(n) && !isEdge(n) && !/skill/i.test(n));
if (any) return clip(null, any, null, 'attackAny');
const s = find('Skill_1_Loop') ?? find('Skill1_Loop') ?? find('Skill_Loop')
?? firstLike(names, /^skill.*attack/i, (n) => !isDown(n) && !isEdge(n))
?? firstLike(names, /^skill.*_loop$/i, (n) => !isDown(n));
if (s) return clip(null, s, null, 'skill');
return idle ? clip(null, idle, null, 'idle') : null;
}
function resolveAttackDown(names, find) {
const a = find('Attack_Down');
if (a) return clip(null, a, null);
const t = triple(find, 'Attack_Down');
if (t) return t;
const c = find('Combat_Down');
if (c) return clip(null, c, null, 'combat');
const any = firstLike(names, /^attack.*down/i, (n) => !isEdge(n));
if (any) return clip(null, any, null, 'attackAny');
return null;
}
/** Skill clip for one prefix (Skill_2, Skill2, Skill_02, Skill), or null. */
function skillFamily(names, find, P, numbered) {
const begin = find(`${P}_Begin`) ?? find(`${P}_Start`);
const end = find(`${P}_End`);
const idle = find(`${P}_Idle`);
const attack = find(`${P}_Attack`)
?? firstLike(names, new RegExp(`^${P}_Attack`, 'i'), (n) => !isDown(n) && !isEdge(n));
const single = find(P);
// Numbered prefixes only: any other P_* variant (e.g. Skill_1_A). Never for plain 'Skill',
// which would otherwise grab another skill's animations.
const variant = numbered
? firstLike(names, new RegExp(`^${P}_`, 'i'), (n) => !isDown(n) && !isEdge(n) && !/_idle$/i.test(n))
: null;
const loop = find(`${P}_Loop`) ?? attack ?? single ?? variant ?? idle ?? begin;
if (!loop) return null;
return { ...clip(loop === begin ? null : begin, loop, loop === end ? null : end), idle: idle ?? null };
}
function resolveSkill(names, find, index, attack) {
const n = index + 1;
const pad = String(n).padStart(2, '0');
const prefixes = [`Skill_${n}`, `Skill${n}`, `Skill_${pad}`, 'Skill'];
for (const P of prefixes) {
const c = skillFamily(names, find, P, P !== 'Skill');
if (c) {
const { idle, ...rest } = c;
return { ...rest, index, idle };
}
}
// Last resort: directional-only skill clips (Skill_Right_Loop, Skill_Loop_Up).
const dir = directionalAliases(names);
if (dir.names.length > names.length) {
const findDir = makeFinder(dir.names);
for (const P of prefixes) {
const c = skillFamily(dir.names, findDir, P, P !== 'Skill');
if (c) {
return { begin: dir.real(c.begin), loop: dir.real(c.loop), end: dir.real(c.end), index, idle: dir.real(c.idle) };
}
}
}
if (attack) return { ...attack, via: 'attack', index, idle: null };
return null;
}
function resolveMove(names, find, form) {
const t = triple(find, 'Move');
if (t) return t;
const m = find('Move') ?? form('Move');
if (m) return clip(null, m, null);
const r = triple(find, 'Run');
if (r) return r;
const run = find('Run') ?? form('Run');
if (run) return clip(null, run, null);
const any = firstLike(names, /^move/i, (n) => !isEdge(n)) ?? firstLike(names, /(^|_)move/i, (n) => !isEdge(n));
return any ? clip(null, any, null) : null;
}
function resolveStun(find) {
const stun = find('Stun');
const begin = find('Stun_Begin');
if (stun) return clip(begin, stun, find('Stun_End'));
if (begin) return clip(null, begin, null);
return null;
}
function resolveDie(names, find, form) {
return find('Die') ?? form('Die')
?? firstLike(names, /^die/i)
?? firstLike(names, /_die$/i, (n) => !/stun/i.test(n))
?? null;
}
/**
* Resolve animation roles for one Spine model.
* @param {string[]} animationNames all animation names in the skeleton
* @param {object} [opts]
* @param {number[]} [opts.skillIndices] 0-based skill indices to resolve (first = primary); default [0]
* @param {Record<string, number>} [opts.durations] animation durations in seconds (prefers animated idles)
* @returns {Roles}
*/
export function resolveRoles(animationNames, opts = {}) {
const names = Array.isArray(animationNames) ? animationNames.filter((n) => typeof n === 'string' && n.length) : [];
const find = makeFinder(names);
const durations = opts.durations && typeof opts.durations === 'object' ? opts.durations : null;
const idle = resolveIdle(names, find, durations);
const form = formFinder(find, idle);
const deploy = find('Start') ?? form('Start') ?? idle;
const attack = resolveAttack(names, find, form, idle);
const attackDown = resolveAttackDown(names, find);
const indices = [...new Set((opts.skillIndices?.length ? opts.skillIndices : [0])
.filter((i) => Number.isInteger(i) && i >= 0 && i < 10))];
if (!indices.length) indices.push(0);
/** @type {Record<string, SkillClip>} */
const skills = {};
for (const i of indices) {
const s = resolveSkill(names, find, i, attack);
if (s) skills[String(i)] = s;
}
const roles = {
idle,
deploy,
attack,
attackDown,
skill: skills[String(indices[0])] ?? null,
die: resolveDie(names, find, form),
move: resolveMove(names, find, form),
stun: resolveStun(find),
};
if (indices.length > 1) roles.skills = skills;
return roles;
}
/**
* All animation names referenced by a Roles object (for validation).
* @param {Roles} roles
* @returns {string[]}
*/
export function roleAnimationNames(roles) {
const out = new Set();
const addClip = (c) => {
if (!c) return;
for (const k of ['begin', 'loop', 'end', 'idle']) if (typeof c[k] === 'string') out.add(c[k]);
};
if (!roles) return [];
for (const k of ['idle', 'deploy', 'die']) if (typeof roles[k] === 'string') out.add(roles[k]);
for (const k of ['attack', 'attackDown', 'skill', 'move', 'stun']) addClip(roles[k]);
if (roles.skills) for (const c of Object.values(roles.skills)) addClip(c);
return [...out];
}
+130
View File
@@ -0,0 +1,130 @@
// Spine (3.8-style) texture-atlas parsing and normalization.
//
// Semantics follow pixi-spine 4's TextureAtlas reader (the runtime we ship):
// a page starts at the first non-blank line after a blank line (or file start);
// the page name is followed by `key: value` page fields (size/format/filter/
// repeat/pma); every other non-blank line starts a region, whose `key: value`
// lines follow it. Normalization (research 07 §5.3):
// - insert `size: W,H` right after the page name when missing (fexli atlases
// omit it; spine-ts divides by 0 and pixi-spine warns), or correct it when it
// disagrees with the real PNG size;
// - insert `pma: true` for premultiplied-alpha textures (Ark-Models enemies).
// Normalization is idempotent: running it twice yields the same text.
/**
* @typedef {{ name: string, line: number, fields: Record<string,string>, fieldLines: Record<string,number>, lastFieldLine: number, regions: string[] }} AtlasPage
*/
function splitEntry(line) {
const t = line.trim();
if (!t) return null;
const colon = t.indexOf(':');
if (colon === -1) return null;
return [t.slice(0, colon).trim(), t.slice(colon + 1).trim()];
}
/**
* Parse an atlas into pages (with page fields and region names).
* @param {string} text
* @returns {{ lines: string[], pages: AtlasPage[] }}
*/
export function parseAtlas(text) {
const lines = String(text).replace(/^/, '').replace(/\r\n?/g, '\n').split('\n');
/** @type {AtlasPage[]} */
const pages = [];
let page = null;
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) { page = null; i++; continue; }
if (page === null) {
page = { name: line.trim(), line: i, fields: {}, fieldLines: {}, lastFieldLine: i, regions: [] };
pages.push(page);
i++;
while (i < lines.length) {
const e = splitEntry(lines[i]);
if (!e) break;
page.fields[e[0]] = e[1];
page.fieldLines[e[0]] = i;
page.lastFieldLine = i;
i++;
}
continue;
}
// Region: name line followed by entries.
page.regions.push(line);
i++;
while (i < lines.length && splitEntry(lines[i])) i++;
}
return { lines, pages };
}
/**
* Normalize an atlas text.
* @param {string} text original atlas text
* @param {object} opts
* @param {(pageName:string)=>({width:number,height:number}|null)} opts.pageSize real texture size lookup
* @param {boolean} [opts.pma] add `pma: true` to every page
* @param {(pageName:string)=>string} [opts.renamePage] local file name for a page (rewrites the page line)
* @returns {{ text: string, changed: boolean, pages: string[], missingSize: string[], fixedSize: string[] }}
*/
export function normalizeAtlas(text, { pageSize, pma = false, renamePage = null }) {
const { lines, pages } = parseAtlas(text);
/** insertions after a given line index: Map<lineIndex, string[]> */
const inserts = new Map();
const replace = new Map();
const missingSize = [];
const fixedSize = [];
const addAfter = (idx, s) => { if (!inserts.has(idx)) inserts.set(idx, []); inserts.get(idx).push(s); };
for (const p of pages) {
if (renamePage) {
const local = renamePage(p.name);
if (typeof local === 'string' && local && local !== p.name) replace.set(p.line, local);
}
const real = pageSize ? pageSize(p.name) : null;
if (p.fields.size === undefined) {
if (real) addAfter(p.line, `size: ${real.width},${real.height}`);
else missingSize.push(p.name);
} else if (real) {
const m = /^(\d+)\s*,\s*(\d+)$/.exec(p.fields.size);
if (!m || Number(m[1]) !== real.width || Number(m[2]) !== real.height) {
replace.set(p.fieldLines.size, `size: ${real.width},${real.height}`);
fixedSize.push(p.name);
}
}
if (pma && p.fields.pma === undefined) {
// Append after the last page field (or after the inserted size line).
addAfter(p.lastFieldLine, 'pma: true');
}
}
const out = [];
for (let i = 0; i < lines.length; i++) {
out.push(replace.has(i) ? replace.get(i) : lines[i]);
const extra = inserts.get(i);
if (extra) {
// size must come right after the page name; pma after the other fields.
extra.sort((a, b) => (a.startsWith('size:') ? -1 : 0) - (b.startsWith('size:') ? -1 : 0));
out.push(...extra);
}
}
const result = out.join('\n');
const original = String(text).replace(/^/, '').replace(/\r\n?/g, '\n');
return { text: result, changed: result !== original, pages: pages.map((p) => (replace.get(p.line) ?? p.name)), missingSize, fixedSize };
}
/**
* List page image names and region names of an atlas.
* @param {string} text
* @returns {{ pages: string[], regions: Set<string>, hasSize: boolean, hasPma: boolean }}
*/
export function atlasInfo(text) {
const { pages } = parseAtlas(text);
const regions = new Set();
for (const p of pages) for (const r of p.regions) regions.add(r);
return {
pages: pages.map((p) => p.name),
regions,
hasSize: pages.length > 0 && pages.every((p) => /^\d+\s*,\s*\d+$/.test(p.fields.size || '')),
hasPma: pages.length > 0 && pages.every((p) => p.fields.pma === 'true'),
};
}
+215
View File
@@ -0,0 +1,215 @@
// Audio bank resolution from the official excel/audio_data.json.
//
// - Unit combat SFX banks are named `battle.<EVENT>.<unitId>[.<ability>[.<n>…]]`
// (e.g. battle.ON_ABILITY_START.char_102_texas.attack); skill banks are
// `battle.ON_SKILL_START.<skillId>`. pickUnitSfx() turns them into the roles the
// client plays: attack (swing/cast), hit (impact), skill (activation), die, born.
// - BGM banks (`battle.ON_GAME_READY.<event>`, `sys.ON_ACTIVITY_LOADED.<act>`)
// carry an optional intro and a loop.
// - UI SFX: autochess banks (ui./battle.ON_ACT1AUTOCHESS_*) mapped to event
// names used by public/js/audio.js, plus a few generic UI sounds where the
// autochess mode has no dedicated bank (buy/refresh/error/pick/drop).
// Every sound is addressed by its path under sound_beta_2 (lower-case, .mp3).
const PREFIX_RE = /^audio\/sound_beta_2\//i;
/**
* Convert an audio_data asset id to its lower-case path under sound_beta_2.
* @param {string} asset e.g. 'Audio/Sound_Beta_2/Player/p_atk/p_atk_sword_n'
* @returns {string|null} e.g. 'player/p_atk/p_atk_sword_n.mp3'
*/
export function assetToPath(asset) {
if (typeof asset !== 'string' || !asset) return null;
const p = asset.replace(PREFIX_RE, '').replace(/\\/g, '/').toLowerCase();
if (!p || p.includes('..')) return null;
return p.endsWith('.mp3') ? p : p + '.mp3';
}
/**
* Index an audio_data.json object.
* @param {any} audioData parsed excel/audio_data.json
* @returns {{ bank: (name:string)=>string[], bgm: (name:string)=>({intro:string|null, loop:string}|null),
* unitBanks: Map<string, Map<string, string[]>>, skillBanks: Map<string, Map<string,string[]>> }}
*/
export function indexAudio(audioData) {
const banks = new Map();
for (const b of Array.isArray(audioData?.soundFXBanks) ? audioData.soundFXBanks : []) {
if (!b || typeof b.name !== 'string') continue;
const paths = (Array.isArray(b.sounds) ? b.sounds : []).map((s) => assetToPath(s?.asset)).filter(Boolean);
if (!banks.has(b.name)) banks.set(b.name, []);
const list = banks.get(b.name);
for (const p of paths) if (!list.includes(p)) list.push(p);
}
const alias = audioData?.bankAlias && typeof audioData.bankAlias === 'object' ? audioData.bankAlias : {};
const bank = (name, depth = 0) => {
if (banks.has(name) && banks.get(name).length) return banks.get(name);
if (depth < 4 && typeof alias[name] === 'string') return bank(alias[name], depth + 1);
return [];
};
const bgmBanks = new Map();
for (const b of Array.isArray(audioData?.bgmBanks) ? audioData.bgmBanks : []) {
if (b && typeof b.name === 'string' && typeof b.loop === 'string') {
bgmBanks.set(b.name, { intro: assetToPath(b.intro), loop: assetToPath(b.loop) });
}
}
const bgm = (name, depth = 0) => {
if (bgmBanks.has(name)) return bgmBanks.get(name);
if (depth < 4 && typeof alias[name] === 'string') return bgm(alias[name], depth + 1);
return null;
};
// Per-unit and per-skill bank tables.
const unitBanks = new Map();
const skillBanks = new Map();
const addUnit = (name, paths) => {
const parts = name.split('.');
if (parts.length < 3 || parts[0] !== 'battle') return;
if (/^ON_SKILL_(START|FINISH|SPECIAL_POINT)$/.test(parts[1])) {
const skillId = parts.slice(2).join('.');
if (!skillBanks.has(skillId)) skillBanks.set(skillId, new Map());
skillBanks.get(skillId).set(parts[1], paths);
return;
}
const unit = parts[2];
if (!/^(char|enemy|token|trap)_/.test(unit)) return;
const key = parts[1] + (parts.length > 3 ? '.' + parts.slice(3).join('.') : '');
if (!unitBanks.has(unit)) unitBanks.set(unit, new Map());
unitBanks.get(unit).set(key, paths);
};
for (const [name, paths] of banks) if (paths.length) addUnit(name, paths);
for (const name of Object.keys(alias)) if (!banks.has(name)) { const p = bank(name); if (p.length) addUnit(name, p); }
return { bank, bgm, unitBanks, skillBanks };
}
/** Sort key for ability sub-keys: plain first, then numeric suffixes ascending. */
function abilityOrder(a, b) {
const na = a.split('.').length; const nb = b.split('.').length;
if (na !== nb) return na - nb;
return a.localeCompare(b, 'en', { numeric: true });
}
function firstMatching(banks, event, abilities) {
const keys = [...banks.keys()].filter((k) => k.startsWith(event + '.'));
for (const ab of abilities) {
const exact = `${event}.${ab}`;
if (banks.get(exact)?.length) return banks.get(exact);
const numbered = keys.filter((k) => k.startsWith(exact + '.')).sort(abilityOrder);
for (const k of numbered) if (banks.get(k)?.length) return banks.get(k);
}
return null;
}
/**
* Pick role → candidate sound paths for one unit.
* @param {Map<string,string[]>|undefined} banks unit bank table (from indexAudio().unitBanks)
* @returns {{ attack?: string[], hit?: string[], die?: string[], born?: string[] }}
*/
export function pickUnitSfx(banks) {
const out = {};
if (!banks || !banks.size) return out;
// Looser pass: abilities whose name mentions attack/combat (PowerAttack, StunCombat, CrossAttack…).
// Other abilities (skills, talents 'T.*', mode switches) are not normal attacks.
const attackLike = (event) => {
const keys = [...banks.keys()]
.filter((k) => k.startsWith(event + '.') && /attack|combat/i.test(k.slice(event.length + 1).split('.')[0]))
.sort(abilityOrder);
for (const k of keys) if (banks.get(k)?.length) return banks.get(k);
return null;
};
const attack = firstMatching(banks, 'ON_ABILITY_START', ['attack', 'combat'])
?? firstMatching(banks, 'ON_ABILITY_ON', ['attack', 'combat'])
?? attackLike('ON_ABILITY_START') ?? attackLike('ON_ABILITY_ON');
const hit = firstMatching(banks, 'ON_ABILITY_HIT', ['attack', 'combat']) ?? attackLike('ON_ABILITY_HIT');
if (attack) out.attack = attack;
if (hit) out.hit = hit;
if (banks.get('ON_UNIT_DEAD')?.length) out.die = banks.get('ON_UNIT_DEAD');
if (banks.get('ON_UNIT_BORN')?.length) out.born = banks.get('ON_UNIT_BORN');
return out;
}
/**
* UI / battle-flow SFX map: name → { bank } or { path } (path under sound_beta_2).
* Bank names are the official act1autochess banks (shared by act2autochess).
*/
export const UI_SFX = Object.freeze({
click: { path: 'general/g_ui/g_ui_btn_h.mp3' },
back: { path: 'general/g_ui/g_ui_btn_u.mp3' },
confirm: { path: 'general/g_ui/g_ui_confirm_h.mp3' },
tab: { path: 'general/g_ui/g_ui_tabswitch.mp3' },
pick: { path: 'general/g_ui/g_ui_pick.mp3' },
drop: { path: 'general/g_ui/g_ui_unpick.mp3' },
error: { path: 'general/g_ui/g_ui_scwarning.mp3' },
buy: { bank: 'battle.ON_ACT1AUTOCHESS_MAGIC_PLACE_HAND' },
sell: { bank: 'ui.ON_ACT1AUTOCHESS_GETMONEY' },
income: { bank: 'ui.ON_ACT1AUTOCHESS_GETMONEY' },
refresh: { path: 'general/g_ui/g_ui_rtargetrefresh.mp3' },
freeze: { bank: 'ui.ON_ACT1AUTOCHESS_SHOP_LOCK' },
levelup: { bank: 'ui.ON_ACT1AUTOCHESS_SHOP_UPGRADE' },
merge: { bank: 'battle.ON_ACT1AUTOCHESS_CHAR_BONUS' },
equip: { bank: 'battle.ON_ACT1AUTOCHESS_EQUIP_DONE' },
itemMerge: { bank: 'battle.ON_ACT1AUTOCHESS_EQUIP_BONUS' },
bondUp: { bank: 'battle.ON_ACT1AUTOCHESS_ADD_BOND' },
artPlace: { bank: 'battle.ON_ACT1AUTOCHESS_MAGIC_PLACE_BATTLE' },
ready: { bank: 'ui.ON_ACT1AUTOCHESS_PLAYER_READY' },
timer: { bank: 'ui.ON_ACT1AUTOCHESS_COUNTDOWN' },
draft: { bank: 'ui.ON_ACT1AUTOCHESS_STRATEGY' },
yourTurn: { bank: 'ui.ON_ACT1AUTOCHESS_YOURTURN' },
yourTurnCircle: { bank: 'ui.ON_ACT1AUTOCHESS_YOURTURN_CIRCLE' },
target: { bank: 'ui.ON_ACT1AUTOCHESS_TARGET' },
broadcast: { bank: 'ui.ON_ACT1AUTOCHESS_BROADCASTHINT' },
danger: { bank: 'battle.ON_ACT1AUTOCHESS_ENTER_DANGER' },
emote: { bank: 'ui.ON_ACT1AUTOCHESS_EMOJIDIALOGUE' },
roundStart: { bank: 'ui.ON_ACT1AUTOCHESS_ROUNDSTART' },
rest: { bank: 'ui.ON_ACT1AUTOCHESS_REST' },
battleStart: { bank: 'ui.ON_ACT1AUTOCHESS_BATTLESTART' },
battleStartBoss: { bank: 'ui.ON_ACT1AUTOCHESS_BATTLESTART_BOSS' },
bossRoundTeam: { bank: 'ui.ON_ACT1AUTOCHESS_BOSSROUND_TEAM' },
bossRoundSingle: { bank: 'ui.ON_ACT1AUTOCHESS_BOSSROUND_SINGLE' },
bossRoundSecret: { bank: 'ui.ON_ACT1AUTOCHESS_BOSSROUND_SECRET' },
killBoss: { bank: 'ui.ON_ACT1AUTOCHESS_KILLBOSS' },
killBossAll: { bank: 'ui.ON_ACT1AUTOCHESS_KILLBOSS_ALL' },
killBossNormal: { bank: 'ui.ON_ACT1AUTOCHESS_KILLBOSS_NORMAL' },
defenceStart: { bank: 'ui.ON_ACT1AUTOCHESS_DEFENCE_START' },
defenceUnite: { bank: 'ui.ON_ACT1AUTOCHESS_DEFENCE_UNITE' },
battleOverReduce: { bank: 'ui.ON_ACT1AUTOCHESS_BATTLEOVER_REDUCE' },
battleOverNoReduce: { bank: 'ui.ON_ACT1AUTOCHESS_BATTLEOVER_NOREDUCE' },
battleOverNormal: { bank: 'ui.ON_ACT1AUTOCHESS_BATTLEOVER_NORMAL' },
goFirst: { bank: 'ui.ON_ACT1AUTOCHESS_GOFIRST' },
disconnect: { bank: 'ui.ON_ACT1AUTOCHESS_DISCONNECT' },
settlementSucceed: { bank: 'ui.ON_ACT1AUTOCHESS_SETTLEMENT_SUCCEED' },
settlementFail: { bank: 'ui.ON_ACT1AUTOCHESS_SETTLEMENT_FAIL' },
settlementTeam: { bank: 'ui.ON_ACT1AUTOCHESS_SETTLEMENT_TEAM' },
settlementBossSign: { bank: 'ui.ON_ACT1AUTOCHESS_SETTLEMENT_BOSSSIGN' },
goodEvaluation: { bank: 'ui.ON_ACT1AUTOCHESS_GOODEVALUATION' },
load: { bank: 'ui.ON_ACT1AUTOCHESS_LOAD' },
start: { bank: 'ui.ON_ACT1AUTOCHESS_START' },
matchSucceed: { bank: 'ui.ON_ACT1AUTOCHESS_MATCH_SUCCEED' },
matchFail: { bank: 'ui.ON_ACT1AUTOCHESS_MATCH_FAIL' },
matchCancel: { bank: 'ui.ON_ACT1AUTOCHESS_MATCH_CANCEL' },
joinRoom: { bank: 'ui.ON_ACT1AUTOCHESS_PLAYER_JOINROOM' },
});
/** In-battle generic SFX: name → { bank } or { path }. */
export const BATTLE_SFX = Object.freeze({
deploy: { path: 'battle/b_char/b_char_set.mp3' },
tokenDeploy: { path: 'battle/b_char/b_char_tokenset.mp3' },
charDie: { path: 'battle/b_char/b_char_dead.mp3' },
enemyDie: { path: 'battle/b_enemy/b_enemy_dead_n.mp3' },
enemyDieHeavy: { path: 'battle/b_enemy/b_enemy_dead_h.mp3' },
enemyHit: { path: 'enemy/e_imp/e_imp_general_w.mp3' },
heal: { bank: 'battle.ON_MODIFIER_HEAL' },
win: { path: 'battle/b_ui/b_ui_win.mp3' },
lose: { path: 'battle/b_ui/b_ui_lose.mp3' },
killCoin: { bank: 'battle.ON_CUSTOM_TRIGGER.autochess_kill_gain_coin' },
});
/**
* Resolve a { bank } / { path } spec to candidate sound paths.
* @param {{bank?:string, path?:string}} spec
* @param {(name:string)=>string[]} bank bank lookup from indexAudio()
* @returns {string[]}
*/
export function resolveSpec(spec, bank) {
if (spec?.path) return [spec.path];
if (spec?.bank) return bank(spec.bank);
return [];
}
+70
View File
@@ -0,0 +1,70 @@
// Cached upstream JSON indexes needed by the asset pipeline:
// .cache/gamedata/excel/audio_data.json (Kengxxiao/ArknightsGameData, zh_CN)
// .cache/ark-models/models_data.json (isHarryh/Ark-Models enemy Spine index)
// Downloaded once when missing (or with --refresh-index), then reused.
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { RAW, mirrorUrl } from './sources.mjs';
/**
* Read a cached JSON file, downloading it first when missing or unparsable.
* @param {object} o
* @param {string} o.cacheFile absolute cache path
* @param {string} o.url raw GitHub URL
* @param {boolean} [o.refresh] force re-download
* @param {boolean} [o.offline] never download (throw when the cache is missing)
* @param {(m:string)=>void} [o.log]
* @returns {Promise<any>} parsed JSON
*/
export async function cachedJson({ cacheFile, url, refresh = false, offline = false, log = console.log }) {
if (!refresh || offline) {
try { return JSON.parse(await readFile(cacheFile, 'utf8')); } catch (e) {
if (offline) throw new Error(`--offline: cached index ${cacheFile} is missing or corrupt (${e.message}); run once online`);
}
}
let lastErr = null;
for (const src of [url, mirrorUrl(url)].filter(Boolean)) {
for (let attempt = 1; attempt <= 3; attempt++) {
try {
log(`[cache] downloading ${src}`);
const res = await fetch(src, { signal: AbortSignal.timeout(180000) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const text = await res.text();
const json = JSON.parse(text);
await mkdir(dirname(cacheFile), { recursive: true });
await writeFile(cacheFile + '.tmp', text);
await rename(cacheFile + '.tmp', cacheFile);
return json;
} catch (e) {
lastErr = e;
await new Promise((r) => setTimeout(r, 500 * attempt));
}
}
}
throw new Error(`cannot fetch ${url}: ${lastErr?.message}`);
}
/**
* Load audio_data.json (official) and Ark-Models models_data.json.
* @param {string} root project root
* @param {{refresh?:boolean, offline?:boolean, log?:(m:string)=>void}} [opts]
* @returns {Promise<{ audioData: any, modelsData: any }>}
*/
export async function loadIndexes(root, opts = {}) {
const audioData = await cachedJson({
cacheFile: join(root, '.cache', 'gamedata', 'excel', 'audio_data.json'),
url: RAW.gamedata + 'excel/audio_data.json',
refresh: opts.refresh,
offline: opts.offline,
log: opts.log,
});
const modelsData = await cachedJson({
cacheFile: join(root, '.cache', 'ark-models', 'models_data.json'),
url: RAW.arkModels + 'models_data.json',
refresh: opts.refresh,
offline: opts.offline,
log: opts.log,
});
return { audioData, modelsData };
}
+219
View File
@@ -0,0 +1,219 @@
// Concurrent, idempotent, verifying downloader for the asset pipeline.
//
// - Bounded concurrency (default 16) over a job list; each job writes exactly one
// file under the output root, atomically (temp file + rename), so a partial
// download never appears at the final path.
// - Per source: up to `retries` attempts with exponential backoff on network
// errors, timeouts, 403/429/5xx and payloads that fail format validation;
// a 404/410 moves on immediately. Sources: each candidate URL, then its
// jsDelivr mirror (sources.mirrorUrl).
// - Idempotent: an existing file is kept when its size matches the ledger entry
// of a previous download or the expected byte count from research, or (when
// neither is known) when it passes format validation. The ledger lives in
// .cache/assets-ledger.json.
import { mkdir, readFile, rename, stat, unlink, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { mirrorUrl } from './sources.mjs';
import { validate } from './formats.mjs';
/**
* @typedef {{ rel: string, urls: string[], kind: string, bytes?: number, mutable?: boolean }} Job
* @typedef {{ status: 'ok'|'skip'|'miss'|'error', bytes: number, url?: string, error?: string, sizeChanged?: boolean }} JobResult
*/
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
class HttpError extends Error {
constructor(status, url) { super(`HTTP ${status} ${url}`); this.status = status; }
}
function fmtMB(n) { return (n / 1048576).toFixed(1) + ' MB'; }
export class Downloader {
/**
* @param {object} o
* @param {string} o.root absolute output directory (public/assets)
* @param {string} o.ledgerPath absolute path of the ledger JSON
* @param {number} [o.concurrency]
* @param {number} [o.retries]
* @param {number} [o.timeoutMs]
* @param {boolean} [o.force] re-download even when files exist
* @param {(msg:string)=>void} [o.log]
* @param {typeof fetch} [o.fetchImpl]
* @param {number} [o.backoffMs] base retry delay (doubles per attempt)
*/
constructor({ root, ledgerPath, concurrency = 16, retries = 3, timeoutMs = 120000, force = false, log = console.log, fetchImpl = globalThis.fetch, backoffMs = 400 }) {
this.root = root;
this.ledgerPath = ledgerPath;
this.concurrency = Math.max(1, Math.min(64, Number(concurrency) || 16));
this.retries = Math.max(1, Number(retries) || 3);
this.timeoutMs = timeoutMs;
this.force = force;
this.log = log;
this.fetch = fetchImpl;
this.backoffMs = Math.max(0, Number(backoffMs) || 0);
this.ledger = { files: {} };
this.totals = { ok: 0, skip: 0, miss: 0, error: 0, bytesDownloaded: 0, sizeChanged: 0 };
this.dirty = 0;
this.saving = false;
}
/** Load the ledger (missing or corrupt ledger → empty). */
async loadLedger() {
try {
const j = JSON.parse(await readFile(this.ledgerPath, 'utf8'));
if (j && typeof j === 'object' && j.files && typeof j.files === 'object') this.ledger = j;
} catch { /* first run */ }
}
/** Persist the ledger. */
async saveLedger() {
await mkdir(dirname(this.ledgerPath), { recursive: true });
const tmp = this.ledgerPath + '.tmp';
await writeFile(tmp, JSON.stringify(this.ledger));
await rename(tmp, this.ledgerPath);
this.dirty = 0;
}
/**
* Decide whether an existing file can be kept.
* @param {Job} job
* @returns {Promise<number>} size of the kept file, or -1 to (re)download
*/
async existingSize(job) {
if (this.force) return -1;
const abs = join(this.root, job.rel);
let st;
try { st = await stat(abs); } catch { return -1; }
if (!st.isFile() || st.size <= 0) return -1;
const led = this.ledger.files[job.rel];
if (job.mutable) {
// Post-processed files (atlases) change size; validate content instead.
try { return validate(job.kind, await readFile(abs)) ? st.size : -1; } catch { return -1; }
}
if (led && led.bytes === st.size) return st.size;
if (job.bytes && job.bytes === st.size) return st.size;
if (led && led.bytes !== st.size) return -1; // changed since we wrote it
// Unknown provenance (e.g. ledger deleted): keep it only if it validates.
try { return validate(job.kind, await readFile(abs)) ? st.size : -1; } catch { return -1; }
}
/**
* Fetch one URL with retries.
* @returns {Promise<{buf:Buffer}|{notFound:true}|{error:string}>}
*/
async fetchWithRetries(url, kind) {
let lastErr = 'unknown error';
for (let attempt = 1; attempt <= this.retries; attempt++) {
try {
const res = await this.fetch(url, {
headers: { 'user-agent': 'stronghold-protocol-fetch-assets/1.0' },
signal: AbortSignal.timeout(this.timeoutMs),
redirect: 'follow',
});
if (res.status === 404 || res.status === 410) {
try { await res.body?.cancel(); } catch { /* ignore */ }
return { notFound: true };
}
if (!res.ok) {
try { await res.body?.cancel(); } catch { /* ignore */ }
throw new HttpError(res.status, url);
}
const buf = Buffer.from(await res.arrayBuffer());
const len = Number(res.headers.get('content-length'));
const enc = res.headers.get('content-encoding');
if (!enc && Number.isFinite(len) && len > 0 && len !== buf.length) {
throw new Error(`truncated body ${buf.length}/${len} ${url}`);
}
if (!validate(kind, buf)) throw new Error(`invalid ${kind} payload (${buf.length} B) ${url}`);
return { buf };
} catch (e) {
lastErr = e?.message || String(e);
if (attempt < this.retries && this.backoffMs) await sleep(this.backoffMs * 2 ** (attempt - 1) + Math.floor(Math.random() * this.backoffMs));
}
}
return { error: lastErr };
}
/**
* Download one job (all candidate URLs and mirrors).
* @param {Job} job
* @returns {Promise<JobResult>}
*/
async runJob(job) {
const kept = await this.existingSize(job);
if (kept >= 0) return { status: 'skip', bytes: kept };
let lastError = null;
for (const url of job.urls) {
const sources = [url, mirrorUrl(url)].filter(Boolean);
for (const src of sources) {
const r = await this.fetchWithRetries(src, job.kind);
if (r.notFound) continue;
if (r.error) { lastError = r.error; continue; }
const abs = join(this.root, job.rel);
await mkdir(dirname(abs), { recursive: true });
const tmp = `${abs}.part${process.pid}`;
try {
await writeFile(tmp, r.buf);
await rename(tmp, abs);
} catch (e) {
try { await unlink(tmp); } catch { /* ignore */ }
return { status: 'error', bytes: 0, error: `write failed: ${e.message}` };
}
this.ledger.files[job.rel] = { url: src, bytes: r.buf.length };
this.dirty++;
const sizeChanged = !!(job.bytes && job.bytes !== r.buf.length && !job.mutable);
return { status: 'ok', bytes: r.buf.length, url: src, sizeChanged };
}
}
return lastError ? { status: 'error', bytes: 0, error: lastError } : { status: 'miss', bytes: 0, error: 'not found (404) on all sources' };
}
/**
* Run a list of jobs with bounded concurrency. Duplicate `rel`s are run once.
* @param {Job[]} jobs
* @param {string} label progress label
* @returns {Promise<Map<string, JobResult>>}
*/
async run(jobs, label = 'download') {
const unique = [];
const seen = new Set();
for (const j of jobs) { if (!seen.has(j.rel)) { seen.add(j.rel); unique.push(j); } }
const results = new Map();
const t0 = Date.now();
let done = 0; let bytes = 0; let lastLog = 0;
const counts = { ok: 0, skip: 0, miss: 0, error: 0 };
const progress = (force) => {
const now = Date.now();
if (!force && now - lastLog < 2000) return;
lastLog = now;
const secs = Math.max(0.001, (now - t0) / 1000);
this.log(`[${label}] ${done}/${unique.length} ok=${counts.ok} skip=${counts.skip} miss=${counts.miss} err=${counts.error} ` +
`${fmtMB(bytes)} ${(bytes / 1048576 / secs).toFixed(2)} MB/s`);
};
let next = 0;
const worker = async () => {
while (next < unique.length) {
const job = unique[next++];
let r;
try { r = await this.runJob(job); } catch (e) { r = { status: 'error', bytes: 0, error: e?.message || String(e) }; }
results.set(job.rel, r);
counts[r.status]++;
this.totals[r.status]++;
if (r.status === 'ok') { bytes += r.bytes; this.totals.bytesDownloaded += r.bytes; }
if (r.sizeChanged) this.totals.sizeChanged++;
done++;
if (this.dirty >= 200 && !this.saving) {
this.saving = true;
try { await this.saveLedger(); } catch { /* retried at the end */ } finally { this.saving = false; }
}
progress(false);
}
};
await Promise.all(Array.from({ length: Math.min(this.concurrency, unique.length) }, worker));
if (unique.length) progress(true);
await this.saveLedger();
return results;
}
}
+86
View File
@@ -0,0 +1,86 @@
// Self-hosted UI fonts (research 07 §8): Bender (numerals/HUD) and Novecento
// Wide (Latin headers), from TimWangZi/The-font-of-Arknights. Downloaded next
// to the site as public/fonts/*.{otf,ttf}, converted to .woff2 (tools/assets/
// woff2.mjs) and exposed through public/fonts/fonts.css. Noto Sans/Serif SC are
// NOT self-hosted (Google Fonts with system fallback, see DESIGN §10).
import { readFile, writeFile, mkdir, rename, stat } from 'node:fs/promises';
import { join } from 'node:path';
import { RAW, joinUrl } from './sources.mjs';
import { encodeWoff2, decodeWoff2Tables, readSfnt } from './woff2.mjs';
/** Font files: family, weight, source path in the font repo, local base name. */
export const FONTS = Object.freeze([
{ family: 'Bender', weight: 400, style: 'normal', src: 'Bender/BENDER.OTF', name: 'bender-regular', ext: 'otf', bytes: 52536 },
{ family: 'Bender', weight: 300, style: 'normal', src: 'Bender/BenderLight.woff.ttf', name: 'bender-light', ext: 'ttf', bytes: 68256 },
{ family: 'Novecento Wide', weight: 400, style: 'normal', src: 'Novecento-Wide-Normal-2.otf', name: 'novecento-wide-normal', ext: 'otf', bytes: 45356 },
]);
/**
* Download jobs for the font files (rel paths are relative to public/fonts).
* @returns {{rel:string, urls:string[], kind:string, bytes:number}[]}
*/
export function fontJobs() {
return FONTS.map((f) => ({ rel: `${f.name}.${f.ext}`, urls: [joinUrl(RAW.fonts, f.src)], kind: 'font', bytes: f.bytes }));
}
async function exists(p) { try { return (await stat(p)).isFile(); } catch { return false; } }
/**
* Convert downloaded fonts to WOFF2 (skipping up-to-date outputs) and write fonts.css.
* @param {string} fontsDir absolute public/fonts directory
* @param {(m:string)=>void} log
* @returns {Promise<{ files: Record<string, {family:string, weight:number, woff2?:string, original?:string}>, css: string|null, errors: string[] }>}
*/
export async function buildFonts(fontsDir, log = console.log) {
await mkdir(fontsDir, { recursive: true });
const files = {};
const errors = [];
const faces = [];
for (const f of FONTS) {
const orig = join(fontsDir, `${f.name}.${f.ext}`);
const woff2 = join(fontsDir, `${f.name}.woff2`);
if (!(await exists(orig))) { errors.push(`missing font ${f.name}.${f.ext}`); continue; }
const entry = { family: f.family, weight: f.weight, original: `/fonts/${f.name}.${f.ext}` };
try {
const src = await readFile(orig);
readSfnt(src); // validate
let ok = false;
if (await exists(woff2)) {
// Up to date when it decodes to exactly the source tables.
try {
const dec = decodeWoff2Tables(await readFile(woff2));
const s = readSfnt(src);
ok = dec.tables.length === s.tables.length && s.tables.every((t) => dec.tables.find((d) => d.tag === t.tag)?.data.equals(t.data));
} catch { ok = false; }
}
if (!ok) {
const out = encodeWoff2(src);
const tmp = woff2 + '.tmp';
await writeFile(tmp, out);
await rename(tmp, woff2);
log(`[fonts] ${f.name}.woff2 ${src.length} → ${out.length} B`);
}
entry.woff2 = `/fonts/${f.name}.woff2`;
} catch (e) {
errors.push(`${f.name}: ${e.message}`);
}
files[f.name] = entry;
const srcs = [];
if (entry.woff2) srcs.push(`url('${entry.woff2}') format('woff2')`);
srcs.push(`url('${entry.original}') format('${f.ext === 'ttf' ? 'truetype' : 'opentype'}')`);
faces.push(`@font-face {\n font-family: '${f.family}';\n font-style: ${f.style};\n font-weight: ${f.weight};\n` +
` font-display: swap;\n src: ${srcs.join(',\n ')};\n}`);
}
let css = null;
if (faces.length) {
css = `/* Generated by tools/fetch-assets.mjs — do not edit.\n` +
` * Bender (c) Jovanny Lemonad / Oleg Zhuravlev, Gladkikh Ivan — free font.\n` +
` * Novecento Wide (c) Jan Tonellato / Synthview — free font.\n` +
` * Usage: font-family: Bender, Rajdhani, 'Noto Sans SC', sans-serif;\n` +
` * font-family: 'Novecento Wide', 'Saira Condensed', 'Noto Sans SC', sans-serif; */\n\n` +
faces.join('\n\n') + '\n';
await writeFile(join(fontsDir, 'fonts.css'), css);
}
return { files, css, errors };
}
+121
View File
@@ -0,0 +1,121 @@
// Binary format sniffing/validation for downloaded assets (PNG, MP3, fonts,
// Spine skel/atlas, JSON). Used to reject truncated or error-page downloads and
// to decide whether an existing file on disk can be kept (idempotent re-runs).
const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
/**
* Read width/height from a PNG IHDR chunk.
* @param {Buffer|Uint8Array} buf
* @returns {{width:number,height:number}|null} null when not a PNG
*/
export function pngSize(buf) {
if (!buf || buf.length < 24) return null;
const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf.buffer, buf.byteOffset, buf.byteLength);
if (!b.subarray(0, 8).equals(PNG_SIG)) return null;
if (b.toString('latin1', 12, 16) !== 'IHDR') return null;
const width = b.readUInt32BE(16);
const height = b.readUInt32BE(20);
if (!width || !height) return null;
return { width, height };
}
/**
* True when the buffer is a complete PNG (signature + IHDR + trailing IEND chunk).
* @param {Buffer} buf
* @returns {boolean}
*/
export function isCompletePng(buf) {
if (!pngSize(buf)) return false;
// IEND chunk: length(4)=0, 'IEND', CRC(4) → the last 12 bytes.
return buf.length >= 12 && buf.toString('latin1', buf.length - 8, buf.length - 4) === 'IEND';
}
/**
* True when the buffer looks like an MP3 (ID3v2 tag or MPEG audio frame sync).
* @param {Buffer} buf
* @returns {boolean}
*/
export function isMp3(buf) {
if (!buf || buf.length < 128) return false;
if (buf.toString('latin1', 0, 3) === 'ID3') return true;
return buf[0] === 0xff && (buf[1] & 0xe0) === 0xe0;
}
/**
* True when the buffer is an OpenType/TrueType font (sfnt).
* @param {Buffer} buf
* @returns {boolean}
*/
export function isSfnt(buf) {
if (!buf || buf.length < 12) return false;
const tag = buf.readUInt32BE(0);
return tag === 0x00010000 || tag === 0x4f54544f /* OTTO */ || tag === 0x74727565 /* true */;
}
/**
* True when the buffer is a WOFF2 file.
* @param {Buffer} buf
* @returns {boolean}
*/
export function isWoff2(buf) {
return !!buf && buf.length >= 48 && buf.readUInt32BE(0) === 0x774f4632;
}
/**
* True when the text looks like a Spine atlas (has at least one page image line).
* @param {Buffer|string} data
* @returns {boolean}
*/
export function isAtlasText(data) {
const s = typeof data === 'string' ? data : Buffer.from(data).toString('utf8');
if (s.includes('\u0000')) return false;
return /^[^\s:][^\r\n:]*\.(png|webp|jpg)\s*$/im.test(s);
}
/**
* True when the buffer looks like a Spine binary skeleton (not an HTML/text error page).
* Spine 3.8 binaries start with a varint-prefixed hash string.
* @param {Buffer} buf
* @returns {boolean}
*/
export function isSkelBinary(buf) {
if (!buf || buf.length < 32) return false;
const head = buf.toString('latin1', 0, 16).toLowerCase();
return !head.startsWith('<!doctype') && !head.startsWith('<html') && !head.startsWith('404');
}
/**
* Validate a buffer for the given asset kind.
* @param {'png'|'mp3'|'font'|'atlas'|'skel'|'json'|'bin'} kind
* @param {Buffer} buf
* @returns {boolean}
*/
export function validate(kind, buf) {
switch (kind) {
case 'png': return isCompletePng(buf);
case 'mp3': return isMp3(buf);
case 'font': return isSfnt(buf);
case 'atlas': return isAtlasText(buf);
case 'skel': return isSkelBinary(buf);
case 'json':
try { JSON.parse(buf.toString('utf8')); return true; } catch { return false; }
default: return !!buf && buf.length > 0;
}
}
/**
* Infer the asset kind from a file name.
* @param {string} name
* @returns {'png'|'mp3'|'font'|'atlas'|'skel'|'json'|'bin'}
*/
export function kindOf(name) {
const ext = String(name).toLowerCase().split('.').pop();
if (ext === 'png') return 'png';
if (ext === 'mp3') return 'mp3';
if (ext === 'otf' || ext === 'ttf') return 'font';
if (ext === 'atlas') return 'atlas';
if (ext === 'skel') return 'skel';
if (ext === 'json') return 'json';
return 'bin';
}
+137
View File
@@ -0,0 +1,137 @@
// Manifest assembly: walks the plan template (tools/assets/plan.mjs), collects
// the single-file download alternatives, and resolves the template against the
// files that actually exist on disk into data/assets.json. Entries whose files
// are missing are dropped (never emitted as broken URLs).
import { existsSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join } from 'node:path';
import { assetUrl, mirrorUrl } from './sources.mjs';
/** Manifest schema version (bump on breaking shape changes). */
export const MANIFEST_VERSION = 1;
const isLeaf = (n) => !!n && typeof n === 'object' && Array.isArray(n.alts);
const isModelRef = (n) => !!n && typeof n === 'object' && typeof n.model === 'string' && Object.keys(n).length === 1;
/**
* Collect every leaf of the template with its dotted path.
* @param {any} node
* @param {string} [path]
* @param {{path:string, leaf:any}[]} [out]
* @returns {{path:string, leaf:any}[]}
*/
export function collectLeaves(node, path = '', out = []) {
if (isLeaf(node)) { out.push({ path, leaf: node }); return out; }
if (isModelRef(node) || !node || typeof node !== 'object') return out;
for (const [k, v] of Object.entries(node)) collectLeaves(v, path ? `${path}.${k}` : k, out);
return out;
}
/**
* Download leaves round by round: round r tries each unsatisfied leaf's r-th alternative.
* A leaf only moves on to its next alternative after a definitive miss (404 on every source);
* a transient failure (network error, 5xx, invalid payload after all retries) stops that leaf, so
* a fallback file (e.g. the base enemy's icon) never lands on the primary's path by accident and
* the next run retries the primary.
* @param {{path:string, leaf:any}[]} leaves
* @param {import('./downloader.mjs').Downloader} dl
* @param {string} root public/assets
* @param {string} [label]
* @returns {Promise<string[]>} dotted paths of leaves that stopped on a transient error
*/
export async function downloadLeaves(leaves, dl, root, label = 'files') {
const tried = new Map(leaves.map((l) => [l, 0]));
const blocked = new Set();
const satisfied = (l) => l.leaf.alts.slice(0, tried.get(l)).some((a) => existsSync(join(root, a.rel)));
let last = new Map();
for (let round = 0; round < 8; round++) {
const jobs = [];
for (const l of leaves) {
const t = tried.get(l);
if (blocked.has(l)) continue;
if (t > 0 && satisfied(l)) continue;
if (t > 0 && last.get(l.leaf.alts[t - 1].rel)?.status === 'error') { blocked.add(l); continue; }
if (t >= l.leaf.alts.length) continue;
jobs.push(l.leaf.alts[t]);
tried.set(l, t + 1);
}
if (!jobs.length) break;
last = await dl.run(jobs, round === 0 ? label : `${label} fallback#${round}`);
}
return [...blocked].map((l) => l.path);
}
/**
* Resolve the template against the disk.
* @param {any} template
* @param {object} o
* @param {string} o.root public/assets
* @param {Map<string, any>} o.spine resolved Spine entries by model key
* @param {(rel:string)=>string|undefined} [o.sourceOf] URL a file was downloaded from (ledger), to report
* fallbacks that share the primary's path (e.g. an enemy icon taken from its base id)
* @returns {{ value: any, misses: string[], fallbacks: string[], files: Set<string> }}
*/
export function resolveTemplate(template, { root, spine, sourceOf = () => undefined }) {
const misses = [];
const fallbacks = [];
const files = new Set();
const walk = (node, path) => {
if (node === null || node === undefined) return undefined;
if (isLeaf(node)) {
for (let i = 0; i < node.alts.length; i++) {
const a = node.alts[i];
if (existsSync(join(root, a.rel))) {
const src = sourceOf(a.rel);
const primary = node.alts[0].urls.flatMap((u) => [u, mirrorUrl(u)]);
if (i > 0) fallbacks.push(`${path} ← ${src || a.urls[0]}`);
else if (src && !primary.includes(src)) fallbacks.push(`${path} ← ${src}`);
files.add(a.rel);
return assetUrl(a.rel);
}
}
misses.push(path);
return undefined;
}
if (isModelRef(node)) {
const e = spine.get(node.model);
if (!e) { misses.push(`${path} (spine ${node.model})`); return undefined; }
for (const u of [e.skel, e.atlas, ...e.textures]) files.add(u.replace(/^\/assets\//, ''));
return e;
}
if (Array.isArray(node)) return node.map((x, i) => walk(x, `${path}[${i}]`)).filter((x) => x !== undefined);
if (typeof node !== 'object') return node;
const out = {};
for (const [k, v] of Object.entries(node)) {
const r = walk(v, path ? `${path}.${k}` : k);
if (r === undefined) continue;
if (r && typeof r === 'object' && !Array.isArray(r) && !Object.keys(r).length && isContainer(v)) continue;
out[k] = r;
}
return out;
};
const isContainer = (v) => v && typeof v === 'object' && !isLeaf(v) && !isModelRef(v);
const value = walk(template, '');
return { value, misses, fallbacks, files };
}
/**
* Total size in bytes of files under root.
* @param {string} root
* @param {Iterable<string>} rels
* @returns {number}
*/
export function totalBytes(root, rels) {
let n = 0;
for (const r of rels) { try { n += statSync(join(root, r)).size; } catch { /* missing */ } }
return n;
}
/**
* Deterministic short content hash of a JSON value (for cache busting).
* @param {any} value
* @returns {string}
*/
export function contentHash(value) {
return createHash('sha1').update(JSON.stringify(value)).digest('hex').slice(0, 12);
}
+463
View File
@@ -0,0 +1,463 @@
// 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.
// 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 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, BGM and SFX.
import { RAW, joinUrl, safeName, urlBase, urlDir } from './sources.mjs';
import { kindOf } from './formats.mjs';
import { pickUnitSfx, UI_SFX, BATTLE_SFX, resolveSpec } from './audio.mjs';
/** Enemies with no Spine anywhere: render them with another enemy's model (research 07 §5.6). */
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'];
/** Extra UI sprites (not in 07-assets.json groups): [group, key, path under ArknightsAssets2 cn assets/dyn]. */
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`]);
return L;
})();
/** 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;
}
/** 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)));
}
/** 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);
}
}
// ---------------------------------------------------------------------------
// 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 {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)
* @returns {{ template: any, models: Map<string, any>, notes: string[] }}
*/
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, extraEnemyIds = [], extraTokenIds = [], extraHandbook = {} }) {
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 charIds = Object.keys(assets07?.operators || {}).sort();
for (const id of charIds) {
const o = assets07.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
const sfx = pickUnitSfx(audio.unitBanks.get(id));
const u = {};
for (const r of ['attack', 'hit', 'die', 'born']) if (sfx[r]) u[r] = soundLeaf(sfx[r]);
const skillSfx = {};
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);
}
const primarySkill = skillSfx[String(idx[0])];
if (primarySkill) u.skill = primarySkill;
if (Object.keys(skillSfx).length > 1) u.skills = skillSfx;
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;
tokens[id] = entry;
const sfx = pickUnitSfx(audio.unitBanks.get(id));
const u = {};
for (const r of ['attack', 'hit', 'die', 'born']) if (sfx[r]) u[r] = soundLeaf(sfx[r]);
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;
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 sfx = pickUnitSfx(banks);
const u = {};
for (const r of ['attack', 'hit', 'die', 'born']) if (sfx[r]) u[r] = soundLeaf(sfx[r]);
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(assets07?.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(assets07?.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;
};
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')),
};
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`); }
const template = {
chars, enemies, tokens, bonds, items, bands, skills, skillsById, ui, prof,
audio: { bgm, bossBgm: Object.fromEntries(Object.entries(bossBgm).sort(([a], [b]) => a.localeCompare(b, 'en', { numeric: true }))), sfx: { ui: sfxUi, battle: sfxBattle, units: unitsSfx } },
};
return { template, models, notes };
}
+98
View File
@@ -0,0 +1,98 @@
// Spine 3.8 binary skeleton (.skel) inspection in Node, using the same parser
// the client ships (@pixi-spine/runtime-3.8, a dependency of pixi-spine).
//
// We only need metadata: animation names + durations, event names, the times
// of `OnAttack` events per animation (hit timing, research 07 §5.4) and the
// skeleton bounds. Attachments are created without textures, so no atlas or
// canvas is needed; attachment paths are checked against the atlas region names
// to detect mismatched downloads.
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const require = createRequire(join(root, 'package.json'));
let runtime = null;
/**
* Lazily load @pixi-spine/runtime-3.8.
* @returns {any} the runtime module
* @throws when pixi-spine is not installed (run `npm install`)
*/
function loadRuntime() {
if (runtime) return runtime;
runtime = require('@pixi-spine/runtime-3.8');
return runtime;
}
/**
* True when the Spine parser can be loaded.
* @returns {boolean}
*/
export function skelParserAvailable() {
try { loadRuntime(); return true; } catch { return false; }
}
function makeLoader(r, regions, missing) {
const check = (path) => { if (regions && !regions.has(path)) missing.add(path); };
return {
newRegionAttachment(skin, name, path) { check(path); return new r.RegionAttachment(name); },
newMeshAttachment(skin, name, path) { check(path); return new r.MeshAttachment(name); },
newBoundingBoxAttachment(skin, name) { return new r.BoundingBoxAttachment(name); },
newPathAttachment(skin, name) { return new r.PathAttachment(name); },
newPointAttachment(skin, name) { return new r.PointAttachment(name); },
newClippingAttachment(skin, name) { return new r.ClippingAttachment(name); },
};
}
const round3 = (x) => Math.round(Number(x) * 1000) / 1000;
/**
* @typedef {{ version: string, animations: string[], durations: Record<string, number>,
* events: string[], hits: Record<string, number[]>, bounds: {x:number,y:number,width:number,height:number}|null,
* missingRegions: string[] }} SkelInfo
*/
/**
* Parse a Spine 3.8 binary skeleton.
* @param {Uint8Array} bytes .skel file contents
* @param {Set<string>} [atlasRegions] region names of the matching atlas (optional, for validation)
* @returns {SkelInfo}
* @throws on malformed data
*/
export function parseSkel(bytes, atlasRegions) {
const r = loadRuntime();
const missing = new Set();
const bin = new r.SkeletonBinary(makeLoader(r, atlasRegions, missing));
const data = bin.readSkeletonData(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
const animations = data.animations.map((a) => a.name);
const durations = {};
const hits = {};
for (const a of data.animations) {
durations[a.name] = round3(a.duration);
for (const t of a.timelines) {
if (!t || !Array.isArray(t.events)) continue;
for (const e of t.events) {
const nm = e?.data?.name;
if (typeof nm === 'string' && /^onattack$/i.test(nm)) {
(hits[a.name] ||= []).push(round3(e.time));
}
}
}
if (hits[a.name]) hits[a.name].sort((x, y) => x - y);
}
const w = Number(data.width); const h = Number(data.height);
const bounds = Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0
? { x: round3(data.x), y: round3(data.y), width: round3(w), height: round3(h) }
: null;
return {
version: String(data.version ?? ''),
animations,
durations,
events: data.events.map((e) => e.name),
hits,
bounds,
missingRegions: [...missing].sort(),
};
}
+94
View File
@@ -0,0 +1,94 @@
// Upstream asset sources (GitHub dumps of the official client), URL helpers and
// local-path helpers shared by the asset pipeline (tools/fetch-assets.mjs).
//
// Every URL we download from is a raw.githubusercontent.com URL; mirrorUrl()
// maps it to the equivalent jsDelivr URL used as a fallback when GitHub raw
// fails. jsDelivr 404s on the ArknightsAssets2 `voice` branch, so no mirror is
// offered for audio. See docs/ASSETS.md for the full source list and credits.
/** Raw base URLs (always end with '/'). */
export const RAW = Object.freeze({
yuanyan: 'https://raw.githubusercontent.com/yuanyan3060/ArknightsGameResource/main/',
fexli: 'https://raw.githubusercontent.com/fexli/ArknightsResource/main/',
arkModels: 'https://raw.githubusercontent.com/isHarryh/Ark-Models/main/',
aa2: 'https://raw.githubusercontent.com/ArknightsAssets/ArknightsAssets2/cn/assets/dyn/',
aa2voice: 'https://raw.githubusercontent.com/ArknightsAssets/ArknightsAssets2/voice/assets/dyn/audio/sound_beta_2/',
fonts: 'https://raw.githubusercontent.com/TimWangZi/The-font-of-Arknights/master/font/',
gamedata: 'https://raw.githubusercontent.com/Kengxxiao/ArknightsGameData/master/zh_CN/gamedata/',
});
const RAW_RE = /^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/;
/**
* Map a raw.githubusercontent.com URL to its jsDelivr mirror.
* @param {string} url
* @returns {string|null} mirror URL, or null when no usable mirror exists
*/
export function mirrorUrl(url) {
const m = RAW_RE.exec(String(url));
if (!m) return null;
const [, owner, repo, branch, path] = m;
// jsDelivr does not serve the (huge) voice branch of ArknightsAssets2.
if (owner === 'ArknightsAssets' && branch === 'voice') return null;
return `https://cdn.jsdelivr.net/gh/${owner}/${repo}@${branch}/${path}`;
}
/**
* Percent-encode each segment of a repo-relative path ('[uc]x/a b.png' → '%5Buc%5Dx/a%20b.png').
* @param {string} relPath
* @returns {string}
*/
export function encodePath(relPath) {
return String(relPath).split('/').map((s) => encodeURIComponent(s)).join('/');
}
/**
* Join a base URL and a *decoded* repo-relative path, encoding the path.
* @param {string} base base URL ending with '/'
* @param {string} relPath decoded path
* @returns {string}
*/
export function joinUrl(base, relPath) {
return base + encodePath(relPath);
}
/**
* Sanitize a single file-name component for local storage / URLs:
* keeps [A-Za-z0-9._-], turns everything else (spaces, brackets, '#') into '_'.
* @param {string} name
* @returns {string}
*/
export function safeName(name) {
const s = String(name).replace(/[^A-Za-z0-9._-]/g, '_');
return s.length ? s : '_';
}
/**
* Site-root URL path for a file under public/assets.
* @param {string} rel path relative to public/assets (forward slashes)
* @returns {string} e.g. '/assets/char/avatar/char_002_amiya.png'
*/
export function assetUrl(rel) {
return '/assets/' + rel;
}
/**
* Directory part of a URL (everything up to and including the last '/').
* @param {string} url
* @returns {string}
*/
export function urlDir(url) {
const i = String(url).lastIndexOf('/');
return i >= 0 ? url.slice(0, i + 1) : '';
}
/**
* Last path component of a URL, percent-decoded.
* @param {string} url
* @returns {string}
*/
export function urlBase(url) {
const s = String(url).split('?')[0];
const last = s.slice(s.lastIndexOf('/') + 1);
try { return decodeURIComponent(last); } catch { return last; }
}
+130
View File
@@ -0,0 +1,130 @@
// Spine model pipeline: download skel/atlas/page PNGs, fetch extra atlas pages,
// normalize atlases (size:/pma:), parse skeletons and resolve animation roles.
// Produces the per-model `spine` entries of data/assets.json.
import { readFile, writeFile, stat, rename, mkdir, unlink } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { assetUrl, safeName, urlDir } from './sources.mjs';
import { kindOf, pngSize } from './formats.mjs';
import { atlasInfo, normalizeAtlas } from './atlas.mjs';
import { parseSkel } from './skel.mjs';
import { resolveRoles } from './anim-roles.mjs';
async function fileStat(p) { try { const s = await stat(p); return s.isFile() ? s : null; } catch { return null; } }
/**
* @typedef {{ skel: string, atlas: string, textures: string[], pma: boolean, anims: any,
* animations: Record<string, number>, events: string[], hits: Record<string, number[]>, bounds: any }} SpineEntry
*/
/**
* Run the whole Spine pipeline for every planned model.
* @param {Map<string, any>} models plan.models
* @param {object} o
* @param {string} o.root absolute public/assets directory
* @param {import('./downloader.mjs').Downloader} o.dl
* @param {string} o.cachePath parse cache JSON path
* @param {boolean} [o.download] false = only post-process what is on disk
* @param {(m:string)=>void} [o.log]
* @returns {Promise<{ entries: Map<string, SpineEntry>, problems: string[] }>}
*/
export async function processModels(models, { root, dl, cachePath, download = true, log = console.log }) {
const problems = [];
const list = [...models.values()];
if (download) {
const jobs = [];
for (const m of list) jobs.push(m.skel, m.atlas, ...m.pngs);
await dl.run(jobs, 'spine');
// Atlases may reference more pages than the index lists.
const extra = [];
for (const m of list) {
const text = await readFile(join(root, m.atlas.rel), 'utf8').catch(() => null);
if (!text) continue;
for (const page of atlasInfo(text).pages) {
const rel = m.dir + safeName(page);
if (!m.pngs.some((p) => p.rel === rel)) {
// Same folder as the atlas actually came from (ledger), then the planned folders.
const got = dl.ledger.files[m.atlas.rel]?.url;
const dirs = [...new Set([got ? urlDir(got) : null, m.baseUrl, ...m.atlas.urls.map(urlDir)].filter(Boolean))];
const a = { rel, urls: dirs.map((d) => d + encodeURIComponent(page)), kind: kindOf(page) };
m.pngs.push(a);
extra.push(a);
}
}
}
if (extra.length) await dl.run(extra, 'spine pages');
}
let cache = {};
try { cache = JSON.parse(await readFile(cachePath, 'utf8')) || {}; } catch { cache = {}; }
const nextCache = {};
const entries = new Map();
let parsed = 0; let cached = 0;
let ledgerDirty = false;
const t0 = Date.now();
for (const m of list) {
const skelAbs = join(root, m.skel.rel);
const atlasAbs = join(root, m.atlas.rel);
const skelSt = await fileStat(skelAbs);
const atlasText = await readFile(atlasAbs, 'utf8').catch(() => null);
if (!skelSt || !atlasText) { problems.push(`${m.key}: missing ${!skelSt ? 'skel' : 'atlas'}`); continue; }
// Normalize the atlas against the real page sizes.
const info0 = atlasInfo(atlasText);
const sizes = new Map();
let pagesOk = info0.pages.length > 0;
for (const page of info0.pages) {
const buf = await readFile(join(root, m.dir + safeName(page))).catch(() => null);
const sz = buf ? pngSize(buf) : null;
if (!sz) { pagesOk = false; problems.push(`${m.key}: missing/invalid page ${page}`); continue; }
sizes.set(page, sz);
}
if (!pagesOk) continue;
const norm = normalizeAtlas(atlasText, { pageSize: (p) => sizes.get(p) || null, pma: !!m.pma, renamePage: (p) => safeName(p) });
if (norm.missingSize.length) { problems.push(`${m.key}: cannot size pages ${norm.missingSize.join(',')}`); continue; }
if (norm.changed) {
await writeFile(atlasAbs + '.tmp', norm.text);
await rename(atlasAbs + '.tmp', atlasAbs);
}
const atlasSt = await fileStat(atlasAbs);
const info = atlasInfo(norm.text);
// Parse the skeleton (cached by file size + mtime).
const ck = `${m.skel.rel}|${skelSt.size}|${Math.floor(skelSt.mtimeMs)}|${atlasSt?.size}|${Math.floor(atlasSt?.mtimeMs ?? 0)}`;
let sk = cache[m.skel.rel]?.key === ck ? cache[m.skel.rel].info : null;
if (sk) cached++;
else {
try {
sk = parseSkel(await readFile(skelAbs), info.regions);
parsed++;
} catch (e) {
// A corrupt skeleton would otherwise be kept forever (its size matches the ledger):
// delete it and forget it so the next online run downloads it again.
problems.push(`${m.key}: skel parse failed (${e.message}); deleted, re-run to re-download`);
try { await unlink(skelAbs); } catch { /* ignore */ }
if (dl?.ledger?.files) delete dl.ledger.files[m.skel.rel];
ledgerDirty = true;
continue;
}
}
nextCache[m.skel.rel] = { key: ck, info: sk };
if (sk.missingRegions?.length) problems.push(`${m.key}: ${sk.missingRegions.length} attachment(s) not in atlas (e.g. ${sk.missingRegions[0]})`);
if (!sk.animations.length) { problems.push(`${m.key}: skeleton has no animations`); continue; }
const anims = resolveRoles(sk.animations, { skillIndices: m.skillIndices, durations: sk.durations });
entries.set(m.key, {
skel: assetUrl(m.skel.rel),
atlas: assetUrl(m.atlas.rel),
textures: info.pages.map((p) => assetUrl(m.dir + p)),
pma: !!m.pma,
anims,
animations: sk.durations,
events: sk.events,
hits: sk.hits,
bounds: sk.bounds,
});
if (Date.now() - t0 > 0 && (parsed + cached) % 100 === 0) log(`[spine] processed ${parsed + cached}/${list.length}`);
}
await mkdir(dirname(cachePath), { recursive: true });
await writeFile(cachePath, JSON.stringify(nextCache));
if (ledgerDirty && dl?.saveLedger) { try { await dl.saveLedger(); } catch { /* next run re-validates */ } }
log(`[spine] models ok=${entries.size}/${list.length} (parsed ${parsed}, cached ${cached})`);
return { entries, problems };
}
+168
View File
@@ -0,0 +1,168 @@
// Minimal WOFF2 encoder/decoder (W3C WOFF 2.0) with *null* table transforms,
// built on Node's zlib Brotli. Used to convert the self-hosted OTF/TTF fonts
// (Bender, Novecento) to .woff2 without adding a dependency.
//
// Encoder: every table is stored untransformed (glyf/loca with transform
// version 3 = null, all other tables version 0 = null), concatenated in
// directory order (tags ascending, loca directly after glyf) and compressed as
// one Brotli stream (font mode, quality 11). The file is padded to 4 bytes.
// Decoder: only what our own encoder produces (null transforms, no metadata),
// used by tests to verify lossless round trips.
import zlib from 'node:zlib';
const KNOWN_TAGS = [
'cmap', 'head', 'hhea', 'hmtx', 'maxp', 'name', 'OS/2', 'post', 'cvt ', 'fpgm', 'glyf', 'loca', 'prep', 'CFF ',
'VORG', 'EBDT', 'EBLC', 'gasp', 'hdmx', 'kern', 'LTSH', 'PCLT', 'VDMX', 'vhea', 'vmtx', 'BASE', 'GDEF', 'GPOS',
'GSUB', 'EBSC', 'JSTF', 'MATH', 'CBDT', 'CBLC', 'COLR', 'CPAL', 'SVG ', 'sbix', 'acnt', 'avar', 'bdat', 'bloc',
'bsln', 'cvar', 'fdsc', 'feat', 'fmtx', 'fvar', 'gvar', 'hsty', 'just', 'lcar', 'mort', 'morx', 'opbd', 'prop',
'trak', 'Zapf', 'Silf', 'Glat', 'Gloc', 'Feat', 'Sill',
];
const round4 = (n) => (n + 3) & ~3;
/**
* Encode an unsigned integer as WOFF2 UIntBase128.
* @param {number} n 0 ≤ n < 2^32
* @returns {number[]}
*/
export function uintBase128(n) {
if (!Number.isInteger(n) || n < 0 || n > 0xffffffff) throw new RangeError(`UIntBase128 out of range: ${n}`);
const bytes = [];
let v = n;
do { bytes.unshift(v % 128); v = Math.floor(v / 128); } while (v > 0);
for (let i = 0; i < bytes.length - 1; i++) bytes[i] |= 0x80;
return bytes;
}
function readBase128(buf, pos) {
let v = 0;
for (let i = 0; i < 5; i++) {
const b = buf[pos.i++];
if (b === undefined) throw new Error('truncated UIntBase128');
if (i === 0 && b === 0x80) throw new Error('UIntBase128 leading zero');
v = v * 128 + (b & 0x7f);
if (v > 0xffffffff) throw new Error('UIntBase128 overflow');
if (!(b & 0x80)) return v;
}
throw new Error('UIntBase128 too long');
}
/**
* Parse the table directory of an sfnt (OTF/TTF) font.
* @param {Buffer} sfnt
* @returns {{ flavor: number, tables: {tag:string, data:Buffer}[] }}
*/
export function readSfnt(sfnt) {
if (!Buffer.isBuffer(sfnt) || sfnt.length < 12) throw new Error('not an sfnt font');
const flavor = sfnt.readUInt32BE(0);
if (![0x00010000, 0x4f54544f, 0x74727565].includes(flavor)) throw new Error('unsupported sfnt flavor (TTC/WOFF?)');
const numTables = sfnt.readUInt16BE(4);
if (12 + numTables * 16 > sfnt.length) throw new Error('truncated table directory');
const tables = [];
for (let i = 0; i < numTables; i++) {
const rec = 12 + i * 16;
const tag = sfnt.toString('latin1', rec, rec + 4);
const offset = sfnt.readUInt32BE(rec + 8);
const length = sfnt.readUInt32BE(rec + 12);
if (offset + length > sfnt.length) throw new Error(`table ${tag} out of bounds`);
tables.push({ tag, data: sfnt.subarray(offset, offset + length) });
}
return { flavor, tables };
}
function orderTables(tables) {
const sorted = [...tables].sort((a, b) => (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0));
const li = sorted.findIndex((t) => t.tag === 'loca');
const gi = sorted.findIndex((t) => t.tag === 'glyf');
if (li >= 0 && gi >= 0 && li !== gi + 1) {
const [loca] = sorted.splice(li, 1);
sorted.splice(sorted.findIndex((t) => t.tag === 'glyf') + 1, 0, loca);
}
return sorted;
}
/**
* Convert an OTF/TTF font to WOFF2 (null transforms).
* @param {Buffer} sfnt
* @returns {Buffer}
*/
export function encodeWoff2(sfnt) {
const { flavor, tables } = readSfnt(sfnt);
const ordered = orderTables(tables);
const hasGlyf = ordered.some((t) => t.tag === 'glyf');
const hasLoca = ordered.some((t) => t.tag === 'loca');
if (hasGlyf !== hasLoca) throw new Error('glyf and loca must both be present');
const dir = [];
for (const t of ordered) {
const idx = KNOWN_TAGS.indexOf(t.tag);
const transform = t.tag === 'glyf' || t.tag === 'loca' ? 3 : 0; // null transform
dir.push((transform << 6) | (idx >= 0 ? idx : 63));
if (idx < 0) for (let i = 0; i < 4; i++) dir.push(t.tag.charCodeAt(i) & 0xff);
dir.push(...uintBase128(t.data.length));
}
const stream = Buffer.concat(ordered.map((t) => t.data));
const compressed = zlib.brotliCompressSync(stream, {
params: {
[zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_FONT,
[zlib.constants.BROTLI_PARAM_QUALITY]: 11,
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: stream.length,
},
});
const totalSfntSize = 12 + 16 * ordered.length + ordered.reduce((s, t) => s + round4(t.data.length), 0);
const unpadded = 48 + dir.length + compressed.length;
const length = round4(unpadded);
const out = Buffer.alloc(length);
out.writeUInt32BE(0x774f4632, 0); // 'wOF2'
out.writeUInt32BE(flavor, 4);
out.writeUInt32BE(length, 8);
out.writeUInt16BE(ordered.length, 12);
out.writeUInt16BE(0, 14);
out.writeUInt32BE(totalSfntSize, 16);
out.writeUInt32BE(compressed.length, 20);
out.writeUInt16BE(1, 24); // majorVersion
out.writeUInt16BE(0, 26); // minorVersion
// meta/priv offsets and lengths stay 0 (bytes 28..47).
Buffer.from(dir).copy(out, 48);
compressed.copy(out, 48 + dir.length);
return out;
}
/**
* Decode a WOFF2 produced by encodeWoff2 back to its tables (verification helper).
* @param {Buffer} woff2
* @returns {{ flavor: number, tables: {tag:string, data:Buffer}[] }}
* @throws when the file uses transforms/metadata or is malformed
*/
export function decodeWoff2Tables(woff2) {
if (!Buffer.isBuffer(woff2) || woff2.length < 48 || woff2.readUInt32BE(0) !== 0x774f4632) throw new Error('not a WOFF2 file');
const flavor = woff2.readUInt32BE(4);
const length = woff2.readUInt32BE(8);
if (length !== woff2.length || length % 4 !== 0) throw new Error('bad WOFF2 length');
const numTables = woff2.readUInt16BE(12);
const compressedLength = woff2.readUInt32BE(20);
const pos = { i: 48 };
const entries = [];
for (let n = 0; n < numTables; n++) {
const flags = woff2[pos.i++];
const idx = flags & 0x3f;
const transform = flags >> 6;
let tag;
if (idx === 63) { tag = woff2.toString('latin1', pos.i, pos.i + 4); pos.i += 4; } else tag = KNOWN_TAGS[idx];
const origLength = readBase128(woff2, pos);
const isGlyfLoca = tag === 'glyf' || tag === 'loca';
if ((isGlyfLoca && transform !== 3) || (!isGlyfLoca && transform !== 0)) throw new Error(`transformed table ${tag} not supported`);
entries.push({ tag, origLength });
}
const end = pos.i + compressedLength;
if (end > woff2.length || round4(end) !== length) throw new Error('bad compressed block size');
const stream = zlib.brotliDecompressSync(woff2.subarray(pos.i, end));
let off = 0;
const tables = entries.map((e) => {
const data = stream.subarray(off, off + e.origLength);
off += e.origLength;
return { tag: e.tag, data };
});
if (off !== stream.length) throw new Error('stream length mismatch');
return { flavor, tables };
}