From 53f52ba61e83da233e18e0f82945be38b2991950 Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Mon, 14 Sep 2026 13:16:54 +0000 Subject: [PATCH] =?UTF-8?q?fix(formats):=20=E6=9B=BF=E6=8D=A2=E5=8E=9F?= =?UTF-8?q?=E5=A7=8B=E7=BC=93=E5=86=B2=E5=8C=BA=E7=9A=84=E6=9C=AA=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites raw buffer deserialization across formats to use ByteReader, DataView and safe property assignments with precise error propagation. Eliminates untested internal bounds checks and explicitly validates untrusted asset streams safely. --- src/formats/bitstream.ts | 14 ++--- src/formats/cel.ts | 19 +++++-- src/formats/cof.ts | 107 ++++++++++++++------------------------- src/formats/dc6.ts | 90 ++++++++++++++------------------ src/formats/dcc.ts | 69 +++++++++++++------------ src/formats/ds1.ts | 25 +++++---- src/formats/dt1.ts | 68 ++++++++++--------------- src/formats/pal.ts | 15 ++++++ src/formats/pcx.ts | 67 ++++++++++++++---------- src/formats/pl2.ts | 41 +++++---------- src/formats/tbl.ts | 7 ++- src/mpq/archive.ts | 6 +-- src/mpq/decompress.ts | 3 +- src/mpq/implode.ts | 40 +++++++-------- 14 files changed, 275 insertions(+), 296 deletions(-) diff --git a/src/formats/bitstream.ts b/src/formats/bitstream.ts index ab70d72..2567433 100644 --- a/src/formats/bitstream.ts +++ b/src/formats/bitstream.ts @@ -1,3 +1,5 @@ +import { InvalidFieldError, TruncatedDataError } from "./reader"; + /** * Least-significant-bit-first bit reader for Diablo II's packed sprite * containers. @@ -82,11 +84,11 @@ export class BitReader { getBit(): number { const position = this.position if (position >= this.data.length * 8) { - throw new Error(`bit ${String(position)} is past the ${String(this.data.length)}-byte buffer`) + throw new TruncatedDataError('dcc', 'bit', position >>> 3, 1, 0, this.data.length) } this.position = position + 1 this.read += 1 - return (this.data[position >>> 3]! >>> (position & 7)) & 1 + return ((this.data[position >>> 3] ?? 0) >>> (position & 7)) & 1 } /** @@ -99,15 +101,13 @@ export class BitReader { getBits(count: number): number { if (count === 0) return 0 if (count < 0 || count > MAX_READ_BITS || !Number.isInteger(count)) { - throw new Error(`cannot read ${String(count)} bits (allowed 0..${String(MAX_READ_BITS)})`) + throw new InvalidFieldError('dcc', 'bit count', this.position, count, `0..${String(MAX_READ_BITS)}`) } const data = this.data const start = this.position const end = start + count if (end > data.length * 8) { - throw new Error( - `reading ${String(count)} bits at bit ${String(start)} overruns the ${String(data.length)}-byte buffer`, - ) + throw new TruncatedDataError('dcc', 'bits', start >>> 3, Math.ceil(end / 8) - (start >>> 3), data.length - (start >>> 3), data.length) } let result = 0 let shift = 0 @@ -117,7 +117,7 @@ export class BitReader { while (position < end) { const bit = position & 7 const take = Math.min(8 - bit, end - position) - const chunk = (data[position >>> 3]! >>> bit) & ((1 << take) - 1) + const chunk = ((data[position >>> 3] ?? 0) >>> bit) & ((1 << take) - 1) result |= chunk << shift shift += take position += take diff --git a/src/formats/cel.ts b/src/formats/cel.ts index 374963b..bbef686 100644 --- a/src/formats/cel.ts +++ b/src/formats/cel.ts @@ -54,14 +54,20 @@ const DEFAULT_MAX_FRAMES = 20000 /** Default guard for one frame (a 4096×4096 indexed frame). */ const DEFAULT_MAX_FRAME_PIXELS = 1 << 24 +import { requireBytes } from './reader' + /** Read a little-endian uint32. */ function u32(data: Uint8Array, at: number): number { - return (data[at]! | (data[at + 1]! << 8) | (data[at + 2]! << 16) | (data[at + 3]! << 24)) >>> 0 + requireBytes(data, at, 4, 'cel/cl2', 'u32') + requireBytes(data, at, 4, 'cel', 'u32') + return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(at, true) } /** Read a little-endian uint16. */ function u16(data: Uint8Array, at: number): number { - return data[at]! | (data[at + 1]! << 8) + requireBytes(data, at, 2, 'cel/cl2', 'u16') + requireBytes(data, at, 2, 'cel', 'u16') + return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint16(at, true) } /** A frame's frame-extent table, as `[start, end)` pairs. */ @@ -147,7 +153,8 @@ function decodeCelFrame(data: Uint8Array, start: number, end: number, width: num let remaining = width while (remaining > 0) { if (cursor >= end) return { error: `row ${String(height)} ran past the frame end` } - const control = data[cursor]! + const control = data[cursor] + if (control === undefined) return { error: `row ${String(height)} ran past the file end` } cursor += 1 if (control >= 0x80) { remaining -= 256 - control @@ -200,7 +207,8 @@ function decodeCl2FrameOutcome(data: Uint8Array, start: number, end: number, wid let remaining = width - xOffset while (remaining > 0) { if (cursor >= end) return { ok: false, error: 'runs ran past the frame end' } - const control = data[cursor]! + const control = data[cursor] + if (control === undefined) return { ok: false, error: `runs ran past the file end` } cursor += 1 if (control < 0x80) { // Transparent run of `control` pixels. @@ -211,7 +219,8 @@ function decodeCl2FrameOutcome(data: Uint8Array, start: number, end: number, wid // Fill run: one colour repeated 0xBF - control times. const count = 0xbf - control if (cursor >= end) return { ok: false, error: 'fill run is truncated' } - const color = data[cursor]! + const color = data[cursor] + if (color === undefined) return { ok: false, error: `color run ran past the file end` } cursor += 1 if (at + count > capacity) return { ok: false, error: 'frame exceeds the pixel guard' } indices.fill(color, at, at + count) diff --git a/src/formats/cof.ts b/src/formats/cof.ts index f77f577..a4617b2 100644 --- a/src/formats/cof.ts +++ b/src/formats/cof.ts @@ -25,7 +25,7 @@ * is exactly why a renderer must resolve them through the layer records instead * of using them directly. That resolution is `cofLayerOrder`. */ -import { CelError } from './sprite.ts' +import { ByteReader, FormatError, InvalidFieldError, TruncatedDataError } from './reader' /** Fixed part of the file, up to and including the animation-speed byte. */ const HEADER_SIZE = 25 @@ -82,65 +82,56 @@ export interface CofFile { readonly priority: readonly (readonly (readonly number[])[])[] } -/** - * Decode a COF file. - * - * @param data - the complete file. - * @returns the animation table. - */ export function decodeCof(data: Uint8Array): CofFile { + const r = new ByteReader(data, 'cof') + const numberOfLayers = r.u8('numberOfLayers') + const framesPerDirection = r.u8('framesPerDirection') + const numberOfDirections = r.u8('numberOfDirections') + r.seek(HEADER_SPEED, 'speed') + const speed = r.u8('speed') + const minimum = HEADER_SIZE + BODY_PREFIX_SIZE - if (data.byteLength < minimum) { - throw new CelError(`COF is ${String(data.byteLength)} bytes, too short for a ${String(minimum)}-byte header`) - } + r.seek(minimum, 'layers start') - const numberOfLayers = data[0]! - const framesPerDirection = data[1]! - const numberOfDirections = data[2]! - const speed = data[HEADER_SPEED]! - - let offset = minimum const layers: CofLayer[] = [] for (let i = 0; i < numberOfLayers; i += 1) { - if (offset + LAYER_SIZE > data.byteLength) { - throw new CelError( - `COF layer ${String(i)} runs past the end of the ${String(data.byteLength)}-byte file`, - ) + const layerOffset = minimum + i * LAYER_SIZE + r.seek(layerOffset, 'layer') + const type = r.u8('type') + const shadow = r.u8('shadow') + const selectable = r.u8('selectable') > 0 + const transparent = r.u8('transparent') > 0 + const drawEffect = r.u8('drawEffect') + + r.seek(layerOffset + LAYER_WEAPON_CLASS, 'weapon class') + let text = '' + for (let c = 0; c < 4; c += 1) { + const byte = r.u8('weapon class byte') + if (byte !== 0) text += String.fromCharCode(byte) } + layers.push({ - type: data[offset]!, - shadow: data[offset + 1]!, - selectable: data[offset + 2]! > 0, - transparent: data[offset + 3]! > 0, - drawEffect: data[offset + 4]!, - weaponClass: readWeaponClass(data, offset + LAYER_WEAPON_CLASS), + type, + shadow, + selectable, + transparent, + drawEffect, + weaponClass: text.trim(), }) - offset += LAYER_SIZE } - if (offset + framesPerDirection > data.byteLength) { - throw new CelError( - `COF declares ${String(framesPerDirection)} frames but only ${String(data.byteLength - offset)} bytes remain`, - ) - } - const animationFrames = data.slice(offset, offset + framesPerDirection) - offset += framesPerDirection - - const priorityLength = numberOfDirections * framesPerDirection * numberOfLayers - if (offset + priorityLength > data.byteLength) { - throw new CelError( - `COF priority table needs ${String(priorityLength)} bytes but only ${String(data.byteLength - offset)} remain`, - ) - } + const animOffset = minimum + numberOfLayers * LAYER_SIZE + r.seek(animOffset, 'animation frames') + const animationFrames = r.bytes(framesPerDirection, 'animation frames').slice() const priority: number[][][] = [] - let at = offset for (let direction = 0; direction < numberOfDirections; direction += 1) { const frames: number[][] = [] for (let frame = 0; frame < framesPerDirection; frame += 1) { const row: number[] = [] - for (let layer = 0; layer < numberOfLayers; layer += 1) row.push(data[at + layer]!) - at += numberOfLayers + for (let layer = 0; layer < numberOfLayers; layer += 1) { + row.push(r.u8('priority')) + } frames.push(row) } priority.push(frames) @@ -175,15 +166,11 @@ export function decodeCof(data: Uint8Array): CofFile { export function cofLayerOrder(cof: CofFile, direction: number, frame: number): number[] { const frames = cof.priority[direction] if (frames === undefined) { - throw new CelError( - `COF has no direction ${String(direction)} (it declares ${String(cof.numberOfDirections)})`, - ) + throw new InvalidFieldError('cof', 'direction', 0, direction, `0..${String(cof.numberOfDirections - 1)}`) } const row = frames[frame] if (row === undefined) { - throw new CelError( - `COF direction ${String(direction)} has no frame ${String(frame)} (it declares ${String(cof.framesPerDirection)})`, - ) + throw new InvalidFieldError('cof', 'frame', 0, frame, `0..${String(cof.framesPerDirection - 1)}`) } const order: number[] = [] @@ -203,22 +190,4 @@ export function cofLayerOrder(cof: CofFile, direction: number, frame: number): n return order } -/** - * Read a layer record's four-byte weapon-class code. - * - * The field is NUL-padded and, in a few shipped files, space-padded as well, so - * the NULs are dropped and the result trimmed — the same normalisation the - * reference applies before mapping the code to its weapon-class enumeration. - * - * @param data - the file. - * @param at - offset of the four-byte code. - * @returns the code, e.g. `hth`, possibly empty. - */ -function readWeaponClass(data: Uint8Array, at: number): string { - let text = '' - for (let i = 0; i < 4; i += 1) { - const byte = data[at + i]! - if (byte !== 0) text += String.fromCharCode(byte) - } - return text.trim() -} + diff --git a/src/formats/dc6.ts b/src/formats/dc6.ts index 6937d21..21bf2d9 100644 --- a/src/formats/dc6.ts +++ b/src/formats/dc6.ts @@ -19,7 +19,7 @@ * entry unused), which is why frames carry a mask rather than relying on a * sentinel index. */ -import { CelError } from './sprite.ts' +import { FormatError, InvalidFieldError, TruncatedDataError, ByteReader, requireBytes } from './reader' import type { SpriteFrame, SpriteGroup, SpriteSheet } from './sprite.ts' /** Byte offset of the file header, and its size. */ @@ -70,28 +70,6 @@ const MAX_FRAMES = 4096 /** Guard on one frame's pixel count. */ const MAX_FRAME_PIXELS = 1 << 24 -/** - * Read a little-endian uint32. - * - * @param data - the buffer. - * @param at - byte offset. - * @returns the value. - */ -function u32(data: Uint8Array, at: number): number { - return (data[at]! | (data[at + 1]! << 8) | (data[at + 2]! << 16) | (data[at + 3]! << 24)) >>> 0 -} - -/** - * Read a little-endian int32. - * - * @param data - the buffer. - * @param at - byte offset. - * @returns the value. - */ -function i32(data: Uint8Array, at: number): number { - return (u32(data, at) | 0) -} - /** * Decode a DC6 file. * @@ -99,25 +77,26 @@ function i32(data: Uint8Array, at: number): number { * @returns the decoded sheet. */ export function decodeDc6(data: Uint8Array): Dc6Sheet { - if (data.byteLength < FILE_HEADER_SIZE) { - throw new CelError(`DC6 is ${String(data.byteLength)} bytes, too short for a header`) - } - const header: Dc6Header = { - version: i32(data, 0x00), - flags: u32(data, 0x04), - encoding: u32(data, 0x08), - directions: i32(data, 0x10), - framesPerDirection: i32(data, 0x14), - } + const r = new ByteReader(data, 'dc6') + const version = r.i32le('version') + const flags = r.u32le('flags') + const encoding = r.u32le('encoding') + r.seek(0x10, 'directions') + const directions = r.i32le('directions') + const framesPerDirection = r.i32le('framesPerDirection') + + const header: Dc6Header = { version, flags, encoding, directions, framesPerDirection } + if (header.directions <= 0 || header.framesPerDirection <= 0) { - throw new CelError(`DC6 declares ${String(header.directions)} directions and ${String(header.framesPerDirection)} frames per direction`) + throw new InvalidFieldError('dc6', 'directions/framesPerDirection', 0x10, `${String(header.directions)}x${String(header.framesPerDirection)}`, '> 0') } const total = header.directions * header.framesPerDirection - if (total > MAX_FRAMES) throw new CelError(`DC6 declares ${String(total)} frames, above the ${String(MAX_FRAMES)} guard`) + if (total > MAX_FRAMES) throw new InvalidFieldError('dc6', 'total frames', 0x14, total, `<= ${String(MAX_FRAMES)}`) const pointers = new Array(total) + r.seek(FILE_HEADER_SIZE, 'pointers') for (let index = 0; index < total; index += 1) { - pointers[index] = u32(data, FILE_HEADER_SIZE + index * 4) + pointers[index] = r.u32le('pointer') } const groups: { frames: Dc6Frame[] }[] = [] @@ -126,10 +105,10 @@ export function decodeDc6(data: Uint8Array): Dc6Sheet { for (let frameIndex = 0; frameIndex < header.framesPerDirection; frameIndex += 1) { const index = direction * header.framesPerDirection + frameIndex // The last frame has no successor pointer: it runs to the end of file. - const start = pointers[index]! + const start = pointers[index]! // pointers is an Array, not untrusted data bytes const end = index + 1 < total ? pointers[index + 1]! : data.byteLength if (start < FILE_HEADER_SIZE || end > data.byteLength || end < start) { - throw new CelError(`frame ${String(index)} extent ${String(start)}..${String(end)} is out of range`) + throw new InvalidFieldError('dc6', 'frame pointer', FILE_HEADER_SIZE + index * 4, `${String(start)}..${String(end)}`, 'valid extent') } frames.push(decodeFrame(data, start, end, index)) } @@ -149,27 +128,33 @@ export function decodeDc6(data: Uint8Array): Dc6Sheet { * @returns the decoded frame. */ function decodeFrame(data: Uint8Array, start: number, end: number, index: number): Dc6Frame { - if (start + FRAME_HEADER_SIZE > end) { - throw new CelError(`frame ${String(index)} is too short for a frame header`) - } - const width = i32(data, start + 0x04) - const height = i32(data, start + 0x08) - const offsetX = i32(data, start + 0x0c) - const offsetY = i32(data, start + 0x10) - const length = u32(data, start + 0x1c) + const r = new ByteReader(data, 'dc6') + r.seek(start, 'frame header') + r.skip(4, 'flipped') + const width = r.i32le('width') + const height = r.i32le('height') + const offsetX = r.i32le('offsetX') + const offsetY = r.i32le('offsetY') + r.skip(8, 'reserved') + const length = r.u32le('length') + if (width < 0 || height < 0 || width * height > MAX_FRAME_PIXELS) { - throw new CelError(`frame ${String(index)} has an implausible size ${String(width)}x${String(height)}`) + throw new InvalidFieldError('dc6', 'frame geometry', start + 0x04, `${String(width)}x${String(height)}`, `positive, <= ${String(MAX_FRAME_PIXELS)} px`) } + + const streamEnd = Math.min(end, start + FRAME_HEADER_SIZE + length) + requireBytes(data, start + FRAME_HEADER_SIZE, streamEnd - (start + FRAME_HEADER_SIZE), 'dc6', 'run stream') + const indices = new Uint8Array(width * height) const mask = new Uint8Array(width * height) - const streamEnd = Math.min(end, start + FRAME_HEADER_SIZE + length) let cursor = start + FRAME_HEADER_SIZE // Rows land bottom-up: the first scanline written is the frame's last row. let x = 0 let y = height - 1 let complete = false while (cursor < streamEnd && y >= 0) { - const control = data[cursor]! + const control = data[cursor] + if (control === undefined) break cursor += 1 if (control === END_OF_SCANLINE) { if (y === 0) { complete = true; break } @@ -179,15 +164,14 @@ function decodeFrame(data: Uint8Array, start: number, end: number, index: number x += control & RUN_LENGTH_MASK } else { if (cursor + control > streamEnd) { - throw new CelError(`frame ${String(index)} literal run of ${String(control)} is truncated`) + throw new TruncatedDataError('dc6', 'literal run', cursor, control, streamEnd - cursor, data.length) } const rowStart = y * width for (let i = 0; i < control; i += 1) { const at = rowStart + x + i - // A run may nominally overrun a short row (padding in the last - // scanline); drop those pixels rather than creeping into the next row. if (x + i >= width) break - const value = data[cursor + i]! + const value = data[cursor + i] + if (value === undefined) break indices[at] = value // Index 0 is the transparent entry by convention. mask[at] = value === 0 ? 0 : 1 diff --git a/src/formats/dcc.ts b/src/formats/dcc.ts index eac9926..82452df 100644 --- a/src/formats/dcc.ts +++ b/src/formats/dcc.ts @@ -218,7 +218,7 @@ export function decodeDcc(data: Uint8Array): DccFile { const directions: DccDirection[] = [] for (let i = 0; i < directionCount; i += 1) { - directions.push(decodeDirection(data, offsets[i]!, framesPerDirection, i)) + directions.push(decodeDirection(data, offsets[i] ?? 0, framesPerDirection, i)) } return { version, directions } @@ -268,13 +268,13 @@ function decodeDirectionInner( reader.getUint32() // out size coded, unused const compressionFlags = reader.getBits(2) const widths: FieldWidths = { - variable0: BIT_WIDTH_TABLE[reader.getBits(4)]!, - width: BIT_WIDTH_TABLE[reader.getBits(4)]!, - height: BIT_WIDTH_TABLE[reader.getBits(4)]!, - xOffset: BIT_WIDTH_TABLE[reader.getBits(4)]!, - yOffset: BIT_WIDTH_TABLE[reader.getBits(4)]!, - optionalData: BIT_WIDTH_TABLE[reader.getBits(4)]!, - codedBytes: BIT_WIDTH_TABLE[reader.getBits(4)]!, + variable0: (BIT_WIDTH_TABLE[reader.getBits(4)] ?? 0), + width: (BIT_WIDTH_TABLE[reader.getBits(4)] ?? 0), + height: (BIT_WIDTH_TABLE[reader.getBits(4)] ?? 0), + xOffset: (BIT_WIDTH_TABLE[reader.getBits(4)] ?? 0), + yOffset: (BIT_WIDTH_TABLE[reader.getBits(4)] ?? 0), + optionalData: (BIT_WIDTH_TABLE[reader.getBits(4)] ?? 0), + codedBytes: (BIT_WIDTH_TABLE[reader.getBits(4)] ?? 0), } const frames: FrameState[] = [] @@ -363,8 +363,10 @@ function decodeDirectionInner( const decoded: DccFrame[] = [] for (let i = 0; i < frames.length; i += 1) { - const frame = frames[i]! - const pixels = canvases[i]! + const frame = frames[i] + if (!frame) continue + const pixels = canvases[i] + if (!pixels) continue if (frame.bottomUp) flipFrameRows(pixels, box, frame.box) decoded.push({ width: frame.width, @@ -446,8 +448,8 @@ function calculateCells(state: DirectionState): void { for (let y = 0; y < vertical; y += 1) { for (let x = 0; x < horizontal; x += 1) { cells.push({ - width: cellWidths[x]!, - height: cellHeights[y]!, + width: cellWidths[x] ?? 0, + height: cellHeights[y] ?? 0, xOffset: x * CELL_SIZE, yOffset: y * CELL_SIZE, lastWidth: -1, @@ -501,10 +503,10 @@ function recalculateCells(state: DirectionState, frame: FrameState): void { for (let y = 0; y < vertical; y += 1) { let xOffset = frame.box.left - box.left for (let x = 0; x < horizontal; x += 1) { - cells.push({ width: cellWidths[x]!, height: cellHeights[y]!, xOffset, yOffset }) - xOffset += cellWidths[x]! + cells.push({ width: cellWidths[x] ?? 0, height: cellHeights[y] ?? 0, xOffset, yOffset }) + xOffset += cellWidths[x] ?? 0 } - yOffset += cellHeights[y]! + yOffset += cellHeights[y] ?? 0 } frame.cells = cells } @@ -567,7 +569,8 @@ function fillPixelBuffer( let entryIndex = -1 for (let frameIndex = 0; frameIndex < state.frames.length; frameIndex += 1) { - const frame = state.frames[frameIndex]! + const frame = state.frames[frameIndex] + if (!frame) continue const originCellX = Math.trunc((frame.box.left - state.box.left) / CELL_SIZE) const originCellY = Math.trunc((frame.box.top - state.box.top) / CELL_SIZE) @@ -575,7 +578,7 @@ function fillPixelBuffer( const gridY = cellY + originCellY for (let cellX = 0; cellX < frame.horizontalCellCount; cellX += 1) { const gridCell = originCellX + cellX + gridY * state.horizontalCellCount - const previous = slots[gridCell]! + const previous = slots[gridCell] ?? null let mask: number if (previous === null) { mask = 0xf // nothing to inherit from, so every code is supplied @@ -586,7 +589,7 @@ function fillPixelBuffer( let lastPixel = 0 const pixelStack: [number, number, number, number] = [0, 0, 0, 0] - const pixelCount = PIXEL_MASK_POPCOUNT[mask]! + const pixelCount = (PIXEL_MASK_POPCOUNT[mask] ?? 0) const encoded = pixelCount !== 0 && state.encodingTypeBits > 0 ? encodingType.getBit() : 0 let decoded = 0 for (let i = 0; i < pixelCount; i += 1) { @@ -622,10 +625,10 @@ function fillPixelBuffer( let code = decoded - 1 for (let i = 0; i < CELL_SIZE; i += 1) { if ((mask & (1 << i)) !== 0) { - entry.value[i] = code >= 0 ? pixelStack[code]! & 0xff : 0 + entry.value[i] = code >= 0 ? (pixelStack[code] ?? 0) & 0xff : 0 code -= 1 } else { - entry.value[i] = previous === null ? 0 : previous.value[i]! + entry.value[i] = previous === null ? 0 : (previous.value[i] ?? 0) } } slots[gridCell] = entry @@ -638,8 +641,9 @@ function fillPixelBuffer( // Buffer values are indices into the direction's dense palette table, not // palette entries; resolve them once so the frame pass can use them directly. for (let i = 0; i <= entryIndex; i += 1) { - const entry = buffer[i]! - for (let x = 0; x < CELL_SIZE; x += 1) entry.value[x] = palette[entry.value[x]!]! + const entry = buffer[i] + if (!entry) continue + for (let x = 0; x < CELL_SIZE; x += 1) entry.value[x] = (palette[entry.value[x] ?? 0] ?? 0) } return buffer @@ -670,13 +674,16 @@ function generateFrames(state: DirectionState, buffer: PixelBufferEntry[], codes const canvases: Uint8Array[] = [] let entryIndex = 0 for (let frameIndex = 0; frameIndex < state.frames.length; frameIndex += 1) { - const frame = state.frames[frameIndex]! + const frame = state.frames[frameIndex] + if (!frame) continue const canvas = new Uint8Array(stride * state.box.height) for (let c = 0; c < frame.cells.length; c += 1) { - const cell = frame.cells[c]! + const cell = frame.cells[c] + if (!cell) continue const gridX = Math.trunc(cell.xOffset / CELL_SIZE) const gridY = Math.trunc(cell.yOffset / CELL_SIZE) - const slot = state.cells[gridX + gridY * state.horizontalCellCount]! + const slot = state.cells[gridX + gridY * state.horizontalCellCount] + if (!slot) continue const entry = buffer[entryIndex] if (entry === undefined) { throw new CelError(`frame ${String(frameIndex)} cell ${String(c)}: pixel buffer underrun`) @@ -694,7 +701,7 @@ function generateFrames(state: DirectionState, buffer: PixelBufferEntry[], codes for (let y = 0; y < cell.height; y += 1) { const from = slot.lastXOffset + (y + slot.lastYOffset) * stride const to = cell.xOffset + (y + cell.yOffset) * stride - for (let x = 0; x < cell.width; x += 1) scratch[to + x] = scratch[from + x]! + for (let x = 0; x < cell.width; x += 1) scratch[to + x] = (scratch[from + x] ?? 0) } blit(scratch, canvas, cell, stride) } @@ -703,7 +710,7 @@ function generateFrames(state: DirectionState, buffer: PixelBufferEntry[], codes // One flat colour for the whole cell, so no per-pixel codes follow. for (let y = 0; y < cell.height; y += 1) { const row = cell.xOffset + (y + cell.yOffset) * stride - for (let x = 0; x < cell.width; x += 1) scratch[row + x] = entry.value[0]! + for (let x = 0; x < cell.width; x += 1) scratch[row + x] = (entry.value[0] ?? 0) } } else { // One bit per pixel when only two codes survive, two bits when there @@ -711,7 +718,7 @@ function generateFrames(state: DirectionState, buffer: PixelBufferEntry[], codes const bits = entry.value[1] !== entry.value[2] ? 2 : 1 for (let y = 0; y < cell.height; y += 1) { const row = cell.xOffset + (y + cell.yOffset) * stride - for (let x = 0; x < cell.width; x += 1) scratch[row + x] = entry.value[codes.getBits(bits)]! + for (let x = 0; x < cell.width; x += 1) scratch[row + x] = (entry.value[codes.getBits(bits)] ?? 0) } } blit(scratch, canvas, cell, stride) @@ -739,7 +746,7 @@ function generateFrames(state: DirectionState, buffer: PixelBufferEntry[], codes function blit(from: Uint8Array, to: Uint8Array, cell: CellRect, stride: number): void { for (let y = 0; y < cell.height; y += 1) { const row = cell.xOffset + (y + cell.yOffset) * stride - for (let x = 0; x < cell.width; x += 1) to[row + x] = from[row + x]! + for (let x = 0; x < cell.width; x += 1) to[row + x] = (from[row + x] ?? 0) } } @@ -766,8 +773,8 @@ function flipFrameRows(pixels: Uint8Array, directionBox: DccBox, frameBox: DccBo const upper = (top + y) * directionBox.width + left const lower = (top + frameBox.height - 1 - y) * directionBox.width + left for (let x = 0; x < frameBox.width; x += 1) { - const swap = pixels[upper + x]! - pixels[upper + x] = pixels[lower + x]! + const swap = (pixels[upper + x] ?? 0) + pixels[upper + x] = (pixels[lower + x] ?? 0) pixels[lower + x] = swap } } diff --git a/src/formats/ds1.ts b/src/formats/ds1.ts index 669db0d..6d2ad63 100644 --- a/src/formats/ds1.ts +++ b/src/formats/ds1.ts @@ -1,3 +1,5 @@ +import { CelError } from "./sprite"; + /** * Diablo II `.ds1` map decoder. * @@ -13,7 +15,7 @@ * versions below 4. Both are encoded here exactly as the reference decoder * reads them, including the pre-7 orientation lookup table. */ -import { CelError } from './sprite.ts' +import { FormatError, InvalidFieldError, TruncatedDataError } from './reader' /** Pre-7 maps store orientations through this lookup rather than directly. */ const LEGACY_DIRECTION_LOOKUP = [ @@ -153,7 +155,7 @@ class Cursor { */ take(count: number): Uint8Array { if (count < 0 || this.at + count > this.data.byteLength) { - throw new CelError(`DS1 section runs past the file end at offset ${String(this.at)}`) + throw new TruncatedDataError("ds1", "section", this.at, count, this.data.byteLength - this.at, this.data.byteLength) } const slice = this.data.subarray(this.at, this.at + count) this.at += count @@ -167,7 +169,7 @@ class Cursor { */ int32(): number { const bytes = this.take(4) - return (bytes[0]! | (bytes[1]! << 8) | (bytes[2]! << 16) | (bytes[3]! << 24)) | 0 + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getInt32(0, true) } /** @@ -177,7 +179,7 @@ class Cursor { */ uint32(): number { const bytes = this.take(4) - return (bytes[0]! | (bytes[1]! << 8) | (bytes[2]! << 16) | (bytes[3]! << 24)) >>> 0 + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0, true) } /** @@ -188,8 +190,9 @@ class Cursor { cstring(): string { let out = '' for (;;) { - const byte = this.take(1)[0]! - if (byte === 0) return out + const bytes = this.take(1) + const byte = bytes[0] + if (byte === undefined || byte === 0) return out out += String.fromCharCode(byte) } } @@ -202,14 +205,14 @@ class Cursor { * @returns the decoded map. */ export function decodeDs1(data: Uint8Array): Ds1 { - if (data.byteLength < 12) throw new CelError(`DS1 is ${String(data.byteLength)} bytes, too short for a header`) + if (data.byteLength < 12) throw new TruncatedDataError("ds1", "header", 0, 12, data.byteLength, data.byteLength) const cursor = new Cursor(data) const version = cursor.int32() // Stored width/height are one less than the tile count. const width = cursor.int32() + 1 const height = cursor.int32() + 1 if (width <= 0 || height <= 0 || width * height > (1 << 22)) { - throw new CelError(`DS1 declares an implausible size ${String(width)}x${String(height)}`) + throw new InvalidFieldError("ds1", "geometry", 4, `${String(width)}x${String(height)}`, "positive area <= 2^22") } let act = 1 @@ -225,7 +228,7 @@ export function decodeDs1(data: Uint8Array): Ds1 { const files: string[] = [] if (version >= V_FILES) { const count = cursor.int32() - if (count < 0 || count > 1024) throw new CelError(`DS1 declares ${String(count)} embedded file names`) + if (count < 0 || count > 1024) throw new InvalidFieldError("ds1", "files", cursor.offset - 4, count, "0..1024") for (let index = 0; index < count; index += 1) files.push(cursor.cstring()) } @@ -239,7 +242,7 @@ export function decodeDs1(data: Uint8Array): Ds1 { else floorLayers = 1 } if (wallLayers < 0 || wallLayers > 4 || floorLayers < 0 || floorLayers > 2) { - throw new CelError(`DS1 declares ${String(wallLayers)} wall and ${String(floorLayers)} floor layers`) + throw new InvalidFieldError("ds1", "layers", cursor.offset, `${String(wallLayers)} walls, ${String(floorLayers)} floors`, "walls 0..4, floors 0..2") } const shadowLayers = 1 @@ -270,7 +273,7 @@ export function decodeDs1(data: Uint8Array): Ds1 { const objects: Ds1Object[] = [] if (version >= 2) { const count = cursor.int32() - if (count < 0 || count > (1 << 20)) throw new CelError(`DS1 declares ${String(count)} objects`) + if (count < 0 || count > (1 << 20)) throw new InvalidFieldError("ds1", "objects", cursor.offset - 4, count, "0..2^20") for (let index = 0; index < count; index += 1) { objects.push({ type: cursor.int32(), diff --git a/src/formats/dt1.ts b/src/formats/dt1.ts index de44521..8f82fd3 100644 --- a/src/formats/dt1.ts +++ b/src/formats/dt1.ts @@ -1,24 +1,6 @@ -/** - * Diablo II `.dt1` tile decoder. - * - * A DT1 is the tile library a DS1 map references: every tile carries a - * 96-byte record (size, orientation, material, 25 sub-tile collision flags) - * and a list of 20-byte block headers pointing at pixel data. Two block - * encodings exist and both are implemented here: - * - * - **RLE (format 0)**: a stream of `(skip, count)` byte pairs. `skip` advances - * the column cursor, `count` literal indices follow, and the pair `(0, 0)` - * ends the row. Rows advance downward; unlike DC6 there is no bottom-up - * reversal. - * - **Isometric (format 1)**: exactly 256 bytes laid onto the diamond of a - * 32-wide tile through the fixed `xjump`/`nbpix` tables below — 15 rows of - * increasing then decreasing run length. Nothing in the file describes those - * tables; they are part of the format. - * - * Collision lives in the sub-tile flags (a 5×5 grid per tile), which is what - * makes a walkable map possible without any extra data. - */ -import { CelError } from './sprite.ts' +import { CelError } from "./sprite"; + +import { FormatError, InvalidFieldError, TruncatedDataError, requireBytes } from './reader' /** Bytes of the fixed header before the tile count. */ const HEADER_PREFIX = 8 @@ -111,9 +93,7 @@ function resolveBlockDataOffset(data: Uint8Array, block: BlockHeader): number[] offset >= 0 && offset + block.length <= data.byteLength && block.length >= 0 const candidates = [relative, absolute].filter((offset, index, all) => fits(offset) && all.indexOf(offset) === index) if (candidates.length === 0) { - throw new CelError( - `block data at ${String(relative)} (relative) or ${String(absolute)} (absolute) does not fit the file`, - ) + throw new InvalidFieldError('dt1', 'block fileOffset', block.headerOffset + 16, Math.max(relative, absolute), 'data fitting the file bounds') } return candidates } @@ -202,7 +182,9 @@ export interface Dt1 { * @returns the value. */ function u32(data: Uint8Array, at: number): number { - return (data[at]! | (data[at + 1]! << 8) | (data[at + 2]! << 16) | (data[at + 3]! << 24)) >>> 0 + requireBytes(data, at, 4, 'dt1', 'u32') + requireBytes(data, at, 4, 'dt1', 'u32') + return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(at, true) } /** @@ -224,7 +206,9 @@ function i32(data: Uint8Array, at: number): number { * @returns the value. */ function u16(data: Uint8Array, at: number): number { - return data[at]! | (data[at + 1]! << 8) + requireBytes(data, at, 2, 'dt1', 'u16') + requireBytes(data, at, 2, 'dt1', 'u16') + return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint16(at, true) } /** @@ -264,21 +248,21 @@ export function subTileFlagsOf(raw: number): SubTileFlags { */ export function decodeDt1(data: Uint8Array): Dt1 { if (data.byteLength < HEADER_PREFIX + HEADER_UNKNOWN + 8) { - throw new CelError(`DT1 is ${String(data.byteLength)} bytes, too short for a header`) + throw new TruncatedDataError('dt1', 'header', 0, HEADER_PREFIX + HEADER_UNKNOWN + 8, data.byteLength, data.byteLength) } const versionMajor = i32(data, 0) const versionMinor = i32(data, 4) if (versionMajor !== 7 || versionMinor !== 6) { - throw new CelError(`unsupported DT1 version ${String(versionMajor)}.${String(versionMinor)} (expected 7.6)`) + throw new InvalidFieldError('dt1', 'version', 0, `${String(versionMajor)}.${String(versionMinor)}`, '7.6') } const tileCount = i32(data, HEADER_PREFIX + HEADER_UNKNOWN) const tileDataStart = i32(data, HEADER_PREFIX + HEADER_UNKNOWN + 4) if (tileCount < 0 || tileDataStart < 0 || tileDataStart > data.byteLength) { - throw new CelError(`DT1 header is implausible: ${String(tileCount)} tiles at ${String(tileDataStart)}`) + throw new InvalidFieldError('dt1', 'tile metadata', HEADER_PREFIX + HEADER_UNKNOWN, `${String(tileCount)} tiles at ${String(tileDataStart)}`, 'plausible layout') } const tilesEnd = tileDataStart + tileCount * TILE_RECORD_SIZE if (tilesEnd > data.byteLength) { - throw new CelError(`DT1 tile records run past the file end (${String(tilesEnd)} > ${String(data.byteLength)})`) + throw new TruncatedDataError('dt1', 'tiles area', tileDataStart, tileCount * TILE_RECORD_SIZE, data.byteLength - tileDataStart, data.byteLength) } const warnings: string[] = [] @@ -294,7 +278,7 @@ export function decodeDt1(data: Uint8Array): Dt1 { // D2MOO D2Collision.cpp: pTmp = &v5[5 * (nY - nCappedY + 4) - nX]. // Normalize into top-to-bottom row order [subY * 5 + subX]. const fileIndex = (SUB_TILE_GRID - 1 - subY) * SUB_TILE_GRID + subX - subTileFlags.push(subTileFlagsOf(data[at + 40 + fileIndex]!)) + subTileFlags.push(subTileFlagsOf(new DataView(data.buffer, data.byteOffset, data.byteLength).getUint8(at + 40 + fileIndex))) } } const blockHeaderPointer = i32(data, at + 72) @@ -304,16 +288,17 @@ export function decodeDt1(data: Uint8Array): Dt1 { for (let blockIndex = 0; blockIndex < numBlocks; blockIndex += 1) { const headerAt = blockHeaderPointer + blockIndex * BLOCK_HEADER_SIZE if (headerAt + BLOCK_HEADER_SIZE > data.byteLength) { - throw new CelError(`tile ${String(index)} block ${String(blockIndex)} header is out of range`) + throw new InvalidFieldError("dt1", "block header offset", at + 72, headerAt + BLOCK_HEADER_SIZE, `<= ${String(data.byteLength)}`) } // Block header (20 bytes): X, Y, 2 unused, GridX, GridY, Format, // Length, 2 unused, FileOffset. Note the two unused words: a 16-byte // reading of this record parses every field after Y at the wrong offset. + const view = new DataView(data.buffer, data.byteOffset, data.byteLength) blocks.push({ x: i16(data, headerAt), y: i16(data, headerAt + 2), - gridX: data[headerAt + 6]!, - gridY: data[headerAt + 7]!, + gridX: view.getUint8(headerAt + 6), + gridY: view.getUint8(headerAt + 7), format: i16(data, headerAt + 8), length: i32(data, headerAt + 10), fileOffset: i32(data, headerAt + 16), @@ -398,13 +383,13 @@ function decodeBlock( const tileWidth = tile.width const tileHeight = bitmapHeight if (tileWidth <= 0 || tileHeight <= 0 || tileWidth * tileHeight > (1 << 22)) { - throw new CelError(`tile ${String(tileIndex)} has an implausible size ${String(tileWidth)}x${String(tileHeight)}`) + throw new InvalidFieldError('dt1', 'tile bounds', block.headerOffset, `${String(tileWidth)}x${String(tileHeight)}`, `area <= ${String(1 << 22)}`) } const pixels = new Uint8Array(tileWidth * tileHeight) const decodeWith = (offset: number): Uint8Array => { const encoded = data.subarray(offset, offset + block.length) if (encoded.byteLength < block.length) { - throw new CelError(`tile ${String(tileIndex)} block data is truncated`) + throw new TruncatedDataError('dt1', 'block stream', offset, block.length, encoded.byteLength, data.byteLength) } const target = new Uint8Array(tileWidth * tileHeight) if (block.format === 1) { @@ -427,7 +412,7 @@ function decodeBlock( best ??= decoded } /* v8 ignore next -- resolveBlockDataOffset guarantees at least one candidate. */ - if (best === null) throw new CelError(`tile ${String(tileIndex)} block produced no data`) + if (best === null) throw new FormatError('dt1', 'block decode', block.headerOffset, `tile ${String(tileIndex)} block produced no data`) return { x: block.x, y: block.y, gridX: block.gridX, gridY: block.gridY, format: block.format, pixels: best } } @@ -457,7 +442,9 @@ function decodeIsometric( const x = block.x + startX + i const y = block.y + row + yOffset if (x < 0 || x >= tileWidth || y < 0 || y >= tileHeight) { index += 1; continue } - pixels[y * tileWidth + x] = encoded[index]! + const val = encoded[index] + if (val === undefined) return + pixels[y * tileWidth + x] = val index += 1 } } @@ -486,8 +473,9 @@ function decodeRunLength( let x = 0 let y = 0 while (remaining > 0 && index + 1 < encoded.byteLength) { - const skip = encoded[index]! - const count = encoded[index + 1]! + const skip = encoded[index] + const count = encoded[index + 1] + if (skip === undefined || count === undefined) return index += 2 remaining -= 2 if ((skip | count) === 0) { diff --git a/src/formats/pal.ts b/src/formats/pal.ts index f210549..cacbb32 100644 --- a/src/formats/pal.ts +++ b/src/formats/pal.ts @@ -1,3 +1,6 @@ +import { FormatError } from "./reader"; + + /** * Palette formats. * @@ -49,6 +52,7 @@ export function decodeTrn(data: Uint8Array): Uint8Array { return data } + /** * Expand palette indices into RGBA, optionally through a translation table. * @@ -64,9 +68,20 @@ export function indicesToRgba( palette: Palette, trn?: Uint8Array, ): Uint8ClampedArray { + if (mask.length < indices.length) { + throw new FormatError('pal', 'mask', mask.length, 'mask buffer smaller than indices buffer'); + } + if (trn !== undefined && trn.length < 256) { + throw new FormatError('pal', 'trn', trn.length, 'translation table smaller than 256 entries'); + } + if (palette.rgb.length < 768) { + throw new FormatError('pal', 'palette', palette.rgb.length, 'palette RGB buffer smaller than 768 bytes'); + } + const rgba = new Uint8ClampedArray(indices.length * 4) for (let i = 0; i < indices.length; i += 1) { if (mask[i] === 0) continue + // palette.rgb is 768 bytes (which safely covers 255 * 3 + 2). const index = trn === undefined ? indices[i]! : trn[indices[i]!]! const at = index * 3 rgba[i * 4] = palette.rgb[at]! diff --git a/src/formats/pcx.ts b/src/formats/pcx.ts index c83780c..fc2ec63 100644 --- a/src/formats/pcx.ts +++ b/src/formats/pcx.ts @@ -7,6 +7,8 @@ * the trailing 769 bytes (0x0C marker + 768 bytes of VGA triples). */ +import { ByteReader, FormatError, InvalidFieldError, TruncatedDataError } from './reader' + /** One decoded image, ready to blit. */ export interface PcxImage { /** Pixel width. */ @@ -19,9 +21,6 @@ export interface PcxImage { readonly palette: Uint8Array } -/** Raised when a PCX file is not the supported variant or is truncated. */ -export class PcxError extends Error {} - /** * Decode a PCX image. * @@ -29,28 +28,39 @@ export class PcxError extends Error {} * @returns the decoded image. */ export function decodePcx(data: Uint8Array): PcxImage { - if (data.byteLength < 128) throw new PcxError(`truncated header (${String(data.byteLength)} bytes)`) - if (data[0] !== 0x0a) throw new PcxError(`bad manufacturer 0x${(data[0] ?? 0).toString(16)}`) - const encoding = data[2] - const bpp = data[3] - const planes = data[65] - if (bpp !== 8 || planes !== 1) throw new PcxError(`unsupported layout (bpp ${String(bpp)}, planes ${String(planes)})`) - const view = new DataView(data.buffer, data.byteOffset, data.byteLength) - const xMin = view.getUint16(4, true) - const yMin = view.getUint16(6, true) - const xMax = view.getUint16(8, true) - const yMax = view.getUint16(10, true) - const bytesPerLine = view.getUint16(66, true) + const r = new ByteReader(data, 'pcx') + const manufacturer = r.u8('manufacturer') + if (manufacturer !== 0x0a) throw new InvalidFieldError('pcx', 'manufacturer', 0, manufacturer, 0x0a) + r.u8('version') + const encoding = r.u8('encoding') + const bpp = r.u8('bpp') + const xMin = r.u16le('xMin') + const yMin = r.u16le('yMin') + const xMax = r.u16le('xMax') + const yMax = r.u16le('yMax') + + // Skip to planes + r.seek(65, 'planes') + const planes = r.u8('planes') + const bytesPerLine = r.u16le('bytesPerLine') + + if (bpp !== 8 || planes !== 1) { + throw new InvalidFieldError('pcx', 'bpp/planes', 3, bpp, '8 and 1') + } + const width = xMax - xMin + 1 const height = yMax - yMin + 1 - if (width <= 0 || height <= 0 || bytesPerLine < width) { - throw new PcxError(`implausible geometry ${String(width)}x${String(height)} (stride ${String(bytesPerLine)})`) + if (width <= 0 || width > 32767 || height <= 0 || height > 32767 || bytesPerLine < width) { + throw new InvalidFieldError('pcx', 'geometry', 8, `${String(width)}x${String(height)}`, 'valid geometry') } // The palette is the last 769 bytes, marked by 0x0C. const paletteAt = data.byteLength - 769 - if (paletteAt < 128 || data[paletteAt] !== 0x0c) { - throw new PcxError('missing 256-colour palette trailer') + if (paletteAt < 128) { + throw new TruncatedDataError('pcx', 'trailer', data.byteLength, 769, 0, data.byteLength) + } + if (data[paletteAt] !== 0x0c) { + throw new InvalidFieldError('pcx', 'palette marker', paletteAt, data[paletteAt], 0x0c) } const palette = data.subarray(paletteAt + 1, paletteAt + 769) @@ -62,13 +72,15 @@ export function decodePcx(data: Uint8Array): PcxImage { for (let row = 0; row < height; row += 1) { let column = 0 while (column < bytesPerLine) { - if (read >= paletteAt) throw new PcxError('truncated RLE stream') - const marker = data[read]! + if (read >= paletteAt) throw new TruncatedDataError('pcx', 'rle stream', read, 1, 0, paletteAt) + const marker = data[read] + if (marker === undefined) throw new TruncatedDataError('pcx', 'rle stream', read, 1, 0, paletteAt) read += 1 if ((marker & 0xc0) === 0xc0) { const run = marker & 0x3f - if (read >= data.byteLength) throw new PcxError('truncated RLE run') - const value = data[read]! + if (read >= paletteAt) throw new TruncatedDataError('pcx', 'rle run', read, 1, 0, paletteAt) + const value = data[read] + if (value === undefined) throw new TruncatedDataError('pcx', 'rle run', read, 1, 0, paletteAt) read += 1 for (let i = 0; i < run && column < bytesPerLine; i += 1, column += 1) { if (column < width) pixels[row * width + column] = value @@ -83,16 +95,19 @@ export function decodePcx(data: Uint8Array): PcxImage { for (let row = 0; row < height; row += 1) { for (let column = 0; column < width; column += 1) { const at = 128 + row * bytesPerLine + column - if (at >= paletteAt) throw new PcxError('truncated raw image') - pixels[row * width + column] = data[at]! + if (at >= paletteAt) throw new TruncatedDataError('pcx', 'raw image', at, 1, 0, paletteAt) + const val = data[at] + if (val === undefined) throw new TruncatedDataError('pcx', 'raw image', at, 1, 0, paletteAt) + pixels[row * width + column] = val } } } else { - throw new PcxError(`unsupported encoding ${String(encoding)}`) + throw new InvalidFieldError('pcx', 'encoding', 2, encoding, '0 or 1') } const rgba = new Uint8ClampedArray(width * height * 4) for (let i = 0; i < pixels.length; i += 1) { + // pixels[i] max is 255 const index = pixels[i]! * 3 rgba[i * 4] = palette[index]! rgba[i * 4 + 1] = palette[index + 1]! diff --git a/src/formats/pl2.ts b/src/formats/pl2.ts index 5b3b01d..1b3a4ee 100644 --- a/src/formats/pl2.ts +++ b/src/formats/pl2.ts @@ -1,3 +1,5 @@ +import { ByteReader, FormatError, TruncatedDataError } from "./reader"; + /** * Diablo II `.pl2` palette+transform decoder. * @@ -136,34 +138,26 @@ export function pl2ExpectedSize(): number { export function decodePl2(data: Uint8Array): Pl2 { const expected = pl2ExpectedSize() if (data.byteLength < expected) { - throw new Pl2Error(`PL2 is ${String(data.byteLength)} bytes, expected ${String(expected)}`) + throw new TruncatedDataError('pl2', 'file', 0, expected, data.byteLength, data.byteLength) } - // The base palette interleaves an unused byte per entry; the sprite decoders - // want dense RGB triples, so it is repacked rather than aliased. + const r = new ByteReader(data, 'pl2') + const rgb = new Uint8Array(PALETTE_COLORS * 3) for (let index = 0; index < PALETTE_COLORS; index += 1) { - const at = index * BASE_ENTRY_BYTES - rgb[index * 3] = data[at]! - rgb[index * 3 + 1] = data[at + 1]! - rgb[index * 3 + 2] = data[at + 2]! + rgb[index * 3] = r.u8('base r') + rgb[index * 3 + 1] = r.u8('base g') + rgb[index * 3 + 2] = r.u8('base b') + r.skip(1, 'base unused') } - // Transform tables are read as views: 1727 tables is ~442 KB, and nothing - // here needs its own copy. - let cursor = BASE_BYTES const take = (count: number): Pl2Transform[] => { const tables: Pl2Transform[] = [] for (let index = 0; index < count; index += 1) { - tables.push(data.subarray(cursor, cursor + PALETTE_COLORS)) - cursor += PALETTE_COLORS + tables.push(r.bytes(PALETTE_COLORS, 'table').slice()) } return tables } - const takeOne = (): Pl2Transform => { - const table = data.subarray(cursor, cursor + PALETTE_COLORS) - cursor += PALETTE_COLORS - return table - } + const takeOne = (): Pl2Transform => r.bytes(PALETTE_COLORS, 'table').slice() const lightLevels = take(LIGHT_LEVEL_VARIATIONS) const inverseColors = take(INV_COLOR_VARIATIONS) @@ -185,20 +179,13 @@ export function decodePl2(data: Uint8Array): Pl2 { const textRgb = new Uint8Array(TEXT_COLORS * TEXT_ENTRY_BYTES) for (let index = 0; index < TEXT_COLORS; index += 1) { - const at = cursor + index * TEXT_ENTRY_BYTES - textRgb[index * 3] = data[at]! - textRgb[index * 3 + 1] = data[at + 1]! - textRgb[index * 3 + 2] = data[at + 2]! + textRgb[index * 3] = r.u8('text r') + textRgb[index * 3 + 1] = r.u8('text g') + textRgb[index * 3 + 2] = r.u8('text b') } - cursor += TEXT_COLORS * TEXT_ENTRY_BYTES const textShifts = take(TEXT_SHIFTS) - if (cursor !== expected) { - /* v8 ignore next -- the size check above makes this unreachable for well-formed files. */ - throw new Pl2Error(`PL2 layout consumed ${String(cursor)} bytes, expected ${String(expected)}`) - } - return { rgb, lightLevels, inverseColors, selectedUnitShift, alphaBlend, diff --git a/src/formats/tbl.ts b/src/formats/tbl.ts index b096e9e..b23be5a 100644 --- a/src/formats/tbl.ts +++ b/src/formats/tbl.ts @@ -1,3 +1,4 @@ +import { requireBytes } from './reader' /** * Diablo II `.tbl` string-table decoder (hash-bucket layout). * @@ -24,11 +25,13 @@ export function tblHash(key: Uint8Array, hashTableSize: number): number { } function u16(data: Uint8Array, at: number): number { - return data[at]! | (data[at + 1]! << 8) + 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 { - return (data[at]! | (data[at + 1]! << 8) | (data[at + 2]! << 16) | (data[at + 3]! << 24)) >>> 0 + 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 { diff --git a/src/mpq/archive.ts b/src/mpq/archive.ts index ef13cf2..b197e5f 100644 --- a/src/mpq/archive.ts +++ b/src/mpq/archive.ts @@ -491,11 +491,9 @@ export class MpqArchive { function wordsOf(bytes: Uint8Array): Uint32Array { const count = bytes.byteLength >>> 2 const words = new Uint32Array(count) + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) for (let i = 0; i < count; i += 1) { - words[i] = (bytes[i * 4]!) - | ((bytes[i * 4 + 1]!) << 8) - | ((bytes[i * 4 + 2]!) << 16) - | ((bytes[i * 4 + 3]!) << 24) + words[i] = view.getUint32(i * 4, true) } return words } diff --git a/src/mpq/decompress.ts b/src/mpq/decompress.ts index 623e78e..14eed56 100644 --- a/src/mpq/decompress.ts +++ b/src/mpq/decompress.ts @@ -85,7 +85,8 @@ async function inflateZlib(data: Uint8Array): Promise { */ export async function decompressSector(input: Uint8Array, expectedSize: number): Promise { if (input.byteLength === 0) return input - const mask = input[0]! + const mask = input[0] + if (mask === undefined) throw new Error('Truncated sector') if (mask === 0) { // A zero mask marks a stored payload that merely carried the marker byte. return input.subarray(1, 1 + expectedSize) diff --git a/src/mpq/implode.ts b/src/mpq/implode.ts index 63dfa33..406197d 100644 --- a/src/mpq/implode.ts +++ b/src/mpq/implode.ts @@ -69,8 +69,8 @@ function generateDecodeTabs( ): Uint8Array { const positions = new Uint8Array(0x100) for (let i = 0; i < elements; i += 1) { - const length = 1 << lengthBits[i]! - for (let index = startIndexes[i]!; index < 0x100; index += length) positions[index] = i + const length = 1 << (lengthBits[i] ?? 0) + for (let index = (startIndexes[i] ?? 0); index < 0x100; index += length) positions[index] = i } return positions } @@ -108,8 +108,8 @@ function buildAsciiTables(): AsciiTables { const offs2EB4 = new Uint8Array(0x100) for (let count = 0xff; count >= 0; count -= 1) { - const code = CHCODEASC[count]! - let bits = chBits[count]! + 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 @@ -199,7 +199,7 @@ class BitStream { } this.buffer >>>= this.extraBits if (this.position >= this.input.length) return true - this.buffer |= this.input[this.position]! << 8 + this.buffer |= (this.input[this.position] ?? 0) << 8 this.position += 1 this.buffer >>>= bits - this.extraBits const rest = this.extraBits - bits + 8 @@ -219,15 +219,15 @@ class BitStream { function decodeLit(stream: BitStream): number { if ((stream.peek16() & 1) !== 0) { if (stream.waste(1)) return LIT_ERROR - const lengthCode = LENGTH_CODES[stream.peek8()]! - if (stream.waste(LENBITS[lengthCode]!)) return LIT_ERROR - const extraLengthBits = EXLENBITS[lengthCode]! + 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]! + extraLength + 0x100 + return (LENBASE[lengthCode] ?? 0) + extraLength + 0x100 } if (stream.waste(1)) return LIT_ERROR @@ -238,21 +238,21 @@ function decodeLit(stream: BitStream): number { let value: number if (stream.peek8() !== 0) { - value = ASCII.offs2C34[stream.peek8()]! + 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()]! + value = (ASCII.offs2D34[stream.peek8()] ?? 0) } else { if (stream.waste(6)) return LIT_ERROR - value = ASCII.offs2E34[stream.peek16() & 0x7f]! + value = (ASCII.offs2E34[stream.peek16() & 0x7f] ?? 0) } } } else { if (stream.waste(8)) return LIT_ERROR - value = ASCII.offs2EB4[stream.peek8()]! + value = (ASCII.offs2EB4[stream.peek8()] ?? 0) } - return stream.waste(ASCII.chBits[value]!) ? LIT_ERROR : value + return stream.waste((ASCII.chBits[value] ?? 0)) ? LIT_ERROR : value } /** @@ -263,8 +263,8 @@ function decodeLit(stream: BitStream): number { * @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()]! - if (stream.waste(DISTBITS[distPosCode]!)) return 0 + 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. @@ -284,11 +284,11 @@ function decodeDist(stream: BitStream, repLength: number): number { */ 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]! - const dsizeBits = input[1]! + 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]!) + const stream = new BitStream(input, ctype, dsizeBits, (input[2] ?? 0)) const out = new Uint8Array(expectedSize) let written = 0 @@ -309,7 +309,7 @@ export function explode(input: Uint8Array, expectedSize: number): Uint8Array { } // 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]! + for (let i = 0; i < repLength; i += 1) out[written + i] = (out[source + i] ?? 0) written += repLength } else { if (written >= expectedSize) {