149 lines
5.4 KiB
TypeScript
149 lines
5.4 KiB
TypeScript
import { describe, test, expect } from 'vitest'
|
|
import { readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
import { decodeTbl, TblError, tblHash, lookupTblFast } from '../src/formats/tbl.ts'
|
|
import { encodeTbl } from '../scripts/lib/tbl-writer.ts'
|
|
import type { TblEntry } from '../scripts/lib/tbl-writer.ts'
|
|
|
|
const fixturePath = join(__dirname, '../samples/fixtures/tbl/patchstring_113c_eng.tbl')
|
|
const fixtureData = readFileSync(fixturePath)
|
|
|
|
describe('TBL Decoder', () => {
|
|
test('Header parse and Layout invariant', () => {
|
|
const data = fixtureData
|
|
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
|
const crc = view.getUint16(0, true)
|
|
const numElements = view.getUint16(2, true)
|
|
const hashTableSize = view.getUint32(4, true)
|
|
const version = view.getUint8(8)
|
|
const stringOffset = view.getUint32(9, true)
|
|
const maxTries = view.getUint32(13, true)
|
|
const fileSize = view.getUint32(17, true)
|
|
|
|
expect(crc).toBe(23974)
|
|
expect(numElements).toBe(1169)
|
|
expect(hashTableSize).toBe(1171)
|
|
expect(version).toBe(1)
|
|
expect(stringOffset).toBe(22266)
|
|
expect(maxTries).toBe(1058)
|
|
expect(fileSize).toBe(52523)
|
|
|
|
expect(21 + numElements * 2 + hashTableSize * 17).toBe(stringOffset)
|
|
expect(fileSize).toBe(data.byteLength)
|
|
})
|
|
|
|
test('Entry count and dictionary', () => {
|
|
const decoded = decodeTbl(fixtureData)
|
|
let count = 0; decoded.forEach(v => { if (v !== undefined) count++ }); expect(count).toBe(1169)
|
|
})
|
|
|
|
test('Known strings', () => {
|
|
const decoded = decodeTbl(fixtureData)
|
|
expect(decoded.dict.get('Party1X')).toBe('%s permits you to loot his corpse.')
|
|
expect(decoded.dict.get('D2bnetHelp16b')).toBe('/unignore <*accountname>')
|
|
expect(decoded.dict.get('skillxld66')).toBe(' ')
|
|
})
|
|
|
|
test('Fast lookup matches full decode', () => {
|
|
expect(lookupTblFast(fixtureData, 'Party1X')).toBe('%s permits you to loot his corpse.')
|
|
expect(lookupTblFast(fixtureData, 'D2bnetHelp16b')).toBe('/unignore <*accountname>')
|
|
expect(lookupTblFast(fixtureData, 'skillxld66')).toBe(' ')
|
|
})
|
|
|
|
test('Hash self-check', () => {
|
|
const data = fixtureData
|
|
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
|
|
const numElements = view.getUint16(2, true)
|
|
const hashTableSize = view.getUint32(4, true)
|
|
|
|
const nodesStart = 21 + numElements * 2
|
|
let checkedCount = 0
|
|
|
|
for (let i = 0; i < hashTableSize; i++) {
|
|
const base = nodesStart + i * 17
|
|
const isActive = view.getUint8(base)
|
|
if (!isActive) continue
|
|
|
|
const hashValue = view.getUint32(base + 3, true)
|
|
const keyOffset = view.getUint32(base + 7, true)
|
|
|
|
let end = keyOffset
|
|
while (data[end] !== 0) end++
|
|
const keyBuf = data.subarray(keyOffset, end)
|
|
|
|
const computed = tblHash(keyBuf, hashTableSize)
|
|
expect(computed).toBe(hashValue)
|
|
|
|
checkedCount++
|
|
}
|
|
|
|
expect(checkedCount).toBe(1169)
|
|
})
|
|
|
|
test('Round-trip encoding', () => {
|
|
const entries: TblEntry[] = [
|
|
{ key: "Stamina Potion", value: "Stamina Potion" },
|
|
{ key: "Empty", value: "" },
|
|
null,
|
|
{ key: "Tome", value: "Tome of Town Portal" },
|
|
null,
|
|
{ key: "CJK", value: "完美宝石" },
|
|
{ key: "Latin", value: "Straße" },
|
|
null,
|
|
{ key: "Emoji", value: "🌀 Emoji beyond the BMP" },
|
|
{ key: "Long", value: 'x'.repeat(300) },
|
|
]
|
|
|
|
// Add enough entities to force collisions
|
|
for (let i = 0; i < 200; i++) {
|
|
entries.push({ key: `CollC${i}`, value: `ValC${i}` })
|
|
}
|
|
|
|
// Duplicate-ish keys differing by case
|
|
entries.push({ key: "Apple", value: "Uppercase" })
|
|
entries.push({ key: "apple", value: "Lowercase" })
|
|
|
|
const encoded = encodeTbl(entries)
|
|
const decoded = decodeTbl(encoded, 'utf-8')
|
|
|
|
expect(decoded.dict.get("Empty")).toBe("")
|
|
expect(decoded.dict.get("Latin")).toBe("Straße")
|
|
expect(decoded.dict.get("CJK")).toBe("完美宝石")
|
|
expect(decoded.dict.get("Apple")).toBe("Uppercase")
|
|
expect(decoded.dict.get("apple")).toBe("Lowercase")
|
|
expect(decoded.dict.get("CollC199")).toBe("ValC199")
|
|
|
|
// Also test fast lookup
|
|
expect(lookupTblFast(encoded, "Latin", "utf-8")).toBe("Straße")
|
|
})
|
|
|
|
test('Malformed input: truncated buffer', () => {
|
|
const shortBuf = new Uint8Array(20)
|
|
expect(() => decodeTbl(shortBuf)).toThrowError(TblError)
|
|
expect(() => decodeTbl(shortBuf)).toThrowError(/too short/)
|
|
})
|
|
|
|
test('Malformed input: stringOffset past EOF', () => {
|
|
const buf = new Uint8Array(fixtureData)
|
|
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
|
|
// Make stringOffset larger than file length
|
|
view.setUint32(9, buf.length + 10, true)
|
|
|
|
expect(() => decodeTbl(buf)).toThrowError(TblError)
|
|
expect(() => decodeTbl(buf)).toThrowError(/stringOffset/)
|
|
})
|
|
|
|
test('Malformed input: node offset out of range', () => {
|
|
const buf = new Uint8Array(fixtureData)
|
|
const view = new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
|
|
|
|
// Get the first active node and corrupt its key offset
|
|
const numElements = view.getUint16(2, true)
|
|
const nodesStart = 21 + numElements * 2
|
|
view.setUint32(nodesStart + 7, buf.length + 5, true) // key offset is at +7
|
|
|
|
expect(() => decodeTbl(buf)).toThrowError(TblError)
|
|
expect(() => decodeTbl(buf)).toThrowError(/keyOffset/)
|
|
})
|
|
})
|