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) {