216 lines
7.5 KiB
TypeScript
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
|
|
}
|