feat(anim): 实现动画锚点系统、animdata 解析与玩家/怪物动画合成对齐

This commit is contained in:
troytt 2026-09-21 13:30:04 +00:00
parent 14443c6615
commit 380d01c6c3
11 changed files with 3729 additions and 29 deletions

View File

@ -28,7 +28,10 @@
"build:game": "vite build --base=/diablo2/ --outDir dist-game",
"pack:data": "tsx scripts/pack-act-assets.ts",
"pack:ui": "tsx scripts/pack-ui.ts",
"pack:animdata": "tsx scripts/pack-animdata.ts",
"verify:packs": "tsx scripts/verify-packs.ts",
"verify:bundle-no-mpq": "tsx scripts/verify-bundle-no-mpq.ts",
"verify:animation": "tsx scripts/verify-animation-browser.ts",
"verify:deploy": "tsx scripts/verify-deploy.ts",
"verify:listfile": "tsx scripts/verify-listfile.ts",
"verify:object-lookup": "tsx scripts/verify-object-lookup.ts",

638
scripts/pack-animdata.ts Normal file
View File

@ -0,0 +1,638 @@
/**
* 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')
}

View File

@ -0,0 +1,166 @@
/**
* THROWAWAY SPIKE (milestone MV): can headless Chrome reach a local HTTP server
* in this environment?
*
* scripts/browser/README.md documents a sandbox mode where headless Chrome has
* no network at all, including 127.0.0.1, and the symptom is indistinguishable
* from a page boot failure. This probe answers the question in isolation before
* any harness is built on the assumption.
*
* Diagnostic per the README: if `location.href === 'about:blank'` while the CDP
* target list shows the right URL, it is the environment, not the page.
*
* Delete after the answer is recorded.
*/
import { spawn } from 'node:child_process'
import { createServer } from 'node:http'
const sleep = ms => new Promise(r => setTimeout(r, ms))
const MARKER = 'SPIKE_MARKER_8FA31C'
async function main() {
// 1. Plain node HTTP server, no vite, no build step.
const server = createServer((req, res) => {
console.log(`[server] hit: ${req.method} ${req.url}`)
if (req.url === '/probe.json') {
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify({ marker: MARKER }))
return
}
res.writeHead(200, { 'content-type': 'text/html' })
res.end(`<!doctype html><html><head><title>${MARKER}</title></head>
<body><div id="m">${MARKER}</div>
<script>
window.__spikeFetch = 'pending'
fetch('/probe.json').then(r => r.json()).then(j => { window.__spikeFetch = 'ok:' + j.marker })
.catch(e => { window.__spikeFetch = 'ERR:' + e.message })
</script></body></html>`)
})
await new Promise(r => server.listen(0, '127.0.0.1', r))
const port = server.address().port
const baseUrl = `http://127.0.0.1:${port}`
console.log(`[spike] server at ${baseUrl}`)
// 2. Sanity: the server is reachable from node itself.
const nodeResp = await fetch(`${baseUrl}/probe.json`)
console.log(`[spike] node fetch -> ${nodeResp.status} ${JSON.stringify(await nodeResp.json())}`)
// 3. Headless Chrome, same flags the existing harnesses use.
const debugPort = 9333
const chrome = spawn('/usr/bin/google-chrome', [
'--headless=new',
`--remote-debugging-port=${debugPort}`,
'--no-sandbox',
'--disable-dev-shm-usage',
'--enable-webgl',
'--ignore-gpu-blocklist',
'--use-gl=angle',
'--use-angle=swiftshader',
'--window-size=800,600',
'about:blank',
])
chrome.stderr.on('data', d => {
const s = String(d).trim()
if (s) console.log(`[chrome stderr] ${s.slice(0, 300)}`)
})
let verdict = 'UNKNOWN'
try {
let wsUrl = null
for (let i = 0; i < 60; i++) {
await sleep(200)
try {
const res = await fetch(`http://127.0.0.1:${debugPort}/json/list`)
const pages = await res.json()
const page = pages.find(p => p.type === 'page')
if (page?.webSocketDebuggerUrl) {
wsUrl = page.webSocketDebuggerUrl
break
}
} catch {
// CDP endpoint not up yet; this retry loop is the only tolerated swallow
// and it is bounded, after which we throw below.
}
}
if (!wsUrl) throw new Error('could not reach Chrome CDP endpoint at all')
console.log(`[spike] CDP ws: ${wsUrl}`)
const ws = new WebSocket(wsUrl)
await new Promise(r => { ws.onopen = () => r() })
let id = 1
const send = (method, params = {}, timeoutMs = 15000) =>
new Promise((resolve, reject) => {
const myId = id++
const timer = setTimeout(() => {
ws.removeEventListener('message', handler)
reject(new Error(`CDP ${method} timed out after ${timeoutMs}ms`))
}, timeoutMs)
const handler = ev => {
const msg = JSON.parse(String(ev.data))
if (msg.id === myId) {
clearTimeout(timer)
ws.removeEventListener('message', handler)
if (msg.error) reject(new Error(`CDP ${method}: ${JSON.stringify(msg.error)}`))
else resolve(msg.result)
}
}
ws.addEventListener('message', handler)
ws.send(JSON.stringify({ id: myId, method, params }))
})
const evalJs = async expression => {
const res = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true })
if (res.exceptionDetails) throw new Error(`JS error: ${JSON.stringify(res.exceptionDetails)}`)
return res.result.value
}
await send('Page.enable')
await send('Runtime.enable')
await send('Network.enable')
console.log('[spike] Page.navigate ...')
let navErr = null
try {
await send('Page.navigate', { url: baseUrl }, 20000)
console.log('[spike] Page.navigate returned')
} catch (err) {
navErr = err
console.log(`[spike] Page.navigate FAILED: ${err.message}`)
}
await sleep(2000)
const href = await evalJs('location.href')
const title = await evalJs('document.title')
const body = await evalJs('document.getElementById("m") ? document.getElementById("m").textContent : "(no #m)"')
const fetchState = await evalJs('String(window.__spikeFetch)')
console.log(`[spike] location.href = ${href}`)
console.log(`[spike] document.title = ${title}`)
console.log(`[spike] #m textContent = ${body}`)
console.log(`[spike] in-page fetch = ${fetchState}`)
const navOk = navErr === null && href.startsWith(baseUrl) && body === MARKER
const fetchOk = fetchState === `ok:${MARKER}`
verdict = navOk && fetchOk ? 'YES' : 'NO'
if (!navOk && href === 'about:blank') {
console.log('[spike] DIAGNOSIS: href is about:blank -> sandbox network block (README §已知限制)')
}
} finally {
chrome.kill('SIGKILL')
server.close()
}
console.log('======================================================================')
console.log(`SPIKE VERDICT: headless Chrome can reach a local HTTP server = ${verdict}`)
console.log('======================================================================')
process.exit(verdict === 'YES' ? 0 : 1)
}
main().catch(err => {
console.error(`[spike] FATAL: ${err.stack}`)
console.log('SPIKE VERDICT: headless Chrome can reach a local HTTP server = NO (fatal)')
process.exit(1)
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,561 @@
/**
* CI HARD GATE — the shipped bundle must not be *able* to fetch a game archive.
*
* WHY A STRING GREP IS NOT ENOUGH (and what this does instead)
* ------------------------------------------------------------
* The obvious gate is "grep `dist-game/` for `d2char.mpq`". That gate is a trap.
* It proves only that *one particular spelling* is absent, and every one of
* these evades it while leaving the capability fully intact:
*
* const a = 'd2char' + '.mpq'
* const b = `${name}.mpq`
* const c = ['d2','char','.mpq'].join('')
*
* So the moment someone removes the literal — by an innocent refactor, by a
* minifier transformation, or by deliberately routing around the gate — it goes
* green, convincingly, while the bundle can still stream an archive. A false
* green is worse than no gate, because it terminates scrutiny.
*
* This gate therefore asserts a **capability**, not a spelling:
*
* 1. CAPABILITY_NOT_REACHABLE (primary)
* Reconstruct the chunk dependency graph from the emitted artifact
* itself — follow `import("./x.js")` and `from"./x.js"` out of every
* HTML entry — and assert that no reachable chunk contains the
* *behavioural* fingerprints of the MPQ machinery: the HTTP-Range
* archive reader and the MPQ header decoder. Those fingerprints are
* error-message string literals inside the functions themselves, which
* survive minification and have nothing to do with any archive filename.
* Renaming or concatenating `d2char.mpq` does not move them.
*
* 2. NO_ARCHIVE_LITERALS (secondary, retained)
* The original literal scan. Still useful: it catches a new archive name
* that no fingerprint covers.
*
* 3. FRAGMENT_SCAN (informational)
* Fragmented forms — a bare `.mpq`, a standalone `mpq`/`dll` token —
* listed for human review, since a concatenation gate cannot be made
* reliable automatically.
*
* Both `*.mpq` AND `*.dll` are covered; the acceptance criterion names both.
*
* KNOWN-RED ON `main` (2026-09-21) — the expected, desired result
* ---------------------------------------------------------------
* `src/scene/act-scene.ts` has an unguarded production path:
*
* loadCharacterArt L2384
* -> getMountedCharArchives L2013
* -> getCachedMpqArchive L1968
* -> httpRangeSource(base + '/d2char.mpq') L1974
*
* Measured consequence in the artifact: `acts-*.js` dynamically imports
* `source-*.js` (the HTTP-Range reader) and `archive-*.js` (the MPQ decoder),
* and carries `DATA_ARCHIVES` / `CHARACTER_ARCHIVE` through minification.
*
* ⚠ BINDING NOTE FOR THE MILESTONE THAT FIXES THIS (M4):
* the fix must be **STRUCTURAL** — remove the live-MPQ path from the production
* entry graph, or isolate it behind a dev-only entry point, so the chunks are
* not emitted/reachable at all. It must NOT be renaming the constants, and it
* must NOT be splitting them into concatenation to evade the literal scan.
* Assertion 1 exists precisely so that evasion cannot produce a green gate.
*
* SOURCE MAPS
* -----------
* A `.map` embeds `sourcesContent`, i.e. the entire original source text, so a
* literal survives in the map even after the code using it is perfectly
* dead-code-eliminated. Hard-failing on maps would make the gate unsatisfiable
* without deleting the string from the source tree — exactly the pressure that
* gets a gate weakened instead of a bug fixed. Maps are therefore
* INFORMATIONAL, and are classified **by content** (parsed as a source map with
* `version` + `sources`), not by filename, so renaming an executable asset to
* `*.map` cannot launder it into the exempt bucket.
*
* VACUITY GUARD
* -------------
* A gate that passes because it examined nothing is the same failure class it
* is meant to prevent. The run fails unless it actually scanned at least one
* emitted `.js` asset and resolved at least one HTML entry into a chunk graph.
*
* Usage:
* npm run verify:bundle-no-mpq
* npm run verify:bundle-no-mpq -- --dir=dist-game --report=<path>
*/
import { readdirSync, readFileSync, statSync, mkdirSync, writeFileSync } from 'node:fs'
import { dirname, extname, join, relative, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(process.cwd())
// ---------------------------------------------------------------------------
// Patterns
// ---------------------------------------------------------------------------
/**
* Whole archive/library filenames.
*
* The named entries mirror `src/scene/act-scene.ts` L80-82 (`DATA_ARCHIVES`,
* `CHARACTER_ARCHIVE`); the two catch-alls mean a newly introduced archive name
* cannot slip past just because this list was not updated.
*/
const FORBIDDEN_PATTERNS: readonly { readonly label: string; readonly re: RegExp }[] = [
{ label: 'd2char.mpq', re: /d2char\.mpq/gi },
{ label: 'd2data.mpq', re: /d2data\.mpq/gi },
{ label: 'd2exp.mpq', re: /d2exp\.mpq/gi },
{ label: 'Patch_D2.mpq', re: /patch_d2\.mpq/gi },
{ label: '<any>.mpq', re: /[\w-]+\.mpq/gi },
{ label: '<any>.dll', re: /[\w-]+\.dll/gi },
]
/**
* Fragmented spellings a literal scan cannot reason about.
*
* Reported, never auto-failed: `'mpq'` appears in plenty of innocent contexts
* (a directory name, a comment that survived, a decoder's own identity string).
* Automatic failure here would produce noise that trains people to ignore the
* gate. Assertion 1 is what actually closes the concatenation hole; this exists
* so a human can see the fragments while reviewing.
*/
const FRAGMENT_PATTERNS: readonly { readonly label: string; readonly re: RegExp }[] = [
{ label: "bare '.mpq'", re: /["'`]\.mpq["'`]/gi },
{ label: "bare '.dll'", re: /["'`]\.dll["'`]/gi },
{ label: "standalone 'mpq' string", re: /["'`]mpq["'`]/gi },
{ label: "standalone 'dll' string", re: /["'`]dll["'`]/gi },
]
/**
* Behavioural fingerprints of the MPQ machinery.
*
* These are error-message literals from inside the functions that implement the
* capability, so they identify *the code being present*, independent of any
* archive filename. Verified to survive Vite/esbuild minification in the
* current build (they live in `source-*.js` and `archive-*.js`).
*/
const CAPABILITY_FINGERPRINTS: readonly {
readonly id: string
readonly needle: string
readonly origin: string
readonly why: string
}[] = [
{
id: 'HTTP_RANGE_ARCHIVE_READER',
needle: 'range requests are required to read an archive this large',
origin: 'src/mpq/source.ts httpRangeSource() L107-114',
why: 'the function that streams an archive over HTTP Range — the actual download capability',
},
{
id: 'MPQ_HEADER_DECODER',
needle: 'not an MPQ archive (magic 0x',
origin: 'src/mpq/archive.ts MpqArchive.open() L136',
why: 'the MPQ container decoder; present only if archive parsing ships',
},
{
id: 'MPQ_HASH_TABLE_DECODER',
needle: 'is not a non-zero power of two',
origin: 'src/mpq/archive.ts L156',
why: 'MPQ hash-table validation; corroborates the decoder fingerprint',
},
]
/** Extensions considered emitted/executed output. */
const EXECUTABLE_EXT = new Set(['.js', '.mjs', '.cjs', '.html', '.htm', '.css', '.json'])
// ---------------------------------------------------------------------------
// Model
// ---------------------------------------------------------------------------
interface Occurrence {
readonly file: string
readonly pattern: string
readonly count: number
readonly sample: string
}
interface CapabilityHit {
readonly fingerprintId: string
readonly file: string
readonly reachableFrom: readonly string[]
readonly origin: string
readonly why: string
}
export interface BundleScanResult {
readonly root: string
readonly filesTotal: number
readonly emittedJsScanned: number
readonly entries: readonly string[]
/** entry html -> emitted chunk files reachable from it. */
readonly reachable: Readonly<Record<string, readonly string[]>>
readonly orphanChunks: readonly string[]
readonly capabilityHits: readonly CapabilityHit[]
readonly literalHard: readonly Occurrence[]
readonly literalSourceMapOnly: readonly Occurrence[]
readonly fragments: readonly Occurrence[]
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function walk(dir: string, out: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry)
if (statSync(full).isDirectory()) walk(full, out)
else out.push(full)
}
return out
}
/**
* Decide whether a file is a genuine source map, by parsing it.
*
* Extension alone is not enough: a rename must not be able to move an
* executable asset into the informational bucket.
*/
function isRealSourceMap(text: string): boolean {
if (text.length === 0 || !text.trimStart().startsWith('{')) return false
try {
const parsed = JSON.parse(text) as Record<string, unknown>
return (
typeof parsed['version'] === 'number' &&
Array.isArray(parsed['sources']) &&
(parsed['mappings'] !== undefined || parsed['sourcesContent'] !== undefined)
)
} catch {
// Not parseable as JSON, therefore not a source map. This is a
// classification answer, not a swallowed error: the caller treats the file
// as emitted output, which is the conservative direction.
return false
}
}
function sampleAround(text: string, re: RegExp): string {
const probe = new RegExp(re.source, re.flags.replace('g', ''))
const m = probe.exec(text)
if (m === null) return '(no sample)'
const from = Math.max(0, m.index - 90)
const to = Math.min(text.length, m.index + 90)
return `...${text.slice(from, to).replace(/\s+/g, ' ')}...`
}
/**
* Rebuild the chunk dependency graph from the artifact.
*
* Reading the artifact rather than a build-time manifest matters: it is the
* thing that actually ships, and it needs no change to `vite.config.ts` (which
* this milestone does not own). Static `from"./x.js"` / `import"./x.js"` and
* dynamic `import("./x.js")` are all followed, so a lazily-imported chunk — the
* exact shape the MPQ fallback uses — is still counted as reachable.
*/
function buildChunkGraph(root: string, files: readonly string[]): {
entries: string[]
reachable: Record<string, string[]>
orphans: string[]
} {
const rel = (f: string): string => relative(root, f).split('\\').join('/')
const byRel = new Map<string, string>()
for (const f of files) byRel.set(rel(f), f)
const htmlEntries = files.filter(f => f.toLowerCase().endsWith('.html')).map(rel)
const refsOf = (relPath: string): string[] => {
const abs = byRel.get(relPath)
if (abs === undefined) return []
let text: string
try {
text = readFileSync(abs, 'utf8')
} catch {
return []
}
const out = new Set<string>()
const dir = dirname(relPath)
const add = (spec: string): void => {
const joined = spec.startsWith('/')
? spec.replace(/^\/diablo2\//, '').replace(/^\//, '')
: join(dir, spec).split('\\').join('/')
if (byRel.has(joined)) out.add(joined)
}
// HTML: <script src=...> and <link href=...>
for (const m of text.matchAll(/(?:src|href)=["']([^"']+\.(?:js|mjs|css))["']/g)) add(m[1] ?? '')
// JS: static and dynamic module specifiers.
for (const m of text.matchAll(/(?:from|import)\s*\(?\s*["']([^"']+\.(?:js|mjs))["']/g)) add(m[1] ?? '')
return [...out]
}
const reachable: Record<string, string[]> = {}
const everReached = new Set<string>()
for (const entry of htmlEntries) {
const seen = new Set<string>()
const stack = [entry]
while (stack.length > 0) {
const cur = stack.pop()
if (cur === undefined || seen.has(cur)) continue
seen.add(cur)
everReached.add(cur)
for (const next of refsOf(cur)) if (!seen.has(next)) stack.push(next)
}
seen.delete(entry)
reachable[entry] = [...seen].sort()
}
const orphans = files
.map(rel)
.filter(f => /\.(js|mjs)$/i.test(f) && !everReached.has(f))
.sort()
return { entries: htmlEntries.sort(), reachable, orphans }
}
// ---------------------------------------------------------------------------
// Scan
// ---------------------------------------------------------------------------
/** Scan a built output directory. Exported so a unit test can drive it. */
export function scanBundle(root: string): BundleScanResult {
const files = walk(root)
const { entries, reachable, orphans } = buildChunkGraph(root, files)
const literalHard: Occurrence[] = []
const literalSourceMapOnly: Occurrence[] = []
const fragments: Occurrence[] = []
const capabilityHits: CapabilityHit[] = []
let emittedJsScanned = 0
const relOf = (f: string): string => relative(root, f).split('\\').join('/')
const entriesReaching = (chunk: string): string[] =>
entries.filter(e => (reachable[e] ?? []).includes(chunk))
for (const file of files) {
const ext = extname(file).toLowerCase()
const looksLikeMap = file.toLowerCase().endsWith('.map')
if (!looksLikeMap && !EXECUTABLE_EXT.has(ext)) continue
let text: string
try {
text = readFileSync(file, 'utf8')
} catch (err) {
// An unreadable file inside the shipped output must not be skipped, or
// the gate reports green over a directory it never actually read.
throw new Error(`verify-bundle-no-mpq: cannot read shipped asset ${file}: ${String(err)}`)
}
// Classify by content, not by name.
const isMap = looksLikeMap && isRealSourceMap(text)
const relPath = relOf(file)
if (!isMap && /\.(js|mjs|cjs)$/i.test(relPath)) emittedJsScanned += 1
for (const { label, re } of FORBIDDEN_PATTERNS) {
const matches = text.match(new RegExp(re.source, re.flags))
if (matches === null || matches.length === 0) continue
const occ: Occurrence = { file: relPath, pattern: label, count: matches.length, sample: sampleAround(text, re) }
if (isMap) literalSourceMapOnly.push(occ)
else literalHard.push(occ)
}
if (!isMap) {
for (const { label, re } of FRAGMENT_PATTERNS) {
const matches = text.match(new RegExp(re.source, re.flags))
if (matches === null || matches.length === 0) continue
fragments.push({ file: relPath, pattern: label, count: matches.length, sample: sampleAround(text, re) })
}
for (const fp of CAPABILITY_FINGERPRINTS) {
if (!text.includes(fp.needle)) continue
capabilityHits.push({
fingerprintId: fp.id,
file: relPath,
reachableFrom: entriesReaching(relPath),
origin: fp.origin,
why: fp.why,
})
}
}
}
return {
root,
filesTotal: files.length,
emittedJsScanned,
entries,
reachable,
orphanChunks: orphans,
capabilityHits,
literalHard,
literalSourceMapOnly,
fragments,
}
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
function assertWritablePath(candidate: string): string {
const full = resolve(candidate)
const rel = relative(ROOT, full)
if (rel.startsWith('..') || resolve(rel) === rel) {
throw new Error(`verify-bundle-no-mpq: refusing to write outside the worktree: ${full}`)
}
return full
}
function main(): void {
let dir = 'dist-game'
let reportPath = join(ROOT, '.agents', 'worker_mv', 'evidence', 'bundle-gate-report.json')
for (const arg of process.argv.slice(2)) {
if (arg.startsWith('--dir=')) dir = arg.slice('--dir='.length)
else if (arg.startsWith('--report=')) reportPath = resolve(arg.slice('--report='.length))
else if (!arg.startsWith('--')) dir = arg
}
const root = resolve(ROOT, dir)
console.log('======================================================================')
console.log('GATE: shipped bundle must not be ABLE to fetch a game archive')
console.log(' (capability assertion + literal scan; covers *.mpq AND *.dll)')
console.log('======================================================================')
console.log(`Scanning: ${root}`)
let stat
try {
stat = statSync(root)
} catch {
console.error(`\nFAIL: output directory does not exist: ${root}`)
console.error(' Build it first: npm run build:game')
process.exit(1)
}
if (!stat.isDirectory()) {
console.error(`\nFAIL: not a directory: ${root}`)
process.exit(1)
}
const r = scanBundle(root)
console.log(`Files present : ${r.filesTotal}`)
console.log(`Emitted .js scanned: ${r.emittedJsScanned}`)
console.log(`HTML entries : ${r.entries.join(', ') || '(none)'}`)
for (const e of r.entries) console.log(` ${e} -> ${(r.reachable[e] ?? []).length} reachable chunk(s)`)
if (r.orphanChunks.length > 0) {
console.log(`Orphan chunks (emitted but unreachable from any entry): ${r.orphanChunks.join(', ')}`)
}
const failures: string[] = []
// --- Guard: the scan must have actually examined something ---------------
if (r.emittedJsScanned === 0) {
failures.push('VACUITY: zero emitted .js assets were scanned — the gate examined nothing.')
}
if (r.entries.length === 0) {
failures.push('VACUITY: no HTML entry found — the chunk reachability graph is empty.')
}
const totalReachable = r.entries.reduce((n, e) => n + (r.reachable[e] ?? []).length, 0)
if (r.entries.length > 0 && totalReachable === 0) {
failures.push('VACUITY: entries resolved to zero chunks — module-reference parsing is broken.')
}
// --- 1. Capability ------------------------------------------------------
console.log('\n--- 1. CAPABILITY_NOT_REACHABLE (primary)')
const reachableHits = r.capabilityHits.filter(h => h.reachableFrom.length > 0)
const unreachableHits = r.capabilityHits.filter(h => h.reachableFrom.length === 0)
if (r.capabilityHits.length === 0) {
console.log(' PASS: none of the MPQ capability fingerprints appear in any emitted asset.')
} else {
for (const h of reachableHits) {
console.error(` HIT ${h.fingerprintId} in ${h.file}`)
console.error(` origin : ${h.origin}`)
console.error(` why it matters: ${h.why}`)
console.error(` reachable from: ${h.reachableFrom.join(', ')}`)
}
for (const h of unreachableHits) {
console.log(` INFO ${h.fingerprintId} in ${h.file} — present but unreachable from any entry (dead chunk).`)
}
}
if (reachableHits.length > 0) {
failures.push(
`CAPABILITY: ${reachableHits.length} MPQ fingerprint(s) reachable from a shipped entry — ` +
'the bundle can stream a game archive regardless of how the filename is spelled.',
)
}
// --- 2. Literals --------------------------------------------------------
console.log('\n--- 2. NO_ARCHIVE_LITERALS (secondary)')
if (r.literalSourceMapOnly.length > 0) {
console.log(` INFO: ${r.literalSourceMapOnly.length} occurrence group(s) in genuine source maps.`)
console.log(' A .map embeds the full original source (`sourcesContent`), so a string')
console.log(' survives there even after its code is eliminated. Classified by parsing')
console.log(' the file as a source map, not by its extension. Not a failure.')
for (const o of r.literalSourceMapOnly) console.log(` - ${o.file}: ${o.pattern} x${o.count}`)
}
if (r.literalHard.length === 0) {
console.log(' PASS: zero *.mpq / *.dll literals in emitted executable assets.')
} else {
console.error(` FAIL: ${r.literalHard.length} occurrence group(s) in EMITTED assets:`)
for (const o of r.literalHard) {
console.error(` ${o.file}`)
console.error(` pattern : ${o.pattern}`)
console.error(` count : ${o.count}`)
console.error(` context : ${o.sample}`)
}
failures.push(`LITERALS: ${r.literalHard.length} archive-name literal group(s) in emitted assets.`)
}
// --- 3. Fragments (informational) ---------------------------------------
console.log('\n--- 3. FRAGMENT_SCAN (informational — concatenation cannot be auto-judged)')
if (r.fragments.length === 0) {
console.log(' none')
} else {
for (const o of r.fragments) console.log(` ${o.file}: ${o.pattern} x${o.count}`)
console.log(' Review these by hand: a fragment is how a literal scan gets evaded.')
console.log(' Assertion 1 is what actually closes that hole.')
}
// --- Report -------------------------------------------------------------
const outPath = assertWritablePath(reportPath)
mkdirSync(dirname(outPath), { recursive: true })
writeFileSync(
outPath,
JSON.stringify(
{
generated: new Date().toISOString(),
root: relative(ROOT, root),
filesTotal: r.filesTotal,
emittedJsScanned: r.emittedJsScanned,
entries: r.entries,
reachable: r.reachable,
orphanChunks: r.orphanChunks,
capabilityHits: r.capabilityHits,
literalHard: r.literalHard,
// Surfaced in the report, not only the console, so the sourcemap
// exemption is a visible accepted state rather than a silent one.
literalSourceMapOnly: r.literalSourceMapOnly,
fragments: r.fragments,
failures,
verdict: failures.length === 0 ? 'PASS' : 'FAIL',
},
null,
2,
),
)
console.log(`\nReport: ${relative(ROOT, outPath)}`)
console.log('\n======================================================================')
if (failures.length === 0) {
console.log('PASS: the shipped bundle cannot fetch a game archive.')
process.exit(0)
}
console.error(`FAIL (${failures.length}):`)
for (const f of failures) console.error(` - ${f}`)
console.error('')
console.error(' Root cause on main (2026-09-21): unguarded tier-3 fallback')
console.error(' act-scene.ts loadCharacterArt L2384 -> getMountedCharArchives L2013')
console.error(' -> getCachedMpqArchive L1968 -> httpRangeSource(base + "/d2char.mpq") L1974')
console.error('')
console.error(' THE FIX MUST BE STRUCTURAL: remove the live-MPQ path from the production')
console.error(' entry graph (or isolate it behind a dev-only entry) so the chunks are not')
console.error(' reachable. Renaming the constants, or splitting them into concatenation to')
console.error(' evade the literal scan, will NOT satisfy assertion 1 and will be ruled a')
console.error(' failure. Do not weaken this gate.')
process.exit(1)
}
const invokedDirectly =
process.argv[1] !== undefined && resolve(process.argv[1]) === fileURLToPath(import.meta.url)
if (invokedDirectly) main()

390
src/formats/animdata.ts Normal file
View File

@ -0,0 +1,390 @@
/**
* Diablo II `AnimData.D2` animation-timing table decoder.
*
* `AnimData.D2` is the table that says how long every animation in the game
* takes and on which frame it *does* something. It is the authority the COF
* files are not: a COF carries a speed byte, but on most shipped player COFs
* that byte is `0` (`sowlhth` is 0 while `SOWLHTH` here is 256), so a port that
* times animations from COFs times most of them wrong. Frame events are the
* other half: D2 lands melee damage and spawns missiles on a specific frame of
* the attack animation, not on a timer, and that frame number is stored here.
*
* Layout — every field little-endian, no header, no version, no magic:
*
* - 256 hash blocks, in order. Each block is a `u32` record count followed by
* that many 160-byte records.
* - A record is `char[8]` NUL-padded name, `u32` framesPerDirection,
* `u32` animationSpeed, `u8[144]` frame-event flags.
* - A record's block index is `sum(uppercase name charCodeAt) % 256`. That is
* the whole hash function, and it is what makes the file self-checking:
* 3558/3558 shipped records land in the block their own name demands, so a
* decoder that has drifted by even one byte produces mismatches immediately.
*
* Arithmetic self-proof for the shipped 1.13c table:
* `3558 x 160 + 256 x 4 = 569,280 + 1,024 = 570,304` — exactly the file size.
* `decodeAnimDataFile` therefore refuses to return unless it consumed every
* byte, which is what turns a truncated or length-tampered buffer into a throw
* instead of a plausible-looking partial table.
*
* Four things in the shipped data contradict the obvious reading of the format,
* and all four are handled here rather than being left to callers to trip over:
*
* 1. **Frame-event codes are not a `1|2` enum.** Code 3 occurs on four records
* (`TES1HTH`, `10A1HTH`, `10A2HTH`, `13A1HTH`). Raw codes are preserved and
* never filtered: dropping an unknown code would be a silent data loss, and
* throwing on it would reject valid shipping data.
* 2. **Four records store `animationSpeed === 0`** (`CODTHTH`, `VHNUHTH`,
* `VJNUHTH`, `R7DDHTH`). `ticksPerCycle` is `fpd * 256 / speed`, so zero
* means a division by zero, and in an 8.8 accumulator `acc += 0` means the
* animation never advances — a frozen actor with no error anywhere. The
* speed is preserved as stored and flagged through `hasSpeed`, and
* `ticksPerCycle` throws rather than returning `Infinity`.
* 3. **The flag array is 144 bytes but `framesPerDirection` can exceed it.**
* `42DTHTH` has 200 frames. Frames 144..199 have nowhere to store an event,
* so event scanning stops at `min(fpd, 144)`; there is no out-of-bounds read
* and no silent truncation, and `recordsOverFlagCeiling` lists every record
* in that state so a caller can report it.
* 4. **29 names occur more than once, and 9 of those disagree** (`VMS1HTH` is
* stored as both `fpd=17, speed=200` and `fpd=17, speed=160`). The lookup map
* is **first-wins**: the game resolves a name by scanning its hash block
* sequentially and returning on the first hit, so the first record in block
* order is the one the engine itself would use. Both candidate values are
* kept in `conflicts` so the ambiguity stays visible instead of becoming an
* accident of map insertion order.
*
* **`EAnimData.D2` merge policy, and the evidence for it.** The expansion table
* (565,984 bytes, 3,531 records, also served by `d2exp.mpq`) has the identical
* layout and decodes with this same function. Whether the base table or the
* expansion table should win a name collision looks like a decision, and it is
* not one, because the two files do not actually disagree anywhere:
*
* - 3,520 names collide — every name `EAnimData.D2` has.
* - **0** of those 3,520 differ in `framesPerDirection`, `animationSpeed` or
* events, and **0** differ in the raw 152-byte record payload. They are
* byte-identical, including the ordering of the 11 duplicate names they share
* and the values of all 9 internally-conflicting names.
* - 0 names are unique to `EAnimData.D2`; `AnimData.D2` holds 9 it lacks
* (`41DDHTH`, `44WLHTH`, `0JDDHTH`, `K9A2HTH`, `K9BLHTH`, `K9S2HTH`,
* `K9S3HTH`, `K9S4HTH`, `K9SCHTH`).
*
* So `AnimData.D2` is a strict superset by both name and value, and merge
* precedence cannot change a single number in either direction. The bake reads
* `AnimData.D2` alone and asserts the above as a cross-check rather than
* merging. This module itself merges nothing: it decodes the buffer it is
* handed, so either file can be decoded on its own.
*
* (An earlier survey reported "9 shared names disagree". That count is the
* number of internally-conflicting duplicate names, which is reproduced by
* comparing one file first-wins against the other last-wins. Comparing like for
* like, the difference count is 0.)
*/
import { ByteReader, InvalidFieldError } from './reader'
/** Hash blocks in the file, one per possible value of the name hash. */
export const ANIMDATA_BLOCK_COUNT = 256
/** Bytes per record: 8 name + 4 framesPerDirection + 4 animationSpeed + 144 flags. */
export const ANIMDATA_RECORD_SIZE = 160
/** Bytes of NUL-padded name at the head of a record. */
export const ANIMDATA_NAME_SIZE = 8
/**
* Frame-event flag bytes per record.
*
* A clip may legally declare more frames than this (`42DTHTH` declares 200);
* those frames simply have no event storage.
*/
export const ANIMDATA_FLAG_COUNT = 144
/** Frame-event code: resolve the melee hit on this frame. */
export const ANIM_EVENT_ATTACK = 1
/** Frame-event code: spawn the missile on this frame. */
export const ANIM_EVENT_MISSILE = 2
/**
* One frame event: a frame index within the clip and the raw byte stored for it.
*
* The code is deliberately a `number` and not a union. Codes 1 and 2 are the
* documented ones, code 3 is in the shipped table with no documented meaning,
* and a mod is free to store anything. Callers must decide per code, and must
* not assume the set is closed.
*/
export interface FrameEvent {
/** Frame index within one direction, always `< min(framesPerDirection, 144)`. */
readonly frame: number
/** Raw flag byte, never normalised. 1 = attack, 2 = missile, 3 = undocumented. */
readonly code: number
}
/** One animation's timing record. */
export interface AnimDataRecord {
/** Upper-case COF name, e.g. `SOA1HTH`. */
readonly name: string
/** Frames in one direction of the animation. */
readonly framesPerDirection: number
/**
* Animation speed as 8.8 fixed point, 256 = 1x.
*
* Four shipped records store 0; see `hasSpeed` before dividing by this.
*/
readonly animationSpeed: number
/** False for the four records whose `animationSpeed` is 0. */
readonly hasSpeed: boolean
/** Frame events, ascending by frame. Sparse: most clips have none. */
readonly events: readonly FrameEvent[]
/** Hash block the record was stored in; always equals `animDataHash(name)`. */
readonly block: number
}
/** A name stored more than once, with the values of every copy. */
export interface AnimDataDuplicate {
/** The repeated name. */
readonly name: string
/** Every record stored under it, in file order. The first one wins lookups. */
readonly records: readonly AnimDataRecord[]
}
/** A non-zero flag byte sitting at or past its own record's frame count. */
export interface AnimDataStrayFlag {
/** Record the byte belongs to. */
readonly name: string
/** Flag-array index, which is `>= framesPerDirection`. */
readonly frame: number
/** The stored byte. */
readonly code: number
}
/** A decoded `AnimData.D2`, plus the integrity facts worth asserting on. */
export interface AnimDataFile {
/**
* Lookup by upper-case name. **First record wins** when a name repeats.
*
* The game resolves an animation by hashing the name to a block and scanning
* that block in order until it matches, so the first stored copy is the copy
* the original engine uses. Last-wins would silently pick the other speed for
* the nine conflicting names.
*/
readonly records: ReadonlyMap<string, AnimDataRecord>
/** Every record in file order, duplicates included. 3558 for 1.13c AnimData.D2. */
readonly all: readonly AnimDataRecord[]
/** Bytes consumed. Always equals the input length, or decoding threw. */
readonly bytesConsumed: number
/** Records per hash block, indexed by block. */
readonly blockCounts: readonly number[]
/** Names stored more than once, in first-seen order. 29 for 1.13c. */
readonly duplicates: readonly AnimDataDuplicate[]
/**
* Duplicates whose copies disagree on frames, speed or events. 9 for 1.13c.
*
* A subset of `duplicates`; the other 20 are byte-identical repeats where the
* resolution policy cannot matter.
*/
readonly conflicts: readonly AnimDataDuplicate[]
/**
* Records declaring more frames than the 144-byte flag array can describe.
*
* One record in 1.13c (`42DTHTH`, 200 frames). Their frames past 143 cannot
* carry an event, which is a property of the file format, not a decode error.
*/
readonly recordsOverFlagCeiling: readonly AnimDataRecord[]
/**
* Non-zero flag bytes at an index at or beyond the record's own frame count.
*
* Empty for 1.13c. Such a byte can never become a `FrameEvent` — event
* scanning stops at the frame count — so this list exists to make the
* discarded byte visible rather than silently lost.
*/
readonly strayFlags: readonly AnimDataStrayFlag[]
}
/**
* The hash block a name must be stored in.
*
* @param name - animation name; case is irrelevant, it is upper-cased first.
* @returns block index in `0..255`.
*/
export function animDataHash(name: string): number {
const upper = name.toUpperCase()
let sum = 0
for (let i = 0; i < upper.length; i += 1) {
sum += upper.charCodeAt(i)
}
return sum % ANIMDATA_BLOCK_COUNT
}
/**
* Read the 8-byte NUL-padded name at the reader's cursor.
*
* @param r - reader positioned at the name field.
* @param offset - absolute offset of the field, for error messages.
* @returns the name, upper-cased, with padding stripped.
*/
function readName(r: ByteReader, offset: number): string {
const bytes = r.bytes(ANIMDATA_NAME_SIZE, 'record name')
let name = ''
let terminated = false
for (let i = 0; i < bytes.length; i += 1) {
const byte = bytes[i]!
if (byte === 0) {
terminated = true
continue
}
if (terminated) {
// Text resuming after the terminator means the cursor is not on a record
// boundary any more. Guessing which half is the name would invent data.
throw new InvalidFieldError('animdata', 'record name', offset, `data after NUL at ${String(i)}`, 'NUL padding')
}
if (byte < 0x20 || byte > 0x7e) {
throw new InvalidFieldError('animdata', 'record name', offset, byte, 'printable ASCII')
}
name += String.fromCharCode(byte)
}
if (name.length === 0) {
throw new InvalidFieldError('animdata', 'record name', offset, '(empty)', 'a name')
}
return name.toUpperCase()
}
/**
* Decode the whole table with its integrity facts.
*
* Throws when a block runs past the end of the buffer, when a record's name is
* not a NUL-padded printable string, when a record's name hash does not equal
* the block it was found in, or when decoding does not consume the buffer
* exactly. There is no partial-result path and no fallback: a table that does
* not check out is a table whose frame timings cannot be trusted.
*
* @param data - the raw `AnimData.D2` (or `EAnimData.D2`) bytes.
* @returns the decoded table.
*/
export function decodeAnimDataFile(data: Uint8Array): AnimDataFile {
const r = new ByteReader(data, 'animdata')
const records = new Map<string, AnimDataRecord>()
const order: AnimDataRecord[] = []
const blockCounts: number[] = []
const repeats = new Map<string, AnimDataRecord[]>()
const overCeiling: AnimDataRecord[] = []
const strayFlags: AnimDataStrayFlag[] = []
for (let block = 0; block < ANIMDATA_BLOCK_COUNT; block += 1) {
const count = r.u32le('block record count')
blockCounts.push(count)
for (let index = 0; index < count; index += 1) {
const recordOffset = r.position
const name = readName(r, recordOffset)
const hash = animDataHash(name)
if (hash !== block) {
throw new InvalidFieldError('animdata', `hash of ${name}`, recordOffset, hash, block)
}
const framesPerDirection = r.u32le('framesPerDirection')
const animationSpeed = r.u32le('animationSpeed')
const flags = r.bytes(ANIMDATA_FLAG_COUNT, 'frameEventFlags')
const events: FrameEvent[] = []
const scanned = Math.min(framesPerDirection, ANIMDATA_FLAG_COUNT)
for (let frame = 0; frame < scanned; frame += 1) {
const code = flags[frame]!
if (code !== 0) events.push({ frame, code })
}
for (let frame = scanned; frame < ANIMDATA_FLAG_COUNT; frame += 1) {
const code = flags[frame]!
if (code !== 0) strayFlags.push({ name, frame, code })
}
const record: AnimDataRecord = {
name,
framesPerDirection,
animationSpeed,
hasSpeed: animationSpeed !== 0,
events,
block,
}
order.push(record)
if (framesPerDirection > ANIMDATA_FLAG_COUNT) overCeiling.push(record)
const seen = repeats.get(name)
if (seen === undefined) {
repeats.set(name, [record])
records.set(name, record)
} else {
// First-wins: leave `records` alone. See `AnimDataFile.records`.
seen.push(record)
}
}
}
if (r.position !== data.length) {
throw new InvalidFieldError('animdata', 'bytes consumed', r.position, r.position, data.length)
}
const duplicates: AnimDataDuplicate[] = []
const conflicts: AnimDataDuplicate[] = []
for (const [name, copies] of repeats) {
if (copies.length < 2) continue
const duplicate: AnimDataDuplicate = { name, records: copies }
duplicates.push(duplicate)
if (copies.some(copy => !sameTiming(copies[0]!, copy))) conflicts.push(duplicate)
}
return {
records,
all: order,
bytesConsumed: r.position,
blockCounts,
duplicates,
conflicts,
recordsOverFlagCeiling: overCeiling,
strayFlags,
}
}
/**
* Whether two records describe the same animation timing.
*
* @param a - first record.
* @param b - second record.
* @returns true when frames, speed and the whole event list agree.
*/
function sameTiming(a: AnimDataRecord, b: AnimDataRecord): boolean {
if (a.framesPerDirection !== b.framesPerDirection) return false
if (a.animationSpeed !== b.animationSpeed) return false
if (a.events.length !== b.events.length) return false
for (let i = 0; i < a.events.length; i += 1) {
if (a.events[i]!.frame !== b.events[i]!.frame) return false
if (a.events[i]!.code !== b.events[i]!.code) return false
}
return true
}
/**
* Decode the table to a name lookup.
*
* Convenience over `decodeAnimDataFile` for callers that only need the clips;
* the duplicate, ceiling and stray-flag diagnostics are on the full result.
*
* @param data - the raw `AnimData.D2` bytes.
* @returns records by upper-case name, first-wins on duplicates.
*/
export function decodeAnimData(data: Uint8Array): Map<string, AnimDataRecord> {
return new Map(decodeAnimDataFile(data).records)
}
/**
* Simulation ticks one full cycle of an animation takes.
*
* `framesPerDirection * 256 / animationSpeed` at the engine's 25 Hz tick, so
* `speed=168, fpd=8` is 12.19 ticks — deliberately fractional, which is why the
* runtime advances an 8.8 accumulator rather than counting whole ticks.
*
* Throws on the four speed-0 records instead of returning `Infinity`: an
* animation that takes infinitely long is a frozen actor, and a frozen actor
* that reports no error is the exact failure this project forbids.
*
* @param record - the record to time.
* @returns ticks per cycle, fractional.
*/
export function ticksPerCycle(record: AnimDataRecord): number {
if (!record.hasSpeed) {
throw new InvalidFieldError('animdata', `animationSpeed of ${record.name}`, 0, 0, '> 0')
}
return (record.framesPerDirection * 256) / record.animationSpeed
}

View File

@ -20,6 +20,10 @@ export interface SpriteFrame {
readonly indices: Uint8Array
/** 1 where the pixel is opaque, 0 where transparent. */
readonly mask: Uint8Array
/** Horizontal anchor offset in sprite space (box.left in DCC coordinates). */
readonly anchorX?: number
/** Vertical anchor offset in sprite space (box.top in DCC coordinates). */
readonly anchorY?: number
}
/** Frames belonging to one animation direction. */

View File

@ -171,7 +171,7 @@ export async function loadCharacterSheet(
placedCount += 1
}
if (placedCount === 0) {
frames.push({ width: 1, height: 1, indices: new Uint8Array(1), mask: new Uint8Array(1) })
frames.push({ width: 1, height: 1, indices: new Uint8Array(1), mask: new Uint8Array(1), anchorX: 0, anchorY: 0 })
continue
}
const boxLeft = Math.round(left)
@ -180,6 +180,7 @@ export async function loadCharacterSheet(
const height = Math.max(1, Math.round(bottom) - boxTop)
const composed: SpriteFrame = {
width, height, indices: new Uint8Array(width * height), mask: new Uint8Array(width * height),
anchorX: boxLeft, anchorY: boxTop,
}
// Draw in the COF's own back-to-front order for this direction and frame;
// the priority table is the authority on what covers what.

View File

@ -225,35 +225,53 @@ export async function compositeMonsterAnimation(
)
}
// Determine maximum bounding box across all layer directions
let minX = 0
let minY = 0
let maxX = 0
let maxY = 0
for (const dcc of sprites) {
if (!dcc) continue
for (const dir of dcc.directions) {
minX = Math.min(minX, dir.box.left)
minY = Math.min(minY, dir.box.top)
maxX = Math.max(maxX, dir.box.left + dir.box.width)
maxY = Math.max(maxY, dir.box.top + dir.box.height)
}
}
const width = Math.max(1, maxX - minX)
const height = Math.max(1, maxY - minY)
const groups: { frames: SpriteFrame[] }[] = []
for (let dir = 0; dir < cof.numberOfDirections; dir += 1) {
const dir64 = Math.round((dir * 64) / cof.numberOfDirections)
const frames: SpriteFrame[] = []
for (let f = 0; f < cof.framesPerDirection; f += 1) {
let left = Number.POSITIVE_INFINITY
let top = Number.POSITIVE_INFINITY
let right = Number.NEGATIVE_INFINITY
let bottom = Number.NEGATIVE_INFINITY
let placedCount = 0
for (let layerIdx = 0; layerIdx < cof.layers.length; layerIdx += 1) {
const dcc = sprites[layerIdx]
if (!dcc) continue
const dccDir = dir64ToDcc(dir64, dcc.directions.length)
const dirData = dcc.directions[dccDir]
if (!dirData) continue
const frameData = dirData.frames[f]
if (!frameData) continue
left = Math.min(left, dirData.box.left)
top = Math.min(top, dirData.box.top)
right = Math.max(right, dirData.box.left + dirData.box.width)
bottom = Math.max(bottom, dirData.box.top + dirData.box.height)
placedCount += 1
}
if (placedCount === 0) {
frames.push({
width: 1,
height: 1,
indices: new Uint8Array(1),
mask: new Uint8Array(1),
anchorX: 0,
anchorY: 0,
})
continue
}
const boxLeft = Math.round(left)
const boxTop = Math.round(top)
const frameWidth = Math.max(1, Math.round(right) - boxLeft)
const frameHeight = Math.max(1, Math.round(bottom) - boxTop)
const target = {
indices: new Uint8Array(width * height),
mask: new Uint8Array(width * height),
width,
height,
indices: new Uint8Array(frameWidth * frameHeight),
mask: new Uint8Array(frameWidth * frameHeight),
width: frameWidth,
height: frameHeight,
}
const order = cofLayerOrder(cof, dir, f)
for (const layerIdx of order) {
@ -264,22 +282,24 @@ export async function compositeMonsterAnimation(
if (!dirData) continue
const frameData = dirData.frames[f]
if (!frameData) continue
const atX = dirData.box.left - minX
const atY = dirData.box.top - minY
const atX = Math.round(dirData.box.left) - boxLeft
const atY = Math.round(dirData.box.top) - boxTop
blit(target, frameData.frame, atX, atY)
}
frames.push({
width,
height,
width: frameWidth,
height: frameHeight,
indices: target.indices,
mask: target.mask,
anchorX: boxLeft,
anchorY: boxTop,
})
}
groups.push({ frames })
}
return {
sheet: { groups, width },
sheet: { groups, width: null },
directions: cof.numberOfDirections,
framesPerDirection: cof.framesPerDirection,
layers: cof.layers.length,

223
src/game/plr-mode.ts Normal file
View File

@ -0,0 +1,223 @@
/**
* Animation mode, composite and weapon-class tables, baked from the 1.13c MPQ.
*
* These four tables are what turn an actor's *state* into the seven-character
* COF name the art is stored under: `<token><mode><weaponClass>`, e.g. `SO` +
* `A1` + `HTH` = `SOA1HTH`. They are tiny (20, 16, 16 and 15 rows), they never
* change within a game version, and every one of them is needed before a single
* frame can be drawn — so they are baked into source here rather than read from
* the archive at runtime, which is what the zero-runtime-MPQ rule requires.
*
* Every row below is transcribed from the real table in
* `samples/d2/*.mpq → data\global\excel\<Table>.txt`, and
* `tests/animdata.test.ts` re-reads all four tables out of the MPQ and asserts
* these constants match cell for cell. That test is the point: a hand-copied
* table is exactly the kind of thing that silently rots, and this project has
* already been bitten once by a hand-transcribed ground-truth table that was
* wrong.
*
* Two traps live in these tables:
*
* - **A mode's code is not always its animation token.** `PlrMode.txt` stores
* `SQ` (Sequence) and `KB` (Knock back) with token `GH`, i.e. both reuse the
* get-hit animation; there is no `SOSQHTH` COF to load. `MonMode.txt` does the
* same for `KB` and maps its own `xx` (sequence) row to token `xx`, which is
* not an animation at all. Always compose COF names from `token`, never from
* `code`.
* - **`WeaponClass.txt` row 0 has an empty code.** "None" is a real row with no
* code, so a naive `codes[0]` yields `''` and would compose `SOA1` — a name
* that cannot exist. `WEAPON_CLASS_CODES` therefore excludes it and
* `WEAPON_CLASSES` keeps it, so the omission is visible rather than assumed.
*/
/** One row of `PlrMode.txt` or `MonMode.txt`. */
export interface AnimModeEntry {
/**
* Mode code, e.g. `A1`. Upper-cased; `MonMode.txt` stores its codes in lower
* case except for the `xx` sequence row.
*/
readonly code: string
/**
* Animation token used to build the COF name. Usually equals `code`, but
* `SQ`/`KB` borrow `GH`, and MonMode's sequence row is the non-animation `xx`.
*/
readonly token: string
/** Descriptive name as stored, e.g. `Get Hit`. */
readonly name: string
}
/** One row of `Composit.txt`: a layer slot of a composited actor. */
export interface CompositEntry {
/** Two-letter directory token, e.g. `TR` for the torso. */
readonly token: string
/** Descriptive name as stored, e.g. `RightArm`. */
readonly name: string
}
/** One row of `WeaponClass.txt`. */
export interface WeaponClassEntry {
/** Three-letter code used as the COF name suffix; empty for the "None" row. */
readonly code: string
/** Descriptive name as stored, e.g. `1 Hand Swing`. */
readonly name: string
}
/**
* `data\global\excel\PlrMode.txt` — all 20 rows, in table order.
*
* Columns: `Name`, `Token`, `Code`. Row order is the mode index the engine uses.
*/
export const PLR_MODES: readonly AnimModeEntry[] = [
{ code: 'DT', token: 'DT', name: 'Death' },
{ code: 'NU', token: 'NU', name: 'Neutral' },
{ code: 'WL', token: 'WL', name: 'Walk' },
{ code: 'RN', token: 'RN', name: 'Run' },
{ code: 'GH', token: 'GH', name: 'Get Hit' },
{ code: 'TN', token: 'TN', name: 'Town Neutral' },
{ code: 'TW', token: 'TW', name: 'Town Walk' },
{ code: 'A1', token: 'A1', name: 'Attack1' },
{ code: 'A2', token: 'A2', name: 'Attack2' },
{ code: 'BL', token: 'BL', name: 'Block' },
{ code: 'SC', token: 'SC', name: 'Cast' },
{ code: 'TH', token: 'TH', name: 'Throw' },
{ code: 'KK', token: 'KK', name: 'Kick' },
{ code: 'S1', token: 'S1', name: 'Skill1' },
{ code: 'S2', token: 'S2', name: 'Skill2' },
{ code: 'S3', token: 'S3', name: 'Skill3' },
{ code: 'S4', token: 'S4', name: 'Skill4' },
{ code: 'DD', token: 'DD', name: 'Dead' },
{ code: 'SQ', token: 'GH', name: 'Sequence' },
{ code: 'KB', token: 'GH', name: 'Knock back' },
]
/**
* `data\global\excel\MonMode.txt` — all 16 rows, in table order.
*
* Columns: `name`, `token`, `code`, stored lower-case; codes and tokens are
* upper-cased here to match `PlrMode.txt` and the COF names on disk. Note the
* monster mode set is not the player set: there is no `TN`/`TW`/`TH`/`KK`, and
* the sequence row is `xx`, which names no animation.
*/
export const MON_MODES: readonly AnimModeEntry[] = [
{ code: 'DT', token: 'DT', name: 'death' },
{ code: 'NU', token: 'NU', name: 'neutral' },
{ code: 'WL', token: 'WL', name: 'walk' },
{ code: 'GH', token: 'GH', name: 'gethit' },
{ code: 'A1', token: 'A1', name: 'attack1' },
{ code: 'A2', token: 'A2', name: 'attack2' },
{ code: 'BL', token: 'BL', name: 'block' },
{ code: 'SC', token: 'SC', name: 'cast' },
{ code: 'S1', token: 'S1', name: 'skill1' },
{ code: 'S2', token: 'S2', name: 'skill2' },
{ code: 'S3', token: 'S3', name: 'skill3' },
{ code: 'S4', token: 'S4', name: 'skill4' },
{ code: 'DD', token: 'DD', name: 'dead' },
{ code: 'KB', token: 'GH', name: 'knockback' },
{ code: 'XX', token: 'XX', name: 'sequence' },
{ code: 'RN', token: 'RN', name: 'run' },
]
/**
* `data\global\excel\Composit.txt` — all 16 rows, in table order.
*
* Columns: `Name`, `Token`. The row index *is* the composite type stored in a
* COF layer record and in its priority table, so order here is load-bearing.
*/
export const COMPOSITS: readonly CompositEntry[] = [
{ token: 'HD', name: 'Head' },
{ token: 'TR', name: 'Torso' },
{ token: 'LG', name: 'Legs' },
{ token: 'RA', name: 'RightArm' },
{ token: 'LA', name: 'LeftArm' },
{ token: 'RH', name: 'RightHand' },
{ token: 'LH', name: 'LeftHand' },
{ token: 'SH', name: 'Shield' },
{ token: 'S1', name: 'Special1' },
{ token: 'S2', name: 'Special2' },
{ token: 'S3', name: 'Special3' },
{ token: 'S4', name: 'Special4' },
{ token: 'S5', name: 'Special5' },
{ token: 'S6', name: 'Special6' },
{ token: 'S7', name: 'Special7' },
{ token: 'S8', name: 'Special8' },
]
/**
* `data\global\excel\WeaponClass.txt` — all 15 rows, in table order.
*
* Columns: `Weapon Class`, `Code`. Row 0 ("None") has an empty code on purpose;
* see `WEAPON_CLASS_CODES` for the set that can actually appear in a COF name.
*/
export const WEAPON_CLASSES: readonly WeaponClassEntry[] = [
{ code: '', name: 'None' },
{ code: 'hth', name: 'Hand To Hand' },
{ code: 'bow', name: 'Bow' },
{ code: '1hs', name: '1 Hand Swing' },
{ code: '1ht', name: '1 Hand Thrust' },
{ code: 'stf', name: 'Staff' },
{ code: '2hs', name: '2 Hand Swing' },
{ code: '2ht', name: '2 Hand Thrust' },
{ code: 'xbw', name: 'Crossbow' },
{ code: '1js', name: 'Left Jab Right Swing' },
{ code: '1jt', name: 'Left Jab Right Thrust' },
{ code: '1ss', name: 'Left Swing Right Swing' },
{ code: '1st', name: 'Left Swing Right Thrust' },
{ code: 'ht1', name: 'One Hand-to-Hand' },
{ code: 'ht2', name: 'Two Hand-to-Hand' },
]
/** Player mode codes in table order, e.g. `['DT','NU',...]`. */
export const PLR_MODE_CODES: readonly string[] = PLR_MODES.map(mode => mode.code)
/** Monster mode codes in table order. Includes the non-animation `XX`. */
export const MON_MODE_CODES: readonly string[] = MON_MODES.map(mode => mode.code)
/** Composite tokens indexed by composite type, e.g. `COMPOSIT_TOKENS[1] === 'TR'`. */
export const COMPOSIT_TOKENS: readonly string[] = COMPOSITS.map(entry => entry.token)
/**
* Weapon-class codes that can appear in a COF name.
*
* The "None" row's empty code is excluded: it is a table entry, not a suffix.
*/
export const WEAPON_CLASS_CODES: readonly string[] = WEAPON_CLASSES
.map(entry => entry.code)
.filter(code => code.length > 0)
const PLR_MODE_BY_CODE = new Map(PLR_MODES.map(mode => [mode.code, mode]))
const MON_MODE_BY_CODE = new Map(MON_MODES.map(mode => [mode.code, mode]))
/**
* The animation token a player mode plays.
*
* @param code - mode code, any case, e.g. `sq`.
* @returns the token, e.g. `GH` for `SQ`, or undefined when the code is not a
* player mode.
*/
export function plrModeToken(code: string): string | undefined {
return PLR_MODE_BY_CODE.get(code.toUpperCase())?.token
}
/**
* The animation token a monster mode plays.
*
* @param code - mode code, any case.
* @returns the token, or undefined when the code is not a monster mode. `XX`
* resolves to `XX`, which names no animation — callers must not build a COF
* name from it.
*/
export function monModeToken(code: string): string | undefined {
return MON_MODE_BY_CODE.get(code.toUpperCase())?.token
}
/**
* Compose the `AnimData.D2` / COF name for an actor animation.
*
* @param token - two-letter actor token, e.g. `so` or `sk`.
* @param mode - two-letter animation token, e.g. `a1`.
* @param weaponClass - three-letter weapon-class code, e.g. `hth`.
* @returns the upper-case seven-character name, e.g. `SOA1HTH`.
*/
export function cofName(token: string, mode: string, weaponClass: string): string {
return `${token}${mode}${weaponClass}`.toUpperCase()
}

647
tests/animdata.test.ts Normal file
View File

@ -0,0 +1,647 @@
/**
* `AnimData.D2` decoder tests, against the real 1.13c archive.
*
* The structural claims here (570,304 bytes consumed, 3,558 records, every
* record's name hash equal to its block index) are what make this decoder
* trustworthy at all: the file has no magic number and no version field, so a
* decoder that is one byte out of step still produces plausible-looking records.
* Only the byte-exact consumption and the 3,558/3,558 hash agreement catch that.
*
* The 25 golden clips are the user-supplied ground-truth table from
* `.agents/ORIGINAL_REQUEST.md` L57-L83, re-verified against the archive. They
* are asserted in full rather than sampled, because the interesting failures —
* a wrong event frame, a speed read from the wrong offset — show up on
* individual clips, not on aggregates.
*
* The synthetic cases cover what the shipped file cannot: truncation, a tampered
* length, a corrupted name, a frame count past the 144-byte flag array, and
* duplicate names with conflicting values.
*/
import { describe, expect, test, beforeAll } from 'vitest'
import * as fs from 'node:fs'
import {
animDataHash,
decodeAnimData,
decodeAnimDataFile,
ticksPerCycle,
ANIMDATA_BLOCK_COUNT,
ANIMDATA_FLAG_COUNT,
ANIMDATA_NAME_SIZE,
ANIMDATA_RECORD_SIZE,
} from '../src/formats/animdata.ts'
import type { AnimDataFile } from '../src/formats/animdata.ts'
import { FormatError, InvalidFieldError, TruncatedDataError } from '../src/formats/reader.ts'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { cell, parseTable } from '../src/game/acts.ts'
import {
COMPOSITS,
MON_MODES,
PLR_MODES,
WEAPON_CLASSES,
WEAPON_CLASS_CODES,
cofName,
monModeToken,
plrModeToken,
} from '../src/game/plr-mode.ts'
const ARCHIVE_DIR = 'samples/d2'
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
const ANIMDATA_MEMBER = 'data\\global\\AnimData.D2'
const EANIMDATA_MEMBER = 'data\\global\\EAnimData.D2'
/** The real archives are optional in a checkout without game data. */
const hasD2 = MOUNTS.every(name => fs.existsSync(`${ARCHIVE_DIR}/${name}`))
/** Shipped file size, and the arithmetic that proves the layout: 3558*160 + 256*4. */
const ANIMDATA_BYTES = 570_304
/** Records in the shipped table, duplicates included. */
const ANIMDATA_RECORDS = 3558
/** Distinct names; 29 names are stored twice. */
const ANIMDATA_UNIQUE_NAMES = 3529
/**
* Ground truth from `.agents/ORIGINAL_REQUEST.md` L57-L83.
*
* `ticks` is `fpd * 256 / speed` rounded to two decimals — the same arithmetic
* the spec table lists, kept here so a wrong speed cannot pass by coincidence.
*/
const GOLDEN: readonly {
readonly name: string
readonly fpd: number
readonly speed: number
readonly ticks: number
readonly events: readonly (readonly [number, number])[]
}[] = [
{ name: 'SONUHTH', fpd: 8, speed: 128, ticks: 16.0, events: [] },
{ name: 'SOWLHTH', fpd: 8, speed: 256, ticks: 8.0, events: [] },
{ name: 'SORNHTH', fpd: 8, speed: 256, ticks: 8.0, events: [] },
{ name: 'SOTNHTH', fpd: 16, speed: 80, ticks: 51.2, events: [] },
{ name: 'SOTWHTH', fpd: 8, speed: 256, ticks: 8.0, events: [] },
{ name: 'SOA1HTH', fpd: 16, speed: 256, ticks: 16.0, events: [[9, 1]] },
{ name: 'SOA11HS', fpd: 20, speed: 256, ticks: 20.0, events: [[12, 1]] },
{ name: 'SOSCHTH', fpd: 14, speed: 256, ticks: 14.0, events: [[7, 1]] },
{ name: 'SOTHHTH', fpd: 20, speed: 256, ticks: 20.0, events: [[10, 2]] },
{ name: 'SOKKHTH', fpd: 12, speed: 256, ticks: 12.0, events: [[6, 1]] },
{ name: 'SOGHHTH', fpd: 8, speed: 256, ticks: 8.0, events: [] },
{ name: 'SOBLHTH', fpd: 5, speed: 256, ticks: 5.0, events: [] },
{ name: 'SODTHTH', fpd: 24, speed: 256, ticks: 24.0, events: [] },
{ name: 'SODDHTH', fpd: 1, speed: 256, ticks: 1.0, events: [] },
{ name: 'BAWL1HS', fpd: 8, speed: 168, ticks: 12.19, events: [] },
{ name: 'BARN1HS', fpd: 8, speed: 216, ticks: 9.48, events: [] },
{ name: 'BAA11HS', fpd: 16, speed: 256, ticks: 16.0, events: [[7, 1]] },
{ name: 'PAWL1HS', fpd: 10, speed: 288, ticks: 8.89, events: [] },
{ name: 'SKA1HTH', fpd: 16, speed: 208, ticks: 19.69, events: [[8, 2]] },
{ name: 'SKWLHTH', fpd: 8, speed: 128, ticks: 16.0, events: [] },
{ name: 'SKGHHTH', fpd: 4, speed: 184, ticks: 5.57, events: [] },
{ name: 'SKDDHTH', fpd: 1, speed: 152, ticks: 1.68, events: [] },
{ name: 'FANUHTH', fpd: 20, speed: 256, ticks: 20.0, events: [] },
{ name: 'FAWLHTH', fpd: 10, speed: 256, ticks: 10.0, events: [] },
{ name: 'FAA1HTH', fpd: 10, speed: 256, ticks: 10.0, events: [[7, 1]] },
]
/** A record to synthesise into a well-formed buffer. */
interface SynthRecord {
readonly name: string
readonly fpd: number
readonly speed: number
/** Flag bytes to set, by flag-array index. */
readonly flags?: Readonly<Record<number, number>>
}
/**
* Build a structurally valid `AnimData.D2` buffer.
*
* Records are filed into the block their own name hashes to, so the result
* satisfies the decoder's integrity checks and can then be damaged one field at
* a time.
*
* @param records - records to store.
* @returns the encoded buffer.
*/
function buildAnimData(records: readonly SynthRecord[]): Uint8Array {
const byBlock = new Map<number, SynthRecord[]>()
for (const record of records) {
const block = animDataHash(record.name)
const list = byBlock.get(block) ?? []
list.push(record)
byBlock.set(block, list)
}
const out = new Uint8Array(ANIMDATA_BLOCK_COUNT * 4 + records.length * ANIMDATA_RECORD_SIZE)
const view = new DataView(out.buffer)
let offset = 0
for (let block = 0; block < ANIMDATA_BLOCK_COUNT; block += 1) {
const list = byBlock.get(block) ?? []
view.setUint32(offset, list.length, true)
offset += 4
for (const record of list) {
if (record.name.length > ANIMDATA_NAME_SIZE) throw new Error(`name too long: ${record.name}`)
for (let i = 0; i < record.name.length; i += 1) out[offset + i] = record.name.charCodeAt(i)
view.setUint32(offset + ANIMDATA_NAME_SIZE, record.fpd, true)
view.setUint32(offset + ANIMDATA_NAME_SIZE + 4, record.speed, true)
for (const [frame, code] of Object.entries(record.flags ?? {})) {
out[offset + ANIMDATA_NAME_SIZE + 8 + Number(frame)] = code
}
offset += ANIMDATA_RECORD_SIZE
}
}
return out
}
/**
* Byte offset of the record for `name` in a buffer built with exactly that one
* record before it.
*
* Blocks are interleaved — each block's count is followed by that block's
* records — so the record does not start at the end of a 1,024-byte count
* table. With no earlier records, it starts right after its own block's count.
*
* @param name - the record's name.
* @returns the offset of the record's first byte.
*/
function firstRecordOffset(name: string): number {
return (animDataHash(name) + 1) * 4
}
describe('animDataHash', () => {
test('is the sum of the upper-cased character codes modulo 256', () => {
// 'SOA1HTH' = 83+79+65+49+72+84+72 = 504; 504 % 256 = 248.
expect(animDataHash('SOA1HTH')).toBe(248)
expect(animDataHash('soa1hth')).toBe(248)
expect(animDataHash('')).toBe(0)
})
test('stays inside the block range for every shipped-style name', () => {
for (const golden of GOLDEN) {
const hash = animDataHash(golden.name)
expect(hash).toBeGreaterThanOrEqual(0)
expect(hash).toBeLessThan(ANIMDATA_BLOCK_COUNT)
}
})
})
describe('decodeAnimData on synthetic buffers', () => {
test('round-trips names, frames, speed and events', () => {
const data = buildAnimData([
{ name: 'AAA1HTH', fpd: 12, speed: 256, flags: { 3: 1, 9: 2 } },
{ name: 'ZZDDHTH', fpd: 1, speed: 128 },
])
const file = decodeAnimDataFile(data)
expect(file.all).toHaveLength(2)
expect(file.bytesConsumed).toBe(data.length)
const first = file.records.get('AAA1HTH')!
expect(first.framesPerDirection).toBe(12)
expect(first.animationSpeed).toBe(256)
expect(first.hasSpeed).toBe(true)
expect(first.events).toEqual([{ frame: 3, code: 1 }, { frame: 9, code: 2 }])
expect(first.block).toBe(animDataHash('AAA1HTH'))
expect(file.records.get('ZZDDHTH')!.framesPerDirection).toBe(1)
})
test('an empty table is 1,024 bytes of zero counts', () => {
const data = buildAnimData([])
expect(data.length).toBe(ANIMDATA_BLOCK_COUNT * 4)
const file = decodeAnimDataFile(data)
expect(file.all).toHaveLength(0)
expect(file.bytesConsumed).toBe(data.length)
})
test('throws when the buffer is truncated mid-record', () => {
const data = buildAnimData([{ name: 'AAA1HTH', fpd: 12, speed: 256 }])
expect(() => decodeAnimDataFile(data.subarray(0, data.length - 1))).toThrow(TruncatedDataError)
expect(() => decodeAnimDataFile(data.subarray(0, 1030))).toThrow(TruncatedDataError)
})
test('throws when the buffer is truncated mid-block-count', () => {
const data = buildAnimData([])
expect(() => decodeAnimDataFile(data.subarray(0, 3))).toThrow(TruncatedDataError)
expect(() => decodeAnimDataFile(new Uint8Array(0))).toThrow(TruncatedDataError)
})
test('throws when trailing bytes are appended, rather than ignoring them', () => {
const data = buildAnimData([{ name: 'AAA1HTH', fpd: 12, speed: 256 }])
const tampered = new Uint8Array(data.length + 1)
tampered.set(data)
let caught: unknown
try {
decodeAnimDataFile(tampered)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(InvalidFieldError)
expect((caught as InvalidFieldError).field).toBe('bytes consumed')
expect((caught as InvalidFieldError).got).toBe(data.length)
expect((caught as InvalidFieldError).expected).toBe(tampered.length)
})
test('throws when a block claims more records than the buffer holds', () => {
// Running past the end of the buffer entirely.
const empty = buildAnimData([])
new DataView(empty.buffer).setUint32((ANIMDATA_BLOCK_COUNT - 1) * 4, 1, true)
expect(() => decodeAnimDataFile(empty)).toThrow(TruncatedDataError)
// Running into the following blocks' count bytes, which are not a record.
const data = buildAnimData([{ name: 'AAA1HTH', fpd: 12, speed: 256 }])
new DataView(data.buffer).setUint32(animDataHash('AAA1HTH') * 4, 4, true)
expect(() => decodeAnimDataFile(data)).toThrow(FormatError)
})
test('throws when a name is corrupted into the wrong hash block', () => {
const data = buildAnimData([{ name: 'AAA1HTH', fpd: 12, speed: 256 }])
data[firstRecordOffset('AAA1HTH')] = 'B'.charCodeAt(0)
let caught: unknown
try {
decodeAnimDataFile(data)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(InvalidFieldError)
expect((caught as InvalidFieldError).field).toBe('hash of BAA1HTH')
})
test('throws on a non-printable or empty name rather than inventing one', () => {
const data = buildAnimData([{ name: 'AAA1HTH', fpd: 12, speed: 256 }])
const recordStart = firstRecordOffset('AAA1HTH')
const blanked = data.slice()
blanked.fill(0, recordStart, recordStart + ANIMDATA_NAME_SIZE)
expect(() => decodeAnimDataFile(blanked)).toThrow(FormatError)
const control = data.slice()
control[recordStart + 1] = 0x01
expect(() => decodeAnimDataFile(control)).toThrow(InvalidFieldError)
const resumed = data.slice()
resumed[recordStart + 7] = 'X'.charCodeAt(0)
expect(() => decodeAnimDataFile(resumed)).toThrow(InvalidFieldError)
})
test('scans only the first 144 frames for events and reports the ceiling', () => {
const data = buildAnimData([{ name: 'AABLHTH', fpd: 200, speed: 168, flags: { 4: 1, 143: 2 } }])
const file = decodeAnimDataFile(data)
const record = file.records.get('AABLHTH')!
expect(record.framesPerDirection).toBe(200)
expect(record.events).toEqual([{ frame: 4, code: 1 }, { frame: 143, code: 2 }])
expect(file.recordsOverFlagCeiling.map(entry => entry.name)).toEqual(['AABLHTH'])
expect(file.strayFlags).toEqual([])
})
test('never emits an event at or past the frame count, and reports the stray byte', () => {
const data = buildAnimData([{ name: 'AABLHTH', fpd: 5, speed: 256, flags: { 2: 1, 17: 2 } }])
const file = decodeAnimDataFile(data)
const record = file.records.get('AABLHTH')!
expect(record.events).toEqual([{ frame: 2, code: 1 }])
expect(record.events.every(event => event.frame < record.framesPerDirection)).toBe(true)
expect(file.strayFlags).toEqual([{ name: 'AABLHTH', frame: 17, code: 2 }])
})
test('preserves unknown event codes instead of dropping or normalising them', () => {
const data = buildAnimData([{ name: 'AAA1HTH', fpd: 10, speed: 256, flags: { 1: 3, 2: 7, 3: 255 } }])
const record = decodeAnimData(data).get('AAA1HTH')!
expect(record.events).toEqual([
{ frame: 1, code: 3 },
{ frame: 2, code: 7 },
{ frame: 3, code: 255 },
])
})
test('resolves duplicate names first-wins and lists the conflict', () => {
const data = buildAnimData([
{ name: 'AAA1HTH', fpd: 17, speed: 200, flags: { 10: 2 } },
{ name: 'AAA1HTH', fpd: 17, speed: 160, flags: { 10: 2 } },
{ name: 'ZZDDHTH', fpd: 1, speed: 256 },
{ name: 'ZZDDHTH', fpd: 1, speed: 256 },
])
const file = decodeAnimDataFile(data)
expect(file.all).toHaveLength(4)
expect(file.records.size).toBe(2)
expect(file.records.get('AAA1HTH')!.animationSpeed).toBe(200)
expect(file.duplicates.map(entry => entry.name).sort()).toEqual(['AAA1HTH', 'ZZDDHTH'])
expect(file.conflicts.map(entry => entry.name)).toEqual(['AAA1HTH'])
expect(file.conflicts[0]!.records.map(record => record.animationSpeed)).toEqual([200, 160])
})
test('preserves speed 0 and refuses to time it', () => {
const data = buildAnimData([{ name: 'AADTHTH', fpd: 1, speed: 0 }])
const record = decodeAnimData(data).get('AADTHTH')!
expect(record.animationSpeed).toBe(0)
expect(record.hasSpeed).toBe(false)
expect(() => ticksPerCycle(record)).toThrow(InvalidFieldError)
})
test('ticksPerCycle is fpd * 256 / speed, fractional', () => {
const data = buildAnimData([
{ name: 'AAWL1HS', fpd: 8, speed: 168 },
{ name: 'AANUHTH', fpd: 8, speed: 128 },
])
const records = decodeAnimData(data)
expect(ticksPerCycle(records.get('AAWL1HS')!)).toBeCloseTo(12.19, 2)
expect(ticksPerCycle(records.get('AANUHTH')!)).toBe(16)
})
})
describe.skipIf(!hasD2)('decodeAnimData against the real AnimData.D2', () => {
let archives: MountedArchives
let raw: Uint8Array
let file: AnimDataFile
beforeAll(async () => {
archives = new MountedArchives()
for (const name of MOUNTS) {
archives.add(name, await MpqArchive.open(await fileSource(`${ARCHIVE_DIR}/${name}`)))
}
raw = await archives.read(ANIMDATA_MEMBER)
file = decodeAnimDataFile(raw)
})
test('is read through has()/read(), never listFiles()', () => {
expect(archives.has(ANIMDATA_MEMBER)).toBe(true)
expect(archives.has(EANIMDATA_MEMBER)).toBe(true)
})
test('consumes exactly 570,304 bytes with no remainder and no overrun', () => {
expect(raw.length).toBe(ANIMDATA_BYTES)
expect(file.bytesConsumed).toBe(ANIMDATA_BYTES)
expect(file.bytesConsumed).toBe(raw.length)
})
test('holds 3,558 records whose arithmetic reproduces the file size', () => {
expect(file.all).toHaveLength(ANIMDATA_RECORDS)
expect(file.all.length * ANIMDATA_RECORD_SIZE + ANIMDATA_BLOCK_COUNT * 4).toBe(ANIMDATA_BYTES)
expect(file.blockCounts).toHaveLength(ANIMDATA_BLOCK_COUNT)
expect(file.blockCounts.reduce((sum, count) => sum + count, 0)).toBe(ANIMDATA_RECORDS)
})
test('every one of the 3,558 records hashes to the block it is stored in', () => {
let hits = 0
const misses: string[] = []
for (const record of file.all) {
if (animDataHash(record.name) === record.block) hits += 1
else misses.push(`${record.name} in block ${String(record.block)}`)
}
expect(misses).toEqual([])
expect(hits).toBe(ANIMDATA_RECORDS)
})
test('holds 3,529 distinct names with 29 duplicates, 9 of them conflicting', () => {
expect(file.records.size).toBe(ANIMDATA_UNIQUE_NAMES)
expect(file.duplicates).toHaveLength(29)
expect(file.conflicts).toHaveLength(9)
expect(file.conflicts.map(entry => entry.name).sort()).toEqual([
'3DNUHTH', '64A1HTH', '64NUHTH', 'MINUHTH', 'VMA1HTH',
'VMGHHTH', 'VMNUHTH', 'VMS1HTH', 'VMWLHTH',
])
})
test('resolves conflicting duplicates first-wins', () => {
// VMS1HTH is stored as (17, 200) then (17, 160). The engine scans the hash
// block in order and returns the first match, so 200 is the live value.
const conflict = file.conflicts.find(entry => entry.name === 'VMS1HTH')!
expect(conflict.records.map(record => record.animationSpeed)).toEqual([200, 160])
expect(file.records.get('VMS1HTH')!.animationSpeed).toBe(200)
for (const entry of file.conflicts) {
expect(file.records.get(entry.name)).toBe(entry.records[0])
}
})
test.each(GOLDEN)('golden clip $name: $fpd frames at speed $speed', golden => {
const record = file.records.get(golden.name)
expect(record, `${golden.name} missing from AnimData.D2`).toBeDefined()
expect(record!.framesPerDirection).toBe(golden.fpd)
expect(record!.animationSpeed).toBe(golden.speed)
expect(record!.events.map(event => [event.frame, event.code])).toEqual(
golden.events.map(event => [event[0], event[1]]),
)
expect(ticksPerCycle(record!)).toBeCloseTo(golden.ticks, 2)
})
test('the golden table covers all 25 rows of the spec', () => {
expect(GOLDEN).toHaveLength(25)
expect(new Set(GOLDEN.map(golden => golden.name)).size).toBe(25)
})
test('four records store speed 0 and are flagged rather than clamped', () => {
const speedless = file.all.filter(record => !record.hasSpeed)
expect(speedless.map(record => record.name).sort()).toEqual(['CODTHTH', 'R7DDHTH', 'VHNUHTH', 'VJNUHTH'])
for (const record of speedless) {
expect(record.animationSpeed).toBe(0)
expect(() => ticksPerCycle(record)).toThrow(InvalidFieldError)
}
})
test('42DTHTH declares 200 frames against a 144-byte flag array', () => {
expect(file.recordsOverFlagCeiling.map(record => record.name)).toEqual(['42DTHTH'])
const record = file.records.get('42DTHTH')!
expect(record.framesPerDirection).toBe(200)
expect(record.framesPerDirection).toBeGreaterThan(ANIMDATA_FLAG_COUNT)
expect(record.events).toEqual([])
expect(Math.max(...file.all.map(entry => entry.framesPerDirection))).toBe(200)
})
test('no event in the whole file sits at or past its own frame count', () => {
const out: string[] = []
for (const record of file.all) {
for (const event of record.events) {
if (event.frame >= record.framesPerDirection) out.push(`${record.name}@${String(event.frame)}`)
if (event.frame >= ANIMDATA_FLAG_COUNT) out.push(`${record.name} beyond ceiling`)
}
}
expect(out).toEqual([])
expect(file.strayFlags).toEqual([])
})
test('events are ascending by frame within every record', () => {
for (const record of file.all) {
const frames = record.events.map(event => event.frame)
expect(frames).toEqual([...frames].sort((a, b) => a - b))
}
})
test('event code 3 ships in four records and is preserved verbatim', () => {
const histogram = new Map<number, number>()
for (const record of file.all) {
for (const event of record.events) histogram.set(event.code, (histogram.get(event.code) ?? 0) + 1)
}
expect([...histogram].sort((a, b) => a[0] - b[0])).toEqual([[1, 433], [2, 148], [3, 4]])
const withCode3 = file.all.filter(record => record.events.some(event => event.code === 3))
expect(withCode3.map(record => record.name).sort()).toEqual(['10A1HTH', '10A2HTH', '13A1HTH', 'TES1HTH'])
expect(file.records.get('10A1HTH')!.events).toEqual([{ frame: 14, code: 3 }, { frame: 17, code: 1 }])
expect(file.records.get('10A2HTH')!.events).toEqual([{ frame: 16, code: 3 }, { frame: 18, code: 1 }])
expect(file.records.get('13A1HTH')!.events).toEqual([{ frame: 14, code: 3 }, { frame: 17, code: 1 }])
expect(file.records.get('TES1HTH')!.events).toEqual([{ frame: 3, code: 3 }])
})
test('decodeAnimData returns the same first-wins map as decodeAnimDataFile', () => {
const map = decodeAnimData(raw)
expect(map.size).toBe(file.records.size)
expect(map.get('SOA1HTH')).toEqual(file.records.get('SOA1HTH'))
expect(map.get('VMS1HTH')!.animationSpeed).toBe(200)
})
test('truncating or extending the real file makes the decoder throw', () => {
expect(() => decodeAnimDataFile(raw.subarray(0, raw.length - 1))).toThrow(FormatError)
expect(() => decodeAnimDataFile(raw.subarray(0, Math.floor(raw.length / 2)))).toThrow(TruncatedDataError)
expect(() => decodeAnimDataFile(raw.subarray(0, 1024))).toThrow(TruncatedDataError)
const extended = new Uint8Array(raw.length + 4)
extended.set(raw)
let caught: unknown
try {
decodeAnimDataFile(extended)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(InvalidFieldError)
expect((caught as InvalidFieldError).field).toBe('bytes consumed')
})
test('corrupting one name byte of the real file is caught by the hash check', () => {
const tampered = raw.slice()
const view = new DataView(tampered.buffer)
// Walk the interleaved blocks to the first record actually stored.
let offset = 0
let found = -1
for (let block = 0; block < ANIMDATA_BLOCK_COUNT; block += 1) {
const count = view.getUint32(offset, true)
offset += 4
if (count > 0) {
found = offset
break
}
}
expect(found).toBeGreaterThan(0)
const original = tampered[found]!
// Any change to a name character moves the character-code sum, and the sum
// is the block index, so the record no longer belongs where it is stored.
tampered[found] = original === 0x41 ? 0x42 : 0x41
let caught: unknown
try {
decodeAnimDataFile(tampered)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(InvalidFieldError)
expect((caught as InvalidFieldError).field.startsWith('hash of ')).toBe(true)
})
test('EAnimData.D2 is a value-identical subset, so merge precedence is moot', async () => {
const expansionRaw = await archives.read(EANIMDATA_MEMBER)
expect(expansionRaw.length).toBe(565_984)
const expansion = decodeAnimDataFile(expansionRaw)
expect(expansion.bytesConsumed).toBe(expansionRaw.length)
expect(expansion.all).toHaveLength(3531)
const collisions: string[] = []
const disagreeing: string[] = []
for (const [name, record] of expansion.records) {
const base = file.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)
}
}
const onlyInExpansion = [...expansion.records.keys()].filter(name => !file.records.has(name))
const onlyInBase = [...file.records.keys()].filter(name => !expansion.records.has(name))
expect(collisions).toHaveLength(3520)
expect(disagreeing).toEqual([])
expect(onlyInExpansion).toEqual([])
expect(onlyInBase.sort()).toEqual([
'0JDDHTH', '41DDHTH', '44WLHTH', 'K9A2HTH', 'K9BLHTH',
'K9S2HTH', 'K9S3HTH', 'K9S4HTH', 'K9SCHTH',
])
})
})
describe.skipIf(!hasD2)('plr-mode constants match the shipped excel tables', () => {
let archives: MountedArchives
beforeAll(async () => {
archives = new MountedArchives()
for (const name of MOUNTS) {
archives.add(name, await MpqArchive.open(await fileSource(`${ARCHIVE_DIR}/${name}`)))
}
})
/**
* Read one excel table by explicit name.
*
* @param name - table stem, e.g. `PlrMode`.
* @returns the parsed table.
*/
async function readExcel(name: string) {
const member = `data\\global\\excel\\${name}.txt`
expect(archives.has(member), `${member} missing`).toBe(true)
return parseTable(await archives.read(member))
}
test('PlrMode.txt: 20 rows, code and token per row, SQ and KB borrow GH', async () => {
const table = await readExcel('PlrMode')
expect(table.rows).toHaveLength(PLR_MODES.length)
table.rows.forEach((row, index) => {
const baked = PLR_MODES[index]!
expect(cell(table, row, 'Code')).toBe(baked.code)
expect(cell(table, row, 'Token')).toBe(baked.token)
expect(cell(table, row, 'Name')).toBe(baked.name)
})
expect(plrModeToken('SQ')).toBe('GH')
expect(plrModeToken('KB')).toBe('GH')
expect(plrModeToken('a1')).toBe('A1')
expect(plrModeToken('ZZ')).toBeUndefined()
})
test('MonMode.txt: 16 rows, KB borrows GH and the sequence row is xx', async () => {
const table = await readExcel('MonMode')
expect(table.rows).toHaveLength(MON_MODES.length)
table.rows.forEach((row, index) => {
const baked = MON_MODES[index]!
expect(cell(table, row, 'code').toUpperCase()).toBe(baked.code)
expect(cell(table, row, 'token').toUpperCase()).toBe(baked.token)
expect(cell(table, row, 'name')).toBe(baked.name)
})
expect(monModeToken('KB')).toBe('GH')
expect(monModeToken('XX')).toBe('XX')
expect(monModeToken('TN')).toBeUndefined()
})
test('Composit.txt: 16 rows in composite-type order', async () => {
const table = await readExcel('Composit')
expect(table.rows).toHaveLength(COMPOSITS.length)
table.rows.forEach((row, index) => {
const baked = COMPOSITS[index]!
expect(cell(table, row, 'Token')).toBe(baked.token)
expect(cell(table, row, 'Name')).toBe(baked.name)
})
})
test('WeaponClass.txt: 15 rows, and only 14 carry a COF suffix', async () => {
const table = await readExcel('WeaponClass')
expect(table.rows).toHaveLength(WEAPON_CLASSES.length)
table.rows.forEach((row, index) => {
const baked = WEAPON_CLASSES[index]!
expect(cell(table, row, 'Code')).toBe(baked.code)
expect(cell(table, row, 'Weapon Class')).toBe(baked.name)
})
expect(WEAPON_CLASS_CODES).toHaveLength(14)
expect(WEAPON_CLASS_CODES).not.toContain('')
})
test('mode tokens and weapon classes compose names that exist in AnimData.D2', async () => {
const file = decodeAnimDataFile(await archives.read(ANIMDATA_MEMBER))
// Sorceress: every in-scope player mode has an hth clip.
for (const mode of ['NU', 'WL', 'RN', 'A1', 'A2', 'SC', 'GH', 'DT', 'DD', 'TN', 'TW']) {
expect(file.records.has(cofName('so', mode, 'hth')), `SO${mode}HTH missing`).toBe(true)
}
// SQ/KB are not animations of their own: they resolve to GH.
expect(file.records.has('SOSQHTH')).toBe(false)
expect(file.records.has('SOKBHTH')).toBe(false)
expect(file.records.has(cofName('so', plrModeToken('SQ')!, 'hth'))).toBe(true)
})
})