391 lines
16 KiB
TypeScript
391 lines
16 KiB
TypeScript
/**
|
|
* 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
|
|
}
|