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