648 lines
27 KiB
TypeScript
648 lines
27 KiB
TypeScript
/**
|
|
* `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)
|
|
})
|
|
})
|