fix(formats): 替换原始缓冲区的未检查读取

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.
This commit is contained in:
troytt 2026-09-14 13:16:54 +00:00
parent a6ca337a10
commit 53f52ba61e
14 changed files with 275 additions and 296 deletions

View File

@ -1,3 +1,5 @@
import { InvalidFieldError, TruncatedDataError } from "./reader";
/** /**
* Least-significant-bit-first bit reader for Diablo II's packed sprite * Least-significant-bit-first bit reader for Diablo II's packed sprite
* containers. * containers.
@ -82,11 +84,11 @@ export class BitReader {
getBit(): number { getBit(): number {
const position = this.position const position = this.position
if (position >= this.data.length * 8) { 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.position = position + 1
this.read += 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 { getBits(count: number): number {
if (count === 0) return 0 if (count === 0) return 0
if (count < 0 || count > MAX_READ_BITS || !Number.isInteger(count)) { 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 data = this.data
const start = this.position const start = this.position
const end = start + count const end = start + count
if (end > data.length * 8) { if (end > data.length * 8) {
throw new Error( throw new TruncatedDataError('dcc', 'bits', start >>> 3, Math.ceil(end / 8) - (start >>> 3), data.length - (start >>> 3), data.length)
`reading ${String(count)} bits at bit ${String(start)} overruns the ${String(data.length)}-byte buffer`,
)
} }
let result = 0 let result = 0
let shift = 0 let shift = 0
@ -117,7 +117,7 @@ export class BitReader {
while (position < end) { while (position < end) {
const bit = position & 7 const bit = position & 7
const take = Math.min(8 - bit, end - position) 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 result |= chunk << shift
shift += take shift += take
position += take position += take

View File

@ -54,14 +54,20 @@ const DEFAULT_MAX_FRAMES = 20000
/** Default guard for one frame (a 4096×4096 indexed frame). */ /** Default guard for one frame (a 4096×4096 indexed frame). */
const DEFAULT_MAX_FRAME_PIXELS = 1 << 24 const DEFAULT_MAX_FRAME_PIXELS = 1 << 24
import { requireBytes } from './reader'
/** Read a little-endian uint32. */ /** Read a little-endian uint32. */
function u32(data: Uint8Array, at: number): number { 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. */ /** Read a little-endian uint16. */
function u16(data: Uint8Array, at: number): number { 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. */ /** 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 let remaining = width
while (remaining > 0) { while (remaining > 0) {
if (cursor >= end) return { error: `row ${String(height)} ran past the frame end` } 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 cursor += 1
if (control >= 0x80) { if (control >= 0x80) {
remaining -= 256 - control remaining -= 256 - control
@ -200,7 +207,8 @@ function decodeCl2FrameOutcome(data: Uint8Array, start: number, end: number, wid
let remaining = width - xOffset let remaining = width - xOffset
while (remaining > 0) { while (remaining > 0) {
if (cursor >= end) return { ok: false, error: 'runs ran past the frame end' } 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 cursor += 1
if (control < 0x80) { if (control < 0x80) {
// Transparent run of `control` pixels. // 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. // Fill run: one colour repeated 0xBF - control times.
const count = 0xbf - control const count = 0xbf - control
if (cursor >= end) return { ok: false, error: 'fill run is truncated' } 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 cursor += 1
if (at + count > capacity) return { ok: false, error: 'frame exceeds the pixel guard' } if (at + count > capacity) return { ok: false, error: 'frame exceeds the pixel guard' }
indices.fill(color, at, at + count) indices.fill(color, at, at + count)

View File

@ -25,7 +25,7 @@
* is exactly why a renderer must resolve them through the layer records instead * is exactly why a renderer must resolve them through the layer records instead
* of using them directly. That resolution is `cofLayerOrder`. * 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. */ /** Fixed part of the file, up to and including the animation-speed byte. */
const HEADER_SIZE = 25 const HEADER_SIZE = 25
@ -82,65 +82,56 @@ export interface CofFile {
readonly priority: readonly (readonly (readonly number[])[])[] 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 { 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 const minimum = HEADER_SIZE + BODY_PREFIX_SIZE
if (data.byteLength < minimum) { r.seek(minimum, 'layers start')
throw new CelError(`COF is ${String(data.byteLength)} bytes, too short for a ${String(minimum)}-byte header`)
}
const numberOfLayers = data[0]!
const framesPerDirection = data[1]!
const numberOfDirections = data[2]!
const speed = data[HEADER_SPEED]!
let offset = minimum
const layers: CofLayer[] = [] const layers: CofLayer[] = []
for (let i = 0; i < numberOfLayers; i += 1) { for (let i = 0; i < numberOfLayers; i += 1) {
if (offset + LAYER_SIZE > data.byteLength) { const layerOffset = minimum + i * LAYER_SIZE
throw new CelError( r.seek(layerOffset, 'layer')
`COF layer ${String(i)} runs past the end of the ${String(data.byteLength)}-byte file`, 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({ layers.push({
type: data[offset]!, type,
shadow: data[offset + 1]!, shadow,
selectable: data[offset + 2]! > 0, selectable,
transparent: data[offset + 3]! > 0, transparent,
drawEffect: data[offset + 4]!, drawEffect,
weaponClass: readWeaponClass(data, offset + LAYER_WEAPON_CLASS), weaponClass: text.trim(),
}) })
offset += LAYER_SIZE
} }
if (offset + framesPerDirection > data.byteLength) { const animOffset = minimum + numberOfLayers * LAYER_SIZE
throw new CelError( r.seek(animOffset, 'animation frames')
`COF declares ${String(framesPerDirection)} frames but only ${String(data.byteLength - offset)} bytes remain`, const animationFrames = r.bytes(framesPerDirection, 'animation frames').slice()
)
}
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 priority: number[][][] = [] const priority: number[][][] = []
let at = offset
for (let direction = 0; direction < numberOfDirections; direction += 1) { for (let direction = 0; direction < numberOfDirections; direction += 1) {
const frames: number[][] = [] const frames: number[][] = []
for (let frame = 0; frame < framesPerDirection; frame += 1) { for (let frame = 0; frame < framesPerDirection; frame += 1) {
const row: number[] = [] const row: number[] = []
for (let layer = 0; layer < numberOfLayers; layer += 1) row.push(data[at + layer]!) for (let layer = 0; layer < numberOfLayers; layer += 1) {
at += numberOfLayers row.push(r.u8('priority'))
}
frames.push(row) frames.push(row)
} }
priority.push(frames) priority.push(frames)
@ -175,15 +166,11 @@ export function decodeCof(data: Uint8Array): CofFile {
export function cofLayerOrder(cof: CofFile, direction: number, frame: number): number[] { export function cofLayerOrder(cof: CofFile, direction: number, frame: number): number[] {
const frames = cof.priority[direction] const frames = cof.priority[direction]
if (frames === undefined) { if (frames === undefined) {
throw new CelError( throw new InvalidFieldError('cof', 'direction', 0, direction, `0..${String(cof.numberOfDirections - 1)}`)
`COF has no direction ${String(direction)} (it declares ${String(cof.numberOfDirections)})`,
)
} }
const row = frames[frame] const row = frames[frame]
if (row === undefined) { if (row === undefined) {
throw new CelError( throw new InvalidFieldError('cof', 'frame', 0, frame, `0..${String(cof.framesPerDirection - 1)}`)
`COF direction ${String(direction)} has no frame ${String(frame)} (it declares ${String(cof.framesPerDirection)})`,
)
} }
const order: number[] = [] const order: number[] = []
@ -203,22 +190,4 @@ export function cofLayerOrder(cof: CofFile, direction: number, frame: number): n
return order 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()
}

View File

@ -19,7 +19,7 @@
* entry unused), which is why frames carry a mask rather than relying on a * entry unused), which is why frames carry a mask rather than relying on a
* sentinel index. * sentinel index.
*/ */
import { CelError } from './sprite.ts' import { FormatError, InvalidFieldError, TruncatedDataError, ByteReader, requireBytes } from './reader'
import type { SpriteFrame, SpriteGroup, SpriteSheet } from './sprite.ts' import type { SpriteFrame, SpriteGroup, SpriteSheet } from './sprite.ts'
/** Byte offset of the file header, and its size. */ /** Byte offset of the file header, and its size. */
@ -70,28 +70,6 @@ const MAX_FRAMES = 4096
/** Guard on one frame's pixel count. */ /** Guard on one frame's pixel count. */
const MAX_FRAME_PIXELS = 1 << 24 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. * Decode a DC6 file.
* *
@ -99,25 +77,26 @@ function i32(data: Uint8Array, at: number): number {
* @returns the decoded sheet. * @returns the decoded sheet.
*/ */
export function decodeDc6(data: Uint8Array): Dc6Sheet { export function decodeDc6(data: Uint8Array): Dc6Sheet {
if (data.byteLength < FILE_HEADER_SIZE) { const r = new ByteReader(data, 'dc6')
throw new CelError(`DC6 is ${String(data.byteLength)} bytes, too short for a header`) const version = r.i32le('version')
} const flags = r.u32le('flags')
const header: Dc6Header = { const encoding = r.u32le('encoding')
version: i32(data, 0x00), r.seek(0x10, 'directions')
flags: u32(data, 0x04), const directions = r.i32le('directions')
encoding: u32(data, 0x08), const framesPerDirection = r.i32le('framesPerDirection')
directions: i32(data, 0x10),
framesPerDirection: i32(data, 0x14), const header: Dc6Header = { version, flags, encoding, directions, framesPerDirection }
}
if (header.directions <= 0 || header.framesPerDirection <= 0) { 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 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<number>(total) const pointers = new Array<number>(total)
r.seek(FILE_HEADER_SIZE, 'pointers')
for (let index = 0; index < total; index += 1) { 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[] }[] = [] const groups: { frames: Dc6Frame[] }[] = []
@ -126,10 +105,10 @@ export function decodeDc6(data: Uint8Array): Dc6Sheet {
for (let frameIndex = 0; frameIndex < header.framesPerDirection; frameIndex += 1) { for (let frameIndex = 0; frameIndex < header.framesPerDirection; frameIndex += 1) {
const index = direction * header.framesPerDirection + frameIndex const index = direction * header.framesPerDirection + frameIndex
// The last frame has no successor pointer: it runs to the end of file. // 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<number>, not untrusted data bytes
const end = index + 1 < total ? pointers[index + 1]! : data.byteLength const end = index + 1 < total ? pointers[index + 1]! : data.byteLength
if (start < FILE_HEADER_SIZE || end > data.byteLength || end < start) { 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)) frames.push(decodeFrame(data, start, end, index))
} }
@ -149,27 +128,33 @@ export function decodeDc6(data: Uint8Array): Dc6Sheet {
* @returns the decoded frame. * @returns the decoded frame.
*/ */
function decodeFrame(data: Uint8Array, start: number, end: number, index: number): Dc6Frame { function decodeFrame(data: Uint8Array, start: number, end: number, index: number): Dc6Frame {
if (start + FRAME_HEADER_SIZE > end) { const r = new ByteReader(data, 'dc6')
throw new CelError(`frame ${String(index)} is too short for a frame header`) r.seek(start, 'frame header')
} r.skip(4, 'flipped')
const width = i32(data, start + 0x04) const width = r.i32le('width')
const height = i32(data, start + 0x08) const height = r.i32le('height')
const offsetX = i32(data, start + 0x0c) const offsetX = r.i32le('offsetX')
const offsetY = i32(data, start + 0x10) const offsetY = r.i32le('offsetY')
const length = u32(data, start + 0x1c) r.skip(8, 'reserved')
const length = r.u32le('length')
if (width < 0 || height < 0 || width * height > MAX_FRAME_PIXELS) { 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 indices = new Uint8Array(width * height)
const mask = 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 let cursor = start + FRAME_HEADER_SIZE
// Rows land bottom-up: the first scanline written is the frame's last row. // Rows land bottom-up: the first scanline written is the frame's last row.
let x = 0 let x = 0
let y = height - 1 let y = height - 1
let complete = false let complete = false
while (cursor < streamEnd && y >= 0) { while (cursor < streamEnd && y >= 0) {
const control = data[cursor]! const control = data[cursor]
if (control === undefined) break
cursor += 1 cursor += 1
if (control === END_OF_SCANLINE) { if (control === END_OF_SCANLINE) {
if (y === 0) { complete = true; break } 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 x += control & RUN_LENGTH_MASK
} else { } else {
if (cursor + control > streamEnd) { 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 const rowStart = y * width
for (let i = 0; i < control; i += 1) { for (let i = 0; i < control; i += 1) {
const at = rowStart + x + i 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 if (x + i >= width) break
const value = data[cursor + i]! const value = data[cursor + i]
if (value === undefined) break
indices[at] = value indices[at] = value
// Index 0 is the transparent entry by convention. // Index 0 is the transparent entry by convention.
mask[at] = value === 0 ? 0 : 1 mask[at] = value === 0 ? 0 : 1

View File

@ -218,7 +218,7 @@ export function decodeDcc(data: Uint8Array): DccFile {
const directions: DccDirection[] = [] const directions: DccDirection[] = []
for (let i = 0; i < directionCount; i += 1) { 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 } return { version, directions }
@ -268,13 +268,13 @@ function decodeDirectionInner(
reader.getUint32() // out size coded, unused reader.getUint32() // out size coded, unused
const compressionFlags = reader.getBits(2) const compressionFlags = reader.getBits(2)
const widths: FieldWidths = { const widths: FieldWidths = {
variable0: BIT_WIDTH_TABLE[reader.getBits(4)]!, variable0: (BIT_WIDTH_TABLE[reader.getBits(4)] ?? 0),
width: BIT_WIDTH_TABLE[reader.getBits(4)]!, width: (BIT_WIDTH_TABLE[reader.getBits(4)] ?? 0),
height: BIT_WIDTH_TABLE[reader.getBits(4)]!, height: (BIT_WIDTH_TABLE[reader.getBits(4)] ?? 0),
xOffset: BIT_WIDTH_TABLE[reader.getBits(4)]!, xOffset: (BIT_WIDTH_TABLE[reader.getBits(4)] ?? 0),
yOffset: BIT_WIDTH_TABLE[reader.getBits(4)]!, yOffset: (BIT_WIDTH_TABLE[reader.getBits(4)] ?? 0),
optionalData: BIT_WIDTH_TABLE[reader.getBits(4)]!, optionalData: (BIT_WIDTH_TABLE[reader.getBits(4)] ?? 0),
codedBytes: BIT_WIDTH_TABLE[reader.getBits(4)]!, codedBytes: (BIT_WIDTH_TABLE[reader.getBits(4)] ?? 0),
} }
const frames: FrameState[] = [] const frames: FrameState[] = []
@ -363,8 +363,10 @@ function decodeDirectionInner(
const decoded: DccFrame[] = [] const decoded: DccFrame[] = []
for (let i = 0; i < frames.length; i += 1) { for (let i = 0; i < frames.length; i += 1) {
const frame = frames[i]! const frame = frames[i]
const pixels = canvases[i]! if (!frame) continue
const pixels = canvases[i]
if (!pixels) continue
if (frame.bottomUp) flipFrameRows(pixels, box, frame.box) if (frame.bottomUp) flipFrameRows(pixels, box, frame.box)
decoded.push({ decoded.push({
width: frame.width, width: frame.width,
@ -446,8 +448,8 @@ function calculateCells(state: DirectionState): void {
for (let y = 0; y < vertical; y += 1) { for (let y = 0; y < vertical; y += 1) {
for (let x = 0; x < horizontal; x += 1) { for (let x = 0; x < horizontal; x += 1) {
cells.push({ cells.push({
width: cellWidths[x]!, width: cellWidths[x] ?? 0,
height: cellHeights[y]!, height: cellHeights[y] ?? 0,
xOffset: x * CELL_SIZE, xOffset: x * CELL_SIZE,
yOffset: y * CELL_SIZE, yOffset: y * CELL_SIZE,
lastWidth: -1, lastWidth: -1,
@ -501,10 +503,10 @@ function recalculateCells(state: DirectionState, frame: FrameState): void {
for (let y = 0; y < vertical; y += 1) { for (let y = 0; y < vertical; y += 1) {
let xOffset = frame.box.left - box.left let xOffset = frame.box.left - box.left
for (let x = 0; x < horizontal; x += 1) { for (let x = 0; x < horizontal; x += 1) {
cells.push({ width: cellWidths[x]!, height: cellHeights[y]!, xOffset, yOffset }) cells.push({ width: cellWidths[x] ?? 0, height: cellHeights[y] ?? 0, xOffset, yOffset })
xOffset += cellWidths[x]! xOffset += cellWidths[x] ?? 0
} }
yOffset += cellHeights[y]! yOffset += cellHeights[y] ?? 0
} }
frame.cells = cells frame.cells = cells
} }
@ -567,7 +569,8 @@ function fillPixelBuffer(
let entryIndex = -1 let entryIndex = -1
for (let frameIndex = 0; frameIndex < state.frames.length; frameIndex += 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 originCellX = Math.trunc((frame.box.left - state.box.left) / CELL_SIZE)
const originCellY = Math.trunc((frame.box.top - state.box.top) / CELL_SIZE) const originCellY = Math.trunc((frame.box.top - state.box.top) / CELL_SIZE)
@ -575,7 +578,7 @@ function fillPixelBuffer(
const gridY = cellY + originCellY const gridY = cellY + originCellY
for (let cellX = 0; cellX < frame.horizontalCellCount; cellX += 1) { for (let cellX = 0; cellX < frame.horizontalCellCount; cellX += 1) {
const gridCell = originCellX + cellX + gridY * state.horizontalCellCount const gridCell = originCellX + cellX + gridY * state.horizontalCellCount
const previous = slots[gridCell]! const previous = slots[gridCell] ?? null
let mask: number let mask: number
if (previous === null) { if (previous === null) {
mask = 0xf // nothing to inherit from, so every code is supplied mask = 0xf // nothing to inherit from, so every code is supplied
@ -586,7 +589,7 @@ function fillPixelBuffer(
let lastPixel = 0 let lastPixel = 0
const pixelStack: [number, number, number, number] = [0, 0, 0, 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 const encoded = pixelCount !== 0 && state.encodingTypeBits > 0 ? encodingType.getBit() : 0
let decoded = 0 let decoded = 0
for (let i = 0; i < pixelCount; i += 1) { for (let i = 0; i < pixelCount; i += 1) {
@ -622,10 +625,10 @@ function fillPixelBuffer(
let code = decoded - 1 let code = decoded - 1
for (let i = 0; i < CELL_SIZE; i += 1) { for (let i = 0; i < CELL_SIZE; i += 1) {
if ((mask & (1 << i)) !== 0) { 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 code -= 1
} else { } else {
entry.value[i] = previous === null ? 0 : previous.value[i]! entry.value[i] = previous === null ? 0 : (previous.value[i] ?? 0)
} }
} }
slots[gridCell] = entry slots[gridCell] = entry
@ -638,8 +641,9 @@ function fillPixelBuffer(
// Buffer values are indices into the direction's dense palette table, not // 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. // palette entries; resolve them once so the frame pass can use them directly.
for (let i = 0; i <= entryIndex; i += 1) { for (let i = 0; i <= entryIndex; i += 1) {
const entry = buffer[i]! const entry = buffer[i]
for (let x = 0; x < CELL_SIZE; x += 1) entry.value[x] = palette[entry.value[x]!]! if (!entry) continue
for (let x = 0; x < CELL_SIZE; x += 1) entry.value[x] = (palette[entry.value[x] ?? 0] ?? 0)
} }
return buffer return buffer
@ -670,13 +674,16 @@ function generateFrames(state: DirectionState, buffer: PixelBufferEntry[], codes
const canvases: Uint8Array[] = [] const canvases: Uint8Array[] = []
let entryIndex = 0 let entryIndex = 0
for (let frameIndex = 0; frameIndex < state.frames.length; frameIndex += 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 canvas = new Uint8Array(stride * state.box.height) const canvas = new Uint8Array(stride * state.box.height)
for (let c = 0; c < frame.cells.length; c += 1) { 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 gridX = Math.trunc(cell.xOffset / CELL_SIZE)
const gridY = Math.trunc(cell.yOffset / 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] const entry = buffer[entryIndex]
if (entry === undefined) { if (entry === undefined) {
throw new CelError(`frame ${String(frameIndex)} cell ${String(c)}: pixel buffer underrun`) 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) { for (let y = 0; y < cell.height; y += 1) {
const from = slot.lastXOffset + (y + slot.lastYOffset) * stride const from = slot.lastXOffset + (y + slot.lastYOffset) * stride
const to = cell.xOffset + (y + cell.yOffset) * 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) 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. // One flat colour for the whole cell, so no per-pixel codes follow.
for (let y = 0; y < cell.height; y += 1) { for (let y = 0; y < cell.height; y += 1) {
const row = cell.xOffset + (y + cell.yOffset) * stride 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 { } else {
// One bit per pixel when only two codes survive, two bits when there // 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 const bits = entry.value[1] !== entry.value[2] ? 2 : 1
for (let y = 0; y < cell.height; y += 1) { for (let y = 0; y < cell.height; y += 1) {
const row = cell.xOffset + (y + cell.yOffset) * stride 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) 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 { function blit(from: Uint8Array, to: Uint8Array, cell: CellRect, stride: number): void {
for (let y = 0; y < cell.height; y += 1) { for (let y = 0; y < cell.height; y += 1) {
const row = cell.xOffset + (y + cell.yOffset) * stride 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 upper = (top + y) * directionBox.width + left
const lower = (top + frameBox.height - 1 - y) * directionBox.width + left const lower = (top + frameBox.height - 1 - y) * directionBox.width + left
for (let x = 0; x < frameBox.width; x += 1) { for (let x = 0; x < frameBox.width; x += 1) {
const swap = pixels[upper + x]! const swap = (pixels[upper + x] ?? 0)
pixels[upper + x] = pixels[lower + x]! pixels[upper + x] = (pixels[lower + x] ?? 0)
pixels[lower + x] = swap pixels[lower + x] = swap
} }
} }

View File

@ -1,3 +1,5 @@
import { CelError } from "./sprite";
/** /**
* Diablo II `.ds1` map decoder. * Diablo II `.ds1` map decoder.
* *
@ -13,7 +15,7 @@
* versions below 4. Both are encoded here exactly as the reference decoder * versions below 4. Both are encoded here exactly as the reference decoder
* reads them, including the pre-7 orientation lookup table. * 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. */ /** Pre-7 maps store orientations through this lookup rather than directly. */
const LEGACY_DIRECTION_LOOKUP = [ const LEGACY_DIRECTION_LOOKUP = [
@ -153,7 +155,7 @@ class Cursor {
*/ */
take(count: number): Uint8Array { take(count: number): Uint8Array {
if (count < 0 || this.at + count > this.data.byteLength) { 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) const slice = this.data.subarray(this.at, this.at + count)
this.at += count this.at += count
@ -167,7 +169,7 @@ class Cursor {
*/ */
int32(): number { int32(): number {
const bytes = this.take(4) 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 { uint32(): number {
const bytes = this.take(4) 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 { cstring(): string {
let out = '' let out = ''
for (;;) { for (;;) {
const byte = this.take(1)[0]! const bytes = this.take(1)
if (byte === 0) return out const byte = bytes[0]
if (byte === undefined || byte === 0) return out
out += String.fromCharCode(byte) out += String.fromCharCode(byte)
} }
} }
@ -202,14 +205,14 @@ class Cursor {
* @returns the decoded map. * @returns the decoded map.
*/ */
export function decodeDs1(data: Uint8Array): Ds1 { 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 cursor = new Cursor(data)
const version = cursor.int32() const version = cursor.int32()
// Stored width/height are one less than the tile count. // Stored width/height are one less than the tile count.
const width = cursor.int32() + 1 const width = cursor.int32() + 1
const height = cursor.int32() + 1 const height = cursor.int32() + 1
if (width <= 0 || height <= 0 || width * height > (1 << 22)) { 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 let act = 1
@ -225,7 +228,7 @@ export function decodeDs1(data: Uint8Array): Ds1 {
const files: string[] = [] const files: string[] = []
if (version >= V_FILES) { if (version >= V_FILES) {
const count = cursor.int32() 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()) 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 else floorLayers = 1
} }
if (wallLayers < 0 || wallLayers > 4 || floorLayers < 0 || floorLayers > 2) { 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 const shadowLayers = 1
@ -270,7 +273,7 @@ export function decodeDs1(data: Uint8Array): Ds1 {
const objects: Ds1Object[] = [] const objects: Ds1Object[] = []
if (version >= 2) { if (version >= 2) {
const count = cursor.int32() 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) { for (let index = 0; index < count; index += 1) {
objects.push({ objects.push({
type: cursor.int32(), type: cursor.int32(),

View File

@ -1,24 +1,6 @@
/** import { CelError } from "./sprite";
* Diablo II `.dt1` tile decoder.
* import { FormatError, InvalidFieldError, TruncatedDataError, requireBytes } from './reader'
* 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'
/** Bytes of the fixed header before the tile count. */ /** Bytes of the fixed header before the tile count. */
const HEADER_PREFIX = 8 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 offset >= 0 && offset + block.length <= data.byteLength && block.length >= 0
const candidates = [relative, absolute].filter((offset, index, all) => fits(offset) && all.indexOf(offset) === index) const candidates = [relative, absolute].filter((offset, index, all) => fits(offset) && all.indexOf(offset) === index)
if (candidates.length === 0) { if (candidates.length === 0) {
throw new CelError( throw new InvalidFieldError('dt1', 'block fileOffset', block.headerOffset + 16, Math.max(relative, absolute), 'data fitting the file bounds')
`block data at ${String(relative)} (relative) or ${String(absolute)} (absolute) does not fit the file`,
)
} }
return candidates return candidates
} }
@ -202,7 +182,9 @@ export interface Dt1 {
* @returns the value. * @returns the value.
*/ */
function u32(data: Uint8Array, at: number): number { 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. * @returns the value.
*/ */
function u16(data: Uint8Array, at: number): number { 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 { export function decodeDt1(data: Uint8Array): Dt1 {
if (data.byteLength < HEADER_PREFIX + HEADER_UNKNOWN + 8) { 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 versionMajor = i32(data, 0)
const versionMinor = i32(data, 4) const versionMinor = i32(data, 4)
if (versionMajor !== 7 || versionMinor !== 6) { 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 tileCount = i32(data, HEADER_PREFIX + HEADER_UNKNOWN)
const tileDataStart = i32(data, HEADER_PREFIX + HEADER_UNKNOWN + 4) const tileDataStart = i32(data, HEADER_PREFIX + HEADER_UNKNOWN + 4)
if (tileCount < 0 || tileDataStart < 0 || tileDataStart > data.byteLength) { 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 const tilesEnd = tileDataStart + tileCount * TILE_RECORD_SIZE
if (tilesEnd > data.byteLength) { 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[] = [] const warnings: string[] = []
@ -294,7 +278,7 @@ export function decodeDt1(data: Uint8Array): Dt1 {
// D2MOO D2Collision.cpp: pTmp = &v5[5 * (nY - nCappedY + 4) - nX]. // D2MOO D2Collision.cpp: pTmp = &v5[5 * (nY - nCappedY + 4) - nX].
// Normalize into top-to-bottom row order [subY * 5 + subX]. // Normalize into top-to-bottom row order [subY * 5 + subX].
const fileIndex = (SUB_TILE_GRID - 1 - subY) * SUB_TILE_GRID + 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) const blockHeaderPointer = i32(data, at + 72)
@ -304,16 +288,17 @@ export function decodeDt1(data: Uint8Array): Dt1 {
for (let blockIndex = 0; blockIndex < numBlocks; blockIndex += 1) { for (let blockIndex = 0; blockIndex < numBlocks; blockIndex += 1) {
const headerAt = blockHeaderPointer + blockIndex * BLOCK_HEADER_SIZE const headerAt = blockHeaderPointer + blockIndex * BLOCK_HEADER_SIZE
if (headerAt + BLOCK_HEADER_SIZE > data.byteLength) { 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, // Block header (20 bytes): X, Y, 2 unused, GridX, GridY, Format,
// Length, 2 unused, FileOffset. Note the two unused words: a 16-byte // 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. // 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({ blocks.push({
x: i16(data, headerAt), x: i16(data, headerAt),
y: i16(data, headerAt + 2), y: i16(data, headerAt + 2),
gridX: data[headerAt + 6]!, gridX: view.getUint8(headerAt + 6),
gridY: data[headerAt + 7]!, gridY: view.getUint8(headerAt + 7),
format: i16(data, headerAt + 8), format: i16(data, headerAt + 8),
length: i32(data, headerAt + 10), length: i32(data, headerAt + 10),
fileOffset: i32(data, headerAt + 16), fileOffset: i32(data, headerAt + 16),
@ -398,13 +383,13 @@ function decodeBlock(
const tileWidth = tile.width const tileWidth = tile.width
const tileHeight = bitmapHeight const tileHeight = bitmapHeight
if (tileWidth <= 0 || tileHeight <= 0 || tileWidth * tileHeight > (1 << 22)) { 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 pixels = new Uint8Array(tileWidth * tileHeight)
const decodeWith = (offset: number): Uint8Array => { const decodeWith = (offset: number): Uint8Array => {
const encoded = data.subarray(offset, offset + block.length) const encoded = data.subarray(offset, offset + block.length)
if (encoded.byteLength < 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) const target = new Uint8Array(tileWidth * tileHeight)
if (block.format === 1) { if (block.format === 1) {
@ -427,7 +412,7 @@ function decodeBlock(
best ??= decoded best ??= decoded
} }
/* v8 ignore next -- resolveBlockDataOffset guarantees at least one candidate. */ /* 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 } 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 x = block.x + startX + i
const y = block.y + row + yOffset const y = block.y + row + yOffset
if (x < 0 || x >= tileWidth || y < 0 || y >= tileHeight) { index += 1; continue } 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 index += 1
} }
} }
@ -486,8 +473,9 @@ function decodeRunLength(
let x = 0 let x = 0
let y = 0 let y = 0
while (remaining > 0 && index + 1 < encoded.byteLength) { while (remaining > 0 && index + 1 < encoded.byteLength) {
const skip = encoded[index]! const skip = encoded[index]
const count = encoded[index + 1]! const count = encoded[index + 1]
if (skip === undefined || count === undefined) return
index += 2 index += 2
remaining -= 2 remaining -= 2
if ((skip | count) === 0) { if ((skip | count) === 0) {

View File

@ -1,3 +1,6 @@
import { FormatError } from "./reader";
/** /**
* Palette formats. * Palette formats.
* *
@ -49,6 +52,7 @@ export function decodeTrn(data: Uint8Array): Uint8Array {
return data return data
} }
/** /**
* Expand palette indices into RGBA, optionally through a translation table. * Expand palette indices into RGBA, optionally through a translation table.
* *
@ -64,9 +68,20 @@ export function indicesToRgba(
palette: Palette, palette: Palette,
trn?: Uint8Array, trn?: Uint8Array,
): Uint8ClampedArray { ): 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) const rgba = new Uint8ClampedArray(indices.length * 4)
for (let i = 0; i < indices.length; i += 1) { for (let i = 0; i < indices.length; i += 1) {
if (mask[i] === 0) continue 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 index = trn === undefined ? indices[i]! : trn[indices[i]!]!
const at = index * 3 const at = index * 3
rgba[i * 4] = palette.rgb[at]! rgba[i * 4] = palette.rgb[at]!

View File

@ -7,6 +7,8 @@
* the trailing 769 bytes (0x0C marker + 768 bytes of VGA triples). * the trailing 769 bytes (0x0C marker + 768 bytes of VGA triples).
*/ */
import { ByteReader, FormatError, InvalidFieldError, TruncatedDataError } from './reader'
/** One decoded image, ready to blit. */ /** One decoded image, ready to blit. */
export interface PcxImage { export interface PcxImage {
/** Pixel width. */ /** Pixel width. */
@ -19,9 +21,6 @@ export interface PcxImage {
readonly palette: Uint8Array 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. * Decode a PCX image.
* *
@ -29,28 +28,39 @@ export class PcxError extends Error {}
* @returns the decoded image. * @returns the decoded image.
*/ */
export function decodePcx(data: Uint8Array): PcxImage { export function decodePcx(data: Uint8Array): PcxImage {
if (data.byteLength < 128) throw new PcxError(`truncated header (${String(data.byteLength)} bytes)`) const r = new ByteReader(data, 'pcx')
if (data[0] !== 0x0a) throw new PcxError(`bad manufacturer 0x${(data[0] ?? 0).toString(16)}`) const manufacturer = r.u8('manufacturer')
const encoding = data[2] if (manufacturer !== 0x0a) throw new InvalidFieldError('pcx', 'manufacturer', 0, manufacturer, 0x0a)
const bpp = data[3] r.u8('version')
const planes = data[65] const encoding = r.u8('encoding')
if (bpp !== 8 || planes !== 1) throw new PcxError(`unsupported layout (bpp ${String(bpp)}, planes ${String(planes)})`) const bpp = r.u8('bpp')
const view = new DataView(data.buffer, data.byteOffset, data.byteLength) const xMin = r.u16le('xMin')
const xMin = view.getUint16(4, true) const yMin = r.u16le('yMin')
const yMin = view.getUint16(6, true) const xMax = r.u16le('xMax')
const xMax = view.getUint16(8, true) const yMax = r.u16le('yMax')
const yMax = view.getUint16(10, true)
const bytesPerLine = view.getUint16(66, true) // 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 width = xMax - xMin + 1
const height = yMax - yMin + 1 const height = yMax - yMin + 1
if (width <= 0 || height <= 0 || bytesPerLine < width) { if (width <= 0 || width > 32767 || height <= 0 || height > 32767 || bytesPerLine < width) {
throw new PcxError(`implausible geometry ${String(width)}x${String(height)} (stride ${String(bytesPerLine)})`) throw new InvalidFieldError('pcx', 'geometry', 8, `${String(width)}x${String(height)}`, 'valid geometry')
} }
// The palette is the last 769 bytes, marked by 0x0C. // The palette is the last 769 bytes, marked by 0x0C.
const paletteAt = data.byteLength - 769 const paletteAt = data.byteLength - 769
if (paletteAt < 128 || data[paletteAt] !== 0x0c) { if (paletteAt < 128) {
throw new PcxError('missing 256-colour palette trailer') 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) 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) { for (let row = 0; row < height; row += 1) {
let column = 0 let column = 0
while (column < bytesPerLine) { while (column < bytesPerLine) {
if (read >= paletteAt) throw new PcxError('truncated RLE stream') if (read >= paletteAt) throw new TruncatedDataError('pcx', 'rle stream', read, 1, 0, paletteAt)
const marker = data[read]! const marker = data[read]
if (marker === undefined) throw new TruncatedDataError('pcx', 'rle stream', read, 1, 0, paletteAt)
read += 1 read += 1
if ((marker & 0xc0) === 0xc0) { if ((marker & 0xc0) === 0xc0) {
const run = marker & 0x3f const run = marker & 0x3f
if (read >= data.byteLength) throw new PcxError('truncated RLE run') if (read >= paletteAt) throw new TruncatedDataError('pcx', 'rle run', read, 1, 0, paletteAt)
const value = data[read]! const value = data[read]
if (value === undefined) throw new TruncatedDataError('pcx', 'rle run', read, 1, 0, paletteAt)
read += 1 read += 1
for (let i = 0; i < run && column < bytesPerLine; i += 1, column += 1) { for (let i = 0; i < run && column < bytesPerLine; i += 1, column += 1) {
if (column < width) pixels[row * width + column] = value 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 row = 0; row < height; row += 1) {
for (let column = 0; column < width; column += 1) { for (let column = 0; column < width; column += 1) {
const at = 128 + row * bytesPerLine + column const at = 128 + row * bytesPerLine + column
if (at >= paletteAt) throw new PcxError('truncated raw image') if (at >= paletteAt) throw new TruncatedDataError('pcx', 'raw image', at, 1, 0, paletteAt)
pixels[row * width + column] = data[at]! const val = data[at]
if (val === undefined) throw new TruncatedDataError('pcx', 'raw image', at, 1, 0, paletteAt)
pixels[row * width + column] = val
} }
} }
} else { } 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) const rgba = new Uint8ClampedArray(width * height * 4)
for (let i = 0; i < pixels.length; i += 1) { for (let i = 0; i < pixels.length; i += 1) {
// pixels[i] max is 255
const index = pixels[i]! * 3 const index = pixels[i]! * 3
rgba[i * 4] = palette[index]! rgba[i * 4] = palette[index]!
rgba[i * 4 + 1] = palette[index + 1]! rgba[i * 4 + 1] = palette[index + 1]!

View File

@ -1,3 +1,5 @@
import { ByteReader, FormatError, TruncatedDataError } from "./reader";
/** /**
* Diablo II `.pl2` palette+transform decoder. * Diablo II `.pl2` palette+transform decoder.
* *
@ -136,34 +138,26 @@ export function pl2ExpectedSize(): number {
export function decodePl2(data: Uint8Array): Pl2 { export function decodePl2(data: Uint8Array): Pl2 {
const expected = pl2ExpectedSize() const expected = pl2ExpectedSize()
if (data.byteLength < expected) { 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 const r = new ByteReader(data, 'pl2')
// want dense RGB triples, so it is repacked rather than aliased.
const rgb = new Uint8Array(PALETTE_COLORS * 3) const rgb = new Uint8Array(PALETTE_COLORS * 3)
for (let index = 0; index < PALETTE_COLORS; index += 1) { for (let index = 0; index < PALETTE_COLORS; index += 1) {
const at = index * BASE_ENTRY_BYTES rgb[index * 3] = r.u8('base r')
rgb[index * 3] = data[at]! rgb[index * 3 + 1] = r.u8('base g')
rgb[index * 3 + 1] = data[at + 1]! rgb[index * 3 + 2] = r.u8('base b')
rgb[index * 3 + 2] = data[at + 2]! 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 take = (count: number): Pl2Transform[] => {
const tables: Pl2Transform[] = [] const tables: Pl2Transform[] = []
for (let index = 0; index < count; index += 1) { for (let index = 0; index < count; index += 1) {
tables.push(data.subarray(cursor, cursor + PALETTE_COLORS)) tables.push(r.bytes(PALETTE_COLORS, 'table').slice())
cursor += PALETTE_COLORS
} }
return tables return tables
} }
const takeOne = (): Pl2Transform => { const takeOne = (): Pl2Transform => r.bytes(PALETTE_COLORS, 'table').slice()
const table = data.subarray(cursor, cursor + PALETTE_COLORS)
cursor += PALETTE_COLORS
return table
}
const lightLevels = take(LIGHT_LEVEL_VARIATIONS) const lightLevels = take(LIGHT_LEVEL_VARIATIONS)
const inverseColors = take(INV_COLOR_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) const textRgb = new Uint8Array(TEXT_COLORS * TEXT_ENTRY_BYTES)
for (let index = 0; index < TEXT_COLORS; index += 1) { for (let index = 0; index < TEXT_COLORS; index += 1) {
const at = cursor + index * TEXT_ENTRY_BYTES textRgb[index * 3] = r.u8('text r')
textRgb[index * 3] = data[at]! textRgb[index * 3 + 1] = r.u8('text g')
textRgb[index * 3 + 1] = data[at + 1]! textRgb[index * 3 + 2] = r.u8('text b')
textRgb[index * 3 + 2] = data[at + 2]!
} }
cursor += TEXT_COLORS * TEXT_ENTRY_BYTES
const textShifts = take(TEXT_SHIFTS) 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 { return {
rgb, rgb,
lightLevels, inverseColors, selectedUnitShift, alphaBlend, lightLevels, inverseColors, selectedUnitShift, alphaBlend,

View File

@ -1,3 +1,4 @@
import { requireBytes } from './reader'
/** /**
* Diablo II `.tbl` string-table decoder (hash-bucket layout). * 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 { 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 { 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 { function readCStr(data: Uint8Array, start: number): Uint8Array {

View File

@ -491,11 +491,9 @@ export class MpqArchive {
function wordsOf(bytes: Uint8Array): Uint32Array { function wordsOf(bytes: Uint8Array): Uint32Array {
const count = bytes.byteLength >>> 2 const count = bytes.byteLength >>> 2
const words = new Uint32Array(count) const words = new Uint32Array(count)
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
for (let i = 0; i < count; i += 1) { for (let i = 0; i < count; i += 1) {
words[i] = (bytes[i * 4]!) words[i] = view.getUint32(i * 4, true)
| ((bytes[i * 4 + 1]!) << 8)
| ((bytes[i * 4 + 2]!) << 16)
| ((bytes[i * 4 + 3]!) << 24)
} }
return words return words
} }

View File

@ -85,7 +85,8 @@ async function inflateZlib(data: Uint8Array): Promise<Uint8Array> {
*/ */
export async function decompressSector(input: Uint8Array, expectedSize: number): Promise<Uint8Array> { export async function decompressSector(input: Uint8Array, expectedSize: number): Promise<Uint8Array> {
if (input.byteLength === 0) return input 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) { if (mask === 0) {
// A zero mask marks a stored payload that merely carried the marker byte. // A zero mask marks a stored payload that merely carried the marker byte.
return input.subarray(1, 1 + expectedSize) return input.subarray(1, 1 + expectedSize)

View File

@ -69,8 +69,8 @@ function generateDecodeTabs(
): Uint8Array { ): Uint8Array {
const positions = new Uint8Array(0x100) const positions = new Uint8Array(0x100)
for (let i = 0; i < elements; i += 1) { for (let i = 0; i < elements; i += 1) {
const length = 1 << lengthBits[i]! const length = 1 << (lengthBits[i] ?? 0)
for (let index = startIndexes[i]!; index < 0x100; index += length) positions[index] = i for (let index = (startIndexes[i] ?? 0); index < 0x100; index += length) positions[index] = i
} }
return positions return positions
} }
@ -108,8 +108,8 @@ function buildAsciiTables(): AsciiTables {
const offs2EB4 = new Uint8Array(0x100) const offs2EB4 = new Uint8Array(0x100)
for (let count = 0xff; count >= 0; count -= 1) { for (let count = 0xff; count >= 0; count -= 1) {
const code = CHCODEASC[count]! const code = (CHCODEASC[count] ?? 0)
let bits = chBits[count]! let bits = (chBits[count] ?? 0)
if (bits <= 8) { if (bits <= 8) {
for (let acc = code; acc < 0x100; acc += 1 << bits) offs2C34[acc] = count for (let acc = code; acc < 0x100; acc += 1 << bits) offs2C34[acc] = count
continue continue
@ -199,7 +199,7 @@ class BitStream {
} }
this.buffer >>>= this.extraBits this.buffer >>>= this.extraBits
if (this.position >= this.input.length) return true 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.position += 1
this.buffer >>>= bits - this.extraBits this.buffer >>>= bits - this.extraBits
const rest = this.extraBits - bits + 8 const rest = this.extraBits - bits + 8
@ -219,15 +219,15 @@ class BitStream {
function decodeLit(stream: BitStream): number { function decodeLit(stream: BitStream): number {
if ((stream.peek16() & 1) !== 0) { if ((stream.peek16() & 1) !== 0) {
if (stream.waste(1)) return LIT_ERROR if (stream.waste(1)) return LIT_ERROR
const lengthCode = LENGTH_CODES[stream.peek8()]! const lengthCode = (LENGTH_CODES[stream.peek8()] ?? 0)
if (stream.waste(LENBITS[lengthCode]!)) return LIT_ERROR if (stream.waste((LENBITS[lengthCode] ?? 0))) return LIT_ERROR
const extraLengthBits = EXLENBITS[lengthCode]! const extraLengthBits = (EXLENBITS[lengthCode] ?? 0)
if (extraLengthBits === 0) return lengthCode + 0x100 if (extraLengthBits === 0) return lengthCode + 0x100
const extraLength = stream.peek16() & ((1 << extraLengthBits) - 1) const extraLength = stream.peek16() & ((1 << extraLengthBits) - 1)
// A stream that runs dry here is tolerated for exactly one code — the // 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. // reference's escape hatch for a final repetition, kept for bit parity.
if (stream.waste(extraLengthBits) && lengthCode + extraLength !== 0x10e) return LIT_ERROR 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 if (stream.waste(1)) return LIT_ERROR
@ -238,21 +238,21 @@ function decodeLit(stream: BitStream): number {
let value: number let value: number
if (stream.peek8() !== 0) { if (stream.peek8() !== 0) {
value = ASCII.offs2C34[stream.peek8()]! value = (ASCII.offs2C34[stream.peek8()] ?? 0)
if (value === 0xff) { if (value === 0xff) {
if ((stream.peek16() & 0x3f) !== 0) { if ((stream.peek16() & 0x3f) !== 0) {
if (stream.waste(4)) return LIT_ERROR if (stream.waste(4)) return LIT_ERROR
value = ASCII.offs2D34[stream.peek8()]! value = (ASCII.offs2D34[stream.peek8()] ?? 0)
} else { } else {
if (stream.waste(6)) return LIT_ERROR if (stream.waste(6)) return LIT_ERROR
value = ASCII.offs2E34[stream.peek16() & 0x7f]! value = (ASCII.offs2E34[stream.peek16() & 0x7f] ?? 0)
} }
} }
} else { } else {
if (stream.waste(8)) return LIT_ERROR 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. * @returns the distance in bytes (1-based), or 0 when the stream ended.
*/ */
function decodeDist(stream: BitStream, repLength: number): number { function decodeDist(stream: BitStream, repLength: number): number {
const distPosCode = DISTANCE_CODES[stream.peek8()]! const distPosCode = (DISTANCE_CODES[stream.peek8()] ?? 0)
if (stream.waste(DISTBITS[distPosCode]!)) return 0 if (stream.waste((DISTBITS[distPosCode] ?? 0))) return 0
if (repLength === 2) { if (repLength === 2) {
// Two-byte repetitions carry two extra bits instead of the full dictionary // Two-byte repetitions carry two extra bits instead of the full dictionary
// width: the encoder knows a distance in 4..(4*distPosCode+3) range. // 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 { export function explode(input: Uint8Array, expectedSize: number): Uint8Array {
if (input.length <= 4) throw new ImplodeError(`implode stream too short (${String(input.length)} bytes)`) if (input.length <= 4) throw new ImplodeError(`implode stream too short (${String(input.length)} bytes)`)
const ctype = input[0]! const ctype = (input[0] ?? 0)
const dsizeBits = input[1]! const dsizeBits = (input[1] ?? 0)
if (ctype !== CMP_BINARY && ctype !== CMP_ASCII) throw new ImplodeError(`unknown implode mode ${String(ctype)}`) 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)}`) 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) const out = new Uint8Array(expectedSize)
let written = 0 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 // Byte at a time on purpose: when the encoder stored a run, the
// repetition overlaps itself, and that overlap is part of the format. // 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 written += repLength
} else { } else {
if (written >= expectedSize) { if (written >= expectedSize) {