diablo2-web/scripts/pack-animdata.ts

639 lines
26 KiB
TypeScript

/**
* 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 [-- <archiveDir> <outDir>]
*/
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\<token>` layout —
* so the mapping is spelled out here and then *verified against the archive*:
* `assertPlayerTokens` requires `<token>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<MountedArchives> {
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 `<token>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<Record<string, number>>
/** Direction counts, emitted only for modes present in `modes`. */
readonly directions: Readonly<Record<string, number>>
/** `A1mv`-style flags, only for modes present in `modes`. */
readonly moveInAnim: Readonly<Record<string, number>>
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<string, number> = {}
const directions: Record<string, number> = {}
const moveInAnim: Record<string, number> = {}
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<string, PackedMonStats2Row>,
): Map<string, string[]> {
const join = new Map<string, string[]>()
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<string> {
const tokens = new Set<string>()
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<string>()
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<string, PackedMonStats2Row>,
tokenJoin: ReadonlyMap<string, readonly string[]>,
): BakeScope {
const playerTokens = PLAYER_TOKENS.map(entry => entry.token.toUpperCase())
const monsterTokens = [...bakedMonsterTokens()].sort()
const clips = new Set<string>()
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<CofProvenance | undefined> {
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 `<outDir>/anim/`.
*/
export async function bakeAnimData(archiveDir = 'samples/d2', outDir = 'samples/d2-packs'): Promise<void> {
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<string, unknown> | 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<string, PackedMonStats2Row>()
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<number, number>()
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<string, unknown>[] = []
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<string, PackedClip> = {}
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')
}