feedback1d WN: special Originium slugs drawn with their own official models (D3)
灼热源石虫 / 炽焰源石虫 (enemy_1305_mhslim / _2, the ELEMENT faction's slugs) were drawn with the plain 源石虫 skeleton: no community dump carries their models, so the asset plan aliased them to enemy_1007_slime. tools/local-extract/extract.py now extracts their official skeletons from the client's enemy art bundles (ENEMY_SPINES, pages merged with their alpha textures) into public/assets/local/spine/enemy/, and fetch-assets plans such a model before any alias (spine.mjs findLocalEnemyModels, plan.mjs localEnemyModels). public/assets/local is never pruned. An audit of all 249 enemies against the client's prefabs found no other enemy drawn with another enemy's model.
This commit is contained in:
1 parent
7f134737ce
commit
46ccfd55f6
7 files changed
+325
-18
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
{"version":1,"hash":"18bc60d83db4","generator":"tools/fetch-assets.mjs","stats":{"files":3959,"bytes":259726913,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":529,"bonds":23,"items":59,"bands":40,"skills":317,"ui":546,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated
|
||||
{"version":1,"hash":"bd5e373accda","generator":"tools/fetch-assets.mjs","stats":{"files":3965,"bytes":259785665,"chars":138,"charsWithBack":135,"enemies":252,"enemiesWithSpine":251,"tokens":20,"tokensWithSpine":13,"spineModels":531,"bonds":23,"items":59,"bands":40,"skills":317,"ui":546,"sfxUnits":382},"chars":{"char_1012_skadi2":{"avatar":"/assets/char/avatar/char_1012_skadi2.png","avatarE2":"/assets/char/avatar/char_1012_skadi2_2.png","portrait":"/assets/char/portrait/char_1012_skadi2_1.png","portraitE2":"/assets/char/portrait/char_1012_skadi2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.skel","atlas":"/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.atlas","textures":["/assets/spine/op/char_1012_skadi2/front/char_1012_skadi2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Skill_1_Loop","end":null,"via":"skill"},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null},"die":"Die","move":null,"stun":{"begin":"Stun_Begin","loop":"Stun","end":null},"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":null},"1":{"begin":"Skill_2_Begin","loop":"Skill_2_Loop","end":null,"index":1,"idle":null},"2":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":null,"index":2,"idle":null}}},"animations":{"Default":0,"Die":1,"Idle":8.767,"Skill_1_Begin":3.2,"Skill_1_Loop":8.767,"Skill_2_Begin":3.933,"Skill_2_Loop":3.333,"Skill_3_Begin":4.4,"Skill_3_Loop":8.767,"Start":1,"Stun":2,"Stun_Begin":1},"events":["OnAttack","OnStart"],"hits":{},"bounds":{"x":-190.586,"y":-25.785,"width":393.516,"height":443.426}}}},"char_1014_nearl2":{"avatar":"/assets/char/avatar/char_1014_nearl2.png","avatarE2":"/assets/char/avatar/char_1014_nearl2_2.png","portrait":"/assets/char/portrait/char_1014_nearl2_1.png","portraitE2":"/assets/char/portrait/char_1014_nearl2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/front/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":"Die","move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":null,"loop":"Skill_2_Die","end":null,"index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Die":1,"Idle":2.667,"Skill_3":1.5,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_1_Loop_Down":1.5,"Skill_2_Die":1.167,"Skill_3_Down":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_3":[0.5],"Skill_1_Loop":[0.467],"Skill_1_Loop_Down":[0.467],"Skill_3_Down":[0.5]},"bounds":{"x":-400.77,"y":-461.007,"width":720.545,"height":872.242}},"back":{"skel":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.skel","atlas":"/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.atlas","textures":["/assets/spine/op/char_1014_nearl2/back/char_1014_nearl2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End"},"attackDown":null,"skill":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"},"die":null,"move":null,"stun":null,"skills":{"0":{"begin":"Skill_1_Begin","loop":"Skill_1_Loop","end":null,"index":0,"idle":"Skill_1_Idle"},"1":{"begin":"Attack_Begin","loop":"Attack_Loop","end":"Attack_End","via":"attack","index":1,"idle":null},"2":{"begin":null,"loop":"Skill_3","end":null,"index":2,"idle":"Skill_3_Idle"}}},"animations":{"Attack_Begin":0.1,"Attack_End":0.167,"Attack_Loop":1.5,"Default":0,"Idle":2.667,"Skill_1_Begin":0.1,"Skill_1_Idle":1,"Skill_1_Loop":1.5,"Skill_3":1.5,"Skill_3_Idle":1.333,"Start":1},"events":["OnAttack","OnStart"],"hits":{"Attack_Loop":[0.433],"Skill_1_Loop":[0.467],"Skill_3":[0.5]},"bounds":null}}},"char_1016_agoat2":{"avatar":"/assets/char/avatar/char_1016_agoat2.png","avatarE2":"/assets/char/avatar/char_1016_agoat2_2.png","portrait":"/assets/char/portrait/char_1016_agoat2_1.png","portraitE2":"/assets/char/portrait/char_1016_agoat2_2.png","spine":{"front":{"skel":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.skel","atlas":"/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.atlas","textures":["/assets/spine/op/char_1016_agoat2/front/char_1016_agoat2.png"],"pma":false,"anims":{"idle":"Idle","deploy":"Start","attack":{"begin":null,"loop":"Attack","end":null},"attackDown":null,"skill":{"begin":"Skill_3_Begin","loop":"Skill_3_Loop","end":"Skill_3_End","index":2,"idle":"Skill_3_Idle"},"diLine truncated
|
||||
+13
-3
@@ -20,7 +20,7 @@ npm run assets # = node tools/vendor.mjs && node tools/fetch-assets.mjs
|
||||
| `--offline` | No network. Re-runs post-processing (atlas fixes, skeleton parsing, WOFF2) on what is already on disk, then rebuilds `data/assets.json`. |
|
||||
| `--dry-run` | Print the plan (file and model counts, alias notes) and exit. |
|
||||
| `--refresh-index` | Re-download the two upstream indexes: `audio_data.json` and `models_data.json`. |
|
||||
| `--prune` | Delete files under `public/assets/` that the manifest no longer references, for example after a mapping change. Without this flag they are only listed in the report. |
|
||||
| `--prune` | Delete files under `public/assets/` that the manifest no longer references, for example after a mapping change. Without this flag they are only listed in the report. `public/assets/local/` (written by `tools/local-extract`) is never pruned. |
|
||||
|
||||
The script is **idempotent**. A file on disk is kept, not re-downloaded, when any one of these holds:
|
||||
- its size matches the ledger entry from a previous download (`.cache/assets-ledger.json`);
|
||||
@@ -71,6 +71,7 @@ The research JSONs in `docs/research/` (03, 05, 07) define **which** ids are nee
|
||||
| Operator battle Spine (Front, Back) | fexli/ArknightsResource `spine/{id}/{id}/{Front,Back}/` | `spine/op/{charId}/{front,back}/{stem}.{skel,atlas,png}` |
|
||||
| Token Spine | fexli: the default model, or else the first skin variant (`spine/{tokenId}/{variant}/Spine/`) | `spine/token/{tokenId}/{stem}.*` |
|
||||
| Enemy Spine (PC build, premultiplied alpha) | isHarryh/Ark-Models `models_enemies/{key}/`, file names from `models_data.json` | `spine/enemy/{enemyId}/{stem}.*` |
|
||||
| Enemy Spine that no dump carries (灼热源石虫 / 炽焰源石虫) | the local client, when `tools/local-extract/extract.py` (`ENEMY_SPINES`) has extracted it; not downloaded | `local/spine/enemy/{enemyId}/{stem}.*` |
|
||||
| BGM | AA2 `voice` branch `audio/sound_beta_2/music/**` | `audio/bgm/{file}.mp3` |
|
||||
| SFX (UI, battle, per unit) | AA2 `voice` `audio/sound_beta_2/**`, mapped from `audio_data.json` banks | `audio/sfx/{same sub-path}.mp3` |
|
||||
| Fonts: Bender Regular and Light, Novecento Wide | TimWangZi/The-font-of-Arknights | `public/fonts/*.{otf,ttf,woff2}`, `public/fonts/fonts.css` |
|
||||
@@ -207,8 +208,17 @@ Other renderer rules from research 07 §5.4–5.5:
|
||||
- **Attack speed:** set the attack `timeScale` to `duration / attackInterval`.
|
||||
- **Model size:** every skeleton is drawn at one `UNIT.modelScale` (render/style.js, 320 skeleton units per tile), which stands for the official standard. The official client also scales each enemy model in its battle prefab: the Graphic / FaceSwitcher / Spine transforms multiply to 0.27 for most enemies and for the operators' battle skins, but not for all of them. For example, 威龙 is 0.16, 妖怪 0.20 and 青铜镜 0.6. The skeletons themselves carry no such scale, because every enemy SkeletonDataAsset uses 0.01. So an enemy is drawn × data/enemies.json `modelScale` (its prefab's product ÷ 0.27, see docs/DATA.md; user playtest #6: 威龙 used to be drawn 1.35× a 妖怪 instead of 1.08×), and its HP bar sits on that model: at its setup-pose bounds' height × the same factors, or, for a skeleton without bounds, at the chibi headroom × `modelScale` (bosses 2.2 tiles). `tools/local-extract/enemy_scales.py` reads the products from a local client, and `tools/build-data.mjs MODEL_SCALES` keeps them.
|
||||
- **Enemy aliases:** `enemies[id].spineAliasOf` means the model belongs to another enemy. Two cases:
|
||||
- `enemy_1305_mhslim` has no Spine anywhere, so it uses the slime model. Tint it.
|
||||
- `_2` boss variants reuse the base model.
|
||||
- `_2` variants whose official prefab is the base one (鸭爵, 高普尼克, 流泪小子, 圆仔, 假想敌:胄, 假想敌:铳): the base model, as in the game.
|
||||
- an enemy whose own model no dump carries and that was not extracted from a local client: 灼热源石虫 / 炽焰源石虫
|
||||
(`enemy_1305_mhslim` / `_2`) fall back to the plain 源石虫 (`plan.mjs ENEMY_SPINE_ALIAS`). Their official skeletons
|
||||
are in the client's enemy art bundles (`refs/arts/enm_art_*.ab`); `tools/local-extract/extract.py` writes them to
|
||||
`public/assets/local/spine/enemy/{enemyId}/` (page textures with their `[alpha]` texture merged in: premultiplied RGB
|
||||
+ A, like Ark-Models), and `npm run assets` then plans that model instead of the alias (`spine.mjs
|
||||
findLocalEnemyModels`, `plan.mjs localEnemyModels`; an upstream model still wins). The release bundle carries them.
|
||||
User feedback after 0.1.0 (D3: "所有特殊源石虫的模型全表现为普通源石虫"): the ELEMENT faction spawns up to ten of them a
|
||||
round. A 2026-10-03 audit of every enemy of `data/enemies.json` (249) against the client's battle prefabs (the
|
||||
skeleton each prefab's Spine renderer draws) found no other enemy drawn with another enemy's model; 伊利昂的木驮兽
|
||||
(`enemy_10159_mntrjn`) starts on its `Full` skin (five passengers) in the game and is drawn with the `default` one.
|
||||
|
||||
### Other fallbacks
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// test/feedback1d-models.test.js — community report D3 after 0.1.0 ("所有特殊源石虫的模型全表现为普通源石虫"): 灼热源石虫 /
|
||||
// 炽焰源石虫 (enemy_1305_mhslim / _2, the ELEMENT faction's slugs — up to 10 a round) were drawn with the plain 源石虫
|
||||
// skeleton because no community dump carries their models (Ark-Models lists them with an empty assetList) and the
|
||||
// asset plan aliased them to enemy_1007_slime. Their official skeletons come from the local client now
|
||||
// (tools/local-extract/extract.py ENEMY_SPINES → public/assets/local/spine/enemy/<id>/) and tools/fetch-assets.mjs
|
||||
// plans such a model in place of the alias (tools/assets/spine.mjs findLocalEnemyModels, plan.mjs localEnemyModels).
|
||||
// 高能 / 冰爆 / 简饲源石虫 and “庞贝” always had their own models (checked in headless Chrome).
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildPlan } from '../tools/assets/plan.mjs';
|
||||
import { findLocalEnemyModels } from '../tools/assets/spine.mjs';
|
||||
import { indexAudio } from '../tools/assets/audio.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const readJson = (rel) => JSON.parse(readFileSync(path.join(ROOT, rel), 'utf8'));
|
||||
const SLUGS = ['enemy_1305_mhslim', 'enemy_1305_mhslim_2'];
|
||||
|
||||
/** The real research inputs; Ark-Models carries the plain slug only (its index lists the special ones empty). */
|
||||
function plan(localEnemyModels) {
|
||||
const modelsData = { data: { '1007_slime': { assetList: { '.skel': 'enemy_1007_slime.skel', '.atlas': 'enemy_1007_slime.atlas', '.png': 'enemy_1007_slime.png' } },
|
||||
'1305_mhslim': { assetList: {} }, '1305_mhslim_2': { assetList: {} } } };
|
||||
return buildPlan({
|
||||
assets07: readJson('docs/research/07-assets.json'), ops03: readJson('docs/research/03-operators.json'),
|
||||
enemies05: readJson('docs/research/05-enemies.json'), maps05: readJson('docs/research/05-maps.json'),
|
||||
audio: indexAudio({}), modelsData, extraEnemyIds: SLUGS, localEnemyModels,
|
||||
});
|
||||
}
|
||||
|
||||
describe('D3: special Originium slugs get their own official models', () => {
|
||||
test('a local-client model is planned instead of the alias to the plain 源石虫', () => {
|
||||
const before = plan(undefined);
|
||||
for (const id of SLUGS) assert.equal(before.template.enemies[id].spineAliasOf, 'enemy_1007_slime', `${id}: no local model → the alias stays`);
|
||||
const local = {};
|
||||
for (const id of SLUGS) local[id] = { dir: `local/spine/enemy/${id}/`, skel: `local/spine/enemy/${id}/${id}.skel`, atlas: `local/spine/enemy/${id}/${id}.atlas`, pngs: [`local/spine/enemy/${id}/${id}.png`] };
|
||||
const p = plan(local);
|
||||
for (const id of SLUGS) {
|
||||
const e = p.template.enemies[id];
|
||||
assert.equal(e.spineAliasOf, undefined, `${id} is not drawn with another enemy's model`);
|
||||
assert.deepEqual(e.spine, { model: `enemy:${id}` });
|
||||
const m = p.models.get(`enemy:${id}`);
|
||||
assert.equal(m.skel.rel, `local/spine/enemy/${id}/${id}.skel`);
|
||||
assert.equal(m.atlas.rel, `local/spine/enemy/${id}/${id}.atlas`);
|
||||
assert.deepEqual(m.pngs.map((x) => x.rel), [`local/spine/enemy/${id}/${id}.png`]);
|
||||
assert.equal(m.pma, true, 'premultiplied like the Ark-Models enemies (atlas gets pma: true)');
|
||||
assert.deepEqual(m.skel.urls, [], 'on disk only: nothing to download');
|
||||
}
|
||||
assert.ok(!p.notes.some((n) => /enemy_1305_mhslim.*aliased/.test(n)), 'no alias note');
|
||||
// an upstream model still wins over a local copy
|
||||
const up = plan({ enemy_1007_slime: { dir: 'local/spine/enemy/enemy_1007_slime/', skel: 'local/spine/enemy/enemy_1007_slime/x.skel', atlas: 'local/spine/enemy/enemy_1007_slime/x.atlas', pngs: ['local/spine/enemy/enemy_1007_slime/x.png'] } });
|
||||
assert.equal(up.models.get('enemy:enemy_1007_slime').skel.rel, 'spine/enemy/enemy_1007_slime/enemy_1007_slime.skel');
|
||||
});
|
||||
|
||||
test('findLocalEnemyModels: skeleton + same-stem atlas + page PNGs under local/spine/enemy/<id>/', async () => {
|
||||
const dir = mkdtempSync(path.join(tmpdir(), 'sp-local-'));
|
||||
try {
|
||||
const put = (rel, body = 'x') => { mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true }); writeFileSync(path.join(dir, rel), body); };
|
||||
put('local/spine/enemy/enemy_1305_mhslim/enemy_1305_mhslim.skel');
|
||||
put('local/spine/enemy/enemy_1305_mhslim/enemy_1305_mhslim.atlas');
|
||||
put('local/spine/enemy/enemy_1305_mhslim/enemy_1305_mhslim.png');
|
||||
put('local/spine/enemy/enemy_1305_mhslim/enemy_1305_mhslim_2.png');
|
||||
put('local/spine/enemy/enemy_x_noatlas/enemy_x_noatlas.skel');
|
||||
put('local/spine/enemy/enemy_x_nopng/enemy_x_nopng.skel');
|
||||
put('local/spine/enemy/enemy_x_nopng/enemy_x_nopng.atlas');
|
||||
put('local/spine/enemy/not-an-enemy/a.skel');
|
||||
const found = await findLocalEnemyModels(dir);
|
||||
assert.deepEqual(Object.keys(found), ['enemy_1305_mhslim']);
|
||||
assert.deepEqual(found.enemy_1305_mhslim, {
|
||||
dir: 'local/spine/enemy/enemy_1305_mhslim/', skel: 'local/spine/enemy/enemy_1305_mhslim/enemy_1305_mhslim.skel',
|
||||
atlas: 'local/spine/enemy/enemy_1305_mhslim/enemy_1305_mhslim.atlas',
|
||||
pngs: ['local/spine/enemy/enemy_1305_mhslim/enemy_1305_mhslim.png', 'local/spine/enemy/enemy_1305_mhslim/enemy_1305_mhslim_2.png'],
|
||||
});
|
||||
assert.deepEqual(await findLocalEnemyModels(path.join(dir, 'missing')), {});
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('the generated manifest never aliases an enemy whose own local model is on disk', { skip: !existsSync(path.join(ROOT, 'public/assets')) && 'public/assets not downloaded' }, () => {
|
||||
const m = readJson('data/assets.json');
|
||||
for (const [id, e] of Object.entries(m.enemies)) {
|
||||
if (!e.spineAliasOf) continue;
|
||||
assert.ok(!existsSync(path.join(ROOT, 'public/assets/local/spine/enemy', id)), `${id}: aliased to ${e.spineAliasOf} although its own model was extracted — re-run npm run assets`);
|
||||
}
|
||||
for (const id of SLUGS) {
|
||||
if (!existsSync(path.join(ROOT, 'public/assets/local/spine/enemy', id))) continue;
|
||||
const sp = m.enemies[id].spine;
|
||||
assert.ok(sp && sp.skel.endsWith(`/${id}/${id}.skel`), `${id} drawn with its own skeleton (${sp && sp.skel})`);
|
||||
assert.equal(sp.pma, true);
|
||||
for (const u of [sp.skel, sp.atlas, ...sp.textures]) assert.ok(existsSync(path.join(ROOT, 'public', u)), u);
|
||||
assert.ok(sp.anims.idle && sp.anims.move && sp.anims.attack && sp.anims.die, `${id}: roles resolved`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0);
|
||||
describe('D3: tools/local-extract/extract.py enemy Spine job (no UnityPy needed)', { skip: !PY && 'no python3' }, () => {
|
||||
const TOOL = path.join(ROOT, 'tools/local-extract');
|
||||
const ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' };
|
||||
test('--print-jobs lists the enemy models; container matching and --only selection', () => {
|
||||
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.deepEqual(JSON.parse(r.stdout).enemySpines, { bundles: 'refs/arts/enm_art_*.ab', sub: 'spine/enemy', ids: SLUGS });
|
||||
const src = `import sys, json\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport extract as e\nW = set(e.ENEMY_SPINES)\n`
|
||||
+ `print(json.dumps([e.enemy_spine_id('Assets/Torappu/Arts/Enemies/Spines/enemy_1305_mhslim/2/enemy_1305_mhslim_2_SkeletonData.asset', W),`
|
||||
+ ` e.enemy_spine_id('Assets/Torappu/Arts/Enemies/Spines/enemy_1305_mhslim/1/enemy_1305_mhslim_SkeletonData.asset', W),`
|
||||
+ ` e.enemy_spine_id('Assets/Torappu/Arts/Enemies/Spines/enemy_1007_slime/enemy_1007_slime_SkeletonData.asset', W),`
|
||||
+ ` e.enemy_spine_id('Assets/Torappu/Arts/Enemies/Spines/enemy_1305_mhslim/1/enemy_1305_mhslim_Material.mat', W),`
|
||||
+ ` [e.wants_sub(o, 'spine/enemy') for o in ([], ['spine'], ['spine/enemy'], ['spine/enemy/enemy_1305_mhslim'], ['spine/token_x'], ['map'])]]))`;
|
||||
const o = spawnSync(PY, ['-c', src], { encoding: 'utf8', env: ENV });
|
||||
assert.equal(o.status, 0, o.stderr);
|
||||
assert.deepEqual(JSON.parse(o.stdout), ['enemy_1305_mhslim_2', 'enemy_1305_mhslim', null, null, [true, true, true, true, false, false]]);
|
||||
});
|
||||
});
|
||||
+23
-5
@@ -20,7 +20,10 @@ 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). */
|
||||
/**
|
||||
* Enemies whose Spine no community dump carries: render them with another enemy's model (research 07 §5.6) — unless
|
||||
* their own official model was extracted from the local client (`localEnemyModels`, tools/local-extract ENEMY_SPINES).
|
||||
*/
|
||||
export const ENEMY_SPINE_ALIAS = Object.freeze({
|
||||
enemy_1305_mhslim: 'enemy_1007_slime',
|
||||
enemy_1305_mhslim_2: 'enemy_1007_slime',
|
||||
@@ -200,9 +203,13 @@ 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,{dir:string, skel:string, atlas:string, pngs:string[]}>} [p.localEnemyModels] enemy models
|
||||
* extracted from the local client, on disk under public/assets (spine.mjs findLocalEnemyModels): an enemy without an
|
||||
* upstream model takes its own local one before any alias (user feedback after 0.1.0, D3: 灼热源石虫 / 炽焰源石虫
|
||||
* were drawn as the plain 源石虫)
|
||||
* @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 = {}, localEnemyModels = {} }) {
|
||||
const notes = [];
|
||||
/** @type {Map<string, any>} */
|
||||
const models = new Map();
|
||||
@@ -332,6 +339,16 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
|
||||
pngs: [mk(png, rec07?.png)],
|
||||
});
|
||||
};
|
||||
// an official model extracted from the local client (no URL: the files are on disk already)
|
||||
const localModel = (eid) => {
|
||||
const l = localEnemyModels?.[eid];
|
||||
if (!l || typeof l.skel !== 'string' || typeof l.atlas !== 'string' || !Array.isArray(l.pngs) || !l.pngs.length) return null;
|
||||
return addModel(`enemy:${eid}`, {
|
||||
kind: 'enemy', dir: l.dir, pma: true, skillIndices: [0], baseUrl: null, local: true,
|
||||
skel: alt(l.skel, []), atlas: { ...alt(l.atlas, []), mutable: true }, pngs: l.pngs.map((p) => alt(p, [])),
|
||||
});
|
||||
};
|
||||
const ownModel = (eid) => arkModel(eid) || localModel(eid);
|
||||
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);
|
||||
@@ -345,8 +362,9 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
|
||||
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);
|
||||
// Spine: own model (upstream, else extracted from the local client), else alias chain (research 07 §5.6).
|
||||
let spine = ownModel(id);
|
||||
if (spine && localEnemyModels?.[id] && models.get(spine.model)?.local) notes.push(`${id}: Spine from the local client`);
|
||||
if (!spine) {
|
||||
const seen = new Set([id]);
|
||||
const queue = [ENEMY_SPINE_ALIAS[id], baseIdOf(id), handbookOf.get(id)].filter(Boolean);
|
||||
@@ -354,7 +372,7 @@ export function buildPlan({ assets07, ops03, enemies05, maps05, audio, modelsDat
|
||||
const cand = queue.shift();
|
||||
if (seen.has(cand)) continue;
|
||||
seen.add(cand);
|
||||
spine = arkModel(cand);
|
||||
spine = ownModel(cand);
|
||||
if (spine) e.spineAliasOf = cand;
|
||||
else queue.push(...[ENEMY_SPINE_ALIAS[cand], baseIdOf(cand), handbookOf.get(cand)].filter(Boolean));
|
||||
}
|
||||
|
||||
+35
-4
@@ -1,8 +1,10 @@
|
||||
// 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.
|
||||
// Produces the per-model `spine` entries of data/assets.json. Enemy models that
|
||||
// no dump carries may come from the local client instead (findLocalEnemyModels:
|
||||
// tools/local-extract/extract.py ENEMY_SPINES → public/assets/local/spine/enemy/).
|
||||
|
||||
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 +14,33 @@ 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/';
|
||||
|
||||
/**
|
||||
* 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 }} SpineEntry
|
||||
@@ -32,12 +61,14 @@ export async function processModels(models, { root, dl, cachePath, download = tr
|
||||
const problems = [];
|
||||
const list = [...models.values()];
|
||||
if (download) {
|
||||
// local-client models (`local`: findLocalEnemyModels) are on disk already: nothing to download
|
||||
const remote = list.filter((m) => !m.local);
|
||||
const jobs = [];
|
||||
for (const m of list) jobs.push(m.skel, m.atlas, ...m.pngs);
|
||||
for (const m of remote) 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) {
|
||||
for (const m of remote) {
|
||||
const text = await readFile(join(root, m.atlas.rel), 'utf8').catch(() => null);
|
||||
if (!text) continue;
|
||||
for (const page of atlasInfo(text).pages) {
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
// 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 are taken from the local client when
|
||||
// tools/local-extract/extract.py has extracted them (public/assets/local/spine/
|
||||
// enemy/<id>/, ASSETS.md "Enemy aliases"); otherwise another enemy's model.
|
||||
//
|
||||
// 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
|
||||
@@ -21,7 +25,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 } 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';
|
||||
@@ -162,6 +166,7 @@ async function main() {
|
||||
extraEnemyIds: Object.keys(dataEnemies || {}),
|
||||
extraTokenIds: Object.keys(dataTokens || {}),
|
||||
extraHandbook,
|
||||
localEnemyModels: await findLocalEnemyModels(ASSETS),
|
||||
});
|
||||
const leaves = collectLeaves(plan.template);
|
||||
log(`[plan] ${leaves.length} files + ${plan.models.size} Spine models ` +
|
||||
@@ -214,8 +219,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 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 || {});
|
||||
|
||||
@@ -8,6 +8,9 @@ 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); tools/fetch-assets.mjs plans them in place of an alias
|
||||
- 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 +93,17 @@ 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 used to alias them to another enemy's skeleton): 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>).
|
||||
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 +134,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 +149,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 +412,98 @@ 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 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
|
||||
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 +519,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 +528,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 +538,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:
|
||||
|
||||
Reference in new issue
Block a user