420 lines
14 KiB
TypeScript
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 }
|