/** * Bake `AnimData.D2` and the animation columns of `MonStats2.txt` into static JSON. * * The runtime is not allowed to touch an MPQ, so every animation's frame count, * speed and frame-event list has to be extracted here, on a developer machine, * and written to `samples/d2-packs/anim/`. This script is the only place those * numbers come from; nothing downstream is permitted to invent one. * * Outputs: * - `anim/animdata.json` — every clip by upper-case COF name, plus the * integrity facts (byte count, record count, duplicate and conflict lists) so * a verifier can re-assert them without re-reading the archive. * - `anim/monstats2.json` — per-monster animation columns: the `m*` mode * presence bits, the `d*` direction counts of the modes that are present, the * in-animation movement flags, the hit box, and the art-token join. * * Three policies are enforced here rather than left to the caller: * * 1. **`AnimData.D2` only.** `EAnimData.D2` decodes with the same decoder but * its name set is a strict subset — zero names are unique to it — so merging * it would add no clips while reintroducing ambiguity. It is read purely as a * cross-check and the comparison is written into the artefact. * 2. **A speed-0 clip inside the bake scope is a hard failure.** Four shipped * records store `animationSpeed === 0`; an 8.8 accumulator fed zero never * advances, so such a clip would ship as an actor frozen mid-animation with * no error anywhere. If one is ever pulled into scope the bake stops and * names it. Out-of-scope ones are reported, not tolerated silently. * 3. **Monster modes are filtered on `m*` and never on `d*`.** `skeleton1` * stores `dSC=8` and `dRN=8` while `mSC`/`mRN` are empty: the direction * columns are populated for modes the monster does not have. Filtering on * `d*` would bake skeleton run and cast animations that do not exist. * * Every table is read with `archives.read()`, never `listFiles()`: * `MonStats2.txt` is one of nine excel tables that are present in the archive * but absent from its `(listfile)`, so a listing-based lookup reports it * missing. * * Usage: * npm run pack:animdata [-- ] */ import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { MountedArchives } from '../src/mpq/mount.ts' import { decodeAnimDataFile, ANIMDATA_FLAG_COUNT, ANIM_EVENT_ATTACK, ANIM_EVENT_MISSILE } from '../src/formats/animdata.ts' import type { AnimDataFile, AnimDataRecord } from '../src/formats/animdata.ts' import { decodeCof } from '../src/formats/cof.ts' import { cell, parseTable } from '../src/game/acts.ts' import type { D2Table } from '../src/game/acts.ts' import { ACT_MONSTER_SPECS, resolveMonsterArtSpec } from '../src/game/monster-mapping.ts' import { CANONICAL_SUPER_UNIQUE_LANDMARKS, CANONICAL_SUPERUNIQUES_TABLE, readSuperUniques, } from '../src/game/monsters.ts' /** Archives to mount, in load order; the last one to hold a member wins. */ const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const /** The timing table. Served by `d2exp.mpq`, not `d2data.mpq`. */ const ANIMDATA_MEMBER = 'data\\global\\AnimData.D2' /** The expansion table, read only as a cross-check. */ const EANIMDATA_MEMBER = 'data\\global\\EAnimData.D2' /** Monster animation columns. Present in the archive, absent from `(listfile)`. */ const MONSTATS2_MEMBER = 'data\\global\\excel\\MonStats2.txt' /** Monster records; carries the `Code` art token and the `MonStatsEx` join key. */ const MONSTATS_MEMBER = 'data\\global\\excel\\MonStats.txt' /** * Class art tokens, in `CharStats.txt` row order. * * `CharStats.txt` names the classes but stores no two-letter art token — the * token lives in the executable and in the `data\global\chars\` layout — * so the mapping is spelled out here and then *verified against the archive*: * `assertPlayerTokens` requires `nuhth.cof` to exist for each one, so a * wrong token fails the bake instead of silently baking nothing. */ const PLAYER_TOKENS: readonly { readonly token: string; readonly charStatsClass: string }[] = [ { token: 'am', charStatsClass: 'Amazon' }, { token: 'so', charStatsClass: 'Sorceress' }, { token: 'ne', charStatsClass: 'Necromancer' }, { token: 'pa', charStatsClass: 'Paladin' }, { token: 'ba', charStatsClass: 'Barbarian' }, { token: 'dz', charStatsClass: 'Druid' }, { token: 'ai', charStatsClass: 'Assassin' }, ] /** * Player modes in this bake's scope (`PROJECT.md` R2; Issue #143 owns the rest). */ const PLAYER_MODES: readonly string[] = ['NU', 'WL', 'RN', 'A1', 'A2', 'SC', 'GH', 'DT', 'DD', 'TN', 'TW'] /** * Monster modes in this bake's scope, before the per-monster `m*` filter. */ const MONSTER_MODES: readonly string[] = ['NU', 'WL', 'RN', 'A1', 'A2', 'SC', 'GH', 'DT', 'DD'] /** Mode columns of `MonStats2.txt`, in table order. */ const MONSTATS2_MODES: readonly string[] = [ 'DT', 'NU', 'WL', 'GH', 'A1', 'A2', 'BL', 'SC', 'S1', 'S2', 'S3', 'S4', 'DD', 'KB', 'SQ', 'RN', ] /** In-animation movement flag columns of `MonStats2.txt`. */ const MONSTATS2_MOVE_MODES: readonly string[] = ['A1', 'A2', 'SC', 'S1', 'S2', 'S3', 'S4'] /** A clip as written to `animdata.json`. */ interface PackedClip { /** Frames in one direction. */ readonly fpd: number /** 8.8 fixed-point speed as stored; 256 = 1x. */ readonly speed: number /** Frame events as `[frame, code]` pairs, ascending. */ readonly events: readonly (readonly [number, number])[] /** Present and true only when `speed` is 0, so the runtime cannot miss it. */ readonly noSpeed?: true } /** * Convert a decoded record into its baked form. * * @param record - the decoded record. * @returns the JSON-shaped clip. */ function toPackedClip(record: AnimDataRecord): PackedClip { const events = record.events.map(event => [event.frame, event.code] as const) const base = { fpd: record.framesPerDirection, speed: record.animationSpeed, events } return record.hasSpeed ? base : { ...base, noSpeed: true } } /** * Mount the game archives, failing on the first one that cannot be opened. * * A missing archive is not survivable here: `AnimData.D2` lives in `d2exp.mpq` * and the character COFs used to verify the class tokens live in `d2char.mpq`, * so skipping a mount would turn a setup mistake into a half-empty artefact. * * @param archiveDir - directory holding the `.mpq` files. * @returns the mounted stack. */ async function mountArchives(archiveDir: string): Promise { const archives = new MountedArchives() for (const name of MOUNTS) { const path = join(archiveDir, name) try { archives.add(name, await MpqArchive.open(await fileSource(path))) } catch (error) { throw new Error(`pack-animdata: cannot mount ${path}: ${(error as Error).message}`) } } return archives } /** * Confirm every class token names a real character COF directory. * * @param archives - the mounted stack. * @throws when a token has no `nuhth.cof`, which would mean the token * list has drifted from the archive. */ function assertPlayerTokens(archives: MountedArchives): void { for (const entry of PLAYER_TOKENS) { const member = `data\\global\\chars\\${entry.token}\\cof\\${entry.token}nuhth.cof` if (!archives.has(member)) { throw new Error(`pack-animdata: class token '${entry.token}' (${entry.charStatsClass}) has no ${member}`) } } } /** * Whether a `MonStats2.txt` mode presence cell counts as set. * * The cells hold `1` or nothing. An empty cell means the monster does not have * the mode at all, which is why this must never be read with a non-zero * fallback. * * @param value - the raw cell. * @returns true when the mode is present. */ function modePresent(value: string): boolean { const trimmed = value.trim() return trimmed.length > 0 && trimmed !== '0' } /** * Parse an integer cell, treating an empty cell as 0. * * @param value - the raw cell. * @returns the number, or 0 when the cell is empty or not numeric. */ function numberCell(value: string): number { const parsed = Number.parseInt(value.trim(), 10) return Number.isFinite(parsed) ? parsed : 0 } /** The animation-relevant part of one `MonStats2.txt` row. */ interface PackedMonStats2Row { readonly baseW: string readonly sizeX: number readonly sizeY: number readonly pixHeight: number readonly meleeRng: number /** Only the modes whose `m*` cell is set. */ readonly modes: Readonly> /** Direction counts, emitted only for modes present in `modes`. */ readonly directions: Readonly> /** `A1mv`-style flags, only for modes present in `modes`. */ readonly moveInAnim: Readonly> readonly hitBox: { readonly noGfxHitTest: number readonly top: number readonly left: number readonly width: number readonly height: number } readonly compositeDeath: number } /** * Extract the animation columns of one `MonStats2.txt` row. * * `directions` and `moveInAnim` are deliberately emitted only for modes the * `m*` bits declare: the direction columns are populated for absent modes too, * so carrying them forward would let a consumer resurrect a mode that does not * exist. * * @param table - the parsed table. * @param row - the row. * @returns the packed row. */ function packMonStats2Row(table: D2Table, row: readonly string[]): PackedMonStats2Row { const modes: Record = {} const directions: Record = {} const moveInAnim: Record = {} for (const mode of MONSTATS2_MODES) { const present = cell(table, row, `m${mode}`) if (!modePresent(present)) continue modes[mode] = numberCell(present) || 1 directions[mode] = numberCell(cell(table, row, `d${mode}`)) if (MONSTATS2_MOVE_MODES.includes(mode)) { moveInAnim[mode] = numberCell(cell(table, row, `${mode}mv`)) } } return { baseW: cell(table, row, 'BaseW').trim(), sizeX: numberCell(cell(table, row, 'SizeX')), sizeY: numberCell(cell(table, row, 'SizeY')), pixHeight: numberCell(cell(table, row, 'pixHeight')), meleeRng: numberCell(cell(table, row, 'MeleeRng')), modes, directions, moveInAnim, hitBox: { noGfxHitTest: numberCell(cell(table, row, 'noGfxHitTest')), top: numberCell(cell(table, row, 'htTop')), left: numberCell(cell(table, row, 'htLeft')), width: numberCell(cell(table, row, 'htWidth')), height: numberCell(cell(table, row, 'htHeight')), }, compositeDeath: numberCell(cell(table, row, 'compositeDeath')), } } /** * Art token to `MonStats2` row ids, via `MonStats.Code` → `MonStats.MonStatsEx`. * * The join is many-to-one in both directions: token `SK` covers `skeleton1..5`, * and several monsters can share a `MonStatsEx` row. Every contributing id is * kept so a consumer can union the `m*` bits and show its work. * * @param monstats - parsed `MonStats.txt`. * @param rows - the packed `MonStats2` rows, keyed by id. * @returns token (upper case) to the ids of the rows it covers. */ function buildTokenJoin( monstats: D2Table, rows: ReadonlyMap, ): Map { const join = new Map() for (const row of monstats.rows) { const token = cell(monstats, row, 'Code').trim().toUpperCase() const ex = cell(monstats, row, 'MonStatsEx').trim() if (token.length === 0 || token === 'XX') continue if (ex.length === 0 || !rows.has(ex)) continue const ids = join.get(token) ?? [] if (!ids.includes(ex)) ids.push(ex) join.set(token, ids) } return join } /** * The monster art tokens this project actually bakes art for. * * Mirrors `scripts/pack-entity-assets.ts`: the Act 1..5 spawn tables plus every * SuperUnique and landmark minion. Scope has to match the entity bake, because * the speed-0 check below is only meaningful for clips that will really ship. * * @returns upper-case tokens. */ function bakedMonsterTokens(): Set { const tokens = new Set() for (let act = 1; act <= 5; act += 1) { for (const spec of ACT_MONSTER_SPECS[act] ?? []) { if (!spec.token || spec.token.toLowerCase() === 'xx') continue tokens.add(spec.token.toUpperCase()) } } const monsterIds = new Set() for (const superUnique of readSuperUniques(CANONICAL_SUPERUNIQUES_TABLE)) { monsterIds.add(superUnique.monsterId) } for (const landmark of CANONICAL_SUPER_UNIQUE_LANDMARKS) { if (landmark.minionMonsterId) monsterIds.add(landmark.minionMonsterId) } for (const id of monsterIds) { const spec = resolveMonsterArtSpec(id) if (spec && spec.token && spec.token.toLowerCase() !== 'xx') tokens.add(spec.token.toUpperCase()) } return tokens } /** The set of clips this CL's entity bake will actually need. */ interface BakeScope { readonly playerTokens: readonly string[] readonly playerModes: readonly string[] readonly monsterTokens: readonly string[] readonly monsterModes: readonly string[] /** Clip names in scope, sorted. Derived from the tables, never hand-listed. */ readonly clips: readonly string[] /** Monster tokens with no `m*` bits found, i.e. no `MonStats2` join. */ readonly monsterTokensWithoutRows: readonly string[] } /** * Work out which clips the R2 bake will need. * * Player clips are every weapon-class variant the table actually stores for an * in-scope mode. Monster clips are the same, but a mode only counts when at * least one `MonStats2` row behind that token has the mode's `m*` bit set — the * union across the rows, so a token covering several monsters never loses a * clip one of them needs. * * @param animdata - the decoded timing table. * @param rows - packed `MonStats2` rows by id. * @param tokenJoin - token to row ids. * @returns the scope. */ function computeBakeScope( animdata: AnimDataFile, rows: ReadonlyMap, tokenJoin: ReadonlyMap, ): BakeScope { const playerTokens = PLAYER_TOKENS.map(entry => entry.token.toUpperCase()) const monsterTokens = [...bakedMonsterTokens()].sort() const clips = new Set() const withoutRows: string[] = [] for (const record of animdata.all) { const token = record.name.slice(0, 2) const mode = record.name.slice(2, 4) if (playerTokens.includes(token)) { if (PLAYER_MODES.includes(mode)) clips.add(record.name) continue } if (!monsterTokens.includes(token)) continue if (!MONSTER_MODES.includes(mode)) continue const ids = tokenJoin.get(token) ?? [] if (ids.length === 0) continue const present = ids.some(id => rows.get(id)?.modes[mode] !== undefined) if (present) clips.add(record.name) } for (const token of monsterTokens) { if ((tokenJoin.get(token) ?? []).length === 0) withoutRows.push(token) } return { playerTokens, playerModes: PLAYER_MODES, monsterTokens, monsterModes: MONSTER_MODES, clips: [...clips].sort(), monsterTokensWithoutRows: withoutRows, } } /** Where a COF carrying the same tag as an undocumented AnimData code was found. */ interface CofProvenance { readonly member: string readonly directions: number readonly framesPerDirection: number readonly speed: number readonly tags: readonly (readonly [number, number])[] } /** * Locate the COF that belongs to a clip and read its frame tags back. * * Used to give the undocumented event codes a provenance: if the same code sits * at the same frame in the COF's own tag array, the byte is shipped data and not * a decode artefact. Nothing is inferred about what the code *means*. * * @param archives - the mounted stack. * @param name - upper-case clip name. * @returns the COF facts, or undefined when no COF carries this name. */ async function findCof(archives: MountedArchives, name: string): Promise { const token = name.slice(0, 2).toLowerCase() const lower = name.toLowerCase() const candidates = [ `data\\global\\monsters\\${token}\\cof\\${lower}.cof`, `data\\global\\chars\\${token}\\cof\\${lower}.cof`, `data\\global\\objects\\${token}\\cof\\${lower}.cof`, ] for (const member of candidates) { if (!archives.has(member)) continue const cof = decodeCof(await archives.read(member)) const tags: (readonly [number, number])[] = [] for (let frame = 0; frame < cof.animationFrames.length; frame += 1) { const tag = cof.animationFrames[frame]! if (tag !== 0) tags.push([frame, tag] as const) } return { member, directions: cof.numberOfDirections, framesPerDirection: cof.framesPerDirection, speed: cof.speed, tags, } } return undefined } /** * Bake the animation tables. * * @param archiveDir - directory holding the `.mpq` files. * @param outDir - pack root; files land in `/anim/`. */ export async function bakeAnimData(archiveDir = 'samples/d2', outDir = 'samples/d2-packs'): Promise { const archives = await mountArchives(archiveDir) assertPlayerTokens(archives) const raw = await archives.read(ANIMDATA_MEMBER) const animdata = decodeAnimDataFile(raw) if (animdata.bytesConsumed !== raw.length) { throw new Error(`pack-animdata: consumed ${String(animdata.bytesConsumed)} of ${String(raw.length)} bytes`) } console.log( `AnimData.D2: ${String(raw.length)} bytes, ${String(animdata.all.length)} records, ` + `${String(animdata.records.size)} unique, ${String(animdata.duplicates.length)} duplicate names, ` + `${String(animdata.conflicts.length)} conflicting`, ) // EAnimData is a cross-check, not a source: measured against 1.13c it collides // on all 3,520 of its names and differs on none of them, so merge precedence // cannot change a value. That is only true while it stays a value-identical // subset, so the property is asserted here instead of assumed. const expansion = archives.has(EANIMDATA_MEMBER) ? decodeAnimDataFile(await archives.read(EANIMDATA_MEMBER)) : undefined let expansionSummary: Record | undefined if (expansion !== undefined) { const onlyInExpansion = [...expansion.records.keys()].filter(name => !animdata.records.has(name)) const onlyInBase = [...animdata.records.keys()].filter(name => !expansion.records.has(name)) const collisions: string[] = [] const disagreeing: string[] = [] for (const [name, record] of expansion.records) { const base = animdata.records.get(name) if (base === undefined) continue collisions.push(name) if ( base.framesPerDirection !== record.framesPerDirection || base.animationSpeed !== record.animationSpeed || JSON.stringify(base.events) !== JSON.stringify(record.events) ) { disagreeing.push(name) } } if (onlyInExpansion.length > 0 || disagreeing.length > 0) { throw new Error( `pack-animdata: EAnimData.D2 is no longer a value-identical subset of AnimData.D2 — ` + `${String(onlyInExpansion.length)} name(s) only it has (${onlyInExpansion.slice(0, 10).join(', ')}), ` + `${String(disagreeing.length)} shared name(s) disagree (${disagreeing.slice(0, 10).join(', ')}). ` + `Baking AnimData.D2 alone would drop or contradict data; the merge policy needs re-deciding.`, ) } expansionSummary = { member: EANIMDATA_MEMBER, records: expansion.all.length, uniqueNames: expansion.records.size, collidingNames: collisions.length, namesOnlyInExpansion: onlyInExpansion, namesOnlyInBase: onlyInBase, sharedNamesDisagreeing: disagreeing, merged: false, mergePolicy: 'AnimData.D2 only. EAnimData.D2 collides on every name it has and differs on none, ' + 'so cross-file precedence cannot change a value; asserted at bake time.', } console.log( `EAnimData.D2: ${String(expansion.all.length)} records, ${String(collisions.length)} colliding names, ` + `${String(disagreeing.length)} of them disagree, ${String(onlyInExpansion.length)} names it alone has, ` + `${String(onlyInBase.length)} names only in AnimData.D2`, ) } const monstats2Table = parseTable(await archives.read(MONSTATS2_MEMBER)) const monstatsTable = parseTable(await archives.read(MONSTATS_MEMBER)) const rows = new Map() for (const row of monstats2Table.rows) { const id = cell(monstats2Table, row, 'Id').trim() if (id.length === 0) continue rows.set(id, packMonStats2Row(monstats2Table, row)) } const tokenJoin = buildTokenJoin(monstatsTable, rows) console.log(`MonStats2.txt: ${String(rows.size)} rows, ${String(tokenJoin.size)} art tokens joined`) const scope = computeBakeScope(animdata, rows, tokenJoin) console.log( `bake scope: ${String(scope.playerTokens.length)} class tokens x ${String(PLAYER_MODES.length)} modes, ` + `${String(scope.monsterTokens.length)} monster tokens x <= ${String(MONSTER_MODES.length)} modes ` + `-> ${String(scope.clips.length)} clips`, ) if (scope.monsterTokensWithoutRows.length > 0) { console.log(` monster tokens with no MonStats2 join: ${scope.monsterTokensWithoutRows.join(', ')}`) } // Policy 2: a speed-0 clip inside the scope is a hard failure, named. const speedless = animdata.all.filter(record => !record.hasSpeed) const inScopeSpeedless = speedless.filter(record => scope.clips.includes(record.name)) if (inScopeSpeedless.length > 0) { const detail = inScopeSpeedless .map(record => `${record.name} (fpd=${String(record.framesPerDirection)}, speed=0)`) .join(', ') throw new Error( `pack-animdata: ${String(inScopeSpeedless.length)} in-scope clip(s) have animationSpeed 0 and would never ` + `advance in the 8.8 accumulator: ${detail}`, ) } console.log( `animationSpeed 0: ${String(speedless.length)} record(s) file-wide (${speedless.map(r => r.name).join(', ')}), ` + `none in scope`, ) for (const record of animdata.recordsOverFlagCeiling) { console.log( ` WARNING ${record.name} declares ${String(record.framesPerDirection)} frames but the flag array is ` + `${String(ANIMDATA_FLAG_COUNT)} bytes; frames ${String(ANIMDATA_FLAG_COUNT)}..${String(record.framesPerDirection - 1)} cannot carry an event`, ) } for (const stray of animdata.strayFlags) { console.log(` WARNING ${stray.name} stores code ${String(stray.code)} at frame ${String(stray.frame)}, past its own frame count`) } const histogram = new Map() for (const record of animdata.all) { for (const event of record.events) histogram.set(event.code, (histogram.get(event.code) ?? 0) + 1) } const undocumented: Record[] = [] for (const record of animdata.all) { const codes = record.events.filter(event => event.code !== ANIM_EVENT_ATTACK && event.code !== ANIM_EVENT_MISSILE) if (codes.length === 0) continue const cof = await findCof(archives, record.name) undocumented.push({ name: record.name, framesPerDirection: record.framesPerDirection, animationSpeed: record.animationSpeed, events: record.events.map(event => [event.frame, event.code]), cof: cof ?? null, cofAgrees: cof === undefined ? null : JSON.stringify(cof.tags) === JSON.stringify(record.events.map(event => [event.frame, event.code])), }) } console.log(`event codes: ${[...histogram].sort((a, b) => a[0] - b[0]).map(([code, count]) => `${String(code)}x${String(count)}`).join(' ')}`) for (const entry of undocumented) { console.log(` undocumented code on ${String(entry['name'])}: ${JSON.stringify(entry['events'])} cofAgrees=${String(entry['cofAgrees'])}`) } const clips: Record = {} for (const [name, record] of animdata.records) clips[name] = toPackedClip(record) const animJson = { schema: 1, source: { member: ANIMDATA_MEMBER, bytes: raw.length, records: animdata.all.length, uniqueNames: animdata.records.size, duplicateNames: animdata.duplicates.length, conflictingNames: animdata.conflicts.length, dedupPolicy: 'first-wins: the engine scans a hash block in order and returns the first match', flagCeiling: ANIMDATA_FLAG_COUNT, ...(expansionSummary === undefined ? {} : { expansion: expansionSummary }), }, clips, conflicts: animdata.conflicts.map(conflict => ({ name: conflict.name, chosen: toPackedClip(conflict.records[0]!), candidates: conflict.records.map(toPackedClip), })), overFlagCeiling: animdata.recordsOverFlagCeiling.map(record => ({ name: record.name, framesPerDirection: record.framesPerDirection, flagCeiling: ANIMDATA_FLAG_COUNT, })), strayFlags: animdata.strayFlags, speedZero: speedless.map(record => record.name), eventCodeHistogram: Object.fromEntries([...histogram].sort((a, b) => a[0] - b[0]).map(([code, count]) => [String(code), count])), undocumentedEventCodes: undocumented, bakeScope: scope, } const monstatsJson = { schema: 1, source: { member: MONSTATS2_MEMBER, rows: rows.size, columns: monstats2Table.header.length, modeFilter: 'm* presence bits only; d* direction counts are populated for absent modes and must not be used', }, rows: Object.fromEntries([...rows].sort((a, b) => a[0].localeCompare(b[0]))), tokenToRows: Object.fromEntries([...tokenJoin].sort((a, b) => a[0].localeCompare(b[0]))), } const animDir = join(outDir, 'anim') await mkdir(animDir, { recursive: true }) const animPath = join(animDir, 'animdata.json') const monstatsPath = join(animDir, 'monstats2.json') await writeFile(animPath, JSON.stringify(animJson)) await writeFile(monstatsPath, JSON.stringify(monstatsJson)) console.log(`wrote ${animPath} (${String(Object.keys(clips).length)} clips)`) console.log(`wrote ${monstatsPath} (${String(rows.size)} rows)`) } if (import.meta.url === `file://${process.argv[1] ?? ''}`) { const [, , archiveDir, outDir] = process.argv await bakeAnimData(archiveDir ?? 'samples/d2', outDir ?? 'samples/d2-packs') }