255 lines
9.9 KiB
TypeScript
255 lines
9.9 KiB
TypeScript
/**
|
|
* Map fixture generator: encode a DT1 tile library and a DS1 map that
|
|
* references it.
|
|
*
|
|
* Written strictly to the published layouts so that an *independent* decoder
|
|
* (the Go reference packages) can read them: if it agrees with this project's
|
|
* TypeScript decoder field by field, the format understanding is confirmed, and
|
|
* if it disagrees the fixtures say exactly which field drifted.
|
|
*
|
|
* Usage: node scripts/make-map-fixtures.ts <output-directory>
|
|
*/
|
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
import { join } from 'node:path'
|
|
|
|
/** Bytes per DT1 tile record. */
|
|
const DT1_TILE_RECORD = 96
|
|
/** Bytes per DT1 block header. */
|
|
const DT1_BLOCK_HEADER = 20
|
|
/** Tile edge in pixels: a Diablo II tile is 5x5 sub-tiles of 32x32. */
|
|
const TILE = 160
|
|
/** Bytes of fixed preamble before the DT1 tile count. */
|
|
const DT1_HEADER_PREFIX = 8 + 260
|
|
|
|
/**
|
|
* Encode an RLE block body: `(skip, count)` pairs then literals, `(0, 0)`
|
|
* ending a row.
|
|
*
|
|
* @param width - tile width.
|
|
* @param height - tile height.
|
|
* @param base - first palette index used.
|
|
* @returns the run stream.
|
|
*/
|
|
function encodeBlockRle(width: number, height: number, base: number): Uint8Array {
|
|
const bytes: number[] = []
|
|
for (let y = 0; y < height; y += 1) {
|
|
const start = y % 4
|
|
const count = width - start - (y % 3)
|
|
bytes.push(start, count)
|
|
for (let i = 0; i < count; i += 1) bytes.push(((base + y * 7 + i) & 0xff) || 1)
|
|
bytes.push(0, 0)
|
|
}
|
|
return new Uint8Array(bytes)
|
|
}
|
|
|
|
/**
|
|
* Encode an isometric block body: exactly 256 bytes for the fixed diamond.
|
|
*
|
|
* @param base - first palette index used.
|
|
* @returns the block bytes.
|
|
*/
|
|
function encodeBlockIsometric(base: number): Uint8Array {
|
|
const bytes = new Uint8Array(256)
|
|
for (let i = 0; i < bytes.byteLength; i += 1) bytes[i] = ((base + i) & 0xff) || 1
|
|
return bytes
|
|
}
|
|
|
|
/**
|
|
* Build a DT1 file with one RLE tile and one isometric tile.
|
|
*
|
|
* @returns the encoded library.
|
|
*/
|
|
function encodeDt1(): Uint8Array {
|
|
const tiles = [
|
|
{ rle: true, type: 0, style: 1, sequence: 0, material: 0x0020, width: TILE, height: -TILE },
|
|
{ rle: true, type: 0, style: 1, sequence: 1, material: 0x0008, width: TILE, height: -TILE },
|
|
{ rle: false, type: 0, style: 2, sequence: 0, material: 0x0040, width: TILE, height: -TILE },
|
|
{ rle: false, type: 0, style: 2, sequence: 1, material: 0x0002, width: TILE, height: -TILE },
|
|
]
|
|
const tileDataStart = DT1_HEADER_PREFIX + 8
|
|
// Wall tiles store a *negative* height (they grow upward from their anchor), so
|
|
// the encoder has to work from the magnitude.
|
|
const bodies = tiles.map((tile, index) => (tile.rle
|
|
? encodeBlockRle(tile.width, Math.abs(tile.height), 9 + index * 40)
|
|
: encodeBlockIsometric(70 + index * 40)))
|
|
|
|
let bodyOffset = tileDataStart + tiles.length * DT1_TILE_RECORD
|
|
const blockHeaderStart = bodyOffset
|
|
bodyOffset += tiles.length * DT1_BLOCK_HEADER
|
|
const blockDataOffsets: number[] = []
|
|
for (const body of bodies) {
|
|
blockDataOffsets.push(bodyOffset)
|
|
bodyOffset += body.byteLength
|
|
}
|
|
|
|
const out = new Uint8Array(bodyOffset)
|
|
const view = new DataView(out.buffer)
|
|
view.setInt32(0, 7, true)
|
|
view.setInt32(4, 6, true)
|
|
view.setInt32(DT1_HEADER_PREFIX, tiles.length, true)
|
|
view.setInt32(DT1_HEADER_PREFIX + 4, tileDataStart, true)
|
|
|
|
tiles.forEach((tile, index) => {
|
|
const at = tileDataStart + index * DT1_TILE_RECORD
|
|
view.setInt32(at + 0, 0, true) // direction
|
|
view.setInt16(at + 4, 0, true) // roof height
|
|
view.setUint16(at + 6, tile.material, true) // material flags
|
|
view.setInt32(at + 8, tile.height, true)
|
|
view.setInt32(at + 12, tile.width, true)
|
|
view.setInt32(at + 20, tile.type, true)
|
|
view.setInt32(at + 24, tile.style, true)
|
|
view.setInt32(at + 28, tile.sequence, true)
|
|
view.setInt32(at + 32, 0, true) // rarity frame index
|
|
// 25 sub-tile flags: a deterministic blocking pattern.
|
|
for (let sub = 0; sub < 25; sub += 1) out[at + 40 + sub] = sub % 3 === 0 ? 0x01 : sub % 5 === 0 ? 0x23 : 0
|
|
view.setInt32(at + 72, blockHeaderStart + index * DT1_BLOCK_HEADER, true)
|
|
view.setInt32(at + 76, DT1_BLOCK_HEADER, true)
|
|
view.setInt32(at + 80, 1, true) // one block
|
|
const headerAt = blockHeaderStart + index * DT1_BLOCK_HEADER
|
|
// Block header (20 bytes): X, Y, 2 unused, GridX, GridY, Format, Length,
|
|
// 2 unused, FileOffset — and the offset is relative to this header.
|
|
view.setInt16(headerAt + 0, 0, true) // block x
|
|
view.setInt16(headerAt + 2, 0, true) // block y
|
|
out[headerAt + 6] = 0 // grid x
|
|
out[headerAt + 7] = 0 // grid y
|
|
view.setInt16(headerAt + 8, tile.rle ? 0 : 1, true)
|
|
view.setInt32(headerAt + 10, bodies[index]!.byteLength, true)
|
|
// The offset is relative to this tile's own block header pointer (each tile
|
|
// has its own), not to the start of the whole block header section.
|
|
view.setInt32(headerAt + 16, blockDataOffsets[index]! - headerAt, true)
|
|
out.set(bodies[index]!, blockDataOffsets[index]!)
|
|
})
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Build a DS1 map referencing the fixture tiles.
|
|
*
|
|
* Uses version 16 so the file exercises act, wall *and* floor layer counts, the
|
|
* interleaved wall/orientation streams and the object list — and, unlike
|
|
* versions 9..13, carries no unknown byte block.
|
|
*
|
|
* @returns the encoded map.
|
|
*/
|
|
function encodeDs1(): Uint8Array {
|
|
const version = 16
|
|
const width = 3
|
|
const height = 3
|
|
const wallLayers = 1
|
|
const floorLayers = 1
|
|
const layerOrder: { kind: 'wall' | 'orientation' | 'floor' | 'shadow' }[] = [
|
|
{ kind: 'wall' }, { kind: 'orientation' }, { kind: 'floor' }, { kind: 'shadow' },
|
|
]
|
|
const cellCount = width * height
|
|
const objects = [
|
|
{ type: 2, id: 1, x: 1, y: 1, flags: 0 },
|
|
{ type: 5, id: 0, x: 2, y: 0, flags: 0x8 },
|
|
]
|
|
const total = 4 * 3 // version, width-1, height-1
|
|
+ 4 // act
|
|
+ 4 // substitution type
|
|
+ 4 // embedded file count
|
|
+ 4 + 4 // wall layers, floor layers
|
|
+ layerOrder.length * cellCount * 4
|
|
+ 4 + objects.length * 20
|
|
+ 4 // NPC count (version 14+ carries NPC paths after the objects)
|
|
const out = new Uint8Array(total)
|
|
const view = new DataView(out.buffer)
|
|
let at = 0
|
|
view.setInt32(at, version, true); at += 4
|
|
view.setInt32(at, width - 1, true); at += 4
|
|
view.setInt32(at, height - 1, true); at += 4
|
|
view.setInt32(at, 2, true); at += 4 // act
|
|
view.setInt32(at, 0, true); at += 4 // substitution type
|
|
view.setInt32(at, 0, true); at += 4 // embedded file count
|
|
view.setInt32(at, wallLayers, true); at += 4
|
|
view.setInt32(at, floorLayers, true); at += 4
|
|
|
|
for (const layer of layerOrder) {
|
|
for (let y = 0; y < height; y += 1) {
|
|
for (let x = 0; x < width; x += 1) {
|
|
const seed = (y * width + x) & 0xff
|
|
let bits: number
|
|
switch (layer.kind) {
|
|
case 'wall':
|
|
// Style 2 holds the two wall tiles; sequence picks between them.
|
|
bits = (seed | (((x + y) % 2) << 8) | (2 << 20)) >>> 0
|
|
break
|
|
case 'orientation':
|
|
bits = 0
|
|
break
|
|
case 'floor':
|
|
// Style 1 holds the two floor tiles.
|
|
bits = ((seed + 1) | (((x * 3 + y) % 2) << 8) | (1 << 20)) >>> 0
|
|
break
|
|
case 'shadow':
|
|
bits = (seed % 8) >>> 0
|
|
break
|
|
}
|
|
view.setUint32(at, bits >>> 0, true); at += 4
|
|
}
|
|
}
|
|
}
|
|
|
|
view.setInt32(at, objects.length, true); at += 4
|
|
for (const object of objects) {
|
|
view.setInt32(at, object.type, true); at += 4
|
|
view.setInt32(at, object.id, true); at += 4
|
|
view.setInt32(at, object.x, true); at += 4
|
|
view.setInt32(at, object.y, true); at += 4
|
|
view.setInt32(at, object.flags, true); at += 4
|
|
}
|
|
// No NPC paths in the fixture: the section's count is zero, which is what
|
|
// version 14+ readers look for after the objects.
|
|
view.setInt32(at, 0, true); at += 4
|
|
return out
|
|
}
|
|
|
|
const outDir = process.argv[2]
|
|
if (outDir === undefined) {
|
|
console.error('usage: node scripts/make-map-fixtures.ts <output-directory>')
|
|
process.exit(2)
|
|
}
|
|
await mkdir(outDir, { recursive: true })
|
|
const dt1 = encodeDt1()
|
|
const ds1 = encodeDs1()
|
|
await writeFile(join(outDir, 'fixture.dt1'), dt1)
|
|
await writeFile(join(outDir, 'fixture.ds1'), ds1)
|
|
|
|
/**
|
|
* The pixel grid each fixture tile is expected to decode to, computed from the
|
|
* same parameters the encoder used. Independent of the decoder under test —
|
|
* which is the point: it turns "the decoder did not throw" into "the decoder put
|
|
* every pixel where the encoder put it".
|
|
*/
|
|
const tileWidth = TILE
|
|
const tileHeight = TILE
|
|
const expectedTiles: { format: number; base: number; pixels: number[] }[] = []
|
|
for (const [index, rle] of [true, true, false, false].entries()) {
|
|
const base = rle ? 9 + index * 40 : 70 + index * 40
|
|
const pixels = new Array<number>(tileWidth * tileHeight).fill(0)
|
|
if (rle) {
|
|
for (let y = 0; y < tileHeight; y += 1) {
|
|
const start = y % 4
|
|
const count = tileWidth - start - (y % 3)
|
|
for (let i = 0; i < count; i += 1) pixels[y * tileWidth + start + i] = ((base + y * 7 + i) & 0xff) || 1
|
|
}
|
|
} else {
|
|
const jump = [14, 12, 10, 8, 6, 4, 2, 0, 2, 4, 6, 8, 10, 12, 14]
|
|
const run = [4, 8, 12, 16, 20, 24, 28, 32, 28, 24, 20, 16, 12, 8, 4]
|
|
let at = 0
|
|
for (let row = 0; row < jump.length; row += 1) {
|
|
for (let i = 0; i < run[row]!; i += 1) {
|
|
pixels[row * tileWidth + jump[row]! + i] = ((base + at) & 0xff) || 1
|
|
at += 1
|
|
}
|
|
}
|
|
}
|
|
expectedTiles.push({ format: rle ? 0 : 1, base, pixels })
|
|
}
|
|
await writeFile(join(outDir, 'expected-dt1.json'), JSON.stringify({ width: tileWidth, height: tileHeight, tiles: expectedTiles }))
|
|
|
|
console.log(`wrote ${join(outDir, 'fixture.dt1')} (${String(dt1.byteLength)} bytes)`)
|
|
console.log(`wrote ${join(outDir, 'fixture.ds1')} (${String(ds1.byteLength)} bytes)`)
|
|
console.log(`wrote ${join(outDir, 'expected-dt1.json')}`)
|