diablo2-web/src/formats/pl2.ts

216 lines
7.5 KiB
TypeScript

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
}