The 3D board textures can be extracted as WebP: each player downloads about 4.7 MB less on a cold start (#186)

Only the extraction (tools/local-extract) and the manifest pointer change; no official textures are committed and nothing in play changes. Contributed by @Cloudnyco.
This commit is contained in:
Cloudnyco authored and GitHub committed 2026-10-06 05:05:42 +08:00
1 parent 54cdad2602
commit a9dfd17bee
6 files changed
+209 -12

No files matched your search

+1 -1
View File
@@ -129,7 +129,7 @@ The `stem` of a Spine model is the upstream file name. Two examples: `char_107_l
- **Fonts:** OTF/TTF files are converted to WOFF2 by a built-in encoder (`tools/assets/woff2.mjs`: Brotli with null transforms).
- Its output was verified lossless against Google's reference `woff2` decoder.
- `fonts.css` lists WOFF2 first and falls back to the original file.
- Images stay PNG. WebP conversion is not done: it would need a native dependency.
- Images stay PNG. WebP conversion is not done: it would need a native dependency. The local-client board textures are the exception: `tools/local-extract/extract.py` (Python, where Pillow is already a dependency) writes WebP copies of the 12 textures the 3D board downloads (`WEBP`: colour maps lossy at quality 95 with the alpha and the RGB under transparent texels kept, normal and data maps lossless) and `data/local-assets.json` lists the copies; the PNGs stay beside them for `tools/crop-board-atlas.mjs`. `extract.py --webp` adds the copies to an existing extraction without the client.
## Manifest schema (`data/assets.json`)
+2
View File
@@ -257,3 +257,5 @@ services:
表情(6 套 × 6 个)和「玩法说明」的 19 页教程图公开镜像也有:`node tools/setup.mjs` 会和其他素材一起下载(约 21 MB),不需要客户端;有本地素材时优先显示本地的。
**没有客户端的服务器**想要上表中的官方素材:从**同一版本**的完整包([Releases](https://github.com/sganggs/Stronghold-Protocol/releases))里,把 `public/assets/local/` 文件夹和 `data/local-assets.json` 复制到服务器项目目录下的相同位置。服务器每次请求都会重新读取这两处,不必重启,玩家刷新页面即可。一定要用与服务器代码相同版本的完整包:各版本提取的内容和清单可能不同(例如灼热 / 炽焰源石虫的模型是 0.1.0 之后才加入的),混用其他版本的文件会缺图或用错图。复制后 `node tools/doctor.mjs` 会显示本地素材的条目数和「3D 棋盘可用」。
**3D 棋盘贴图的下载量**:每位玩家进入对局时都要从开服的电脑下载 3D 棋盘的 12 张贴图。提取时会给这 12 张各写一份 WebP(颜色贴图有损、质量 95,法线和数据贴图无损),清单里列的是 WebP,同名 PNG 留在旁边给裁切工具和 setup 用。这部分下载量从约 6.7 MB 降到约 2 MB,网速慢的远程联机最明显。只有 PNG 的本地素材(例如在这一改动之前提取的)可以用提取时的 Python 环境运行 `tools/local-extract/extract.py --webp` 就地补上,只需要 Pillow,不需要客户端。
+20 -2
View File
@@ -7,11 +7,29 @@
// `map/autochess → TX_autochessi_D`; its crop table `tiles.json` sits next to it (written by
// tools/crop-board-atlas.mjs). Every image referenced by the table is loaded through the shared image cache; a
// missing image drops only the materials that use it. Everything is cached per page (one load for every view).
// A table image is fetched at the URL the manifest lists for the same texture (sourceUrl), so the 3D board
// (board3d/load.js, which reads the manifest only) and this art share one download — also when the manifest lists
// the WebP copy tools/local-extract writes and the table still names the PNG.
let cached = null;
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
/** The manifest group of the board textures (and of their crop table). */
export const BOARD_GROUP = 'map/autochess';
/**
* URL of a crop-table image: the local-art manifest's entry for the same texture (group `BOARD_GROUP`, name = the
* file name without its extension), else the table's own path.
* @param {any} assets store of public/js/assets.js
* @param {string} path tiles.json `source[k].path`
*/
export function sourceUrl(assets, path) {
const name = path.replace(/^.*\//, '').replace(/\.[^.]*$/, '');
const listed = name && typeof assets?.localUrl === 'function' ? assets.localUrl(BOARD_GROUP, name) : null;
return typeof listed === 'string' && listed ? listed : path;
}
/** @param {any} assets store of public/js/assets.js (needs local(), localUrl(), image()) */
export function loadBoardArt(assets) {
if (cached) return cached;
@@ -19,7 +37,7 @@ export function loadBoardArt(assets) {
if (!assets || typeof assets.local !== 'function' || typeof assets.image !== 'function') return null;
const manifest = await assets.local().catch(() => null);
if (!manifest) return null;
const atlasUrl = assets.localUrl ? assets.localUrl('map/autochess', 'TX_autochessi_D') : null;
const atlasUrl = assets.localUrl ? assets.localUrl(BOARD_GROUP, 'TX_autochessi_D') : null;
if (!atlasUrl) return null;
const dir = atlasUrl.replace(/\/[^/]*$/, '');
let tiles = null;
@@ -30,7 +48,7 @@ export function loadBoardArt(assets) {
if (!isObj(tiles) || !isObj(tiles.materials) || !isObj(tiles.source)) return null;
const images = {};
await Promise.all(Object.entries(tiles.source).map(async ([k, s]) => {
const url = isObj(s) && typeof s.path === 'string' ? s.path : null;
const url = isObj(s) && typeof s.path === 'string' && s.path ? sourceUrl(assets, s.path) : null;
if (!url) return;
const img = await assets.image(url).catch(() => null);
if (img && img.width > 0) images[k] = img;
+81 -4
View File
@@ -1,12 +1,14 @@
// test/render/board3d-extract.test.js — the local-client exports behind the official 3D board (DESIGN §13/§15):
// tools/local-extract/extract.py's board jobs (gate / objective effects, blower texture, water & noise maps), the
// derived three.js maps (BC5 normal → RGB normal, Unity metallic/gloss → roughness), the prefab mesh keys, and —
// derived three.js maps (BC5 normal → RGB normal, Unity metallic/gloss → roughness), the WebP copies of the textures
// the renderer downloads (extract.py WEBP), the prefab mesh keys, and —
// when a client was extracted here — the files and manifest entries the renderer loads (render/board3d/load.js).
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, existsSync } from 'node:fs';
import { readFileSync, existsSync, mkdtempSync, 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 { decodePng } from '../../tools/crop-board-atlas.mjs';
@@ -18,6 +20,7 @@ const TOOL = path.join(ROOT, 'tools/local-extract');
const ENV = { ...process.env, PYTHONDONTWRITEBYTECODE: '1' };
const PY = ['python3', 'python'].find((bin) => spawnSync(bin, ['--version']).status === 0);
const HAS_PIL = !!PY && spawnSync(PY, ['-c', 'import PIL'], { env: ENV }).status === 0;
const HAS_WEBP = HAS_PIL && spawnSync(PY, ['-c', "import sys; from PIL import features; sys.exit(0 if features.check('webp') else 1)"], { env: ENV }).status === 0;
const manifest = (() => { try { return JSON.parse(readFileSync(path.join(ROOT, 'data/local-assets.json'), 'utf8')); } catch { return null; } })();
function py(code) {
@@ -62,6 +65,68 @@ print(json.dumps({'n': [list(rn.getpixel((i, 0))) for i in range(3)], 'm': [list
assert.deepEqual(out.m, [[255, 255, 0], [255, 55, 0]]);
});
test('WebP copies: exactly the board textures the renderer downloads; normal and data maps stay lossless', () => {
const r = spawnSync(PY, [path.join(TOOL, 'extract.py'), '--print-jobs'], { encoding: 'utf8', env: ENV });
assert.equal(r.status, 0, r.stderr);
const { webp } = JSON.parse(r.stdout);
assert.deepEqual(webp.map((w) => `${w.sub}/${w.name}`).sort(), Object.values(PACK_IMAGES).map(([g, n]) => `${g}/${n}`).sort());
const mode = Object.fromEntries(webp.map((w) => [w.name, w.mode]));
for (const n of ['TX_autochessi_N_rgb', '[ucp]TX_water_normal', 'TX_autochessi_M_rough', 'T_noise_clouds_01']) assert.equal(mode[n], 'lossless', n);
for (const n of ['TX_autochessi_D', 'TX_autochessi_BG', 'TX_autochessi_common_D']) assert.equal(mode[n], 'lossy', n);
});
test('run_webp / --webp: a copy beside the PNG that the manifest lists; normals exact, alpha and hidden RGB kept', { skip: !HAS_WEBP && 'no Pillow with WebP' }, () => {
const dir = mkdtempSync(path.join(tmpdir(), 'sp-webp-'));
try {
const out = py(`
from pathlib import Path
from PIL import Image
root = Path(${JSON.stringify(dir)})
sub = root / 'map' / 'autochess'
sub.mkdir(parents=True)
d = Image.new('RGBA', (64, 64), (200, 40, 40, 0)) # transparent texels whose colour the opaque board material shows
for x in range(32):
for y in range(64): d.putpixel((x, y), (x * 8, y * 4, 90, 255))
d.save(sub / 'TX_autochessi_D.png')
n = Image.new('RGB', (64, 64))
for x in range(64):
for y in range(64): n.putpixel((x, y), ((x * 37) % 256, (y * 53) % 256, 200 + (x + y) % 56))
n.save(sub / 'TX_autochessi_N_rgb.png')
entry = lambda name, kind: {'path': f'/assets/local/map/autochess/{name}.png', 'w': 64, 'h': 64, 'kind': kind}
groups = {'map/autochess': {'TX_autochessi_D': entry('TX_autochessi_D', 'Texture2D'),
'TX_autochessi_N_rgb': entry('TX_autochessi_N_rgb', 'Derived'),
'TX_autochessi_E': entry('TX_autochessi_E', 'Texture2D')}}
mf = root / 'local-assets.json'
mf.write_text(json.dumps({'version': 1, 'source': 'local-client', 'count': 3, 'groups': groups}))
logs = []
code = e.webp_only(root, mf, logs.append)
doc = json.loads(mf.read_text())
g = doc['groups']['map/autochess']
px = lambda p: list(Image.open(p).convert('RGBA').getdata())
a, b = px(sub / 'TX_autochessi_D.png'), px(sub / 'TX_autochessi_D.webp')
hidden = [q for p, q in zip(a, b) if p[3] == 0]
print(json.dumps({'code': code, 'paths': {k: v['path'] for k, v in g.items()}, 'modes': {k: v.get('webp') for k, v in g.items()},
'count': doc['count'], 'pngKept': (sub / 'TX_autochessi_D.png').exists(),
'alphaSame': all(p[3] == q[3] for p, q in zip(a, b)),
'normalSame': px(sub / 'TX_autochessi_N_rgb.png') == px(sub / 'TX_autochessi_N_rgb.webp'),
'hidden': [sum(q[i] for q in hidden) / len(hidden) for i in range(3)],
'again': e.run_webp(root, 'map/autochess', doc['groups'], logs.append)}))`);
assert.equal(out.code, 0);
const at = (n, ext) => `/assets/local/map/autochess/${n}.${ext}`;
// TX_autochessi_E is listed without a PNG on disk: its entry stays as it was
assert.deepEqual(out.paths, { TX_autochessi_D: at('TX_autochessi_D', 'webp'), TX_autochessi_N_rgb: at('TX_autochessi_N_rgb', 'webp'), TX_autochessi_E: at('TX_autochessi_E', 'png') });
assert.deepEqual(out.modes, { TX_autochessi_D: 'lossy', TX_autochessi_N_rgb: 'lossless', TX_autochessi_E: null });
assert.equal(out.count, 3);
assert.ok(out.pngKept, 'the PNG stays beside the copy (crop tool, setup)');
assert.ok(out.alphaSame, 'alpha lossless');
assert.ok(out.normalSame, 'normal map lossless');
out.hidden.forEach((v, i) => assert.ok(Math.abs(v - [200, 40, 40][i]) < 12, `RGB under alpha 0 kept: ${out.hidden}`));
assert.equal(out.again, 2, 'a second run rewrites both copies');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('prefab_node names the exported OBJ of a mesh whose name is shared (map/fx has two Start_back meshes)', () => {
const out = py(`
from types import SimpleNamespace as NS
@@ -92,6 +157,7 @@ describe('vendored three.js', () => {
describe('extracted board files (when a local client was extracted)', { skip: !manifest?.groups?.['map/fx'] && 'map/fx not extracted' }, () => {
const onDisk = (p) => path.join(ROOT, 'public', decodeURIComponent(p));
const pngOf = (p) => p.replace(/\.webp$/, '.png'); // a WebP copy (extract.py WEBP) keeps its PNG beside it
const hasFiles = existsSync(path.join(ROOT, 'public/assets/local/map/fx'));
test('every pack slot the renderer loads is in the manifest (and on disk)', () => {
@@ -113,13 +179,24 @@ describe('extracted board files (when a local client was extracted)', { skip: !m
test('derived maps on disk: RGB normals point out of the surface; roughness mirrors the smoothness', { skip: !hasFiles && 'files not extracted' }, () => {
const g = manifest.groups['map/autochess'];
const n = decodePng(readFileSync(onDisk(g.TX_autochessi_N_rgb.path)));
const n = decodePng(readFileSync(onDisk(pngOf(g.TX_autochessi_N_rgb.path))));
let zSum = 0, cnt = 0;
for (let i = 0; i < n.rgba.length; i += 4 * 97) { zSum += n.rgba[i + 2]; cnt++; }
assert.ok(zSum / cnt > 230, `mean z ${zSum / cnt}`);
const m = decodePng(readFileSync(onDisk(g.TX_autochessi_M.path)));
const r = decodePng(readFileSync(onDisk(g.TX_autochessi_M_rough.path)));
const r = decodePng(readFileSync(onDisk(pngOf(g.TX_autochessi_M_rough.path))));
assert.deepEqual([r.w, r.h], [m.w, m.h]);
for (let i = 0; i < m.rgba.length; i += 4 * 131) assert.equal(r.rgba[i + 1], 255 - m.rgba[i + 3]);
});
test('the WebP copies the manifest lists are on disk with their PNGs beside them', { skip: !hasFiles && 'files not extracted' }, () => {
for (const [g, entries] of Object.entries(manifest.groups)) {
for (const [n, e] of Object.entries(entries)) {
if (!/\.webp$/.test(e?.path || '')) continue;
assert.ok(existsSync(onDisk(e.path)), `${g}/${n}`);
assert.ok(existsSync(onDisk(pngOf(e.path))), `${g}/${n}: PNG`);
assert.ok(e.webp === 'lossy' || e.webp === 'lossless', `${g}/${n}: mode ${e.webp}`);
}
}
});
});
+23
View File
@@ -18,6 +18,7 @@ import { FX_KINDS, fxSpec, tilesAround } from '../../public/js/render/fx.js';
import { statusIconKey } from '../../public/js/render/style.js';
import { STATUS_KEYS } from '../../public/js/render/textures.js';
import { createAssets, localAssetUrl } from '../../public/js/assets.js';
import { loadBoardArt, resetBoardArt, sourceUrl } from '../../public/js/render/boardArt.js';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const stages = JSON.parse(readFileSync(path.join(ROOT, 'data/stages.json'), 'utf8'));
@@ -311,4 +312,26 @@ describe('local-art manifest helpers', () => {
const broken = createAssets({ manifest: {}, fetch: async () => { throw new Error('offline'); } });
assert.equal(await broken.local(), null);
});
test('board art loads the crop-table textures at the URLs the manifest lists (one download with the 3D board)', async () => {
const at = (n, ext) => `/assets/local/map/autochess/${n}.${ext}`;
const m = { groups: { 'map/autochess': { TX_autochessi_D: { path: at('TX_autochessi_D', 'webp') }, TX_autochessi_BG: { path: at('TX_autochessi_BG', 'webp') } } } };
const asked = [];
const store = { local: async () => m, localUrl: (g, n) => localAssetUrl(m, g, n), image: async (u) => { asked.push(u); return { width: 4 }; } };
assert.equal(sourceUrl(store, at('TX_autochessi_D', 'png')), at('TX_autochessi_D', 'webp'));
assert.equal(sourceUrl(store, at('TX_autochessi_common_D', 'png')), at('TX_autochessi_common_D', 'png'), 'not in the manifest: the table path');
assert.equal(sourceUrl({}, '/x/y.png'), '/x/y.png');
const tiles = { version: 2, materials: {}, source: { D: { path: at('TX_autochessi_D', 'png') }, common: { path: at('TX_autochessi_common_D', 'png') }, BG: { path: at('TX_autochessi_BG', 'png') } } };
const realFetch = globalThis.fetch;
globalThis.fetch = async (u) => (u === '/assets/local/map/autochess/tiles.json' ? { ok: true, json: async () => tiles } : { ok: false, status: 404 });
try {
resetBoardArt();
const art = await loadBoardArt(store);
assert.ok(art?.images.D && art.images.common && art.images.BG);
assert.deepEqual(asked.sort(), [at('TX_autochessi_BG', 'webp'), at('TX_autochessi_D', 'webp'), at('TX_autochessi_common_D', 'png')]);
} finally {
globalThis.fetch = realFetch;
resetBoardArt();
}
});
});
+82 -5
View File
@@ -26,15 +26,19 @@ aklz4.py registers a decoder for it. This script pulls the art the web sources l
- derived PBR maps for three.js (DERIVED): Unity stores the theme's normal map as two channels (BC5: RG = XY, B = 0)
and metallic/gloss with the smoothness in A; the board renderer needs an RGB normal map (Z rebuilt) and a
roughness map in G (1 − smoothness; metalness B = 0), written next to the source as <name>_rgb.png / _rough.png
- WebP copies of the board textures every player downloads with the 3D board (WEBP), written next to their PNGs;
the manifest lists the copy
Usage:
python3 -m venv .venv && .venv/bin/pip install -r tools/local-extract/requirements.txt
.venv/bin/python tools/local-extract/extract.py [--game <AB root>] [--out public/assets/local] [--only <subdir prefix>]
python3 tools/local-extract/extract.py --print-jobs (the job table as JSON; needs no dependencies)
python3 tools/local-extract/extract.py --webp (only the WebP copies, from the PNGs already extracted —
e.g. the local art copied from a release bundle; needs Pillow)
Writes <out>/**.png|.skel|.atlas and data/local-assets.json (manifest of what was extracted). With --only, just the
jobs whose output subdir starts with one of the prefixes run, and their groups replace those of the existing manifest
(every other group is kept as is).
Writes <out>/**.png|.webp|.skel|.atlas and data/local-assets.json (manifest of what was extracted). With --only, just
the jobs whose output subdir starts with one of the prefixes run, and their groups replace those of the existing
manifest (every other group is kept as is).
Everything is (c) Hypergryph; for private, non-commercial fan use only.
"""
import argparse
@@ -118,6 +122,32 @@ DERIVED = [
('map/autochess', 'TX_autochessi_M', 'rough_from_gloss', 'TX_autochessi_M_rough'),
]
# The board textures every player downloads when a match shows the 3D board (public/js/render/board3d/load.js
# PACK_IMAGES; D / common_D / BG also feed the 2D board art, render/boardArt.js): (output subdir, name, mode). A WebP
# copy is written next to the PNG and the manifest lists the copy instead (≈ 6.7 MB → 2.0 MB per cold start); the PNG
# stays for tools/crop-board-atlas.mjs and setup's check. 'lossy' = colour maps at quality 95 with the alpha lossless
# and the RGB under transparent texels kept (`exact`: the board material is opaque and samples it); 'lossless' =
# normal and data maps, whose channels hold independent values that lossy WebP's chroma subsampling would mix (a
# normal map ends up tens of degrees off). A Pillow without WebP support keeps the PNG.
WEBP = [
('map/autochess', 'TX_autochessi_D', 'lossy'),
('map/autochess', 'TX_autochessi_BG', 'lossy'),
('map/autochess', 'TX_autochessi_common_D', 'lossy'),
('map/autochess', 'TX_autochessi_N_rgb', 'lossless'),
('map/autochess', 'TX_autochessi_M_rough', 'lossless'),
('map/autochess', 'TX_autochessi_E', 'lossless'),
('map/autochess', 'TX_autochessi_common_E', 'lossless'),
('map/common', 'TX_wind_device', 'lossy'),
('map/fx', '[opt]merged_textures', 'lossless'),
('map/water', '[ucp]TX_water_normal', 'lossless'),
('map/water', 'TX_Caustics256', 'lossy'),
('map/water', 'T_noise_clouds_01', 'lossless'),
]
WEBP_OPTIONS = {
'lossy': {'quality': 95, 'method': 6, 'alpha_quality': 100, 'exact': True},
'lossless': {'lossless': True, 'quality': 100, 'method': 6, 'exact': True},
}
# Shader bundles loaded beside every job that exports Materials, only so that the materials' shader references
# (external CABs) resolve to a name in materials.json (e.g. Torappu/Scene/StandardDirectional); nothing is exported
# from them.
@@ -314,6 +344,44 @@ def run_derived(out_root, sub, manifest, log):
return n
def run_webp(out_root, sub, manifest, log):
"""Write the WEBP copies of output subdir `sub` from its PNGs and point their manifest entries at them."""
n = 0
for wsub, name, mode in WEBP:
entry = manifest.get(sub, {}).get(name) if wsub == sub else None
png = Path(out_root) / sub / f'{name}.png'
if not entry or not png.exists():
continue
dst = png.with_name(f'{name}.webp')
try:
from PIL import Image
with Image.open(png) as img:
img.save(dst, 'WEBP', **WEBP_OPTIONS[mode])
except Exception as e: # no WebP support in this Pillow, or a broken PNG: the entry keeps the PNG
log(f' warn webp {name}: {e}')
continue
entry.update(path=f'/assets/local/{sub}/{dst.name}', webp=mode)
n += 1
return n
def webp_only(out_root, manifest_path, log):
"""--webp: the WEBP copies of an existing extraction (e.g. the local art copied from a release bundle)."""
try:
doc = json.loads(Path(manifest_path).read_text(encoding='utf-8'))
except (OSError, ValueError) as e:
print(f'No readable manifest at {manifest_path} ({e}). Extract the local art first.', file=sys.stderr)
return 2
groups = doc.get('groups') if isinstance(doc, dict) else None
if not isinstance(groups, dict):
print(f'{manifest_path} has no groups. Extract the local art first.', file=sys.stderr)
return 2
n = sum(run_webp(out_root, sub, groups, log) for sub in dict.fromkeys(s for s, _, _ in WEBP))
Path(manifest_path).write_text(json.dumps(doc, ensure_ascii=False, separators=(',', ':')), encoding='utf-8')
log(f'done: {n} WebP copies, manifest {manifest_path}')
return 0 if n else 1
def merge_manifest(old_groups, new_groups, ran_subs):
"""Groups of the previous manifest minus the subdirs that were re-extracted, plus the new ones (sorted keys)."""
out = {g: v for g, v in (old_groups or {}).items() if g not in ran_subs}
@@ -415,6 +483,9 @@ def export_bundle(ab_root, job, out_root, manifest, log):
n += 1
n += run_derived(out_root, sub, manifest, log)
log(f'{rel}: {n} files -> {sub}')
k = run_webp(out_root, sub, manifest, log)
if k:
log(f' {k} WebP copies -> {sub}')
return n
@@ -555,16 +626,22 @@ def main():
ap.add_argument('--only', action='append', default=[], metavar='SUBDIR',
help='only run the jobs whose output subdir starts with this prefix (repeatable), e.g. emoticon')
ap.add_argument('--print-jobs', action='store_true', help='print the job table as JSON and exit')
ap.add_argument('--webp', action='store_true',
help='only write the WebP copies of the board textures from the PNGs already under --out and list '
'them in the manifest (needs Pillow, not the client)')
args = ap.parse_args()
if args.print_jobs:
jobs = [{'bundle': rel, 'sub': sub, 'kinds': sorted(kinds), 'keep': keep.pattern if keep else None}
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]
webp = [{'sub': sub, 'name': name, 'mode': mode} for sub, name, mode in WEBP]
print(json.dumps({'emoteThemes': [{'themeId': t, 'dir': d} for t, d in EMOTE_THEMES], 'jobs': jobs,
'derived': derived, 'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB,
'ids': ENEMY_SPINES}}, ensure_ascii=False))
'derived': derived, 'webp': webp, 'enemySpines': {'bundles': ENEMY_ART, 'sub': ENEMY_SPINE_SUB,
'ids': ENEMY_SPINES}}, ensure_ascii=False))
return 0
if args.webp:
return webp_only(Path(args.out), Path(args.manifest), print)
ab_root = Path(args.game) if args.game else next((p for p in CANDIDATES if p.exists()), None)
if not ab_root or not ab_root.exists():