210 lines
7.7 KiB
TypeScript
210 lines
7.7 KiB
TypeScript
/**
|
|
* Diablo II `.dc6` sprite decoder.
|
|
*
|
|
* DC6 is Diablo II's replacement for Diablo I's CEL: a multi-direction,
|
|
* multi-frame sheet of palette-indexed frames, stored as one run stream per
|
|
* frame. Two properties are easy to get wrong and are worth stating outright,
|
|
* because both independent reference implementations agree on them:
|
|
*
|
|
* - **Rows are stored bottom-up.** The first decoded scanline is the frame's
|
|
* last row, so the stream must be written starting at `height - 1` and
|
|
* counting down. Filling rows top-down yields a vertically mirrored sprite.
|
|
* - **The run alphabet is three-way**, not two: `0x80` ends a scanline, any
|
|
* other byte with the high bit set is a *transparent* run of `byte & 0x7f`
|
|
* pixels, and a byte below `0x80` introduces that many literal pixels. A
|
|
* decoder that treats `0x80` as "transparent run of zero" desynchronises the
|
|
* rest of the frame.
|
|
*
|
|
* Index 0 is transparent by convention (the encoder leaves the palette's first
|
|
* entry unused), which is why frames carry a mask rather than relying on a
|
|
* sentinel index.
|
|
*/
|
|
import { CelError } from './sprite.ts'
|
|
import type { SpriteFrame, SpriteGroup, SpriteSheet } from './sprite.ts'
|
|
|
|
/** Byte offset of the file header, and its size. */
|
|
const FILE_HEADER_SIZE = 24
|
|
/** Byte offset of a frame header inside its frame, and its size. */
|
|
const FRAME_HEADER_SIZE = 32
|
|
/** Byte count of the per-frame terminator written after the run stream. */
|
|
const FRAME_TERMINATOR_SIZE = 3
|
|
/** Scanline terminator in the run alphabet. */
|
|
const END_OF_SCANLINE = 0x80
|
|
/** Mask extracting a transparent run's length. */
|
|
const RUN_LENGTH_MASK = 0x7f
|
|
|
|
/** The DC6 file header. */
|
|
export interface Dc6Header {
|
|
/** Format version (6 for the shipped format). */
|
|
readonly version: number
|
|
/** Flag word (`1` serialised, `4` 24-bit). */
|
|
readonly flags: number
|
|
/** Encoding word as stored. */
|
|
readonly encoding: number
|
|
/** Number of directions. */
|
|
readonly directions: number
|
|
/** Frames per direction. */
|
|
readonly framesPerDirection: number
|
|
}
|
|
|
|
/** One decoded frame plus the fields the renderer needs for placement. */
|
|
export interface Dc6Frame extends SpriteFrame {
|
|
/** Frame anchor x, as stored. */
|
|
readonly offsetX: number
|
|
/** Frame anchor y, as stored. */
|
|
readonly offsetY: number
|
|
/** The frame's own serial number in the sheet. */
|
|
readonly index: number
|
|
}
|
|
|
|
/** A decoded DC6 sheet, grouped by direction. */
|
|
export interface Dc6Sheet extends SpriteSheet {
|
|
/** The file header. */
|
|
readonly header: Dc6Header
|
|
/** Frames grouped by direction, each carrying its own size. */
|
|
readonly groups: readonly { readonly frames: readonly Dc6Frame[] }[]
|
|
}
|
|
|
|
/** Guard against a corrupt header demanding a huge allocation. */
|
|
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.
|
|
*
|
|
* @param data - the complete file.
|
|
* @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),
|
|
}
|
|
if (header.directions <= 0 || header.framesPerDirection <= 0) {
|
|
throw new CelError(`DC6 declares ${String(header.directions)} directions and ${String(header.framesPerDirection)} frames per direction`)
|
|
}
|
|
const total = header.directions * header.framesPerDirection
|
|
if (total > MAX_FRAMES) throw new CelError(`DC6 declares ${String(total)} frames, above the ${String(MAX_FRAMES)} guard`)
|
|
|
|
const pointers = new Array<number>(total)
|
|
for (let index = 0; index < total; index += 1) {
|
|
pointers[index] = u32(data, FILE_HEADER_SIZE + index * 4)
|
|
}
|
|
|
|
const groups: { frames: Dc6Frame[] }[] = []
|
|
for (let direction = 0; direction < header.directions; direction += 1) {
|
|
const frames: Dc6Frame[] = []
|
|
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 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`)
|
|
}
|
|
frames.push(decodeFrame(data, start, end, index))
|
|
}
|
|
groups.push({ frames })
|
|
}
|
|
|
|
return { header, groups, width: null }
|
|
}
|
|
|
|
/**
|
|
* Decode one frame.
|
|
*
|
|
* @param data - the complete file.
|
|
* @param start - frame start offset.
|
|
* @param end - frame end offset (exclusive).
|
|
* @param index - the frame's serial number.
|
|
* @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)
|
|
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)}`)
|
|
}
|
|
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]!
|
|
cursor += 1
|
|
if (control === END_OF_SCANLINE) {
|
|
if (y === 0) { complete = true; break }
|
|
y -= 1
|
|
x = 0
|
|
} else if ((control & END_OF_SCANLINE) !== 0) {
|
|
x += control & RUN_LENGTH_MASK
|
|
} else {
|
|
if (cursor + control > streamEnd) {
|
|
throw new CelError(`frame ${String(index)} literal run of ${String(control)} is truncated`)
|
|
}
|
|
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]!
|
|
indices[at] = value
|
|
// Index 0 is the transparent entry by convention.
|
|
mask[at] = value === 0 ? 0 : 1
|
|
}
|
|
cursor += control
|
|
x += control
|
|
}
|
|
}
|
|
if (!complete) {
|
|
// The reference decoders stop at the final end-of-scanline; a stream that
|
|
// ends without one is still usable, so this is reported by leaving the
|
|
// remaining rows transparent rather than failing the whole sheet.
|
|
// (Frames with height 1 legitimately end at `y === 0` handled above.)
|
|
}
|
|
return { width, height, indices, mask, offsetX, offsetY, index }
|
|
}
|
|
|
|
/** A decoded frame with its placement fields, as the atlas packer wants it. */
|
|
export type { SpriteGroup }
|