diablo2-web/tests/animdata.test.ts

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)
})
})