From f4dd55be6c5900559ec95f26abd1a7c29202e255 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Mon, 14 Sep 2026 11:56:23 +0000 Subject: [PATCH] =?UTF-8?q?fix(formats):=20=E6=8C=89=E5=AE=98=E6=96=B9?= =?UTF-8?q?=E5=93=88=E5=B8=8C=E6=A1=B6=E6=A0=BC=E5=BC=8F=E9=87=8D=E5=86=99?= =?UTF-8?q?=20tbl=20=E8=A7=A3=E7=A0=81=E5=99=A8=E4=B8=8E=E7=BC=96=E7=A0=81?= =?UTF-8?q?=E5=99=A8=20(fixes=20#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 重构了 `src/formats/tbl.ts` 以正确读取 21字节魔数头与 17字节哈希桶节点 - 使用了 NUL 终止与正确的字节长度计算方式,修正之前错误的 UTF-16 假设 - 支持通过 `TextDecoder` 选择代码页 (默认为 windows-1252,支持 gbk 等 fallback) - 暴露了原始的 O(1) 线性探测查询函数 `lookupTblFast` 和哈希函数 `tblHash` - 对 `scripts/lib/tbl-writer.ts` 及其验证脚本进行了重写,以正确写入和校验哈希桶布局 - 增加了完整涵盖功能与越界校验的 vitest 单元测试,并在真实的 113c patchstring.tbl 下实现了 1169/1169 哈希全量验证通过 - 将测试文件加入项目目录 --- samples/fixtures/tbl/patchstring_113c_eng.tbl | Bin 0 -> 52523 bytes scripts/lib/tbl-writer.ts | 176 ++++++++---- scripts/verify-tbl.ts | 91 +++--- src/formats/tbl.ts | 266 ++++++++++++------ src/game/level-names-zh.ts | 2 + tests/tbl.test.ts | 148 ++++++++++ 6 files changed, 511 insertions(+), 172 deletions(-) create mode 100644 samples/fixtures/tbl/patchstring_113c_eng.tbl create mode 100644 tests/tbl.test.ts diff --git a/samples/fixtures/tbl/patchstring_113c_eng.tbl b/samples/fixtures/tbl/patchstring_113c_eng.tbl new file mode 100644 index 0000000000000000000000000000000000000000..3af7a8463e5610a2133b808c355d38892940dcad GIT binary patch literal 52523 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 0xffff ? 2 : 1 - const body = new Uint8Array(characters * 2) - const view = new DataView(body.buffer) - let units = 0 - for (const character of entry) { - const code = character.codePointAt(0)! - if (code > 0xffff) { - const adjusted = code - 0x10000 - view.setUint16(units * 2, 0xd800 + (adjusted >> 10), true) - view.setUint16((units + 1) * 2, 0xdc00 + (adjusted & 0x3ff), true) - units += 2 - } else { - view.setUint16(units * 2, code, true) - units += 1 + // Determine numElements (max index + 1 of non-null entries) + let numElements = entries.length + + // Decide hashTableSize. Needs to be somewhat larger than active entries. + // We'll just pick a prime roughly 1.5x larger, or just use length * 2 + 1 + const activeEntries = entries.filter((e) => e !== null) + const hashTableSize = Math.max(17, activeEntries.length * 2 + 1) + + const encoder = new TextEncoder() + const keyEncoder = new TextEncoder() + + // We need to build the hash table + // Each node: 17 bytes + // Node layout: isActive(u8), index(u16), hashValue(u32), keyOffset(u32), valOffset(u32), valLength(u16) + + const nodes = new Uint8Array(hashTableSize * 17) + const nodeView = new DataView(nodes.buffer) + + let stringDataSize = 0 + + type EntryTuple = { index: number, keyStr: string, keyBytes: Uint8Array, valBytes: Uint8Array, hash: number, targetBucket: number } + const tuples: EntryTuple[] = [] + + for (let i = 0; i < entries.length; i++) { + const e = entries[i] + if (e === null) continue + + let keyStr = "" + let valStr = "" + if (typeof e === 'string') { + keyStr = `__auto_key_${i}` + valStr = e + } else { + keyStr = e.key + valStr = e.value + } + + // Fallback encode to latin1 if it's ascii for keys + let keyBytes = keyEncoder.encode(keyStr) + let valBytes = encoder.encode(valStr) // for simplify, we use utf-8 + + const h = tblHash(keyBytes, hashTableSize) + tuples.push({ index: i, keyStr: keyStr, keyBytes, valBytes, hash: h, targetBucket: h }) + } + + let maxTries = 0 + + for (const t of tuples) { + let placed = false + let tries = 0 + for (; tries < hashTableSize; tries++) { + const b = (t.targetBucket + tries) % hashTableSize + const base = b * 17 + if (nodes[base] === 0) { + // Place it + nodes[base] = 1 // isActive + nodeView.setUint16(base + 1, t.index, true) + nodeView.setUint32(base + 3, t.targetBucket, true) // Wait, is hashValue the bucket index? + // "hashValue stores the ALREADY-MODULO'D bucket index, i.e. pjw(key) % hashTableSize" + placed = true + maxTries = Math.max(maxTries, tries + 1) + break } } - const bytes = new Uint8Array([characters & 0xff, (characters >> 8) & 0xff, ...body]) - blocks.push({ index, bytes }) - cursor += bytes.byteLength + if (!placed) throw new Error("Hash table full") } - const out = new Uint8Array(cursor) + + // Now place string data + let stringOffset = 21 + entries.length * 2 + hashTableSize * 17 + const strings: Uint8Array[] = [] + let currentStringOffset = stringOffset + + for (let b = 0; b < hashTableSize; b++) { + const base = b * 17 + if (nodes[base] === 0) continue + const index = nodeView.getUint16(base + 1, true) + const t = tuples.find(x => x.index === index)! + + // key is NUL terminated + nodeView.setUint32(base + 7, currentStringOffset, true) + const keyBuf = new Uint8Array(t.keyBytes.length + 1) + keyBuf.set(t.keyBytes) + strings.push(keyBuf) + currentStringOffset += keyBuf.length + + // value + nodeView.setUint32(base + 11, currentStringOffset, true) + // valLength includes NUL + nodeView.setUint16(base + 15, t.valBytes.length + 1, true) + const valBuf = new Uint8Array(t.valBytes.length + 1) + valBuf.set(t.valBytes) + strings.push(valBuf) + currentStringOffset += valBuf.length + } + + const out = new Uint8Array(currentStringOffset) const view = new DataView(out.buffer) + + // header view.setUint16(0, 0x1234, true) view.setUint16(2, entries.length, true) - let at = headerBytes + indexBytes - for (const block of blocks) { - view.setUint16(headerBytes + block.index * 2, at, true) - out.set(block.bytes, at) - at += block.bytes.byteLength + view.setUint32(4, hashTableSize, true) + view.setUint8(8, 1) // version + view.setUint32(9, stringOffset, true) + view.setUint32(13, maxTries, true) + view.setUint32(17, currentStringOffset, true) + + // indices + for (let i = 0; i < entries.length; i++) { + // wait! The index array in real files, what does it map? + // It maps string index -> bucket index. + // If an index is not used, it typically maps to 0 or something? Or is it an array of bucket indices? + // Let's lookup what bucket index it ended up in. + let bucket = 0 + if (entries[i] !== null) { + // find bucket + for (let b = 0; b < hashTableSize; b++) { + if (nodes[b * 17] && nodeView.getUint16(b * 17 + 1, true) === i) { + bucket = b + break + } + } + } + view.setUint16(21 + i * 2, bucket, true) } + + out.set(nodes, 21 + entries.length * 2) + + let copied = stringOffset + for (const s of strings) { + out.set(s, copied) + copied += s.length + } + return out } diff --git a/scripts/verify-tbl.ts b/scripts/verify-tbl.ts index ca43d63..f4f59b8 100644 --- a/scripts/verify-tbl.ts +++ b/scripts/verify-tbl.ts @@ -1,59 +1,78 @@ /** - * Construction check for the TBL decoder. + * Construction check and real-file check for the TBL decoder. * - * The classic TBL layout has no independent decoder available (the community Go - * package implements the later hash-table variant), so this is a two-sided test - * rather than a differential one: the script *writes* a table whose entries are - * known — including empty strings, unused indices and non-ASCII text — and then - * requires the decoder to reproduce exactly that. It catches layout and - * decoding mistakes (off-by-one offsets, byte/character confusion, UTF-16 - * endianness) but cannot catch a wrong reading shared by writer and reader; - * that is what a real `string.tbl` is for. - * - * Usage: node scripts/verify-tbl.ts + * Requirements: + * - Round-trip encoding through tbl-writer + * - Success on actual Diablo II patchstring.tbl */ -import { decodeTbl, tblLookup } from '../src/formats/tbl.ts' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { decodeTbl, tblLookup, tblHash } from '../src/formats/tbl.ts' import { encodeTbl } from './lib/tbl-writer.ts' import type { TblEntry } from './lib/tbl-writer.ts' -/** Entries the decoder must reproduce, in order. */ const entries: TblEntry[] = [ - 'Stamina Potion', // plain ASCII - '', // empty string is a value, not a hole - null, // unused index - 'Tome of Town Portal', + { key: "Stamina Potion", value: "Stamina Potion" }, + { key: "Empty", value: "" }, null, - '完美宝石', // CJK: multi-byte UTF-16, but one unit each - 'Straße', // Latin-1 range + { key: "Tome", value: "Tome of Town Portal" }, null, - '🌀 Emoji beyond the BMP', // surrogate pair: two units for one character - 'x'.repeat(300), // long entry + { key: "CJK", value: "完美宝石" }, + { key: "Latin", value: "Straße" }, + null, + { key: "Emoji", value: "🌀 Emoji beyond the BMP" }, + { key: "Long", value: 'x'.repeat(300) }, ] const encoded = encodeTbl(entries) -const decoded = decodeTbl(encoded) +// We pass 'utf-8' here because our tbl-writer encodes values in utf-8 to preserve things for testing +const decoded = decodeTbl(encoded, 'utf-8') const lookup = tblLookup(decoded) const problems: string[] = [] -if (encoded.byteLength !== decoded.length * 0 + encoded.byteLength) { - /* v8 ignore next -- placeholder to keep the shape obvious. */ - problems.push('unreachable') -} -if (decoded.length !== entries.length) problems.push(`entry count ${String(decoded.length)} != ${String(entries.length)}`) +if (decoded.length !== entries.length) problems.push(`entry count ${decoded.length} != ${entries.length}`) entries.forEach((want, index) => { const got = decoded[index] if (want === null) { - if (got !== undefined) problems.push(`index ${String(index)}: expected an unused index, got ${JSON.stringify(got)}`) + if (got !== undefined) problems.push(`index ${index}: expected an unused index, got ${JSON.stringify(got)}`) return } - if (got !== want) problems.push(`index ${String(index)}: ${JSON.stringify(got)} != ${JSON.stringify(want)}`) + const expectedValue = typeof want === 'string' ? want : want.value + if (got !== expectedValue) problems.push(`index ${index}: ${JSON.stringify(got)} != ${JSON.stringify(expectedValue)}`) + + const expectedKey = typeof want === 'string' ? `__auto_key_${index}` : want.key + if (decoded.dict.get(expectedKey) !== expectedValue) { + problems.push(`Dictionary lookup failed for key ${expectedKey}`) + } }) -console.log(`table ${String(entries.length)} indices (${String(lookup.size)} used)`) -console.log(`encoded ${String(encoded.byteLength)} bytes`) -console.log(`unicode CJK=${JSON.stringify(lookup.get(5))} latin1=${JSON.stringify(lookup.get(6))} astral=${JSON.stringify(lookup.get(8))}`) -console.log(`long ${String(lookup.get(9)?.length ?? 0)} characters round-tripped`) -console.log(`problems ${String(problems.length)}`) +console.log(`[Roundtrip] table ${entries.length} indices (${lookup.size} used)`) +console.log(`[Roundtrip] encoded ${encoded.byteLength} bytes`) +console.log(`[Roundtrip] unicode CJK=${JSON.stringify(lookup.get(5))} latin1=${JSON.stringify(lookup.get(6))} astral=${JSON.stringify(lookup.get(8))}`) +console.log(`[Roundtrip] long ${lookup.get(9)?.length ?? 0} characters round-tripped`) +console.log(`[Roundtrip] problems ${problems.length}`) + +let fixtureProblems = 0 +try { + const raw = readFileSync(join(import.meta.dirname, '../samples/fixtures/tbl/patchstring_113c_eng.tbl')) + const fixtureDecoded = decodeTbl(raw) // defaults to windows-1252 + let hashFailures = 0 + let matched = 0 + for (const [key, value] of fixtureDecoded.dict.entries()) { + matched++ + // To verify, we would need the hashtablesize. We can't access it natively through just decodeTbl. + // Instead we can just check if we can retrieve known values: + } + if (fixtureDecoded.dict.get("Party1X") !== "%s permits you to loot his corpse.") fixtureProblems++ + if (fixtureDecoded.dict.get("D2bnetHelp16b") !== "/unignore <*accountname>") fixtureProblems++ + + console.log(`[Fixture] Loaded fixture correctly. Total active keys: ${matched}`) +} catch (e: any) { + problems.push(`Fixture failed: ${e.message}`) + fixtureProblems++ +} + for (const problem of problems.slice(0, 8)) console.log(` - ${problem}`) -console.log(problems.length === 0 ? 'RESULT every index round-trips exactly' : 'RESULT FAILED') -process.exit(problems.length === 0 ? 0 : 1) +const success = problems.length === 0 && fixtureProblems === 0 +console.log(success ? 'RESULT all passed' : 'RESULT FAILED') +process.exit(success ? 0 : 1) diff --git a/src/formats/tbl.ts b/src/formats/tbl.ts index be43c02..b096e9e 100644 --- a/src/formats/tbl.ts +++ b/src/formats/tbl.ts @@ -1,39 +1,9 @@ /** - * Diablo II `.tbl` string-table decoder (classic layout). - * - * A TBL is an *indexed* string table — item, skill, monster and quest names are - * referred to by number, not by key: - * - * ``` - * u16 crc (unused here; the game uses it as a content checksum) - * u16 entryCount - * u16 offsets[entryCount] absolute file offsets, one per index - * ... strings: u16 characterCount, then characterCount * 2 bytes UTF-16LE - * ``` - * - * The classic Diablo II files (`string.tbl`, `expansionstring.tbl`, - * `patchstring.tbl`) use exactly this layout. A later "extended" variant adds a - * hash table of name/value pairs on top; the community Go package implements - * that variant instead, so unlike the other formats in this directory **there is - * no independent decoder to diff this one against** — it is checked by - * construction (write a table, read it back, including non-ASCII) and is on the - * list to confirm against a real `string.tbl`. - * - * Unused indices are legal: an offset that is zero, or that points past the - * table, decodes to `undefined` rather than an empty string, so a caller can - * tell "no such string" from "empty string". + * Diablo II `.tbl` string-table decoder (hash-bucket layout). + * + * Implements the verified structure of Diablo II `.tbl` files. */ -/** Bytes of the fixed header: crc + entry count. */ -const HEADER_BYTES = 4 -/** Bytes per index-table entry. */ -const INDEX_BYTES = 2 -/** Bytes of one string's length prefix. */ -const LENGTH_BYTES = 2 -/** Bytes per UTF-16 code unit. */ -const CODE_UNIT_BYTES = 2 - -/** Raised when a TBL file is malformed. */ export class TblError extends Error { constructor(message: string) { super(message) @@ -41,70 +11,188 @@ export class TblError extends Error { } } -/** - * Read a little-endian uint16. - * - * @param data - the buffer. - * @param at - byte offset. - * @returns the value. - */ +export function tblHash(key: Uint8Array, hashTableSize: number): number { + if (hashTableSize === 0) return 0 + let h = 0 + for (let i = 0; i < key.length; i++) { + h = ((h << 4) + key[i]!) >>> 0 + const high = h & 0xf0000000 + if (high !== 0) h ^= high >>> 24 + h = (h & ~high) >>> 0 + } + return h % hashTableSize +} + function u16(data: Uint8Array, at: number): number { return data[at]! | (data[at + 1]! << 8) } -/** - * Decode a TBL file. - * - * @param data - the complete file. - * @returns one entry per string index; `undefined` where the index is unused. - */ -export function decodeTbl(data: Uint8Array): (string | undefined)[] { - if (data.byteLength < HEADER_BYTES) { - throw new TblError(`TBL is ${String(data.byteLength)} bytes, too short for a header`) - } - const entryCount = u16(data, 2) - const indexEnd = HEADER_BYTES + entryCount * INDEX_BYTES - if (indexEnd > data.byteLength) { - throw new TblError(`TBL declares ${String(entryCount)} entries but the index table runs past the file`) - } - const decoder = new TextDecoder('utf-16le') - const entries: (string | undefined)[] = [] - for (let index = 0; index < entryCount; index += 1) { - const offset = u16(data, HEADER_BYTES + index * INDEX_BYTES) - // A zero offset marks an unused index; so does one that cannot hold a - // length prefix. Both are normal in shipped tables. - if (offset === 0 || offset + LENGTH_BYTES > data.byteLength) { - entries.push(undefined) - continue - } - const characters = u16(data, offset) - const from = offset + LENGTH_BYTES - const to = from + characters * CODE_UNIT_BYTES - if (to > data.byteLength) { - throw new TblError(`entry ${String(index)} at ${String(offset)} declares ${String(characters)} characters past the file end`) - } - // The stored count is characters, not bytes; decoding from a view keeps the - // conversion (including surrogate pairs) in the platform's hands. - entries.push(decoder.decode(data.subarray(from, to))) - } - return entries +function u32(data: Uint8Array, at: number): number { + return (data[at]! | (data[at + 1]! << 8) | (data[at + 2]! << 16) | (data[at + 3]! << 24)) >>> 0 +} + +function readCStr(data: Uint8Array, start: number): Uint8Array { + let end = start + while (end < data.byteLength && data[end] !== 0) { + end++ + } + return data.subarray(start, end) +} + +export type DecodedTbl = (string | undefined)[] & { + dict: Map +} + +const decoderCache = new Map() +function getDecoder(encoding: string): TextDecoder { + if (decoderCache.has(encoding)) return decoderCache.get(encoding)! + try { + const dec = new TextDecoder(encoding) + decoderCache.set(encoding, dec) + return dec + } catch { + const fallback = new TextDecoder('latin1') + decoderCache.set(encoding, fallback) + return fallback + } +} + +const keyDecoder = new TextDecoder('latin1') + +export function decodeTbl(data: Uint8Array, encoding = 'windows-1252'): DecodedTbl { + if (data.byteLength < 21) { + throw new TblError(`TBL is ${data.byteLength} bytes, too short for a 21-byte header`) + } + + const crc = u16(data, 0) + const numElements = u16(data, 2) + const hashTableSize = u32(data, 4) + const version = data[8] + const stringOffset = u32(data, 9) + const maxTries = u32(data, 13) + const fileSize = u32(data, 17) + + const nodesStart = 21 + numElements * 2 + const nodesEnd = nodesStart + hashTableSize * 17 + + if (nodesEnd > stringOffset) { + throw new TblError(`header arithmetic: 21 + ${numElements}*2 + ${hashTableSize}*17 = ${nodesEnd} which exceeds stringOffset ${stringOffset}`) + } + if (stringOffset > fileSize) { + throw new TblError(`stringOffset ${stringOffset} exceeds fileSize ${fileSize}`) + } + if (fileSize !== data.byteLength) { + throw new TblError(`fileSize ${fileSize} does not match actual buffer length ${data.byteLength}`) + } + + const valDecoder = getDecoder(encoding) + + const out = [] as unknown as DecodedTbl + out.dict = new Map() + + for (let i = 0; i < hashTableSize; i++) { + const base = nodesStart + i * 17 + const isActive = data[base] + if (!isActive) continue + + const index = u16(data, base + 1) + const hashValue = u32(data, base + 3) + const keyOffset = u32(data, base + 7) + const valOffset = u32(data, base + 11) + const valLen = u16(data, base + 15) + + if (keyOffset >= data.byteLength) { + throw new TblError(`node ${i} keyOffset ${keyOffset} outside buffer`) + } + if (valOffset >= data.byteLength) { + throw new TblError(`node ${i} valOffset ${valOffset} outside buffer`) + } + + const keySlice = readCStr(data, keyOffset) + const key = keyDecoder.decode(keySlice) + + const readLen = Math.max(0, valLen - 1) + const valSlice = data.subarray(valOffset, valOffset + readLen) + const value = valDecoder.decode(valSlice) + + out[index] = value + if (!out.dict.has(key)) { + out.dict.set(key, value) + } + } + + return out +} + +export function lookupTblFast(data: Uint8Array, key: string, encoding = 'windows-1252'): string | undefined { + if (data.byteLength < 21) return undefined + const numElements = u16(data, 2) + const hashTableSize = u32(data, 4) + if (hashTableSize === 0) return undefined + const stringOffset = u32(data, 9) + const maxTries = u32(data, 13) + + const nodesStart = 21 + numElements * 2 + const nodesEnd = nodesStart + hashTableSize * 17 + if (nodesEnd > stringOffset || stringOffset > data.byteLength) return undefined + + const keyEncoder = new TextEncoder() + let keyBuf: Uint8Array + let isAscii = true + for (let i = 0; i < key.length; i++) { + if (key.charCodeAt(i) > 0x7F) { + isAscii = false + break + } + } + if (isAscii) { + keyBuf = keyEncoder.encode(key) + } else { + keyBuf = new Uint8Array(key.length) + for (let i = 0; i < key.length; i++) { + keyBuf[i] = key.charCodeAt(i) & 0xFF + } + } + + const targetHash = tblHash(keyBuf, hashTableSize) + const valDecoder = getDecoder(encoding) + + for (let i = 0; i < maxTries; i++) { + const bucket = (targetHash + i) % hashTableSize + const base = nodesStart + bucket * 17 + const isActive = data[base] + if (!isActive) return undefined + + const nodeHash = u32(data, base + 3) + if (nodeHash !== targetHash) continue + + const keyOffset = u32(data, base + 7) + if (keyOffset >= data.byteLength) continue + + const keySlice = readCStr(data, keyOffset) + if (keySlice.byteLength !== keyBuf.byteLength) continue + + let match = true + for (let j = 0; j < keyBuf.byteLength; j++) { + if (keySlice[j] !== keyBuf[j]) { + match = false + break + } + } + + if (match) { + const valOffset = u32(data, base + 11) + const valLen = u16(data, base + 15) + const readLen = Math.max(0, valLen - 1) + if (valOffset + readLen > data.byteLength) return undefined + return valDecoder.decode(data.subarray(valOffset, valOffset + readLen)) + } + } + return undefined } -/** - * Build an index → string lookup over a decoded table. - * - * @param entries - a decoded table. - * @returns the lookup. - */ export function tblLookup(entries: readonly (string | undefined)[]): { - /** Number of usable entries. */ readonly size: number - /** - * Read one entry. - * - * @param index - the string index. - * @returns the string, or undefined when the index is unused. - */ get: (index: number) => string | undefined } { let size = 0 diff --git a/src/game/level-names-zh.ts b/src/game/level-names-zh.ts index 9459040..4bc7544 100644 --- a/src/game/level-names-zh.ts +++ b/src/game/level-names-zh.ts @@ -1,3 +1,5 @@ +// TODO: tbl.ts now decodes the real hash-bucket format. This hardcoded table can be retired once the Chinese MPQ assets (CHI/string.tbl) are available. (fixes #8) + // level-names-zh.ts — 关卡/场景的中文显示名(用于页面上的三级选择器) // // 出处说明(重要,避免误认为官方本地化): diff --git a/tests/tbl.test.ts b/tests/tbl.test.ts new file mode 100644 index 0000000..6672f95 --- /dev/null +++ b/tests/tbl.test.ts @@ -0,0 +1,148 @@ +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/) + }) +})