208 lines
6.0 KiB
TypeScript
208 lines
6.0 KiB
TypeScript
import { requireBytes } from './reader'
|
|
/**
|
|
* Diablo II `.tbl` string-table decoder (hash-bucket layout).
|
|
*
|
|
* Implements the verified structure of Diablo II `.tbl` files.
|
|
*/
|
|
|
|
export class TblError extends Error {
|
|
constructor(message: string) {
|
|
super(message)
|
|
this.name = 'TblError'
|
|
}
|
|
}
|
|
|
|
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 {
|
|
requireBytes(data, at, 2, 'tbl', 'u16')
|
|
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint16(at, true)
|
|
}
|
|
|
|
function u32(data: Uint8Array, at: number): number {
|
|
requireBytes(data, at, 4, 'tbl', 'u32')
|
|
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(at, true) >>> 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<string, string>
|
|
}
|
|
|
|
const decoderCache = new Map<string, TextDecoder>()
|
|
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<string, string>()
|
|
|
|
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
|
|
}
|
|
|
|
export function tblLookup(entries: readonly (string | undefined)[]): {
|
|
readonly size: number
|
|
get: (index: number) => string | undefined
|
|
} {
|
|
let size = 0
|
|
for (const entry of entries) if (entry !== undefined) size += 1
|
|
return {
|
|
size,
|
|
get: index => (index >= 0 && index < entries.length ? entries[index] : undefined),
|
|
}
|
|
}
|