327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
/**
|
|
* PKWARE Data Compression Library "implode" decoder.
|
|
*
|
|
* This is the codec behind `MPQ_COMPRESSION_PKWARE` (`mask 0x08`) — and behind
|
|
* every member flagged `MPQ_FILE_IMPLODE` without `MPQ_FILE_COMPRESS`, which is
|
|
* how Diablo II's `Patch_D2.mpq` stores its tables. Without it, real Diablo II
|
|
* archives are unreadable: the container opens and the block table enumerates,
|
|
* but every payload stays compressed.
|
|
*
|
|
* Semantics follow StormLib's `src/pklib/explode.c` (MIT, Copyright (c)
|
|
* Ladislav Zezula); the constant tables were machine-transcribed rather than
|
|
* retyped (see `implode-tables.ts`), so a wrong nibble cannot hide in a table.
|
|
*
|
|
* The stream is bit-oriented: a 16-bit window refills a byte at a time, and each
|
|
* symbol is either a literal (8 raw bits, or a code from the fixed literal
|
|
* alphabet in "ASCII" mode) or a back-reference. Diablo II uses both modes, so
|
|
* both are implemented.
|
|
*
|
|
* Two deliberate departures from the reference:
|
|
*
|
|
* 1. **A back-reference reaching before the start of the output is an error,
|
|
* not a read of whatever the sliding window held.** The reference keeps a
|
|
* 0x2000-byte window and trusts the encoder to stay in range; here the
|
|
* history *is* the output, so an out-of-range distance is provably corrupt
|
|
* input.
|
|
* 2. **The output length is checked, never assumed.** The caller states the
|
|
* expected size (the archive knows it from the block table) and a stream
|
|
* that ends short or long throws. Padding with zeros produces a buffer that
|
|
* decodes into plausible-looking nonsense — the exact failure this project
|
|
* refuses to ship.
|
|
*/
|
|
import {
|
|
CHBITSASC, CHCODEASC, DISTBITS, DISTCODE, EXLENBITS, LENBASE, LENBITS, LENCODE,
|
|
} from './implode-tables.ts'
|
|
|
|
/** Compression type byte: literals are raw bytes. */
|
|
export const CMP_BINARY = 0
|
|
/** Compression type byte: literals come from the fixed ASCII alphabet. */
|
|
export const CMP_ASCII = 1
|
|
|
|
/** `DecodeLit` result meaning "end of stream". */
|
|
const LIT_END_OF_STREAM = 0x305
|
|
/** `DecodeLit` result meaning "malformed stream". */
|
|
const LIT_ERROR = 0x306
|
|
|
|
/** Raised when an implode stream cannot be decoded. */
|
|
export class ImplodeError extends Error {
|
|
constructor(message: string) {
|
|
super(message)
|
|
this.name = 'ImplodeError'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the "positions" table the decoder probes with the next 8 bits.
|
|
*
|
|
* Each code owns a span of `1 << bits` slots starting at its start index, so the
|
|
* table answers "which code is this prefix" in one indexed read.
|
|
*
|
|
* @param startIndexes - first slot of each code.
|
|
* @param lengthBits - code width in bits.
|
|
* @param elements - number of codes.
|
|
* @returns a 0x100-entry position table.
|
|
*/
|
|
function generateDecodeTabs(
|
|
startIndexes: readonly number[],
|
|
lengthBits: readonly number[],
|
|
elements: number,
|
|
): Uint8Array {
|
|
const positions = new Uint8Array(0x100)
|
|
for (let i = 0; i < elements; i += 1) {
|
|
const length = 1 << (lengthBits[i] ?? 0)
|
|
for (let index = (startIndexes[i] ?? 0); index < 0x100; index += length) positions[index] = i
|
|
}
|
|
return positions
|
|
}
|
|
|
|
/** The literal alphabet's derived tables, built once. */
|
|
interface AsciiTables {
|
|
/** Per-character code width, with >8-bit entries rewritten as the reference does. */
|
|
readonly chBits: Uint8Array
|
|
/** First-level literal lookup (8 bits). */
|
|
readonly offs2C34: Uint8Array
|
|
/** Second-level lookup after 4 further bits. */
|
|
readonly offs2D34: Uint8Array
|
|
/** Second-level lookup after 6 further bits. */
|
|
readonly offs2E34: Uint8Array
|
|
/** Lookup used when the next 8 bits are all zero. */
|
|
readonly offs2EB4: Uint8Array
|
|
}
|
|
|
|
/**
|
|
* Derive the literal tables.
|
|
*
|
|
* A literal code wider than 8 bits is split: its low bits index a first-level
|
|
* table that marks the entry as "needs more bits" (`0xff`), and its high bits
|
|
* index a second-level table once 4 or 6 further bits are consumed. The
|
|
* reference rewrites `ChBitsAsc` in place while doing this, which is why the
|
|
* template gets copied here instead of shared.
|
|
*
|
|
* @returns the derived tables.
|
|
*/
|
|
function buildAsciiTables(): AsciiTables {
|
|
const chBits = Uint8Array.from(CHBITSASC)
|
|
const offs2C34 = new Uint8Array(0x100)
|
|
const offs2D34 = new Uint8Array(0x100)
|
|
const offs2E34 = new Uint8Array(0x100)
|
|
const offs2EB4 = new Uint8Array(0x100)
|
|
|
|
for (let count = 0xff; count >= 0; count -= 1) {
|
|
const code = (CHCODEASC[count] ?? 0)
|
|
let bits = (chBits[count] ?? 0)
|
|
if (bits <= 8) {
|
|
for (let acc = code; acc < 0x100; acc += 1 << bits) offs2C34[acc] = count
|
|
continue
|
|
}
|
|
const low = code & 0xff
|
|
if (low === 0) {
|
|
// The whole code sits above the first 8 bits: one second-level table.
|
|
bits -= 8
|
|
chBits[count] = bits
|
|
for (let acc = code >>> 8; acc < 0x100; acc += 1 << bits) offs2EB4[acc] = count
|
|
continue
|
|
}
|
|
offs2C34[low] = 0xff
|
|
if ((code & 0x3f) !== 0) {
|
|
bits -= 4
|
|
chBits[count] = bits
|
|
for (let acc = code >>> 4; acc < 0x100; acc += 1 << bits) offs2D34[acc] = count
|
|
} else {
|
|
bits -= 6
|
|
chBits[count] = bits
|
|
for (let acc = code >>> 6; acc < 0x80; acc += 1 << bits) offs2E34[acc] = count
|
|
}
|
|
}
|
|
return { chBits, offs2C34, offs2D34, offs2E34, offs2EB4 }
|
|
}
|
|
|
|
/** Length-code lookup, shared by every call. */
|
|
const LENGTH_CODES = generateDecodeTabs(LENCODE, LENBITS, 0x10)
|
|
/** Distance-code lookup, shared by every call. */
|
|
const DISTANCE_CODES = generateDecodeTabs(DISTCODE, DISTBITS, 0x40)
|
|
/** Literal alphabet tables, shared by every call. */
|
|
const ASCII = buildAsciiTables()
|
|
|
|
/**
|
|
* Bit-oriented reader over one compressed sector.
|
|
*
|
|
* `buffer` holds the next 8..16 bits, low bits first; `extraBits` counts the
|
|
* buffered bits beyond the low 8.
|
|
*/
|
|
class BitStream {
|
|
/** The compressed bytes. */
|
|
private readonly input: Uint8Array
|
|
/** Compression type byte (0 = binary literals, 1 = ASCII alphabet). */
|
|
readonly ctype: number
|
|
/** Dictionary width in bits (4..6). */
|
|
readonly dsizeBits: number
|
|
/** Position of the next byte to fold into the window. */
|
|
private position = 3
|
|
/** Buffered bits beyond the low 8. */
|
|
private extraBits = 0
|
|
/** The 16-bit window; the format reads past the low 8 bits directly. */
|
|
private buffer: number
|
|
|
|
constructor(input: Uint8Array, ctype: number, dsizeBits: number, initial: number) {
|
|
this.input = input
|
|
this.ctype = ctype
|
|
this.dsizeBits = dsizeBits
|
|
this.buffer = initial
|
|
}
|
|
|
|
/** Width mask for repetition distances. */
|
|
get dsizeMask(): number {
|
|
return 0xffff >>> (0x10 - this.dsizeBits)
|
|
}
|
|
|
|
/** The buffered window; the format reads beyond the low 8 bits directly. */
|
|
peek16(): number {
|
|
return this.buffer
|
|
}
|
|
|
|
/** The next 8 bits, without consuming them. */
|
|
peek8(): number {
|
|
return this.buffer & 0xff
|
|
}
|
|
|
|
/**
|
|
* Consume `bits` bits.
|
|
*
|
|
* @param bits - bit count (never more than 8 in this format).
|
|
* @returns true when the input ran out before the bits could be taken.
|
|
*/
|
|
waste(bits: number): boolean {
|
|
if (bits <= this.extraBits) {
|
|
this.extraBits -= bits
|
|
this.buffer >>>= bits
|
|
return false
|
|
}
|
|
this.buffer >>>= this.extraBits
|
|
if (this.position >= this.input.length) return true
|
|
this.buffer |= (this.input[this.position] ?? 0) << 8
|
|
this.position += 1
|
|
this.buffer >>>= bits - this.extraBits
|
|
const rest = this.extraBits - bits + 8
|
|
if (rest < 0) throw new ImplodeError(`bit window underflow taking ${String(bits)} bits`)
|
|
this.extraBits = rest
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Decode one symbol.
|
|
*
|
|
* @param stream - the bit stream.
|
|
* @returns 0x00..0xff for a literal, `0x100 + (length - 2)` for a repetition,
|
|
* 0x305 at end of stream, 0x306 on a malformed stream.
|
|
*/
|
|
function decodeLit(stream: BitStream): number {
|
|
if ((stream.peek16() & 1) !== 0) {
|
|
if (stream.waste(1)) return LIT_ERROR
|
|
const lengthCode = (LENGTH_CODES[stream.peek8()] ?? 0)
|
|
if (stream.waste((LENBITS[lengthCode] ?? 0))) return LIT_ERROR
|
|
const extraLengthBits = (EXLENBITS[lengthCode] ?? 0)
|
|
if (extraLengthBits === 0) return lengthCode + 0x100
|
|
const extraLength = stream.peek16() & ((1 << extraLengthBits) - 1)
|
|
// A stream that runs dry here is tolerated for exactly one code — the
|
|
// reference's escape hatch for a final repetition, kept for bit parity.
|
|
if (stream.waste(extraLengthBits) && lengthCode + extraLength !== 0x10e) return LIT_ERROR
|
|
return (LENBASE[lengthCode] ?? 0) + extraLength + 0x100
|
|
}
|
|
|
|
if (stream.waste(1)) return LIT_ERROR
|
|
if (stream.ctype === CMP_BINARY) {
|
|
const byte = stream.peek8()
|
|
return stream.waste(8) ? LIT_ERROR : byte
|
|
}
|
|
|
|
let value: number
|
|
if (stream.peek8() !== 0) {
|
|
value = (ASCII.offs2C34[stream.peek8()] ?? 0)
|
|
if (value === 0xff) {
|
|
if ((stream.peek16() & 0x3f) !== 0) {
|
|
if (stream.waste(4)) return LIT_ERROR
|
|
value = (ASCII.offs2D34[stream.peek8()] ?? 0)
|
|
} else {
|
|
if (stream.waste(6)) return LIT_ERROR
|
|
value = (ASCII.offs2E34[stream.peek16() & 0x7f] ?? 0)
|
|
}
|
|
}
|
|
} else {
|
|
if (stream.waste(8)) return LIT_ERROR
|
|
value = (ASCII.offs2EB4[stream.peek8()] ?? 0)
|
|
}
|
|
return stream.waste((ASCII.chBits[value] ?? 0)) ? LIT_ERROR : value
|
|
}
|
|
|
|
/**
|
|
* Decode a repetition's backward distance.
|
|
*
|
|
* @param stream - the bit stream.
|
|
* @param repLength - the repetition length already decoded.
|
|
* @returns the distance in bytes (1-based), or 0 when the stream ended.
|
|
*/
|
|
function decodeDist(stream: BitStream, repLength: number): number {
|
|
const distPosCode = (DISTANCE_CODES[stream.peek8()] ?? 0)
|
|
if (stream.waste((DISTBITS[distPosCode] ?? 0))) return 0
|
|
if (repLength === 2) {
|
|
// Two-byte repetitions carry two extra bits instead of the full dictionary
|
|
// width: the encoder knows a distance in 4..(4*distPosCode+3) range.
|
|
const distance = (distPosCode << 2) | (stream.peek16() & 0x03)
|
|
return stream.waste(2) ? 0 : distance + 1
|
|
}
|
|
const distance = (distPosCode << stream.dsizeBits) | (stream.peek16() & stream.dsizeMask)
|
|
return stream.waste(stream.dsizeBits) ? 0 : distance + 1
|
|
}
|
|
|
|
/**
|
|
* Decode a whole implode stream into exactly `expectedSize` bytes.
|
|
*
|
|
* @param input - the compressed bytes, stream header included.
|
|
* @param expectedSize - the uncompressed length the archive declares.
|
|
* @returns the decoded bytes.
|
|
*/
|
|
export function explode(input: Uint8Array, expectedSize: number): Uint8Array {
|
|
if (input.length <= 4) throw new ImplodeError(`implode stream too short (${String(input.length)} bytes)`)
|
|
const ctype = (input[0] ?? 0)
|
|
const dsizeBits = (input[1] ?? 0)
|
|
if (ctype !== CMP_BINARY && ctype !== CMP_ASCII) throw new ImplodeError(`unknown implode mode ${String(ctype)}`)
|
|
if (dsizeBits < 4 || dsizeBits > 6) throw new ImplodeError(`invalid implode dictionary size ${String(dsizeBits)}`)
|
|
const stream = new BitStream(input, ctype, dsizeBits, (input[2] ?? 0))
|
|
|
|
const out = new Uint8Array(expectedSize)
|
|
let written = 0
|
|
for (;;) {
|
|
const literal = decodeLit(stream)
|
|
if (literal === LIT_ERROR) throw new ImplodeError('implode stream ended mid-symbol')
|
|
if (literal === LIT_END_OF_STREAM) break
|
|
if (literal >= 0x100) {
|
|
const repLength = literal - 0xfe
|
|
const minusDist = decodeDist(stream, repLength)
|
|
if (minusDist === 0) throw new ImplodeError('implode stream ended inside a repetition')
|
|
const source = written - minusDist
|
|
if (source < 0) {
|
|
throw new ImplodeError(`back-reference ${String(minusDist)} bytes before the start of the output`)
|
|
}
|
|
if (written + repLength > expectedSize) {
|
|
throw new ImplodeError(`output overflow: ${String(written + repLength)} > ${String(expectedSize)}`)
|
|
}
|
|
// Byte at a time on purpose: when the encoder stored a run, the
|
|
// repetition overlaps itself, and that overlap is part of the format.
|
|
for (let i = 0; i < repLength; i += 1) out[written + i] = (out[source + i] ?? 0)
|
|
written += repLength
|
|
} else {
|
|
if (written >= expectedSize) {
|
|
throw new ImplodeError(`output overflow at byte ${String(written)} (expected ${String(expectedSize)})`)
|
|
}
|
|
out[written] = literal
|
|
written += 1
|
|
}
|
|
}
|
|
if (written !== expectedSize) {
|
|
throw new ImplodeError(`decoded ${String(written)} bytes, expected ${String(expectedSize)}`)
|
|
}
|
|
return out
|
|
}
|