// 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), so the // asset plan aliased them to enemy_1007_slime. Their official skeletons only exist in the local client // (tools/local-extract/extract.py ENEMY_SPINES → public/assets/local/spine/enemy//, optional and git-ignored), so // they are an OVERLAY: data/assets.json keeps the alias as the web model (`spine` + `spineAliasOf`) and adds // `spineLocal` (file names in the data/local-assets.json group + the parsed metadata, from the committed // tools/assets/local-enemy-spines.json — never from the disk, so the manifest is the same with or without the // extraction). The client (assets.js spineEntry) draws the official model when data/local-assets.json lists every one // of its files and falls back to the web model when it fails to load (DESIGN §13: local art is optional). // Without the extraction the web alias is drawn tinted toward the slug's own colours (render/units.js ALIAS_TINT, // research 07 §5.6 "a hue shift" [ASSUMED look]), so source installs still tell them apart from the plain slug. // 高能 / 冰爆 / 简饲源石虫 and “庞贝” always had their own models (checked in headless Chrome). // // Root cause (2026-10-04): Ark-Models *indexes* 1305_mhslim / 1305_mhslim_2 with an EMPTY `assetList` — registered, // never uploaded — so `arkModel()` finds nothing and the alias chain drops to enemy_1007_slime, a *different* enemy // rather than a variant of it. The mobile build does ship their own model (straight-alpha pages, no `pma: true` line), // and it is reachable, but its only public mirror is a community wiki rather than a GitHub dump: adding it would put a // non-GitHub host into `sources.mjs` for two models, which the reviewer of this change asked not to do. So the tinted // alias stays the web model, the local extraction stays the overlay, and the last block below locks both halves // (only these two enemies borrow a different enemy's skeleton; every asset source stays a GitHub dump). import { test, describe, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { existsSync, readFileSync, mkdtempSync, mkdirSync, writeFileSync, copyFileSync, rmSync, statSync } 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, ENEMY_SPINE_ALIAS } from '../tools/assets/plan.mjs'; import { RAW } from '../tools/assets/sources.mjs'; import { resolveTemplate, collectLeaves } from '../tools/assets/manifest.mjs'; import { findLocalEnemyModels, localEnemySpineMeta, loadLocalEnemySpines, LOCAL_ENEMY_SPINES_FILE, localEnemySpineGroup } from '../tools/assets/spine.mjs'; import { indexAudio } from '../tools/assets/audio.mjs'; import { createAssets, spineEntry, validSpine } from '../public/js/assets.js'; import { installFakePixi, fakeViewCtx } from './render/fakepixi.js'; import { presetCamera } from '../public/js/render/projection.js'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ASSETS = path.join(ROOT, 'public/assets'); const readJson = (rel) => JSON.parse(readFileSync(path.join(ROOT, rel), 'utf8')); const SLUGS = ['enemy_1305_mhslim', 'enemy_1305_mhslim_2']; const COMMITTED = readJson(LOCAL_ENEMY_SPINES_FILE).models; const MANIFEST = readJson('data/assets.json'); /** The real research inputs; Ark-Models carries the plain slug only (its index lists the special ones empty). */ function plan(localEnemySpines) { 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, localEnemySpines, }); } /** A data/local-assets.json listing the extracted files of `ids` (extract.py's group layout). */ function localManifest(ids, drop = null) { const groups = {}; for (const id of ids) { const g = groups[localEnemySpineGroup(id)] = {}; const m = COMMITTED[id]; for (const f of [m.skel, m.atlas, ...m.textures]) if (f !== drop) g[f] = { path: `/assets/local/spine/enemy/${id}/${f}`, kind: f.endsWith('.png') ? 'Texture2D' : 'TextAsset' }; } return { version: 1, source: 'local-client', groups }; } describe('D3: the official slug models are an optional overlay of the web alias', () => { test('plan: the web model stays the 源石虫 alias; spineLocal comes from the committed metadata as it is', () => { const before = plan(undefined); for (const id of SLUGS) { assert.equal(before.template.enemies[id].spineAliasOf, 'enemy_1007_slime'); assert.equal(before.template.enemies[id].spineLocal, undefined, 'no metadata → no overlay'); } const p = plan(COMMITTED); const slime = { skel: '/assets/spine/enemy/enemy_1007_slime/enemy_1007_slime.skel', atlas: '/assets/spine/enemy/enemy_1007_slime/enemy_1007_slime.atlas', textures: [], anims: {} }; const tmp = mkdtempSync(path.join(tmpdir(), 'sp-plan-')); try { const { value } = resolveTemplate({ enemies: Object.fromEntries(SLUGS.map((id) => [id, p.template.enemies[id]])) }, { root: tmp, spine: new Map([['enemy:enemy_1007_slime', slime]]) }); for (const id of SLUGS) { const e = value.enemies[id]; assert.equal(e.spineAliasOf, 'enemy_1007_slime', `${id}: the web model (works without the local client)`); assert.equal(e.spine, slime); assert.deepEqual(e.spineLocal, { group: `spine/enemy/${id}`, ...COMMITTED[id] }, `${id}: the overlay, null fields kept`); assert.equal(e.spineLocal.anims.attack.begin, null); } } finally { rmSync(tmp, { recursive: true, force: true }); } assert.ok(!collectLeaves(p.template).some((l) => /spineLocal/.test(l.path)), 'the overlay is no file to download'); assert.ok(p.notes.some((n) => /enemy_1305_mhslim: official Spine from the local client/.test(n))); }); test('the committed metadata: both ELEMENT slugs, file names of their extract.py group, roles resolved', () => { assert.deepEqual(Object.keys(COMMITTED).sort(), SLUGS); for (const id of SLUGS) { const m = COMMITTED[id]; assert.equal(m.skel, `${id}.skel`); assert.equal(m.atlas, `${id}.atlas`, 'next to the skeleton (pixi-spine finds the atlas by its name)'); assert.deepEqual(m.textures, [`${id}.png`]); assert.equal(m.pma, true, 'premultiplied pages (extract.py merge_alpha)'); for (const role of ['idle', 'die']) assert.ok(m.anims[role] in m.animations, `${id} ${role}`); assert.ok(m.anims.move.loop in m.animations && m.anims.attack.loop in m.animations, `${id} move / attack`); assert.ok(m.hits.Attack?.length, `${id}: the OnAttack frame`); } }); test('data/assets.json never depends on the local extraction (no /assets/local/ URL; the web model always there)', () => { const urls = []; const walk = (n) => { if (typeof n === 'string') { if (n.includes('/assets/local/')) urls.push(n); } else if (n && typeof n === 'object') Object.values(n).forEach(walk); }; walk(MANIFEST); assert.deepEqual(urls, [], 'setup / doctor / the manifest tests count every /assets/ URL as required'); for (const [id, e] of Object.entries(MANIFEST.enemies)) { if (!e.spineLocal) continue; assert.ok(SLUGS.includes(id), id); assert.deepEqual(e.spineLocal, { group: `spine/enemy/${id}`, ...COMMITTED[id] }, `${id}: the committed metadata`); assert.ok(validSpine(spineEntry(MANIFEST, id)), `${id}: a web model without the local client`); assert.equal(MANIFEST.enemies[id].spineAliasOf, 'enemy_1007_slime'); assert.deepEqual(spineEntry(MANIFEST, id), MANIFEST.enemies.enemy_1007_slime.spine, `${id}: the plain 源石虫 on the web`); } for (const id of SLUGS) assert.ok(MANIFEST.enemies[id].spineLocal, `${id} has its overlay`); }); test('the committed metadata is what the extracted models parse to', { skip: !SLUGS.every((id) => existsSync(path.join(ASSETS, 'local/spine/enemy', id))) && 'enemy models not extracted (tools/local-extract)' }, async () => { const found = await findLocalEnemyModels(ASSETS); const { meta, problems } = await localEnemySpineMeta(ASSETS, found); assert.deepEqual(problems, []); for (const id of SLUGS) assert.deepEqual(meta[id], COMMITTED[id], `${id}: re-run node tools/fetch-assets.mjs --local-spines`); }); test('localEnemySpineMeta parses an extracted model like the pipeline and writes nothing', { skip: !existsSync(path.join(ASSETS, 'spine/enemy/enemy_1007_slime')) && 'public/assets not downloaded' }, async () => { // the plain slug's fetched files stand in for an extraction (same layout: .skel / .atlas / page PNGs) const dir = mkdtempSync(path.join(tmpdir(), 'sp-meta-')); try { const src = path.join(ASSETS, 'spine/enemy/enemy_1007_slime'); const dst = path.join(dir, 'local/spine/enemy/enemy_9999_slime'); mkdirSync(dst, { recursive: true }); for (const f of ['enemy_1007_slime.skel', 'enemy_1007_slime.atlas', 'enemy_1007_slime.png']) copyFileSync(path.join(src, f), path.join(dst, f)); const atlasBefore = readFileSync(path.join(dst, 'enemy_1007_slime.atlas')); const mtime = statSync(path.join(dst, 'enemy_1007_slime.atlas')).mtimeMs; const { meta, problems } = await localEnemySpineMeta(dir, await findLocalEnemyModels(dir)); assert.deepEqual(problems, []); const web = MANIFEST.enemies.enemy_1007_slime.spine; assert.deepEqual(meta.enemy_9999_slime, { skel: 'enemy_1007_slime.skel', atlas: 'enemy_1007_slime.atlas', textures: ['enemy_1007_slime.png'], pma: true, anims: web.anims, animations: web.animations, events: web.events, hits: web.hits, bounds: web.bounds, }); assert.ok(readFileSync(path.join(dst, 'enemy_1007_slime.atlas')).equals(atlasBefore), 'read only'); assert.equal(statSync(path.join(dst, 'enemy_1007_slime.atlas')).mtimeMs, mtime); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('loadLocalEnemySpines: the models of the committed file, {} when it is missing or bad', async () => { assert.deepEqual(await loadLocalEnemySpines(path.join(ROOT, LOCAL_ENEMY_SPINES_FILE)), COMMITTED); assert.deepEqual(await loadLocalEnemySpines(path.join(ROOT, 'no-such-file.json')), {}); const dir = mkdtempSync(path.join(tmpdir(), 'sp-lse-')); try { writeFileSync(path.join(dir, 'a.json'), '{"models":[1]}'); assert.deepEqual(await loadLocalEnemySpines(path.join(dir, 'a.json')), {}); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('findLocalEnemyModels: skeleton + same-stem atlas + page PNGs under local/spine/enemy//', 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 }); } }); }); describe('D3 root cause: only the two slugs borrow a different enemy\'s skeleton, and no non-GitHub source is added', () => { test('plan: ENEMY_SPINE_ALIAS covers exactly the two slugs (Ark-Models indexes them with an empty assetList)', () => { const p = plan(undefined); assert.deepEqual(Object.keys(ENEMY_SPINE_ALIAS).sort(), [...SLUGS].sort(), 'exactly the slugs are aliased by hand'); for (const id of SLUGS) assert.equal(ENEMY_SPINE_ALIAS[id], 'enemy_1007_slime', `${id}: the plain 源石虫`); assert.ok(p.notes.some((n) => n.includes('enemy_1305_mhslim') && /aliased/.test(n)), 'the plan says so in its notes, so a run without the model is explainable'); }); test('data/assets.json: every other spineAliasOf stays inside its own variant family', () => { const borrowed = []; for (const [id, e] of Object.entries(MANIFEST.enemies)) { if (!e.spineAliasOf) continue; const base = /^(enemy_\d+_[a-z0-9]+?)_\d+$/i.exec(id); // enemy_2001_duckmi_2 → enemy_2001_duckmi if (!SLUGS.includes(id) && (!base || base[1] !== e.spineAliasOf)) borrowed.push(`${id} → ${e.spineAliasOf}`); } assert.deepEqual(borrowed, [], 'the six _2 aliases draw the base enemy their own prefab uses; only the two slugs borrow a different enemy'); }); test('every asset source stays a GitHub dump (no third-party mirror for two models)', () => { for (const [name, url] of Object.entries(RAW)) { assert.match(url, /^https:\/\/raw\.githubusercontent\.com\//, `RAW.${name} is a raw.githubusercontent.com URL`); } assert.ok(!/prts|torappu/i.test(JSON.stringify(RAW)), 'no community-wiki host among the download sources'); }); }); describe('D3 client: the official model only when data/local-assets.json lists it', () => { const webOf = (id) => MANIFEST.enemies[id].spine; // the plain 源石虫's model (spineAliasOf enemy_1007_slime) test('assets.js spineEntry: every file listed → the official model (web fallback); anything missing → the web alias', () => { const local = localManifest(SLUGS); for (const id of SLUGS) { const web = webOf(id); const e = spineEntry(MANIFEST, id, { local }); assert.equal(e.skel, `/assets/local/spine/enemy/${id}/${id}.skel`); assert.equal(e.atlas, `/assets/local/spine/enemy/${id}/${id}.atlas`); assert.deepEqual(e.textures, [`/assets/local/spine/enemy/${id}/${id}.png`]); assert.equal(e.pma, true); assert.deepEqual(e.anims, COMMITTED[id].anims); assert.deepEqual(e.hits, COMMITTED[id].hits); assert.equal(e.fallback, web, 'the plain 源石虫 if the official model fails to load'); assert.equal(spineEntry(MANIFEST, id, { local }), e, 'one entry object per manifest pair'); assert.equal(spineEntry(MANIFEST, id), web, 'no local manifest → the web model'); assert.equal(spineEntry(MANIFEST, id, { local: localManifest([]) }), web, 'not extracted → the web model'); assert.equal(spineEntry(MANIFEST, id, { local: localManifest([id], `${id}.png`) }), web, 'a page missing → the web model'); assert.equal(spineEntry(MANIFEST, id, { local: localManifest([id], `${id}.atlas`) }), web, 'the atlas missing → the web model'); } assert.equal(spineEntry(MANIFEST, 'enemy_1007_slime', { local }), webOf('enemy_1007_slime'), 'other enemies unchanged'); // the store hands the local manifest over once local() has it const store = createAssets({ manifest: MANIFEST, localManifest: local }); assert.equal(store.spineEntry(SLUGS[0]).skel, `/assets/local/spine/enemy/${SLUGS[0]}/${SLUGS[0]}.skel`); assert.equal(createAssets({ manifest: MANIFEST }).spineEntry(SLUGS[0]), webOf(SLUGS[0]), 'before / without local(): the web model'); }); test('render/app.js waits for the local manifest with the asset manifest before building unit views', () => { const src = readFileSync(path.join(ROOT, 'public/js/render/app.js'), 'utf8'); assert.match(src, /await withTimeout\(Promise\.all\(\[assets\.ready \? assets\.ready\(\) : null, assets\.local \? assets\.local\(\) : null\]/); }); describe('UnitView', () => { let fake, UnitView; let ALIAS_TINT, UF; before(async () => { fake = installFakePixi(); ({ UnitView, ALIAS_TINT } = await import('../public/js/render/units.js')); ({ UF } = await import('../shared/constants.js')); }); after(() => fake.restore()); const tick = () => new Promise((r) => setImmediate(r)); const cam = () => presetCamera('normal', { width: 1280, height: 720 }); const info = (id) => ({ id: 3, side: 'enemy', kind: 'enemy', defId: id, spine: id, tier: 1, x: 6, y: 10, maxHp: 1000 }); test('an official model that fails to load falls back to the web model (never the icon diamond)', async () => { const local = localManifest(SLUGS); const asked = [], released = []; const store = (fail) => ({ picture: () => null, image: async () => null, spineEntry: (id) => spineEntry(MANIFEST, id, { local }), spine: { acquire: async (e) => { asked.push(e.skel); if (fail(e)) throw new Error('404'); return { animations: Object.keys(e.animations).map((name) => ({ name })) }; }, release: (e) => released.push(e.skel), }, }); const id = SLUGS[0]; const web = webOf(id); const ok = new UnitView(fakeViewCtx(fake.P, { assets: store(() => false), cam }), info(id)); await tick(); await tick(); assert.equal(ok.actor?.entry.skel, `/assets/local/spine/enemy/${id}/${id}.skel`, 'the official model'); assert.equal(ok.actor.entry.anims.move.loop, 'Move', 'with its own clips'); asked.length = 0; const bad = new UnitView(fakeViewCtx(fake.P, { assets: store((e) => e.local), cam }), info(id)); await tick(); await tick(); await tick(); assert.deepEqual(asked, [`/assets/local/spine/enemy/${id}/${id}.skel`, web.skel], 'the official model, then the web one'); assert.ok(released.includes(`/assets/local/spine/enemy/${id}/${id}.skel`), 'the failed acquire is released'); assert.equal(bad.actor?.entry, web, 'drawn with the web model'); assert.equal(bad.entry, web); }); test('the web alias of a local-only slug is drawn tinted toward its own colours; the official model and the plain 源石虫 are not', async () => { const store = (local, fail = () => false) => ({ picture: () => null, image: async () => null, spineEntry: (id) => spineEntry(MANIFEST, id, local ? { local } : undefined), spine: { acquire: async (e) => { if (fail(e)) throw new Error('404'); return { animations: Object.keys(e.animations).map((name) => ({ name })) }; }, release() {} }, }); const local = localManifest(SLUGS); const mk = (id, st) => new UnitView(fakeViewCtx(fake.P, { assets: st, cam }), info(id)); const web0 = mk(SLUGS[0], store(null)), web1 = mk(SLUGS[1], store(null)); const official = mk(SLUGS[0], store(local)), failed = mk(SLUGS[1], store(local, (e) => e.local)); const plain = mk('enemy_1007_slime', store(local)); for (let i = 0; i < 4; i++) await tick(); const all = [web0, web1, official, failed, plain]; for (const v of all) v.update(1 / 60, cam(), 0); assert.equal(web0.actor.entry, webOf(SLUGS[0]), 'not extracted: the plain 源石虫 skeleton'); assert.equal(web0.actor.spine.tint, ALIAS_TINT[SLUGS[0]], '灼热源石虫: orange'); assert.equal(web1.actor.spine.tint, ALIAS_TINT[SLUGS[1]], '炽焰源石虫: red-orange'); assert.notEqual(ALIAS_TINT[SLUGS[0]], ALIAS_TINT[SLUGS[1]], 'the two read apart'); assert.ok(official.actor.entry.local); assert.equal(official.actor.spine.tint, 0xffffff, 'the official model as it is'); assert.equal(failed.actor.entry, webOf(SLUGS[1])); assert.equal(failed.actor.spine.tint, ALIAS_TINT[SLUGS[1]], 'the web fallback of a failed official model is tinted too'); assert.equal(plain.actor.spine.tint, 0xffffff, 'the plain 源石虫 itself'); web0.flags = UF.FROZEN; web0.update(1 / 60, cam(), 1 / 60); assert.equal(web0.actor.spine.tint, 0x9fd4ff, 'a status tint wins (frozen)'); for (const v of all) v.destroy?.(); }); }); }); 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' }; const py = (body) => { const o = spawnSync(PY, ['-c', `import sys, json\nsys.path.insert(0, ${JSON.stringify(TOOL)})\nimport extract as e\n${body}`], { encoding: 'utf8', env: ENV }); assert.equal(o.status, 0, o.stderr); return JSON.parse(o.stdout); }; 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 out = py(`W = 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'])]]))`); assert.deepEqual(out, ['enemy_1305_mhslim_2', 'enemy_1305_mhslim', null, null, [true, true, true, true, false, false]]); }); test('normalize_atlas: the real page size and pma: true, like the fetched enemy atlases (the client loads it as is)', async () => { const { normalizeAtlas } = await import('../tools/assets/atlas.mjs'); // the client's atlas text of 灼热源石虫 (leading blank line, size, no pma), shortened const raw = '\nenemy_1305_mhslim.png\nsize: 256,256\nformat: RGBA8888\nfilter: Linear,Linear\nrepeat: none\nC_Body_1\n rotate: false\n xy: 2, 159\n size: 55, 64\n orig: 55, 64\n offset: 0, 0\n index: -1\n'; const sized = py(`print(json.dumps(e.normalize_atlas(${JSON.stringify(raw)}, {'enemy_1305_mhslim.png': (256, 256)})))`); assert.equal(sized, normalizeAtlas(raw, { pageSize: () => ({ width: 256, height: 256 }), pma: true }).text, 'the same text as tools/assets/atlas.mjs'); assert.match(sized, /repeat: none\npma: true\nC_Body_1\n {2}rotate: false\n {2}xy: 2, 159\n {2}size: 55, 64/, 'region fields untouched'); const noSize = raw.replace('size: 256,256\n', ''); const fixed = py(`print(json.dumps(e.normalize_atlas(${JSON.stringify(noSize)}, {'enemy_1305_mhslim.png': (512, 256)})))`); assert.match(fixed, /\nenemy_1305_mhslim\.png\nsize: 512,256\nformat: RGBA8888/); assert.equal(py(`print(json.dumps(e.normalize_atlas(${JSON.stringify(sized)}, {'enemy_1305_mhslim.png': (256, 256)})))`), sized, 'idempotent'); }); });