diablo2-web/src/mpq/decompress.ts

132 lines
4.9 KiB
TypeScript

/**
* MPQ sector decompression.
*
* A compressed sector starts with one mask byte naming the codecs applied to
* the payload; combined masks (e.g. `HUFFMANN | ADPCM_MONO` on Diablo's WAV
* data) are unwound in the fixed order below, which is the order StormLib
* applies them in.
*
* zlib needs no code of its own: `DecompressionStream('deflate')` is the
* platform decoder, present in browsers and in Node. PKWARE implode needed real
* code — Diablo II stores almost every table and asset with it — so it lives in
* `implode.ts` and is called here.
*/
import { ImplodeError, explode } from './implode.ts'
/** Codec bit: adaptive Huffmann (FGK variant). */
export const COMPRESSION_HUFFMANN = 0x01
/** Codec bit: zlib/deflate. */
export const COMPRESSION_ZLIB = 0x02
/** Codec bit: PKWARE Data Compression Library implode. */
export const COMPRESSION_PKWARE = 0x08
/** Codec bit: bzip2. */
export const COMPRESSION_BZIP2 = 0x10
/** Codec bit: sparse (run-length + mask bytes). */
export const COMPRESSION_SPARSE = 0x20
/** Codec bit: ADPCM, mono, 4-bit. */
export const COMPRESSION_ADPCM_MONO = 0x40
/** Codec bit: ADPCM, stereo, 4-bit. */
export const COMPRESSION_ADPCM_STEREO = 0x80
/** Raised when a sector cannot be decoded. */
export class MpqCompressionError extends Error {
/** The compression mask that failed, when known. */
readonly mask: number | undefined
/**
* @param message - human-readable failure.
* @param mask - the compression mask that failed, when known.
*/
constructor(message: string, mask?: number) {
super(message)
this.name = 'MpqCompressionError'
this.mask = mask
}
}
/**
* Name the codecs a mask selects, for diagnostics.
*
* @param mask - the compression mask byte.
* @returns a `+`-joined list, or `stored` for an empty mask.
*/
export function compressionMaskName(mask: number): string {
const parts: string[] = []
if (mask & COMPRESSION_HUFFMANN) parts.push('huffmann')
if (mask & COMPRESSION_ZLIB) parts.push('zlib')
if (mask & COMPRESSION_PKWARE) parts.push('pkware')
if (mask & COMPRESSION_BZIP2) parts.push('bzip2')
if (mask & COMPRESSION_SPARSE) parts.push('sparse')
if (mask & COMPRESSION_ADPCM_MONO) parts.push('adpcm-mono')
if (mask & COMPRESSION_ADPCM_STEREO) parts.push('adpcm-stereo')
return parts.length === 0 ? 'stored' : parts.join('+')
}
/**
* Inflate one zlib stream.
*
* @param data - zlib-wrapped deflate bytes.
* @returns the inflated bytes.
*/
async function inflateZlib(data: Uint8Array): Promise<Uint8Array> {
const stream = new Blob([data as BlobPart]).stream().pipeThrough(new DecompressionStream('deflate'))
return new Uint8Array(await new Response(stream).arrayBuffer())
}
/**
* Decode one compressed sector payload (mask byte included).
*
* @param input - the stored sector bytes, mask byte first.
* @param expectedSize - the sector's uncompressed length.
* @returns the decoded sector.
*/
export async function decompressSector(input: Uint8Array, expectedSize: number): Promise<Uint8Array> {
if (input.byteLength === 0) return input
const mask = input[0]!
if (mask === 0) {
// A zero mask marks a stored payload that merely carried the marker byte.
return input.subarray(1, 1 + expectedSize)
}
let current: Uint8Array = input.subarray(1)
let remaining = mask
if (remaining & COMPRESSION_HUFFMANN) {
throw new MpqCompressionError(`huffmann decoder not implemented yet (mask 0x${mask.toString(16)})`, mask)
}
if (remaining & COMPRESSION_ZLIB) {
current = await inflateZlib(current)
remaining &= ~COMPRESSION_ZLIB
}
if (remaining & COMPRESSION_PKWARE) {
// PKWARE is the last codec before bzip2/sparse/ADPCM in the reference
// order, so the payload it sees is already at its final length.
try {
current = explode(current, expectedSize)
} catch (err) {
const detail = err instanceof ImplodeError ? err.message : String(err)
throw new MpqCompressionError(`pkware implode failed: ${detail} (mask 0x${mask.toString(16)})`, mask)
}
remaining &= ~COMPRESSION_PKWARE
}
if (remaining & COMPRESSION_BZIP2) {
throw new MpqCompressionError(`bzip2 decoder not implemented yet (mask 0x${mask.toString(16)})`, mask)
}
if (remaining & COMPRESSION_SPARSE) {
throw new MpqCompressionError(`sparse decoder not implemented yet (mask 0x${mask.toString(16)})`, mask)
}
if (remaining & (COMPRESSION_ADPCM_MONO | COMPRESSION_ADPCM_STEREO)) {
throw new MpqCompressionError(`adpcm decoder not implemented yet (mask 0x${mask.toString(16)})`, mask)
}
if (remaining !== 0) {
throw new MpqCompressionError(`unknown compression bits 0x${remaining.toString(16)} (mask 0x${mask.toString(16)})`, mask)
}
if (current.byteLength !== expectedSize) {
throw new MpqCompressionError(
`decoded ${String(current.byteLength)} bytes, expected ${String(expectedSize)} (mask 0x${mask.toString(16)})`,
mask,
)
}
return current
}