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
+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