/** * 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(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 }