diablo2-web/src/formats/dc6.ts

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 }