feedback1 integrate: WN 灼热/炽焰源石虫 models (local overlay, tinted alias); 暴鸰 bomb drop

# Conflicts:
#	docs/SIM.md
#	public/js/render/app.js
#	public/js/render/spine.js
#	public/js/render/units.js
#	server/sim/content/enemies.js
#	server/sim/snapshot.js
This commit is contained in:
sganggs committed 2026-10-03 06:45:59 +08:00
commit 4b15919185
26 files changed
+1383 -90

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
{
"about": "Spine metadata of the enemy models only the local client has (tools/local-extract/extract.py ENEMY_SPINES); data/assets.json enemies[id].spineLocal. Written by node tools/fetch-assets.mjs --local-spines (docs/ASSETS.md \"Enemy aliases\").",
"models": {
"enemy_1305_mhslim": {
"skel": "enemy_1305_mhslim.skel",
"atlas": "enemy_1305_mhslim.atlas",
"textures": [
"enemy_1305_mhslim.png"
],
"pma": true,
"anims": {
"idle": "Idle",
"deploy": "Idle",
"attack": {
"begin": null,
"loop": "Attack",
"end": null
},
"attackDown": null,
"skill": {
"begin": null,
"loop": "Attack",
"end": null,
"via": "attack",
"index": 0,
"idle": null
},
"die": "Die",
"move": {
"begin": null,
"loop": "Move",
"end": null
},
"stun": null
},
"animations": {
"Attack": 1,
"Default": 0,
"Die": 1,
"Idle": 2,
"Move": 1
},
"events": [
"OnAttack"
],
"hits": {
"Attack": [
0.667
]
},
"bounds": {
"x": -118.121,
"y": -1.969,
"width": 230,
"height": 164
}
},
"enemy_1305_mhslim_2": {
"skel": "enemy_1305_mhslim_2.skel",
"atlas": "enemy_1305_mhslim_2.atlas",
"textures": [
"enemy_1305_mhslim_2.png"
],
"pma": true,
"anims": {
"idle": "Idle",
"deploy": "Idle",
"attack": {
"begin": null,
"loop": "Attack",
"end": null
},
"attackDown": null,
"skill": {
"begin": null,
"loop": "Attack",
"end": null,
"via": "attack",
"index": 0,
"idle": null
},
"die": "Die",
"move": {
"begin": null,
"loop": "Move",
"end": null
},
"stun": null
},
"animations": {
"Attack": 1,
"Default": 0,
"Die": 1,
"Idle": 2,
"Move": 1
},
"events": [
"OnAttack"
],
"hits": {
"Attack": [
0.667
]
},
"bounds": {
"x": -118.121,
"y": -1.969,
"width": 230,
"height": 164
}
}
}
}
+14 -3
View File
@@ -1,7 +1,8 @@
// 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).
// are missing are dropped (never emitted as broken URLs); a `literal(value)` node
// is emitted as it is (no files behind it).
import { existsSync, statSync } from 'node:fs';
import { createHash } from 'node:crypto';
@@ -13,6 +14,15 @@ 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;
const LITERAL = Symbol('literal');
const isLiteral = (n) => !!n && typeof n === 'object' && Object.hasOwn(n, LITERAL);
/**
* A template node emitted as it is (a JSON copy; null fields kept): data that is no file to download or resolve, e.g.
* the metadata of a local-client Spine model (plan.mjs enemies[id].spineLocal).
* @param {any} value JSON value
*/
export function literal(value) { return { [LITERAL]: JSON.parse(JSON.stringify(value)) }; }
/**
* Collect every leaf of the template with its dotted path.
@@ -23,7 +33,7 @@ const isModelRef = (n) => !!n && typeof n === 'object' && typeof n.model === 'st
*/
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;
if (isModelRef(node) || isLiteral(node) || !node || typeof node !== 'object') return out;
for (const [k, v] of Object.entries(node)) collectLeaves(v, path ? `${path}.${k}` : k, out);
return out;
}
@@ -78,6 +88,7 @@ export function resolveTemplate(template, { root, spine, sourceOf = () => undefi
const files = new Set();
const walk = (node, path) => {
if (node === null || node === undefined) return undefined;
if (isLiteral(node)) return JSON.parse(JSON.stringify(node[LITERAL]));
if (isLeaf(node)) {
for (let i = 0; i < node.alts.length; i++) {
const a = node.alts[i];
@@ -110,7 +121,7 @@ export function resolveTemplate(template, { root, spine, sourceOf = () => undefi
}
return out;
};
const isContainer = (v) => v && typeof v === 'object' && !isLeaf(v) && !isModelRef(v);
const isContainer = (v) => v && typeof v === 'object' && !isLeaf(v) && !isModelRef(v) && !isLiteral(v);
const value = walk(template, '');
return { value, misses, fallbacks, files };
}
+19 -3
View File
@@ -5,7 +5,8 @@
// { 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.
// { model: '<key>' } a Spine model (skel + atlas + page PNGs) from plan.models;
// literal(value) a value emitted as it is (no files: enemies[id].spineLocal).
// Inputs are the research JSONs (docs/research/03, 05, 07), the official
// audio_data.json and Ark-Models' models_data.json.
//
@@ -19,8 +20,14 @@
import { RAW, joinUrl, safeName, urlBase, urlDir } from './sources.mjs';
import { kindOf } from './formats.mjs';
import { pickUnitSfx, UI_SFX, BATTLE_SFX, resolveSpec } from './audio.mjs';
import { literal } from './manifest.mjs';
/** Enemies with no Spine anywhere: render them with another enemy's model (research 07 §5.6). */
/**
* Enemies whose Spine no community dump carries: the web model is another enemy's (research 07 §5.6). Their official
* models come from the local client only (tools/local-extract ENEMY_SPINES): `localEnemySpines` adds them as the
* optional `spineLocal` overlay, which the client draws when data/local-assets.json lists its files (user feedback
* after 0.1.0, D3: 灼热源石虫 / 炽焰源石虫 were drawn as the plain 源石虫 everywhere).
*/
export const ENEMY_SPINE_ALIAS = Object.freeze({
enemy_1305_mhslim: 'enemy_1007_slime',
enemy_1305_mhslim_2: 'enemy_1007_slime',
@@ -200,9 +207,12 @@ export function collectEnemyIds({ assets07, enemies05, maps05, ops03 }) {
* @param {string[]} [p.extraEnemyIds] more enemy ids that can spawn (e.g. keys of data/enemies.json)
* @param {string[]} [p.extraTokenIds] more token ids (e.g. token_* keys of data/tokens.json)
* @param {Record<string,string>} [p.extraHandbook] enemyId → handbook/model id (e.g. from data/bosses.json)
* @param {Record<string, import('./spine.mjs').LocalSpineMeta>} [p.localEnemySpines] metadata of the enemy models the
* local client has (the committed tools/assets/local-enemy-spines.json, never the disk): each planned enemy listed
* gets `spineLocal` = { group: 'spine/enemy/<id>', ...meta } beside its web `spine`
* @returns {{ template: any, models: Map<string, any>, notes: string[] }}
*/
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, extraEnemyIds = [], extraTokenIds = [], extraHandbook = {} }) {
export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsData, extraEnemyIds = [], extraTokenIds = [], extraHandbook = {}, localEnemySpines = {} }) {
const notes = [];
/** @type {Map<string, any>} */
const models = new Map();
@@ -362,6 +372,12 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
else notes.push(`${id}: Spine aliased to ${e.spineAliasOf}`);
}
e.spine = spine;
// the official model from the local client, drawn instead of `spine` when the extraction is installed (optional)
const loc = localEnemySpines && Object.hasOwn(localEnemySpines, id) ? localEnemySpines[id] : null;
if (loc && typeof loc === 'object') {
e.spineLocal = literal({ group: `spine/enemy/${id}`, ...loc });
notes.push(`${id}: official Spine from the local client when extracted (spineLocal)`);
}
enemies[id] = e;
let banks = audio.unitBanks.get(id);
for (const other of [handbookOf.get(id), e.spineAliasOf, baseIdOf(id)]) {
+99 -1
View File
@@ -1,8 +1,16 @@
// 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.
//
// Enemy models that no dump carries but the local client has (tools/local-extract/
// extract.py ENEMY_SPINES → public/assets/local/spine/enemy/<id>/, optional and
// git-ignored) are an overlay, never the manifest's `spine`: their metadata lives
// in the committed tools/assets/local-enemy-spines.json (loadLocalEnemySpines),
// refreshed from the extracted files by `fetch-assets --local-spines`
// (findLocalEnemyModels + localEnemySpineMeta, read only), so data/assets.json
// is the same with or without the extraction (ASSETS.md "Enemy aliases").
import { readFile, writeFile, stat, rename, mkdir, unlink } from 'node:fs/promises';
import { readFile, readdir, 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';
@@ -12,6 +20,96 @@ import { resolveRoles } from './anim-roles.mjs';
async function fileStat(p) { try { const s = await stat(p); return s.isFile() ? s : null; } catch { return null; } }
/** Where tools/local-extract/extract.py writes enemy Spine models, under public/assets. */
export const LOCAL_ENEMY_SPINE_DIR = 'local/spine/enemy/';
/** Its data/local-assets.json group of an enemy (`spine/enemy/<id>`; the client resolves the file names through it). */
export const localEnemySpineGroup = (id) => `spine/enemy/${id}`;
/**
* Enemy Spine models extracted from the local client (tools/local-extract/extract.py ENEMY_SPINES): every
* `local/spine/enemy/<enemyId>/` holding a skeleton, the atlas of the same stem and at least one page PNG.
* @param {string} root absolute public/assets directory
* @returns {Promise<Record<string, { dir: string, skel: string, atlas: string, pngs: string[] }>>} paths relative to root
*/
export async function findLocalEnemyModels(root) {
const out = {};
let ids = [];
try { ids = (await readdir(join(root, LOCAL_ENEMY_SPINE_DIR), { withFileTypes: true })).filter((d) => d.isDirectory()).map((d) => d.name); } catch { return out; }
for (const id of ids.sort()) {
if (!/^enemy_\d+_[a-z0-9_]+$/i.test(id)) continue;
const dir = `${LOCAL_ENEMY_SPINE_DIR}${id}/`;
let files = [];
try { files = (await readdir(join(root, dir))).sort(); } catch { continue; }
const skel = files.find((f) => f.endsWith('.skel'));
const stem = skel ? skel.slice(0, -'.skel'.length) : null;
const pngs = files.filter((f) => f.endsWith('.png'));
if (!stem || !files.includes(`${stem}.atlas`) || !pngs.length) continue;
out[id] = { dir, skel: dir + skel, atlas: `${dir}${stem}.atlas`, pngs: pngs.map((f) => dir + f) };
}
return out;
}
/**
* @typedef {{ skel: string, atlas: string, textures: string[], pma: boolean, anims: any, animations: Record<string, number>,
* events: string[], hits: Record<string, number[]>, bounds: any }} LocalSpineMeta file names relative to the model's
* data/local-assets.json group (localEnemySpineGroup), the rest as a manifest SpineEntry
*/
/**
* Spine metadata of the extracted enemy models (findLocalEnemyModels), parsed like processModels does — but read only:
* the atlas is sized / pma-normalized in memory (extract.py already writes it so), nothing on disk changes.
* @param {string} root absolute public/assets directory
* @param {Record<string, { dir: string, skel: string, atlas: string, pngs: string[] }>} found
* @returns {Promise<{ meta: Record<string, LocalSpineMeta>, problems: string[] }>}
*/
export async function localEnemySpineMeta(root, found) {
const meta = {};
const problems = [];
const base = (rel) => rel.slice(rel.lastIndexOf('/') + 1);
for (const id of Object.keys(found || {}).sort()) {
const m = found[id];
try {
const text = await readFile(join(root, m.atlas), 'utf8');
const sizes = new Map();
for (const page of atlasInfo(text).pages) {
const sz = pngSize(await readFile(join(root, m.dir + page)));
if (!sz) throw new Error(`invalid page ${page}`);
sizes.set(page, sz);
}
const norm = normalizeAtlas(text, { pageSize: (p) => sizes.get(p) || null, pma: true });
if (norm.missingSize.length) throw new Error(`cannot size pages ${norm.missingSize.join(',')}`);
const info = atlasInfo(norm.text);
if (!info.pages.length) throw new Error('atlas without pages');
const sk = parseSkel(await readFile(join(root, m.skel)), info.regions);
if (!sk.animations.length) throw new Error('skeleton has no animations');
if (sk.missingRegions?.length) problems.push(`${id}: ${sk.missingRegions.length} attachment(s) not in atlas (e.g. ${sk.missingRegions[0]})`);
meta[id] = {
skel: base(m.skel), atlas: base(m.atlas), textures: info.pages, pma: true,
anims: resolveRoles(sk.animations, { skillIndices: [0], durations: sk.durations }),
animations: sk.durations, events: sk.events, hits: sk.hits, bounds: sk.bounds,
};
} catch (e) {
problems.push(`${id}: ${e.message}`);
}
}
return { meta, problems };
}
/** Committed metadata of the local-client enemy models: tools/assets/local-enemy-spines.json. */
export const LOCAL_ENEMY_SPINES_FILE = 'tools/assets/local-enemy-spines.json';
/**
* The `models` of tools/assets/local-enemy-spines.json (enemyId → LocalSpineMeta); {} when the file is missing or bad.
* @param {string} path absolute path of the file
* @returns {Promise<Record<string, LocalSpineMeta>>}
*/
export async function loadLocalEnemySpines(path) {
try {
const j = JSON.parse(await readFile(path, 'utf8'));
return j && typeof j.models === 'object' && j.models && !Array.isArray(j.models) ? j.models : {};
} catch { return {}; }
}
/**
* @typedef {{ skel: string, atlas: string, textures: string[], pma: boolean, anims: any,
* animations: Record<string, number>, events: string[], hits: Record<string, number[]>, bounds: any }} SpineEntry
+54 -6
View File
@@ -5,13 +5,21 @@
// public/fonts/** Bender / Novecento (.otf/.ttf + .woff2) and fonts.css
// data/assets.json manifest used by the client (schema: docs/ASSETS.md)
//
// Enemy models no dump carries get another enemy's model (ASSETS.md "Enemy
// aliases"); the official ones the local client has (tools/local-extract/
// extract.py ENEMY_SPINES, optional) are added as `spineLocal` from the
// committed tools/assets/local-enemy-spines.json — never from the disk, so the
// manifest is the same with or without the extraction. --local-spines rewrites
// that file from the extracted models (after a game update).
//
// Idempotent: existing files with the right size are skipped, so re-running is
// cheap. Downloads use ~16 parallel connections, 3 retries per source and a
// jsDelivr mirror fallback. Spine atlases get `size:` (and `pma: true` for
// enemies); every skeleton is parsed to resolve animation roles.
//
// Usage: node tools/fetch-assets.mjs [--concurrency=16] [--force] [--offline]
// [--dry-run] [--refresh-index] [--prune] [--help]
// [--dry-run] [--refresh-index] [--prune]
// [--local-spines] [--help]
import { readFile, writeFile, mkdir, rename, readdir, unlink } from 'node:fs/promises';
import { existsSync } from 'node:fs';
@@ -21,7 +29,7 @@ import { Downloader } from './assets/downloader.mjs';
import { loadIndexes } from './assets/cache.mjs';
import { indexAudio } from './assets/audio.mjs';
import { buildPlan } from './assets/plan.mjs';
import { processModels } from './assets/spine.mjs';
import { processModels, findLocalEnemyModels, localEnemySpineMeta, loadLocalEnemySpines, LOCAL_ENEMY_SPINES_FILE } from './assets/spine.mjs';
import { collectLeaves, downloadLeaves, resolveTemplate, totalBytes, contentHash, MANIFEST_VERSION } from './assets/manifest.mjs';
import { fontJobs, buildFonts } from './assets/fonts.mjs';
import { skelParserAvailable } from './assets/skel.mjs';
@@ -32,6 +40,7 @@ const FONTS = join(ROOT, 'public', 'fonts');
const CACHE = join(ROOT, '.cache');
const MANIFEST = join(ROOT, 'data', 'assets.json');
const REPORT = join(CACHE, 'assets-report.json');
const LOCAL_SPINES = join(ROOT, LOCAL_ENEMY_SPINES_FILE);
const HELP = `Usage: node tools/fetch-assets.mjs [options]
--concurrency=N parallel downloads (default 16)
@@ -40,15 +49,18 @@ const HELP = `Usage: node tools/fetch-assets.mjs [options]
--dry-run print the plan and exit
--refresh-index re-download audio_data.json / models_data.json indexes
--prune delete files under public/assets that the manifest no longer references
(public/assets/local/** of tools/local-extract is never deleted)
--local-spines rewrite ${LOCAL_ENEMY_SPINES_FILE} from the enemy models extracted
by tools/local-extract/extract.py (public/assets/local/spine/enemy/)
--help this text`;
/**
* Parse CLI flags.
* @param {string[]} argv
* @returns {{concurrency:number, force:boolean, offline:boolean, dryRun:boolean, refreshIndex:boolean, prune:boolean, help:boolean}}
* @returns {{concurrency:number, force:boolean, offline:boolean, dryRun:boolean, refreshIndex:boolean, prune:boolean, localSpines:boolean, help:boolean}}
*/
function parseArgs(argv) {
const o = { concurrency: 16, force: false, offline: false, dryRun: false, refreshIndex: false, prune: false, help: false };
const o = { concurrency: 16, force: false, offline: false, dryRun: false, refreshIndex: false, prune: false, localSpines: false, help: false };
for (const a of argv) {
const [k, v] = a.split('=');
if (k === '--concurrency') o.concurrency = Math.max(1, Math.min(64, parseInt(v, 10) || 16));
@@ -57,6 +69,7 @@ function parseArgs(argv) {
else if (k === '--dry-run') o.dryRun = true;
else if (k === '--refresh-index') o.refreshIndex = true;
else if (k === '--prune') o.prune = true;
else if (k === '--local-spines') o.localSpines = true;
else if (k === '--help' || k === '-h') o.help = true;
else throw new Error(`unknown option ${a}\n${HELP}`);
}
@@ -136,6 +149,38 @@ function requiredMisses(m, charIds) {
return out;
}
/**
* Metadata of the local-client enemy models (the committed LOCAL_SPINES). With --local-spines it is rewritten from the
* models extracted under public/assets/local/spine/enemy/ (read only); otherwise extracted models whose metadata differs
* from the committed one only get a warning — the manifest never depends on what this machine extracted.
*/
async function syncLocalEnemySpines(opts) {
const committed = await loadLocalEnemySpines(LOCAL_SPINES);
const found = await findLocalEnemyModels(ASSETS);
if (!Object.keys(found).length) {
if (opts.localSpines) log(`[local-spines] no extracted enemy model under public/assets/local/spine/enemy/ — ${LOCAL_ENEMY_SPINES_FILE} kept`);
return committed;
}
const { meta, problems } = await localEnemySpineMeta(ASSETS, found);
for (const p of problems) log(`[local-spines] ${p}`);
if (opts.localSpines && !opts.dryRun) {
const models = { ...committed, ...meta };
const sorted = Object.fromEntries(Object.keys(models).sort().map((k) => [k, models[k]]));
const doc = {
about: 'Spine metadata of the enemy models only the local client has (tools/local-extract/extract.py ENEMY_SPINES); '
+ 'data/assets.json enemies[id].spineLocal. Written by node tools/fetch-assets.mjs --local-spines (docs/ASSETS.md "Enemy aliases").',
models: sorted,
};
await writeJsonAtomic(LOCAL_SPINES, doc, 2);
log(`[local-spines] ${Object.keys(meta).length} model(s) → ${LOCAL_ENEMY_SPINES_FILE}`);
return sorted;
}
for (const [id, m] of Object.entries(meta)) {
if (JSON.stringify(m) !== JSON.stringify(committed[id])) log(`[local-spines] ${id}: the extracted model differs from ${LOCAL_ENEMY_SPINES_FILE} (re-run with --local-spines to update it)`);
}
return committed;
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
if (opts.help) { log(HELP); return 0; }
@@ -157,11 +202,13 @@ async function main() {
['data/enemies.json', 'data/tokens.json', 'data/bosses.json'].map((f) => readJson(f).catch(() => null)));
const extraHandbook = {};
for (const b of Object.values(dataBosses || {})) if (b?.enemyKey && typeof b.handbookId === 'string') extraHandbook[b.enemyKey] = b.handbookId;
const localEnemySpines = await syncLocalEnemySpines(opts);
const plan = buildPlan({
assets07, ops03, enemies05, maps05, audio, modelsData,
extraEnemyIds: Object.keys(dataEnemies || {}),
extraTokenIds: Object.keys(dataTokens || {}),
extraHandbook,
localEnemySpines,
});
const leaves = collectLeaves(plan.template);
log(`[plan] ${leaves.length} files + ${plan.models.size} Spine models ` +
@@ -214,8 +261,9 @@ async function main() {
};
await writeJsonAtomic(MANIFEST, manifest);
// Orphans: files on disk that the manifest does not reference (e.g. after a mapping change).
const orphans = (await listFiles(ASSETS)).filter((r) => !resolved.files.has(r));
// Orphans: files on disk that the manifest does not reference (e.g. after a mapping change). public/assets/local/**
// belongs to tools/local-extract (data/local-assets.json) and is never an orphan: --prune used to delete all of it.
const orphans = (await listFiles(ASSETS)).filter((r) => !resolved.files.has(r) && !r.startsWith('local/'));
if (opts.prune) for (const r of orphans) { try { await unlink(join(ASSETS, r)); } catch { /* ignore */ } }
const charIds = Object.keys(assets07.operators || {});
+168 -2
View File
@@ -8,6 +8,11 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
- the 6 in-match emoticon themes of 盟约 (activity_table autoChessData.enabledEmoticonThemeIdList; only the
*_battle sprites, keyed by display_meta_table picId: emoticon/<dir>/<picId>.png, see data/emotes.json)
- the autochess guidebook pages, battle projectile sprites and a few token/skin Spine models missing upstream
- the enemy battle Spine models no community dump carries (ENEMY_SPINES: 灼热源石虫 / 炽焰源石虫), from the enemy art
bundles (refs/arts/enm_art_*.ab) → spine/enemy/<enemyId>/<stem>.skel|.atlas + page PNGs with the [alpha] texture
merged in (premultiplied RGB + A, the Ark-Models format; the atlas gets `size:` / `pma: true` like the fetched
enemies); the client draws them instead of the web alias once this manifest lists them (data/assets.json
enemies[id].spineLocal, docs/ASSETS.md "Enemy aliases")
- for the official 3D board (DESIGN §15): the map theme's Material parameters (map/<theme>/materials.json; shader
names resolved through the shaders/*.ab bundles), the background / device meshes as Wavefront OBJ
(mesh/<bundle>/<mesh>.obj; UnityPy's exporter, X mirrored into a right-handed frame) and their GameObject
@@ -90,6 +95,19 @@ JOBS = [
('arts/maps/effect.ab', 'map/water', {'Texture2D'}, r'water_normal|Caustics|WaterNoise|noise_clouds|Water_Foam|SmoothWaves'),
]
# Enemy battle Spine models that no community dump carries (isHarryh/Ark-Models lists them with an empty assetList, so
# tools/fetch-assets.mjs aliases them to another enemy's skeleton — the web model): every enemy of data/enemies.json whose
# official battle prefab draws a skeleton of its own that is missing upstream (audited against the client's prefabs, user
# feedback after 0.1.0 report D3: 灼热源石虫 / 炽焰源石虫 — the ELEMENT faction's slugs — were drawn as the plain 源石虫).
# Read from the enemy art bundles: Assets/Torappu/Arts/Enemies/Spines/<…>/<id>_SkeletonData.asset → its skeleton
# TextAsset, the atlas TextAsset of its atlas asset and every page texture of the atlas materials (_MainTex, with its
# _AlphaTex merged in as A). Output: spine/enemy/<id>/ (manifest group spine/enemy/<id>); the client draws the model
# when the group lists every file of data/assets.json enemies[id].spineLocal (whose metadata
# tools/assets/local-enemy-spines.json keeps: `node tools/fetch-assets.mjs --local-spines` after a game update).
ENEMY_SPINES = ['enemy_1305_mhslim', 'enemy_1305_mhslim_2']
ENEMY_ART = 'refs/arts/enm_art_*.ab'
ENEMY_SPINE_SUB = 'spine/enemy'
# Derived three.js maps: (output subdir, source texture, kind, output name). 'normal_rg' rebuilds Z of a two-channel
# (BC5) normal map into an RGB tangent-space map; 'rough_from_gloss' turns Unity's metallic/gloss map (smoothness in
# A) into a roughness (G) / metalness (B) map. Written after the job that exported the source texture.
@@ -120,6 +138,14 @@ def job_parts(job):
return rel, sub, kinds, keep
def wants_sub(only, sub):
"""Whether output subdir `sub` runs under --only: no prefixes, or one prefix is `sub`, a parent of it or below it."""
if not only:
return True
return any(sub == p.rstrip('/') or sub.startswith(p.rstrip('/') + '/') or p.rstrip('/').startswith(sub + '/')
for p in only)
def select_jobs(only):
"""The jobs whose output subdir starts with one of the `only` prefixes (all jobs when `only` is empty)."""
if not only:
@@ -127,6 +153,13 @@ def select_jobs(only):
return [j for j in JOBS if any(j[1] == p.rstrip('/') or j[1].startswith(p.rstrip('/') + '/') for p in only)]
def enemy_spine_id(container, wanted):
"""The enemy id of an enemy SkeletonData container path (Assets/Torappu/Arts/Enemies/Spines/<dir>[/<n>]/<id>_
SkeletonData.asset) when it is one of `wanted`, else None."""
m = re.search(r'/Enemies/Spines/(?:[^/]+/)+([^/]+)_SkeletonData\.asset$', container or '', re.I)
return m.group(1) if m and m.group(1) in wanted else None
def _num(v, nd=6):
"""JSON-safe rounded float (NaN / inf → 0)."""
try:
@@ -383,6 +416,135 @@ def export_bundle(ab_root, job, out_root, manifest, log):
return n
def _text_bytes(text_asset):
raw = text_asset.m_Script
return raw.encode('utf-8', 'surrogateescape') if isinstance(raw, str) else bytes(raw)
def merge_alpha(rgb, alpha):
"""RGBA page of a split texture: RGB of the main texture (premultiplied: the client's Spine material has
_StraightAlphaInput 0) and A from its [alpha] texture; RGB is clamped to A (block-compression bleed)."""
from PIL import Image, ImageChops
r, g, b = rgb.convert('RGB').split()
a = alpha.convert('L').resize(rgb.size) if alpha is not None else Image.new('L', rgb.size, 255)
return Image.merge('RGBA', tuple(ImageChops.darker(ch, a) for ch in (r, g, b)) + (a,))
def normalize_atlas(text, sizes):
"""Spine atlas text with every page header carrying its real `size: w,h` (`sizes`: page name → (w, h)) and
`pma: true` (the pages are premultiplied: merge_alpha) — what tools/assets/atlas.mjs normalizeAtlas gives the fetched
enemy atlases, so the client loads the extracted model as it is. Page fields are the unindented `key: value` lines
right after a page name (a line after a blank line, or the first line); everything else is kept."""
lines = text.replace('\r\n', '\n').replace('\r', '\n').lstrip('\ufeff').split('\n')
out, i, n = [], 0, len(lines)
while i < n:
line = lines[i]
starts_page = line.strip() and ':' not in line and not line[:1].isspace() and (i == 0 or not lines[i - 1].strip())
if not starts_page:
out.append(line)
i += 1
continue
name = line.strip()
fields, j = [], i + 1
while j < n and lines[j].strip() and not lines[j][:1].isspace() and ':' in lines[j]:
fields.append(lines[j])
j += 1
keys = [f.split(':', 1)[0].strip() for f in fields]
size = sizes.get(name)
if size:
sz = f'size: {size[0]},{size[1]}'
if 'size' in keys:
fields[keys.index('size')] = sz
else:
fields.insert(0, sz)
if 'pma' not in keys:
fields.append('pma: true')
out.append(line)
out.extend(fields)
i = j
return '\n'.join(out)
def write_enemy_spine(objects, sda, eid, out_root, manifest, log):
"""Write one enemy model (skeleton, atlas, merged pages) of a SkeletonDataAsset typetree; returns the file count."""
get = lambda pptr: objects.get(pptr['m_PathID']) if pptr and not pptr.get('m_FileID') else None # noqa: E731
sub = f'{ENEMY_SPINE_SUB}/{eid}'
out_dir = Path(out_root) / sub
out_dir.mkdir(parents=True, exist_ok=True)
files = {}
skel = get(sda.get('skeletonJSON'))
if skel is None:
log(f' warn {eid}: skeleton TextAsset not in this bundle')
return 0
ta = skel.read()
stem = safe_name(getattr(ta, 'm_Name', '') or eid).removesuffix('.skel')
files[f'{stem}.skel'] = _text_bytes(ta)
pages = {}
for aref in sda.get('atlasAssets') or []:
aa = get(aref)
if aa is None:
continue
at = aa.read_typetree()
af = get(at.get('atlasFile'))
if af is not None:
files[f'{stem}.atlas'] = _text_bytes(af.read())
for mref in at.get('materials') or []:
mo = get(mref)
if mo is None:
continue
texs = dict(mo.read_typetree()['m_SavedProperties']['m_TexEnvs'])
main, alpha = get(texs.get('_MainTex', {}).get('m_Texture')), get(texs.get('_AlphaTex', {}).get('m_Texture'))
if main is None:
continue
mt = main.read()
pages[safe_name(mt.m_Name) + '.png'] = merge_alpha(mt.image, alpha.read().image if alpha is not None else None)
if f'{stem}.atlas' not in files or not pages:
log(f' warn {eid}: atlas or page textures missing')
return 0
sizes = {name: img.size for name, img in pages.items()}
files[f'{stem}.atlas'] = normalize_atlas(files[f'{stem}.atlas'].decode('utf-8'), sizes).encode('utf-8')
for fname, blob in files.items():
(out_dir / fname).write_bytes(blob)
manifest.setdefault(sub, {})[fname] = {'path': f'/assets/local/{sub}/{fname}', 'kind': 'TextAsset'}
for fname, img in pages.items():
img.save(out_dir / fname)
manifest.setdefault(sub, {})[fname] = {'path': f'/assets/local/{sub}/{fname}', 'w': img.width, 'h': img.height,
'kind': 'Texture2D'}
return len(files) + len(pages)
def export_enemy_spines(ab_root, out_root, manifest, log, ids=None):
"""ENEMY_SPINES from the enemy art bundles (each bundle loaded on its own; stops once every model was found)."""
import aklz4 # noqa: F401 (registers the LZ4AK decoder)
import UnityPy
wanted, n = set(ids or ENEMY_SPINES), 0
for f in sorted(Path(ab_root).glob(ENEMY_ART)):
if not wanted:
break
try:
env = UnityPy.load(str(f))
except Exception as e: # corrupt or unsupported bundle: report and continue
log(f'FAIL load {f.name}: {e}')
continue
objects = {o.path_id: o for o in env.objects}
for name, ref in env.container.items():
eid = enemy_spine_id(name, wanted)
if not eid:
continue
try:
k = write_enemy_spine(objects, ref.read_typetree(), eid, out_root, manifest, log)
except Exception as e:
log(f' warn {eid}: {e}')
continue
if k:
wanted.discard(eid)
n += k
log(f'{f.relative_to(ab_root).as_posix()}: {eid} -> {ENEMY_SPINE_SUB}/{eid} ({k} files)')
for eid in sorted(wanted):
log(f'skip (not found) enemy Spine {eid}')
return n
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--game', help='AssetBundle root (…/StreamingAssets/AB/Windows or …/Documents/Bundles)')
@@ -398,7 +560,8 @@ def main():
for rel, sub, kinds, keep in map(job_parts, JOBS)]
derived = [{'sub': sub, 'from': src, 'derive': kind, 'name': name} for sub, src, kind, name in DERIVED]
print(json.dumps({'emoteThemes': [{'themeId': t, 'dir': d} for t, d in EMOTE_THEMES], 'jobs': jobs,
'derived': derived}, ensure_ascii=False))
'derived': derived, 'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB,
'ids': ENEMY_SPINES}}, ensure_ascii=False))
return 0
ab_root = Path(args.game) if args.game else next((p for p in CANDIDATES if p.exists()), None)
@@ -406,7 +569,8 @@ def main():
print('No Arknights install found. Pass --game <AssetBundle root>.', file=sys.stderr)
return 2
jobs = select_jobs(args.only)
if not jobs:
enemy_spines = wants_sub(args.only, ENEMY_SPINE_SUB)
if not jobs and not enemy_spines:
print(f'--only {args.only}: no job matches', file=sys.stderr)
return 2
out_root = Path(args.out)
@@ -415,6 +579,8 @@ def main():
print(f'AB root: {ab_root}')
for job in jobs:
total += export_bundle(ab_root, job, out_root, manifest, print)
if enemy_spines:
total += export_enemy_spines(ab_root, out_root, manifest, print)
old = {}
if args.only and Path(args.manifest).exists():
try:
+7 -3
View File
@@ -189,11 +189,15 @@ export function checkAssets() {
return { ok: present && missing.length === 0 && urls.length > 0, present, manifest: true, total: urls.length, missing: missing.length, sample: missing.slice(0, 5), bytes: Number(m.stats?.bytes) || 0 };
}
/** Local-client art (optional): manifest entry count and whether the 3D board atlas is on disk. */
/**
* Local-client art (optional): manifest entry count, whether the 3D board atlas is on disk and whether the extraction
* has the enemy models only the client has (`spine/enemy/*` groups, extract.py ENEMY_SPINES — added after 0.1.0).
*/
export function checkLocal() {
const m = readJson(LOCAL_MANIFEST);
const count = m && m.groups ? Object.values(m.groups).reduce((n, g) => n + Object.keys(g || {}).length, 0) : 0;
return { manifest: !!m, count, board3d: exists(LOCAL_BOARD_ATLAS), tiles: exists(LOCAL_BOARD_TILES), dirPresent: exists(path.join(ROOT, 'public', 'assets', 'local')) };
const enemySpines = !!(m && m.groups && Object.keys(m.groups).some((g) => g.startsWith('spine/enemy/')));
return { manifest: !!m, count, board3d: exists(LOCAL_BOARD_ATLAS), tiles: exists(LOCAL_BOARD_TILES), enemySpines, dirPresent: exists(path.join(ROOT, 'public', 'assets', 'local')) };
}
/**
@@ -423,7 +427,7 @@ async function main() {
add(already ? 'ok' : 'warn', '本地客户端美术(可选)', `${client.kind} 客户端缺少卫戍协议资源(请在游戏内下载全部资源):${client.path}`);
} else if (already && opts.local !== 'force') {
if (local.board3d && !local.tiles && !opts.check) cropBoardTiles(log);
add('ok', '本地客户端美术(可选)', `已提取 ${local.count} 项${local.board3d ? ',3D 棋盘可用' : ''}(重新提取:--local)`);
add('ok', '本地客户端美术(可选)', `已提取 ${local.count} 项${local.board3d ? ',3D 棋盘可用' : ''}${local.enemySpines ? '' : ',缺少新版的灼热/炽焰源石虫模型'}(重新提取:--local)`);
} else if (opts.check) {
add('skip', '本地客户端美术(可选)', `检测到 ${client.kind} 客户端,可运行 node tools/setup.mjs --local 提取`);
} else {