import { ByteReader, FormatError, TruncatedDataError } from "./reader"; /** * Diablo II `.pl2` palette+transform decoder. * * A PL2 is a base palette followed by a long series of *transform tables*: each * is 256 bytes mapping a palette index to another palette index. They are how * Diablo II does lighting, blending, unit colour shifts and text colours without * ever touching RGB — a unit under a light level or a monster variant is drawn * through a different transform, over the same indexed sprite. That is why the * decoders here keep sprites indexed and resolve colour last. * * The layout is fixed and entirely positional: one 1024-byte base palette, then * tables in this exact order, then a 39-byte text palette and its own transforms. * There is no header, no count and no directory, so the table order *is* the * format — hence the counts written out below rather than derived. * * Confidence note: the base palette's stored byte order is taken from the * reference implementation (r, g, b, unused). A swapped red/blue would only show * against real art, so this is re-checked when a real archive lands. */ /** Palette entries (one transform maps every entry). */ const PALETTE_COLORS = 256 /** Bytes per base-palette entry: r, g, b, one unused byte. */ const BASE_ENTRY_BYTES = 4 /** Base palette size in bytes (1024). */ const BASE_BYTES = PALETTE_COLORS * BASE_ENTRY_BYTES /** Text palette entries. */ const TEXT_COLORS = 13 /** Bytes per text-palette entry (r, g, b). */ const TEXT_ENTRY_BYTES = 3 /** Light-level tables. */ const LIGHT_LEVEL_VARIATIONS = 32 /** Inverse-colour tables. */ const INV_COLOR_VARIATIONS = 16 /** Coarse alpha-blend groups, each with a full fine table. */ const ALPHA_BLEND_COARSE = 3 /** Fine alpha-blend tables per coarse group. */ const ALPHA_BLEND_FINE = 256 /** Additive-blend tables. */ const ADDITIVE_BLENDS = 256 /** Multiplicative-blend tables. */ const MULTIPLY_BLENDS = 256 /** Hue-variation tables. */ const HUE_VARIATIONS = 111 /** Unnamed variation tables (kept as raw tables). */ const UNKNOWN_VARIATIONS = 14 /** Maximum-component blend tables. */ const MAX_COMPONENT_BLENDS = 256 /** Text-colour shift tables. */ const TEXT_SHIFTS = 13 /** One transform table: 256 index remaps. */ export type Pl2Transform = Uint8Array /** A decoded PL2 palette file. */ export interface Pl2 { /** Base palette as 768 RGB triples, ready for the sprite decoders. */ readonly rgb: Uint8Array /** Light-level tables (index into the base palette). */ readonly lightLevels: readonly Pl2Transform[] /** Inverse-colour tables. */ readonly inverseColors: readonly Pl2Transform[] /** Selected-unit shift. */ readonly selectedUnitShift: Pl2Transform /** Alpha-blend tables, grouped coarsely then finely. */ readonly alphaBlend: readonly (readonly Pl2Transform[])[] /** Additive-blend tables. */ readonly additiveBlend: readonly Pl2Transform[] /** Multiplicative-blend tables. */ readonly multiplyBlend: readonly Pl2Transform[] /** Hue-variation tables. */ readonly hueVariations: readonly Pl2Transform[] /** Red-tone shift. */ readonly redTones: Pl2Transform /** Green-tone shift. */ readonly greenTones: Pl2Transform /** Blue-tone shift. */ readonly blueTones: Pl2Transform /** Unnamed variation tables. */ readonly unknownVariations: readonly Pl2Transform[] /** Maximum-component blend tables. */ readonly maxComponentBlend: readonly Pl2Transform[] /** Darkened colour shift. */ readonly darkenedShift: Pl2Transform /** Text palette as RGB triples (39 bytes). */ readonly textRgb: Uint8Array /** Text-colour shift tables. */ readonly textShifts: readonly Pl2Transform[] } /** Raised when a PL2 file is short or malformed. */ export class Pl2Error extends Error { constructor(message: string) { super(message) this.name = 'Pl2Error' } } /** Transform tables in the order they appear, and how many of each. */ const TABLE_ORDER: readonly (readonly [string, number])[] = [ ['lightLevels', LIGHT_LEVEL_VARIATIONS], ['inverseColors', INV_COLOR_VARIATIONS], ['selectedUnitShift', 1], // Coarse alpha groups, each a run of fine tables. ['alphaBlend0', ALPHA_BLEND_FINE], ['alphaBlend1', ALPHA_BLEND_FINE], ['alphaBlend2', ALPHA_BLEND_FINE], ['additiveBlend', ADDITIVE_BLENDS], ['multiplyBlend', MULTIPLY_BLENDS], ['hueVariations', HUE_VARIATIONS], ['redTones', 1], ['greenTones', 1], ['blueTones', 1], ['unknownVariations', UNKNOWN_VARIATIONS], ['maxComponentBlend', MAX_COMPONENT_BLENDS], ['darkenedShift', 1], ] /** * The file size a PL2 must have, in bytes: base palette, every transform, the * text palette and its shifts. * * @returns the expected size. */ export function pl2ExpectedSize(): number { const tables = TABLE_ORDER.reduce((sum, [, count]) => sum + count, 0) + TEXT_SHIFTS return BASE_BYTES + tables * PALETTE_COLORS + TEXT_COLORS * TEXT_ENTRY_BYTES } /** * Decode a PL2 file. * * @param data - the complete file. * @returns the palette and its transforms. */ export function decodePl2(data: Uint8Array): Pl2 { const expected = pl2ExpectedSize() if (data.byteLength < expected) { throw new TruncatedDataError('pl2', 'file', 0, expected, data.byteLength, data.byteLength) } const r = new ByteReader(data, 'pl2') const rgb = new Uint8Array(PALETTE_COLORS * 3) for (let index = 0; index < PALETTE_COLORS; index += 1) { rgb[index * 3] = r.u8('base r') rgb[index * 3 + 1] = r.u8('base g') rgb[index * 3 + 2] = r.u8('base b') r.skip(1, 'base unused') } const take = (count: number): Pl2Transform[] => { const tables: Pl2Transform[] = [] for (let index = 0; index < count; index += 1) { tables.push(r.bytes(PALETTE_COLORS, 'table').slice()) } return tables } const takeOne = (): Pl2Transform => r.bytes(PALETTE_COLORS, 'table').slice() const lightLevels = take(LIGHT_LEVEL_VARIATIONS) const inverseColors = take(INV_COLOR_VARIATIONS) const selectedUnitShift = takeOne() const alphaBlend = [ take(ALPHA_BLEND_FINE), take(ALPHA_BLEND_FINE), take(ALPHA_BLEND_FINE), ] const additiveBlend = take(ADDITIVE_BLENDS) const multiplyBlend = take(MULTIPLY_BLENDS) const hueVariations = take(HUE_VARIATIONS) const redTones = takeOne() const greenTones = takeOne() const blueTones = takeOne() const unknownVariations = take(UNKNOWN_VARIATIONS) const maxComponentBlend = take(MAX_COMPONENT_BLENDS) const darkenedShift = takeOne() const textRgb = new Uint8Array(TEXT_COLORS * TEXT_ENTRY_BYTES) for (let index = 0; index < TEXT_COLORS; index += 1) { textRgb[index * 3] = r.u8('text r') textRgb[index * 3 + 1] = r.u8('text g') textRgb[index * 3 + 2] = r.u8('text b') } const textShifts = take(TEXT_SHIFTS) return { rgb, lightLevels, inverseColors, selectedUnitShift, alphaBlend, additiveBlend, multiplyBlend, hueVariations, redTones, greenTones, blueTones, unknownVariations, maxComponentBlend, darkenedShift, textRgb, textShifts, } } /** * Resolve a transform into a concrete palette. * * @param baseRgb - the base palette as RGB triples. * @param transform - the index remap to apply. * @returns a 768-byte RGB palette. */ export function applyTransform(baseRgb: Uint8Array, transform: Pl2Transform): Uint8Array { const out = new Uint8Array(PALETTE_COLORS * 3) for (let index = 0; index < PALETTE_COLORS; index += 1) { const source = (transform[index] ?? 0) * 3 out[index * 3] = baseRgb[source] ?? 0 out[index * 3 + 1] = baseRgb[source + 1] ?? 0 out[index * 3 + 2] = baseRgb[source + 2] ?? 0 } return out }