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
* 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

View File

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

View File

@ -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()
}

View File

@ -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<number>(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<number>, 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

View File

@ -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
}
}

View File

@ -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(),

View File

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

View File

@ -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]!

View File

@ -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]!

View File

@ -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,

View File

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

View File

@ -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
}

View File

@ -85,7 +85,8 @@ async function inflateZlib(data: Uint8Array): Promise<Uint8Array> {
*/
export async function decompressSector(input: Uint8Array, expectedSize: number): Promise<Uint8Array> {
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)

View File

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