diablo2-web/src/formats/ds1.ts

420 lines
14 KiB
TypeScript

import { CelError } from "./sprite";
/**
* Diablo II `.ds1` map decoder.
*
* A DS1 is a map *layout*: a grid of tiles, each slot holding one reference per
* layer (four wall layers, four orientation layers, two floor layers, a shadow
* layer and a substitution layer, depending on the version). A reference is
* only `(style, sequence)` into a DT1 library — the pixels come from there —
* so DS1 and DT1 are useless apart and are decoded as a pair.
*
* The file is a versioned sequence of optional sections, which is the whole
* difficulty: which fields exist depends on a version number, and the layer
* streams are interleaved in a fixed order that also has a legacy shape for
* versions below 4. Both are encoded here exactly as the reference decoder
* reads them, including the pre-7 orientation lookup table.
*/
import { FormatError, InvalidFieldError, TruncatedDataError } from './reader'
/** Pre-7 maps store orientations through this lookup rather than directly. */
const LEGACY_DIRECTION_LOOKUP = [
0x00, 0x01, 0x02, 0x01, 0x02, 0x03, 0x03, 0x05, 0x05, 0x06,
0x06, 0x07, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e,
0x0f, 0x10, 0x11, 0x12, 0x14,
] as const
/** Version at which the act field exists. */
const V_ACT = 8
/** Version at which the substitution type exists. */
const V_SUBSTITUTION_LAYERS = 10
/** Version at which the embedded file list exists. */
const V_FILES = 3
/** Versions 9..13 carry eight unknown bytes. */
const V_UNKNOWN_LOW = 9
const V_UNKNOWN_HIGH = 13
/** Version at which floor layers exist (below it there is exactly one). */
const V_FLOORS = 4
/** Version at which wall layers exist. */
const V_WALLS = 16
/** Version at which NPC paths exist. */
const V_NPCS = 14
/** Below this version the orientation field goes through the lookup. */
const V_DIRECT_ORIENTATION = 7
/** One wall cell: a tile reference plus the wall's own fields. */
export interface Ds1Wall {
/** Palette/tile property byte. */
readonly prop1: number
/** Sequence within the style (0..63). */
readonly sequence: number
/** Style index within the DT1 library. */
readonly style: number
/** Orientation index (a DT1 `direction`). */
readonly type: number
/** Unnamed field, as stored. */
readonly unknown1: number
/** Unnamed field, as stored. */
readonly unknown2: number
/** The cell is hidden. */
readonly hidden: boolean
}
/** One floor or shadow cell. */
export interface Ds1Floor {
/** Palette/tile property byte. */
readonly prop1: number
/** Sequence within the style (0..63). */
readonly sequence: number
/** Style index within the DT1 library. */
readonly style: number
/** Unnamed field, as stored. */
readonly unknown1: number
/** Unnamed field, as stored. */
readonly unknown2: number
/** The cell is hidden. */
readonly hidden: boolean
}
/** One substitution cell (raw value; its meaning is map-specific). */
export interface Ds1Substitution {
/** The raw 32-bit layer value. */
readonly value: number
}
/** Everything one map cell carries. */
export interface Ds1Cell {
/** Wall layers, outermost first. */
readonly walls: readonly Ds1Wall[]
/** Floor layers, one per declared floor layer. */
readonly floors: readonly Ds1Floor[]
/** Shadow layers (one in shipped maps). */
readonly shadows: readonly Ds1Floor[]
/** Substitution layers. */
readonly substitutions: readonly Ds1Substitution[]
}
/** One placed object (a door, a shrine, a chest, ...). */
export interface Ds1Object {
/** Object type index into the act's object table. */
readonly type: number
/** Object id (the specific variant). */
readonly id: number
/** Tile x. */
readonly x: number
/** Tile y. */
readonly y: number
/** Raw flags word. */
readonly flags: number
}
/** A decoded DS1 map. */
export interface Ds1 {
/** File version. */
readonly version: number
/** Map width in tiles. */
readonly width: number
/** Map height in tiles. */
readonly height: number
/** Act (1..5). */
readonly act: number
/** Substitution layer type (0, 1 or 2). */
readonly substitutionType: number
/** Number of wall layers. */
readonly wallLayers: number
/** Number of floor layers. */
readonly floorLayers: number
/** Cells, row-major: `cells[y][x]`. */
readonly cells: readonly (readonly Ds1Cell[])[]
/** Placed objects. */
readonly objects: readonly Ds1Object[]
/** Byte offset where NPC paths begin (not decoded). */
readonly npcPathOffset: number | null
}
/** A cursor over the file, so the versioned section walk reads linearly. */
class Cursor {
private at = 0
private readonly data: Uint8Array
/**
* @param data - the buffer to walk.
*/
constructor(data: Uint8Array) {
this.data = data
}
/** Current byte offset. */
get offset(): number { return this.at }
/**
* Read `count` bytes and advance.
*
* @param count - byte count.
* @returns the bytes.
*/
take(count: number): Uint8Array {
if (count < 0 || this.at + count > this.data.byteLength) {
throw new TruncatedDataError("ds1", "section", this.at, count, this.data.byteLength - this.at, this.data.byteLength)
}
const slice = this.data.subarray(this.at, this.at + count)
this.at += count
return slice
}
/**
* Read a little-endian int32 and advance.
*
* @returns the value.
*/
int32(): number {
const bytes = this.take(4)
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getInt32(0, true)
}
/**
* Read a little-endian uint32 and advance.
*
* @returns the value.
*/
uint32(): number {
const bytes = this.take(4)
return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(0, true)
}
/**
* Read a NUL-terminated ASCII string and advance.
*
* @returns the string.
*/
cstring(): string {
let out = ''
for (;;) {
const bytes = this.take(1)
const byte = bytes[0]
if (byte === undefined || byte === 0) return out
out += String.fromCharCode(byte)
}
}
}
/**
* Decode a DS1 map.
*
* @param data - the complete file.
* @returns the decoded map.
*/
export function decodeDs1(data: Uint8Array): Ds1 {
if (data.byteLength < 12) throw new TruncatedDataError("ds1", "header", 0, 12, data.byteLength, data.byteLength)
const cursor = new Cursor(data)
const version = cursor.int32()
// Stored width/height are one less than the tile count.
const width = cursor.int32() + 1
const height = cursor.int32() + 1
if (width <= 0 || height <= 0 || width * height > (1 << 22)) {
throw new InvalidFieldError("ds1", "geometry", 4, `${String(width)}x${String(height)}`, "positive area <= 2^22")
}
let act = 1
if (version >= V_ACT) act = cursor.int32()
let substitutionType = 0
let substitutionLayers = 0
if (version >= V_SUBSTITUTION_LAYERS) {
substitutionType = cursor.int32()
if (substitutionType === 1 || substitutionType === 2) substitutionLayers = 1
}
const files: string[] = []
if (version >= V_FILES) {
const count = cursor.int32()
if (count < 0 || count > 1024) throw new InvalidFieldError("ds1", "files", cursor.offset - 4, count, "0..1024")
for (let index = 0; index < count; index += 1) files.push(cursor.cstring())
}
if (version >= V_UNKNOWN_LOW && version <= V_UNKNOWN_HIGH) cursor.take(8)
let wallLayers = 0
let floorLayers = 0
if (version >= V_FLOORS) {
wallLayers = cursor.int32()
if (version >= V_WALLS) floorLayers = cursor.int32()
else floorLayers = 1
}
if (wallLayers < 0 || wallLayers > 4 || floorLayers < 0 || floorLayers > 2) {
throw new InvalidFieldError("ds1", "layers", cursor.offset, `${String(wallLayers)} walls, ${String(floorLayers)} floors`, "walls 0..4, floors 0..2")
}
const shadowLayers = 1
const cells: Ds1Cell[][] = []
for (let y = 0; y < height; y += 1) {
const row: Ds1Cell[] = []
for (let x = 0; x < width; x += 1) {
row.push({
walls: Array.from({ length: wallLayers }, () => emptyWall()),
floors: Array.from({ length: floorLayers }, () => emptyFloor()),
shadows: Array.from({ length: shadowLayers }, () => emptyFloor()),
substitutions: Array.from({ length: substitutionLayers }, () => ({ value: 0 })),
})
}
cells.push(row)
}
for (const layer of layerOrder(version, wallLayers, floorLayers, shadowLayers, substitutionLayers)) {
for (let y = 0; y < height; y += 1) {
const row = cells[y]!
for (let x = 0; x < width; x += 1) {
const bits = cursor.uint32()
applyLayer(row[x]!, layer, bits, version)
}
}
}
const objects: Ds1Object[] = []
if (version >= 2) {
const count = cursor.int32()
if (count < 0 || count > (1 << 20)) throw new InvalidFieldError("ds1", "objects", cursor.offset - 4, count, "0..2^20")
for (let index = 0; index < count; index += 1) {
objects.push({
type: cursor.int32(),
id: cursor.int32(),
x: cursor.int32(),
y: cursor.int32(),
flags: cursor.int32(),
})
}
}
// Substitution groups, NPC paths and NPC extra data follow; they are not
// needed to draw or walk a map, so their offset is recorded instead of being
// guessed at.
const npcPathOffset = version >= V_NPCS ? cursor.offset : null
return {
version, width, height, act, substitutionType,
wallLayers, floorLayers, cells, objects, npcPathOffset,
}
}
/** A blank wall cell. */
function emptyWall(): Ds1Wall {
return { prop1: 0, sequence: 0, style: 0, type: 0, unknown1: 0, unknown2: 0, hidden: false }
}
/** A blank floor/shadow cell. */
function emptyFloor(): Ds1Floor {
return { prop1: 0, sequence: 0, style: 0, unknown1: 0, unknown2: 0, hidden: false }
}
/** Wall layer indices, matching the file's fixed order. */
const WALL_LAYERS = [0, 1, 2, 3] as const
/** Floor layer indices. */
const FLOOR_LAYERS = [0, 1] as const
/**
* The order the layer streams appear in the file.
*
* Below version 4 every type had exactly one layer and the streams follow a
* legacy order; from 4 on, wall and orientation layers alternate per wall
* layer, then floors, then shadow, then substitutions.
*
* @param version - file version.
* @param wallLayers - declared wall layers.
* @param floorLayers - declared floor layers.
* @param shadowLayers - shadow layers.
* @param substitutionLayers - substitution layers.
* @returns the layer stream descriptors in file order.
*/
function layerOrder(
version: number,
wallLayers: number,
floorLayers: number,
shadowLayers: number,
substitutionLayers: number,
): { kind: 'wall' | 'orientation' | 'floor' | 'shadow' | 'substitution'; index: number }[] {
if (version < V_FLOORS) {
return [
{ kind: 'wall', index: 0 },
{ kind: 'floor', index: 0 },
{ kind: 'orientation', index: 0 },
{ kind: 'substitution', index: 0 },
{ kind: 'shadow', index: 0 },
]
}
const order: { kind: 'wall' | 'orientation' | 'floor' | 'shadow' | 'substitution'; index: number }[] = []
for (let index = 0; index < wallLayers; index += 1) {
order.push({ kind: 'wall', index }, { kind: 'orientation', index })
}
for (let index = 0; index < floorLayers; index += 1) order.push({ kind: 'floor', index })
for (let index = 0; index < shadowLayers; index += 1) order.push({ kind: 'shadow', index })
for (let index = 0; index < substitutionLayers; index += 1) order.push({ kind: 'substitution', index })
return order
}
/**
* Apply one 32-bit layer value to its cell.
*
* Cells are small mutable records for exactly this reason: the layer streams
* arrive interleaved, so a cell is assembled from several passes.
*
* @param cell - the cell to update.
* @param layer - which layer this value belongs to.
* @param bits - the raw 32-bit value.
* @param version - file version (older maps fold orientations).
*/
function applyLayer(
cell: Ds1Cell,
layer: { kind: 'wall' | 'orientation' | 'floor' | 'shadow' | 'substitution'; index: number },
bits: number,
version: number,
): void {
const prop1 = bits & 0x000000ff
const sequence = (bits & 0x00003f00) >>> 8
const unknown1 = (bits & 0x000fc000) >>> 14
const style = (bits & 0x03f00000) >>> 20
const unknown2 = (bits & 0x7c000000) >>> 26
const hidden = ((bits & 0x80000000) >>> 31) > 0
switch (layer.kind) {
case 'wall': {
const wall = cell.walls[layer.index]
if (wall === undefined) return
// Walls are mutable during assembly; the exported type is read-only.
Object.assign(wall, { prop1, sequence, unknown1, style, unknown2, hidden })
return
}
case 'orientation': {
const wall = cell.walls[layer.index]
if (wall === undefined) return
let type = bits & 0x000000ff
if (version < V_DIRECT_ORIENTATION && type < LEGACY_DIRECTION_LOOKUP.length) {
type = LEGACY_DIRECTION_LOOKUP[type]!
}
Object.assign(wall, { type })
return
}
case 'floor': {
const floor = cell.floors[layer.index]
if (floor !== undefined) {
const isHidden = hidden || (style === 30 && (sequence === 0 || sequence === 1))
Object.assign(floor, { prop1, sequence, unknown1, style, unknown2, hidden: isHidden })
}
return
}
case 'shadow': {
const shadow = cell.shadows[layer.index]
if (shadow !== undefined) Object.assign(shadow, { prop1, sequence, unknown1, style, unknown2, hidden })
return
}
case 'substitution': {
const substitution = cell.substitutions[layer.index]
if (substitution !== undefined) Object.assign(substitution, { value: bits })
return
}
/* v8 ignore next -- the layer union is closed above. */
default: return
}
}
/** Reserved for the DT1 pairing helpers that follow in the renderer. */
export { WALL_LAYERS, FLOOR_LAYERS }