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:
parent
a6ca337a10
commit
53f52ba61e
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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]!
|
||||||
|
|
|
||||||
|
|
@ -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]!
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue