diablo2-web/src/formats/cof.ts

194 lines
6.8 KiB
TypeScript

/**
* Diablo II `.cof` animation-table decoder.
*
* A COF carries no pixels. It is the glue between an animation and the sprite
* files that make it up: how many directions and frames the animation has, how
* many composite layers the character or object is assembled from, which weapon
* class each layer belongs to, and — the part that is easy to miss — the *draw
* order* of those layers for every direction and frame, since an arm has to be
* in front of the torso when the character faces one way and behind it when the
* character faces the other.
*
* The layout is fixed-width and byte-aligned throughout, so unlike `.dcc` this
* decoder is a plain cursor walk:
*
* - a 25-byte header: layer count, frames per direction, direction count, 21
* bytes the original tool left as uninitialised garbage, and an animation
* speed;
* - three body bytes nobody has ever explained;
* - one 9-byte record per layer;
* - one byte per frame, the "animation frame" tag;
* - one byte per (direction, frame, layer), the priority table.
*
* The priority bytes are *composite types*, not layer indices — the shipped
* Sorceress walk lists types in an order that does not match record order, which
* is exactly why a renderer must resolve them through the layer records instead
* of using them directly. That resolution is `cofLayerOrder`.
*/
import { ByteReader, FormatError, InvalidFieldError, TruncatedDataError } from './reader'
/** Fixed part of the file, up to and including the animation-speed byte. */
const HEADER_SIZE = 25
/** Byte offset of the animation speed inside the header. */
const HEADER_SPEED = 24
/** Unexplained bytes between the header and the first layer record. */
const BODY_PREFIX_SIZE = 3
/** Bytes per layer record: five scalar fields plus a four-byte weapon class. */
const LAYER_SIZE = 9
/** Offset of the weapon-class code inside a layer record. */
const LAYER_WEAPON_CLASS = 5
/** One composite layer of an animation. */
export interface CofLayer {
/** Composite type this layer draws (which body part or equipment slot). */
readonly type: number
/** Shadow flag, as stored. */
readonly shadow: number
/** Whether the layer takes part in hit detection. */
readonly selectable: boolean
/** Whether the layer is drawn with transparency. */
readonly transparent: boolean
/** Draw-effect selector, as stored. */
readonly drawEffect: number
/**
* Weapon-class code this layer applies to, e.g. `hth` for unarmed or `1hs` for
* a one-handed sword, with the record's NUL padding stripped.
*
* Kept as the raw code rather than an enum: this port has no weapon-class
* enumeration, and callers select layers by matching codes they already have
* from the item tables.
*/
readonly weaponClass: string
}
/** A decoded COF animation table. */
export interface CofFile {
/** Number of directions the animation has. */
readonly numberOfDirections: number
/** Number of frames per direction. */
readonly framesPerDirection: number
/** Number of composite layers per frame. */
readonly numberOfLayers: number
/** Animation speed byte; 0 means "default" (25 fps) to the original engine. */
readonly speed: number
/** Layer records, in file order. */
readonly layers: readonly CofLayer[]
/** One tag byte per frame. */
readonly animationFrames: Uint8Array
/**
* Priority table: `priority[direction][frame]` lists the composite types to
* draw, back to front. Resolve it with `cofLayerOrder`.
*/
readonly priority: readonly (readonly (readonly number[])[])[]
}
export function decodeCof(data: Uint8Array): CofFile {
const r = new ByteReader(data, 'cof')
const numberOfLayers = r.u8('numberOfLayers')
const framesPerDirection = r.u8('framesPerDirection')
const numberOfDirections = r.u8('numberOfDirections')
r.seek(HEADER_SPEED, 'speed')
const speed = r.u8('speed')
const minimum = HEADER_SIZE + BODY_PREFIX_SIZE
r.seek(minimum, 'layers start')
const layers: CofLayer[] = []
for (let i = 0; i < numberOfLayers; i += 1) {
const layerOffset = minimum + i * LAYER_SIZE
r.seek(layerOffset, 'layer')
const type = r.u8('type')
const shadow = r.u8('shadow')
const selectable = r.u8('selectable') > 0
const transparent = r.u8('transparent') > 0
const drawEffect = r.u8('drawEffect')
r.seek(layerOffset + LAYER_WEAPON_CLASS, 'weapon class')
let text = ''
for (let c = 0; c < 4; c += 1) {
const byte = r.u8('weapon class byte')
if (byte !== 0) text += String.fromCharCode(byte)
}
layers.push({
type,
shadow,
selectable,
transparent,
drawEffect,
weaponClass: text.trim(),
})
}
const animOffset = minimum + numberOfLayers * LAYER_SIZE
r.seek(animOffset, 'animation frames')
const animationFrames = r.bytes(framesPerDirection, 'animation frames').slice()
const priority: number[][][] = []
for (let direction = 0; direction < numberOfDirections; direction += 1) {
const frames: number[][] = []
for (let frame = 0; frame < framesPerDirection; frame += 1) {
const row: number[] = []
for (let layer = 0; layer < numberOfLayers; layer += 1) {
row.push(r.u8('priority'))
}
frames.push(row)
}
priority.push(frames)
}
return {
numberOfDirections,
framesPerDirection,
numberOfLayers,
speed,
layers,
animationFrames,
priority,
}
}
/**
* The draw order for one direction and frame, as indices into `cof.layers`.
*
* The priority table stores composite *types*, and a COF's layer records are not
* obliged to be sorted by type — the shipped Sorceress walk is not — so the
* types have to be resolved back to record indices before anything can be
* drawn. Entries naming a type no layer declares are dropped rather than
* guessed at: there is provably nothing to draw for them. If two layers share a
* type, the first record wins, so a layer is never drawn twice.
*
* @param cof - the decoded animation table.
* @param direction - direction index.
* @param frame - frame index within the direction.
* @returns layer indices, back to front.
*/
export function cofLayerOrder(cof: CofFile, direction: number, frame: number): number[] {
const frames = cof.priority[direction]
if (frames === undefined) {
throw new InvalidFieldError('cof', 'direction', 0, direction, `0..${String(cof.numberOfDirections - 1)}`)
}
const row = frames[frame]
if (row === undefined) {
throw new InvalidFieldError('cof', 'frame', 0, frame, `0..${String(cof.framesPerDirection - 1)}`)
}
const order: number[] = []
const used = new Set<number>()
for (const type of row) {
let index = -1
for (let i = 0; i < cof.layers.length; i += 1) {
if (cof.layers[i]!.type === type) {
index = i
break
}
}
if (index < 0 || used.has(index)) continue
used.add(index)
order.push(index)
}
return order
}