diablo2-web/src/formats/dt1.ts

535 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { CelError } from "./sprite";
import { FormatError, InvalidFieldError, TruncatedDataError, requireBytes } from './reader'
/** Bytes of the fixed header before the tile count. */
const HEADER_PREFIX = 8
/** Bytes of unknown data between the version and the tile count. */
const HEADER_UNKNOWN = 260
/** Bytes per tile record. */
const TILE_RECORD_SIZE = 96
/** Pixel height of one block's covered area, used to size tile bitmaps. */
const BLOCK_PIXEL_HEIGHT = 32
/** Bytes per block header. */
const BLOCK_HEADER_SIZE = 20
/** Sub-tile grid edge (5×5 collision flags per tile). */
export const SUB_TILE_GRID = 5
/** Bytes of one isometric block. */
const ISOMETRIC_BLOCK_SIZE = 256
/** Row offsets of the isometric diamond, one entry per row. */
const ISO_JUMP = [14, 12, 10, 8, 6, 4, 2, 0, 2, 4, 6, 8, 10, 12, 14] as const
/** Row run lengths of the isometric diamond, one entry per row. */
const ISO_RUN = [4, 8, 12, 16, 20, 24, 28, 32, 28, 24, 20, 16, 12, 8, 4] as const
/** A tile's collision flags, decoded from one of its 25 sub-tile bytes. */
export interface SubTileFlags {
/** Blocks walking (all units). */
readonly blockWalk: boolean
/** Blocks line of sight. */
readonly blockLos: boolean
/** Blocks jumping/leaping. */
readonly blockJump: boolean
/** Blocks the player specifically (but not monsters). */
readonly blockPlayerWalk: boolean
/** Blocks light. */
readonly blockLight: boolean
/** Raw byte, for the bits that are not named. */
readonly raw: number
}
/** One block of a tile: its placement inside the tile and its pixels. */
export interface Dt1Block {
/** Destination x within the tile. */
readonly x: number
/** Destination y within the tile. */
readonly y: number
/** Block grid position, as stored. */
readonly gridX: number
/** Block grid position, as stored. */
readonly gridY: number
/** Encoding: 0 run-length, 1 isometric. */
readonly format: number
/** Palette indices, laid out at tile size (tile width × |tile height|). */
readonly pixels: Uint8Array
}
/** A block header as stored (20 bytes). */
interface BlockHeader {
/** Destination x within the tile. */
readonly x: number
/** Destination y within the tile. */
readonly y: number
/** Sub-tile grid column (floor tiles only). */
readonly gridX: number
/** Sub-tile grid row (floor tiles only). */
readonly gridY: number
/** Encoding: 0 run-length, 1 isometric. */
readonly format: number
/** Encoded byte count. */
readonly length: number
/** Offset of the encoded data, as stored. */
readonly fileOffset: number
/** Offset of the block header that carries it. */
readonly headerOffset: number
}
/**
* Resolve where a block's encoded bytes actually live.
*
* The community format documentation calls the field "offset in file", while the
* working reference implementation reads it relative to the block header. Both
* readings occur in the wild, so the decoder tries the reference's reading
* first and falls back to the absolute one when that range is unusable — and a
* file that satisfies neither is reported rather than decoded into noise.
*
* @param data - the complete file.
* @param block - the block header.
* @returns the absolute offset of the encoded data.
*/
function resolveBlockDataOffset(data: Uint8Array, block: BlockHeader): number[] {
const relative = block.headerOffset + block.fileOffset
const absolute = block.fileOffset
const fits = (offset: number): boolean =>
offset >= 0 && offset + block.length <= data.byteLength && block.length >= 0
const candidates = [relative, absolute].filter((offset, index, all) => fits(offset) && all.indexOf(offset) === index)
if (candidates.length === 0) {
throw new InvalidFieldError('dt1', 'block fileOffset', block.headerOffset + 16, Math.max(relative, absolute), 'data fitting the file bounds')
}
return candidates
}
/** One tile of the library. */
export interface Dt1Tile {
/** Orientation/direction index. */
readonly direction: number
/** Tile height, negative for roofs. */
readonly height: number
/** Tile width. */
readonly width: number
/** Tile type (the `type` half of a DS1 tile reference). */
readonly type: number
/** Tile style (the `style` half of a DS1 tile reference). */
readonly style: number
/** Sequence within the style. */
readonly sequence: number
/** Material bit field (water, wood, stone, ...). */
readonly materialFlags: number
/**
* Roof height, at file offset +4.
*
* OpenDiablo2 reads this field and uses it for one thing: a roof tile's
* `YAdjust` is `-roofHeight` instead of `minBlockY + 80`
* (`d2maprenderer/tile_cache.go`). Kept here so the roof layer can be drawn
* with the engine's own offset when it is wired up.
*/
readonly roofHeight: number
/**
* Animated flag, at file offset +7.
*
* Non-zero (specifically bit 0 set) when this tile is part of an animated sequence.
* For animated tiles, `rarityFrameIndex` specifies the 0-based animation frame index
* rather than a variant selection weight.
*/
readonly animated: number
/**
* Variant weight inside one `style:sequence` group, at file offset +32.
*
* A DT1 ships several tiles for the same `style`/`sequence` and the engine
* picks one **per cell** by weighted random using this field as the weight
* (OpenDiablo2 `getRandomTile`, seeded from the map seed and the cell's x/y).
* Without it every cell of a floor would draw the same variant.
*/
readonly rarityFrameIndex: number
/** 25 sub-tile collision flag sets, row-major on a 5×5 grid. */
readonly subTileFlags: readonly SubTileFlags[]
/**
* Most negative block `y` in this tile.
*
* Blocks are placed with this shift applied (see {@link Dt1Tile.blocks}), and
* the value is also what Diablo II's renderer adds to a wall's cell position
* (`YAdjust = minBlockY + 80`): a wall whose art extends above its cell must
* be pushed back down by exactly the amount the art was shifted. Floors ignore
* it, which is why only walls look wrong when it is dropped.
*/
readonly minBlockY: number
/**
* Height of the decoded bitmap, in pixels.
*
* Usually `|height|`, but a wall's art can legitimately extend past the
* declared height (the reference renderer allocates
* `max(|height|, maxBlockY + 32 - minBlockY)` for exactly this reason). Using
* the declared height alone clips those tiles — two references in Act 5's town
* hit that case.
*/
readonly bitmapHeight: number
/** The tile's blocks, in file order. */
readonly blocks: readonly Dt1Block[]
}
/** A decoded DT1 file. */
export interface Dt1 {
/**
* Structural surprises that did not prevent decoding (empty for a
* well-formed library). Real Blizzard files are expected to leave this empty;
* a non-empty list means a field's meaning differs from the documented one.
*/
readonly warnings: readonly string[]
/** Major version, as stored (7 for shipped files). */
readonly versionMajor: number
/** Minor version, as stored (6 for shipped files). */
readonly versionMinor: number
/** The tile library. */
readonly tiles: readonly Dt1Tile[]
}
/**
* Read a little-endian uint32.
*
* @param data - the buffer.
* @param at - byte offset.
* @returns the value.
*/
function u32(data: Uint8Array, at: number): number {
requireBytes(data, at, 4, 'dt1', 'u32')
requireBytes(data, at, 4, 'dt1', 'u32')
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint32(at, true)
}
/**
* Read a little-endian int32.
*
* @param data - the buffer.
* @param at - byte offset.
* @returns the value.
*/
function i32(data: Uint8Array, at: number): number {
return u32(data, at) | 0
}
/**
* Read a little-endian uint16.
*
* @param data - the buffer.
* @param at - byte offset.
* @returns the value.
*/
function u16(data: Uint8Array, at: number): number {
requireBytes(data, at, 2, 'dt1', 'u16')
requireBytes(data, at, 2, 'dt1', 'u16')
return new DataView(data.buffer, data.byteOffset, data.byteLength).getUint16(at, true)
}
/**
* Read a little-endian int16.
*
* @param data - the buffer.
* @param at - byte offset.
* @returns the value.
*/
function i16(data: Uint8Array, at: number): number {
const value = u16(data, at)
return value >= 0x8000 ? value - 0x10000 : value
}
/**
* Decode one sub-tile flag byte.
*
* @param raw - the flag byte.
* @returns the decoded flags.
*/
export function subTileFlagsOf(raw: number): SubTileFlags {
return {
blockWalk: (raw & 1) === 1,
blockLos: (raw & 2) === 2,
blockJump: (raw & 4) === 4,
blockPlayerWalk: (raw & 8) === 8,
blockLight: (raw & 32) === 32,
raw,
}
}
/**
* Whether a DT1 tile is part of an animated sequence.
*
* In Diablo II DT1 files, byte +7 is the animated flag (bit 0 set for animated floor/wall tiles).
* When animated, `rarityFrameIndex` represents the 0-based animation frame index
* rather than a random variation weight.
*
* @param tile - the DT1 tile to check.
* @returns true if the tile is an animated frame.
*/
export function isAnimatedTile(tile: { readonly animated?: number }): boolean {
return typeof tile.animated === 'number' && ((tile.animated & 1) === 1 || tile.animated > 0)
}
/**
* Decode a DT1 file.
*
* @param data - the complete file.
* @returns the decoded library.
*/
export function decodeDt1(data: Uint8Array): Dt1 {
if (data.byteLength < HEADER_PREFIX + HEADER_UNKNOWN + 8) {
throw new TruncatedDataError('dt1', 'header', 0, HEADER_PREFIX + HEADER_UNKNOWN + 8, data.byteLength, data.byteLength)
}
const versionMajor = i32(data, 0)
const versionMinor = i32(data, 4)
if (versionMajor !== 7 || versionMinor !== 6) {
throw new InvalidFieldError('dt1', 'version', 0, `${String(versionMajor)}.${String(versionMinor)}`, '7.6')
}
const tileCount = i32(data, HEADER_PREFIX + HEADER_UNKNOWN)
const tileDataStart = i32(data, HEADER_PREFIX + HEADER_UNKNOWN + 4)
if (tileCount < 0 || tileDataStart < 0 || tileDataStart > data.byteLength) {
throw new InvalidFieldError('dt1', 'tile metadata', HEADER_PREFIX + HEADER_UNKNOWN, `${String(tileCount)} tiles at ${String(tileDataStart)}`, 'plausible layout')
}
const tilesEnd = tileDataStart + tileCount * TILE_RECORD_SIZE
if (tilesEnd > data.byteLength) {
throw new TruncatedDataError('dt1', 'tiles area', tileDataStart, tileCount * TILE_RECORD_SIZE, data.byteLength - tileDataStart, data.byteLength)
}
const warnings: string[] = []
const records: { tile: Omit<Dt1Tile, 'blocks' | 'minBlockY' | 'bitmapHeight'>; blocks: BlockHeader[] }[] = []
for (let index = 0; index < tileCount; index += 1) {
const at = tileDataStart + index * TILE_RECORD_SIZE
const subTileFlags: SubTileFlags[] = []
for (let subY = 0; subY < SUB_TILE_GRID; subY += 1) {
for (let subX = 0; subX < SUB_TILE_GRID; subX += 1) {
// DT1 sub-tile collision flags are stored bottom-to-top in the binary
// (bytes 0..4 are the bottom row subY=4, bytes 20..24 are the top row subY=0).
// OpenDiablo2 subtileLookup: row 0 -> 20..24, row 4 -> 0..4.
// D2MOO D2Collision.cpp: pTmp = &v5[5 * (nY - nCappedY + 4) - nX].
// Normalize into top-to-bottom row order [subY * 5 + subX].
const fileIndex = (SUB_TILE_GRID - 1 - subY) * SUB_TILE_GRID + subX
subTileFlags.push(subTileFlagsOf(new DataView(data.buffer, data.byteOffset, data.byteLength).getUint8(at + 40 + fileIndex)))
}
}
const blockHeaderPointer = i32(data, at + 72)
const blockHeaderSize = i32(data, at + 76)
const numBlocks = i32(data, at + 80)
const blocks: BlockHeader[] = []
for (let blockIndex = 0; blockIndex < numBlocks; blockIndex += 1) {
const headerAt = blockHeaderPointer + blockIndex * BLOCK_HEADER_SIZE
if (headerAt + BLOCK_HEADER_SIZE > data.byteLength) {
throw new InvalidFieldError("dt1", "block header offset", at + 72, headerAt + BLOCK_HEADER_SIZE, `<= ${String(data.byteLength)}`)
}
// Block header (20 bytes): X, Y, 2 unused, GridX, GridY, Format,
// Length, 2 unused, FileOffset. Note the two unused words: a 16-byte
// reading of this record parses every field after Y at the wrong offset.
const view = new DataView(data.buffer, data.byteOffset, data.byteLength)
blocks.push({
x: i16(data, headerAt),
y: i16(data, headerAt + 2),
gridX: view.getUint8(headerAt + 6),
gridY: view.getUint8(headerAt + 7),
format: i16(data, headerAt + 8),
length: i32(data, headerAt + 10),
fileOffset: i32(data, headerAt + 16),
headerOffset: blockHeaderPointer,
})
}
records.push({
tile: {
direction: i32(data, at + 0),
height: i32(data, at + 8),
width: i32(data, at + 12),
type: i32(data, at + 20),
style: i32(data, at + 24),
sequence: i32(data, at + 28),
materialFlags: u16(data, at + 6),
roofHeight: i16(data, at + 4),
animated: new DataView(data.buffer, data.byteOffset, data.byteLength).getUint8(at + 7),
rarityFrameIndex: i32(data, at + 32),
subTileFlags,
},
blocks,
})
// The field at +76 is *not* a per-block stride: real Act 1 town tiles carry
// 25 blocks and a value of 6900, which is the whole block section —
// `25 * 20 (headers) + 25 * 256 (bodies)`. Treating it as `numBlocks * 20`
// rejected every real Diablo II DT1 while accepting our own fixtures, which
// is exactly the failure mode a fixture-only round trip cannot see. The
// invariant is checked and reported instead of guessed at.
if (numBlocks > 0) {
const bodies = blocks.reduce((sum, block) => sum + block.length, 0)
const expected = numBlocks * BLOCK_HEADER_SIZE + bodies
if (blockHeaderSize !== expected) {
warnings.push(
`tile ${String(index)}: block section is ${String(blockHeaderSize)} bytes, `
+ `but ${String(numBlocks)} headers + ${String(bodies)} body bytes = ${String(expected)}`,
)
}
}
}
// Each tile is shifted by its *own* most negative block y, not by a file-wide
// worst case: a wall's art legitimately extends above its cell (that is what
// makes it a wall), and a shared shift would push every other tile down by the
// tallest wall in the library.
const tiles: Dt1Tile[] = records.map((record, index) => {
let minBlockY = 0
let maxBlockY = 0
for (const block of record.blocks) {
if (block.y < minBlockY) minBlockY = block.y
if (block.y + BLOCK_PIXEL_HEIGHT > maxBlockY) maxBlockY = block.y + BLOCK_PIXEL_HEIGHT
}
const yOffset = -minBlockY
const bitmapHeight = Math.max(Math.abs(record.tile.height), maxBlockY - minBlockY)
return {
...record.tile,
minBlockY,
bitmapHeight,
blocks: record.blocks.map(block => decodeBlock(data, block, record.tile, yOffset, index, bitmapHeight)),
}
})
return { versionMajor, versionMinor, tiles, warnings }
}
/**
* Decode one block's pixels at tile size.
*
* @param data - the complete file.
* @param block - the block header.
* @param tile - the owning tile.
* @param yOffset - vertical shift applied to every block.
* @param tileIndex - the tile's index, for error messages.
* @returns the block with pixels.
*/
function decodeBlock(
data: Uint8Array,
block: BlockHeader,
tile: Omit<Dt1Tile, 'blocks' | 'minBlockY' | 'bitmapHeight'>,
yOffset: number,
tileIndex: number,
bitmapHeight: number,
): Dt1Block {
const tileWidth = tile.width
const tileHeight = bitmapHeight
if (tileWidth <= 0 || tileHeight <= 0 || tileWidth * tileHeight > (1 << 22)) {
throw new InvalidFieldError('dt1', 'tile bounds', block.headerOffset, `${String(tileWidth)}x${String(tileHeight)}`, `area <= ${String(1 << 22)}`)
}
const pixels = new Uint8Array(tileWidth * tileHeight)
const decodeWith = (offset: number): Uint8Array => {
const encoded = data.subarray(offset, offset + block.length)
if (encoded.byteLength < block.length) {
throw new TruncatedDataError('dt1', 'block stream', offset, block.length, encoded.byteLength, data.byteLength)
}
const target = new Uint8Array(tileWidth * tileHeight)
if (block.format === 1) {
decodeIsometric(encoded, target, block, tileWidth, tileHeight, yOffset)
} else {
decodeRunLength(encoded, target, block, tileWidth, tileHeight, yOffset)
}
return target
}
// The offset field's base is not settled by the documentation (`offset in
// file`) versus the reference implementation (relative to the block header),
// so both readings are tried. An all-transparent result is what a wrong-but-
// in-range reading looks like — an RLE stream of zero pairs advances rows and
// draws nothing — so a blank first attempt falls through to the alternative
// instead of being returned as a silently empty tile.
let best: Uint8Array | null = null
for (const offset of resolveBlockDataOffset(data, block)) {
const decoded = decodeWith(offset)
if (decoded.some(value => value !== 0)) return { x: block.x, y: block.y, gridX: block.gridX, gridY: block.gridY, format: block.format, pixels: decoded }
best ??= decoded
}
/* v8 ignore next -- resolveBlockDataOffset guarantees at least one candidate. */
if (best === null) throw new FormatError('dt1', 'block decode', block.headerOffset, `tile ${String(tileIndex)} block produced no data`)
return { x: block.x, y: block.y, gridX: block.gridX, gridY: block.gridY, format: block.format, pixels: best }
}
/**
* Lay an isometric block onto the tile bitmap.
*
* @param encoded - the 256-byte block.
* @param pixels - the tile bitmap to fill.
* @param block - the block placement.
* @param tileWidth - tile width.
* @param tileHeight - tile height.
* @param yOffset - vertical shift applied to every block.
*/
function decodeIsometric(
encoded: Uint8Array,
pixels: Uint8Array,
block: { x: number; y: number },
tileWidth: number,
tileHeight: number,
yOffset: number,
): void {
let index = 0
for (let row = 0; row < ISO_JUMP.length && index < encoded.byteLength; row += 1) {
const startX = ISO_JUMP[row]!
const run = ISO_RUN[row]!
for (let i = 0; i < run; i += 1) {
const x = block.x + startX + i
const y = block.y + row + yOffset
if (x < 0 || x >= tileWidth || y < 0 || y >= tileHeight) { index += 1; continue }
const val = encoded[index]
if (val === undefined) return
pixels[y * tileWidth + x] = val
index += 1
}
}
}
/**
* Lay a run-length encoded block onto the tile bitmap.
*
* @param encoded - the block's run stream.
* @param pixels - the tile bitmap to fill.
* @param block - the block placement.
* @param tileWidth - tile width.
* @param tileHeight - tile height.
* @param yOffset - vertical shift applied to every block.
*/
function decodeRunLength(
encoded: Uint8Array,
pixels: Uint8Array,
block: { x: number; y: number },
tileWidth: number,
tileHeight: number,
yOffset: number,
): void {
let index = 0
let remaining = blockLengthOf(encoded)
let x = 0
let y = 0
while (remaining > 0 && index + 1 < encoded.byteLength) {
const skip = encoded[index]
const count = encoded[index + 1]
if (skip === undefined || count === undefined) return
index += 2
remaining -= 2
if ((skip | count) === 0) {
x = 0
y += 1
continue
}
x += skip
remaining -= count
for (let i = 0; i < count; i += 1) {
const px = block.x + x + i
const py = block.y + y + yOffset
if (px >= 0 && px < tileWidth && py >= 0 && py < tileHeight) {
pixels[py * tileWidth + px] = encoded[index] ?? 0
}
index += 1
}
x += count
}
}
/**
* The block's declared length, which the caller substitutes for the stream's
* own bound. Kept as a helper so the run-length loop reads the same way as the
* reference decoder, which counts down a byte budget rather than testing the
* buffer end.
*
* @param encoded - the block's bytes.
* @returns the byte budget.
*/
function blockLengthOf(encoded: Uint8Array): number {
return encoded.byteLength
}