diablo2-web/src/formats/dc6.ts

194 lines
7.5 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 { FormatError, InvalidFieldError, TruncatedDataError, ByteReader, requireBytes } from './reader'
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
/**
* Decode a DC6 file.
*
* @param data - the complete file.
* @returns the decoded sheet.
*/
export function decodeDc6(data: Uint8Array): Dc6Sheet {
const r = new ByteReader(data, 'dc6')
const version = r.i32le('version')
const flags = r.u32le('flags')
const encoding = r.u32le('encoding')
r.seek(0x10, 'directions')
const directions = r.i32le('directions')
const framesPerDirection = r.i32le('framesPerDirection')
const header: Dc6Header = { version, flags, encoding, directions, framesPerDirection }
if (header.directions <= 0 || header.framesPerDirection <= 0) {
throw new InvalidFieldError('dc6', 'directions/framesPerDirection', 0x10, `${String(header.directions)}x${String(header.framesPerDirection)}`, '> 0')
}
const total = header.directions * header.framesPerDirection
if (total > MAX_FRAMES) throw new InvalidFieldError('dc6', 'total frames', 0x14, total, `<= ${String(MAX_FRAMES)}`)
const pointers = new Array<number>(total)
r.seek(FILE_HEADER_SIZE, 'pointers')
for (let index = 0; index < total; index += 1) {
pointers[index] = r.u32le('pointer')
}
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]! // pointers is an Array<number>, not untrusted data bytes
const end = index + 1 < total ? pointers[index + 1]! : data.byteLength
if (start < FILE_HEADER_SIZE || end > data.byteLength || end < start) {
throw new InvalidFieldError('dc6', 'frame pointer', FILE_HEADER_SIZE + index * 4, `${String(start)}..${String(end)}`, 'valid extent')
}
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 {
const r = new ByteReader(data, 'dc6')
r.seek(start, 'frame header')
r.skip(4, 'flipped')
const width = r.i32le('width')
const height = r.i32le('height')
const offsetX = r.i32le('offsetX')
const offsetY = r.i32le('offsetY')
r.skip(8, 'reserved')
const length = r.u32le('length')
if (width < 0 || height < 0 || width * height > MAX_FRAME_PIXELS) {
throw new InvalidFieldError('dc6', 'frame geometry', start + 0x04, `${String(width)}x${String(height)}`, `positive, <= ${String(MAX_FRAME_PIXELS)} px`)
}
const streamEnd = Math.min(end, start + FRAME_HEADER_SIZE + length)
requireBytes(data, start + FRAME_HEADER_SIZE, streamEnd - (start + FRAME_HEADER_SIZE), 'dc6', 'run stream')
const indices = new Uint8Array(width * height)
const mask = new Uint8Array(width * height)
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]
if (control === undefined) break
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 TruncatedDataError('dc6', 'literal run', cursor, control, streamEnd - cursor, data.length)
}
const rowStart = y * width
for (let i = 0; i < control; i += 1) {
const at = rowStart + x + i
if (x + i >= width) break
const value = data[cursor + i]
if (value === undefined) break
indices[at] = value
// Index 0 is the transparent entry by convention.
mask[at] = value === 0 ? 0 : 1
}
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 }