diablo2-web/src/formats/cel.ts

482 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Diablo I sprite decoders: `.cel` (plain) and `.cl2` (its compressed form).
*
* Both are palette-indexed sprite sheets. What differs is how the run stream
* maps onto the frame grid:
*
* - **CEL** runs are *line-bounded*: every row's runs sum to exactly the frame
* width, so the decoder consumes one row at a time and the frame height is
* the number of rows. A run that overruns its row means corrupt input, not a
* wrapped row, so it is rejected instead of silently wrapping (the reference
* decoder's unsigned counter would wrap instead).
* - **CL2** runs may *cross* row boundaries, so the stream is written flat into
* the frame and line bookkeeping is derived from the overrun, exactly as the
* reference decoder does.
*
* Neither format stores the frame width: the game supplies it per animation
* (Diablo's own tables give, e.g., the walk width per class). A viewer can
* recover it, which {@link detectSpriteWidth} does by requiring every frame in
* the file to be consumed exactly.
*/
import { CelError, spriteWidthCandidates } from './sprite.ts'
import type { SpriteFrame, SpriteGroup, SpriteSheet } from './sprite.ts'
export type { SpriteFrame, SpriteGroup, SpriteSheet } from './sprite.ts'
export { CelError } from './sprite.ts'
/** Options shared by both decoders. */
export interface DecodeOptions {
/**
* Frame width. Required by the formats themselves; when omitted the width is
* auto-detected from the file (see {@link detectSpriteWidth}) for `.cel`, and
* rejected for `.cl2`.
*/
width?: number | undefined
/**
* Per-frame widths, indexed by the frame's position within its group.
*
* Diablo I mixes both conventions: full-animation sheets use one width, while
* tile and cursor sheets give every frame its own (the inventory cursor sheet
* ships a sidecar width list, and dungeon tile sheets take theirs from the
* tile definitions). Takes precedence over {@link width}.
*/
widths?: readonly number[] | undefined
/** Cap on decoded frames, to keep a pathological file from exhausting memory. */
maxFrames?: number | undefined
/** Cap on one frame's pixel count. */
maxFramePixels?: number | undefined
}
/** Sentinel used by the reference decoder to mark a frame header. */
const CEL_FRAME_HEADER_SIZE = 10
/** Default guard: the largest sprite sheet in the game is far below this. */
const DEFAULT_MAX_FRAMES = 20000
/** Default guard for one frame (a 4096×4096 indexed frame). */
const DEFAULT_MAX_FRAME_PIXELS = 1 << 24
import { requireBytes } from './reader'
/** Read a little-endian uint32. */
function u32(data: Uint8Array, at: number): number {
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. */
function u16(data: Uint8Array, at: number): number {
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. */
type Extents = readonly (readonly [number, number])[]
/**
* Resolve a file's groups.
*
* A single-group file is `[count][count + 1 offsets][frames...]`; a multi-group
* file instead opens with a group table. CEL and CL2 differ in how that table
* is read: CEL concatenates its groups after it, CL2 stores offsets to them.
*
* @param data - the complete file.
* @param mode - `'cel'` or `'cl2'`.
* @returns the groups' base offsets, or a single group when there is no table.
*/
function readGroups(data: Uint8Array, mode: 'cel' | 'cl2'): { groupBases: number[]; single: boolean } | { error: string } {
const first = u32(data, 0)
if (first === 0) return { error: 'declares zero frames' }
const lastOffsetAt = first * 4 + 4
if (lastOffsetAt + 4 <= data.byteLength && u32(data, lastOffsetAt) === data.byteLength) {
// Single group: the frame table's last entry is the file size.
return { groupBases: [0], single: true }
}
const numGroups = Math.floor(first / 4)
if (numGroups === 0 || numGroups > 4096) return { error: `implausible group count ${String(numGroups)}` }
if (mode === 'cel') {
// Groups are laid out back to back after the table; their bases are not
// stored, so they are walked by decoding.
return { groupBases: [], single: false }
}
const bases: number[] = []
for (let group = 0; group < numGroups; group += 1) {
const base = u32(data, group * 4)
if (base + 4 > data.byteLength) return { error: `group ${String(group)} offset ${String(base)} is out of range` }
bases.push(base)
}
return { groupBases: bases, single: false }
}
/**
* The frame extents of one group.
*
* @param data - the complete file.
* @param base - the group's base offset.
* @returns the extents, each `[start, end)` in absolute file offsets.
*/
function groupExtents(data: Uint8Array, base: number): Extents | { error: string } {
const count = u32(data, base)
if (count === 0 || count > 65535) return { error: `implausible frame count ${String(count)}` }
const extents: [number, number][] = []
for (let frame = 0; frame < count; frame += 1) {
const start = u32(data, base + 4 * (frame + 1))
const end = u32(data, base + 4 * (frame + 2))
if (start < 0 || end < start || base + end > data.byteLength) {
return { error: `frame ${String(frame)} extent ${String(start)}..${String(end)} is out of range` }
}
extents.push([base + start, base + end])
}
return extents
}
/**
* Decode one CEL frame.
*
* @param data - the complete file.
* @param start - frame start offset.
* @param end - frame end offset.
* @param width - frame width.
* @param maxFramePixels - pixel-count guard.
* @returns the frame.
*/
function decodeCelFrame(data: Uint8Array, start: number, end: number, width: number, maxFramePixels: number): SpriteFrame | { error: string } {
let cursor = start
// Some frames carry a 10-byte header whose first word is its own size.
if (cursor + 2 <= end && u16(data, cursor) === CEL_FRAME_HEADER_SIZE) cursor += CEL_FRAME_HEADER_SIZE
const capacity = Math.min(maxFramePixels, Math.max(width * 4096, width))
const indices = new Uint8Array(capacity)
const mask = new Uint8Array(capacity)
let at = 0
let height = 0
while (cursor < end) {
let remaining = width
while (remaining > 0) {
if (cursor >= end) return { error: `row ${String(height)} ran past the frame end` }
const control = data[cursor]
if (control === undefined) return { error: `row ${String(height)} ran past the file end` }
cursor += 1
if (control >= 0x80) {
remaining -= 256 - control
} else {
const run = control
if (run > remaining) return { error: `row ${String(height)} overruns by ${String(run - remaining)} pixels` }
if (cursor + run > end) return { error: `row ${String(height)} literal run is truncated` }
if (at + run > capacity) return { error: 'frame exceeds the pixel guard' }
indices.set(data.subarray(cursor, cursor + run), at)
mask.fill(1, at, at + run)
cursor += run
at += run
remaining -= run
}
}
height += 1
if (height * width > capacity) return { error: 'frame exceeds the pixel guard' }
}
if (cursor !== end) return { error: 'frame was not consumed exactly' }
return {
width,
height,
indices: indices.subarray(0, width * height),
mask: mask.subarray(0, width * height),
}
}
/**
* Decode one CL2 frame, whose runs may cross row boundaries.
*
* @param data - the complete file.
* @param start - frame start offset.
* @param end - frame end offset.
* @param width - frame width.
* @param maxFramePixels - pixel-count guard.
* @returns the frame, the column the run stream ended on, or the failure.
*/
function decodeCl2FrameOutcome(data: Uint8Array, start: number, end: number, width: number, maxFramePixels: number): { ok: true; frame: SpriteFrame; lastXOffset: number } | { ok: false; error: string } {
// The frame opens with the size of its own header.
if (start + 2 > end) return { ok: false, error: 'frame is too short for its header' }
let cursor = start + u16(data, start)
if (cursor > end) return { ok: false, error: 'frame header points past the frame end' }
const capacity = Math.min(maxFramePixels, Math.max(width * 4096, width))
const indices = new Uint8Array(capacity)
const mask = new Uint8Array(capacity)
let at = 0
let height = 0
let xOffset = 0
while (cursor < end) {
let remaining = width - xOffset
while (remaining > 0) {
if (cursor >= end) return { ok: false, error: 'runs ran past the frame end' }
const control = data[cursor]
if (control === undefined) return { ok: false, error: `runs ran past the file end` }
cursor += 1
if (control < 0x80) {
// Transparent run of `control` pixels.
if (at + control > capacity) return { ok: false, error: 'frame exceeds the pixel guard' }
at += control
remaining -= control
} else if (control <= 0xbe) {
// Fill run: one colour repeated 0xBF - control times.
const count = 0xbf - control
if (cursor >= end) return { ok: false, error: 'fill run is truncated' }
const color = data[cursor]
if (color === undefined) return { ok: false, error: `color run ran past the file end` }
cursor += 1
if (at + count > capacity) return { ok: false, error: 'frame exceeds the pixel guard' }
indices.fill(color, at, at + count)
mask.fill(1, at, at + count)
at += count
remaining -= count
} else {
// Literal run of 256 - control pixels (0xBF is 65 wide).
const count = 256 - control
if (cursor + count > end) return { ok: false, error: 'literal run is truncated' }
if (at + count > capacity) return { ok: false, error: 'frame exceeds the pixel guard' }
indices.set(data.subarray(cursor, cursor + count), at)
mask.fill(1, at, at + count)
cursor += count
at += count
remaining -= count
}
}
if (remaining === 0) {
height += 1
xOffset = 0
} else {
const overrun = -remaining
height += Math.floor(overrun / width) + 1
xOffset = overrun % width
}
}
if (cursor !== end) return { ok: false, error: 'frame was not consumed exactly' }
const pixels = width * height
if (pixels > capacity) return { ok: false, error: 'frame exceeds the pixel guard' }
return {
ok: true,
lastXOffset: xOffset,
frame: {
width,
height,
indices: indices.subarray(0, pixels),
mask: mask.subarray(0, pixels),
},
}
}
/**
* Decode one CL2 frame, discarding the row-alignment diagnostic.
*
* @param data - the complete file.
* @param start - frame start offset.
* @param end - frame end offset.
* @param width - frame width.
* @param maxFramePixels - pixel-count guard.
* @returns the frame or the failure.
*/
function decodeCl2Frame(data: Uint8Array, start: number, end: number, width: number, maxFramePixels: number): SpriteFrame | { error: string } {
const outcome = decodeCl2FrameOutcome(data, start, end, width, maxFramePixels)
return outcome.ok ? outcome.frame : { error: outcome.error }
}
/**
* Guess which frame widths a CL2 file could have been encoded with.
*
* Weaker than the CEL rule, deliberately: a CL2 run stream is consumed to the
* frame end under any width, so exactness proves nothing here. What survives is
* *row alignment* — an encoder pads the final row, so the stream ends on a
* column boundary for the true width. Candidates that satisfy this are
* plausible, not proven; the game's own tables stay authoritative (see
* `PLAYER_SPRITE_WIDTH`).
*
* @param data - the complete file.
* @param candidates - widths to try.
* @returns the widths under which every frame decodes and ends on a row boundary.
*/
export function cl2WidthCandidates(data: Uint8Array, candidates: readonly number[]): number[] {
const groupsInfo = readGroups(data, 'cl2')
if ('error' in groupsInfo) return []
const bases = groupsInfo.single ? [0] : [...groupsInfo.groupBases]
const usable: number[] = []
for (const width of candidates) {
let aligned = bases.length > 0
for (const base of bases) {
const extents = groupExtents(data, base)
if ('error' in extents) { aligned = false; break }
for (const [frameStart, frameEnd] of extents) {
const outcome = decodeCl2FrameOutcome(data, frameStart, frameEnd, width, DEFAULT_MAX_FRAME_PIXELS)
if (!outcome.ok || outcome.lastXOffset !== 0 || outcome.frame.height === 0) { aligned = false; break }
}
if (!aligned) break
}
if (aligned) usable.push(width)
}
return usable
}
/**
* Decode a sprite file.
*
* @param data - the complete file.
* @param mode - `'cel'` or `'cl2'`.
* @param options - width override and guards.
* @returns the decoded sheet.
*/
export function decodeSpriteFile(data: Uint8Array, mode: 'cel' | 'cl2', options: DecodeOptions = {}): SpriteSheet {
const maxFrames = options.maxFrames ?? DEFAULT_MAX_FRAMES
const maxFramePixels = options.maxFramePixels ?? DEFAULT_MAX_FRAME_PIXELS
if (data.byteLength < 8) throw new CelError(`file is only ${String(data.byteLength)} bytes`)
const groupsInfo = readGroups(data, mode)
if ('error' in groupsInfo) throw new CelError(groupsInfo.error)
const perFrame = options.widths
const uniform = options.width ?? (perFrame === undefined && mode === 'cel' ? detectSpriteWidth(data) : null)
if (perFrame === undefined && uniform === null) {
throw new CelError(mode === 'cl2'
? 'CL2 does not store its frame width: pass the width (or per-frame widths) the game uses for this animation'
: 'could not determine the frame width (no single width consumes the file exactly; tile and cursor sheets need per-frame widths)')
}
const groups: SpriteGroup[] = []
let frames = 0
if (groupsInfo.single) {
const extents = groupExtents(data, 0)
if ('error' in extents) throw new CelError(extents.error)
groups.push({ frames: decodeFrames(data, extents, mode, uniform, perFrame, maxFramePixels) })
frames += extents.length
} else if (mode === 'cl2') {
for (const base of groupsInfo.groupBases) {
const extents = groupExtents(data, base)
if ('error' in extents) throw new CelError(extents.error)
if (frames + extents.length > maxFrames) throw new CelError('file exceeds the frame guard')
groups.push({ frames: decodeFrames(data, extents, mode, uniform, perFrame, maxFramePixels) })
frames += extents.length
}
} else {
// CEL multi-group: groups are concatenated, so each group is found by
// decoding the previous one to its end.
let base = Math.floor(u32(data, 0) / 4) * 4
while (base < data.byteLength && groups.length < 4096) {
const extents = groupExtents(data, base)
if ('error' in extents) throw new CelError(extents.error)
if (frames + extents.length > maxFrames) throw new CelError('file exceeds the frame guard')
groups.push({ frames: decodeFrames(data, extents, mode, uniform, perFrame, maxFramePixels) })
frames += extents.length
const last = extents[extents.length - 1]
/* v8 ignore next -- extents is non-empty by construction. */
if (last === undefined) break
base = last[1]
}
}
return { groups, width: uniform }
}
/**
* Decode a run of frames that share one width.
*
* @param data - the complete file.
* @param extents - frame extents.
* @param mode - `'cel'` or `'cl2'`.
* @param width - frame width.
* @param maxFramePixels - pixel-count guard.
* @returns the frames.
*/
function decodeFrames(
data: Uint8Array,
extents: Extents,
mode: 'cel' | 'cl2',
uniform: number | null,
perFrame: readonly number[] | undefined,
maxFramePixels: number,
): SpriteFrame[] {
const frames: SpriteFrame[] = []
for (const [start, end] of extents) {
const width = perFrame === undefined ? uniform : perFrame[frames.length]
if (width === undefined || width === null) {
throw new CelError(`no width for frame ${String(frames.length)} (the width list has ${String(perFrame?.length ?? 0)} entries)`)
}
const frame = mode === 'cel'
? decodeCelFrame(data, start, end, width, maxFramePixels)
: decodeCl2Frame(data, start, end, width, maxFramePixels)
if ('error' in frame) {
throw new CelError(`frame ${String(frames.length)}: ${frame.error}`)
}
frames.push(frame)
}
return frames
}
/**
* Recover a `.cel` file's frame width by requiring every row to be consumed
* exactly.
*
* CEL's runs are line-bounded, so a wrong width makes a row's runs overrun and
* the file is rejected — the width that survives is the real one. This does not
* extend to CL2, whose runs may cross rows: there the run stream is consumed
* under any width, which is why {@link decodeSpriteFile} insists on being told.
*
* @param data - the complete file.
* @param candidates - widths to try, smallest first.
* @returns the smallest width that decodes the whole file, or null.
*/
export function detectSpriteWidth(
data: Uint8Array,
candidates: readonly number[] = spriteWidthCandidates(),
): number | null {
const groupsInfo = readGroups(data, 'cel')
if ('error' in groupsInfo) return null
for (const width of candidates) {
if (decodesExactly(data, groupsInfo, width)) return width
}
return null
}
/**
* Whether one width decodes every CEL frame of every group exactly.
*
* @param data - the complete file.
* @param groupsInfo - the resolved group layout.
* @param width - candidate width.
* @returns true when the whole file is consumed with no leftovers.
*/
function decodesExactly(
data: Uint8Array,
groupsInfo: { groupBases: readonly number[]; single: boolean },
width: number,
): boolean {
const guard = DEFAULT_MAX_FRAME_PIXELS
const bases = groupsInfo.single ? [0] : celGroupBases(data)
if (bases.length === 0) return false
for (const base of bases) {
const extents = groupExtents(data, base)
if ('error' in extents) return false
for (const [start, end] of extents) {
const frame = decodeCelFrame(data, start, end, width, guard)
if ('error' in frame) return false
if (frame.height === 0) return false
}
}
return true
}
/**
* Walk a multi-group CEL file's concatenated group bases.
*
* @param data - the complete file.
* @returns the group bases.
*/
function celGroupBases(data: Uint8Array): number[] {
const bases: number[] = []
let base = Math.floor(u32(data, 0) / 4) * 4
while (base + 8 <= data.byteLength && bases.length < 4096) {
bases.push(base)
const extents = groupExtents(data, base)
if ('error' in extents) break
const last = extents[extents.length - 1]
/* v8 ignore next -- extents is non-empty by construction. */
if (last === undefined) break
if (last[1] <= base) break
base = last[1]
}
return bases
}