diablo2-web/scripts/pack-act-assets.ts

1730 lines
68 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.

/**
* Bake map assets out of the archives into web-native packs.
*
* The page can already read the real archives, but doing so costs it 38 MB and
* ~11k range requests per visit (measured), because it decodes DS1/DT1/PL2 and
* builds an atlas in the browser every time. This script moves all of that
* offline: it walks the same code the page walks — `resolveLevel`, `decodeDs1`,
* `decodeDt1`, `buildIsoMapScene`, `decodeDc6` — and writes the *result* as
* indexed PNG tile pages plus one JSON scene per map. The page then decodes
* nothing but PNG.
*
* Design decisions worth knowing:
*
* - **Indexed PNG, one palette per act.** Diablo II art is palette-indexed and
* each act ships its own `pal.pl2`; PNG colour type 3 carries both, so the
* browser's own decoder does the palette expansion on the GPU upload path.
* - **Pages of at most 2048², hot frames first.** Frames used near the spawn go
* into the first page(s), so the page can paint after loading one or two PNGs
* instead of the whole map.
* - **The scene JSON is the authority, not a re-derivation.** Draw lists are
* already in painter's order and the collision grid is exactly the one the
* live path produces; `scripts/verify-packs.ts` re-derives both from the MPQs
* and fails if they differ by a byte.
*
* Usage: node scripts/pack-act-assets.ts [archive-directory] [output-directory]
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { join } from 'node:path'
import { MpqArchive } from '../src/mpq/archive.ts'
import { fileSource } from '../src/mpq/file-source.ts'
import { MountedArchives } from '../src/mpq/mount.ts'
import { cell, loadActTables, parseTable, resolveLevel, resolveLevelLibraries, tileMemberPath } from '../src/game/acts.ts'
import type { ActTables, LevelInfo, D2Table } from '../src/game/acts.ts'
import { decodeDs1 } from '../src/formats/ds1.ts'
import type { Ds1 } from '../src/formats/ds1.ts'
import { decodeDt1 } from '../src/formats/dt1.ts'
import type { Dt1 } from '../src/formats/dt1.ts'
import { decodePl2 } from '../src/formats/pl2.ts'
import { levelSeed, buildIsoMapScene, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts'
import { planLevelMonsters } from '../src/game/monsters.ts'
import type { IsoMapScene } from '../src/game/d2map.ts'
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT } from '../src/game/objects.ts'
import { decodeDcc } from '../src/formats/dcc.ts'
import { decodeDc6 } from '../src/formats/dc6.ts'
import type { SpriteFrame } from '../src/formats/sprite.ts'
import { generateMaze, classifyMazePieceName } from '../src/game/maze.ts'
import type { MazePiece, MazePieceKind, MazeWaypoint } from '../src/game/maze.ts'
import { generateWilderness } from '../src/game/wilderness.ts'
import type { PlannedGate, WildernessEntrance, WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts'
import { generatePreset } from '../src/game/preset.ts'
import type { PresetPiece } from '../src/game/preset.ts'
export interface MazeWarp {
readonly room: number
readonly direction: 'up' | 'down'
readonly kind?: string
readonly pieceName?: string
readonly centreX: number
readonly centreY: number
}
import {
assignGateSides, buildWorldGraph, edgesFrom, findWarpGeometry, isClickableWarp,
parseLevelRows, parseWarpGeometry, SIDES,
} from '../src/game/world-graph.ts'
import type { Side, WorldEdge, WorldGraph } from '../src/game/world-graph.ts'
import {
cellToSubTile, findBorderOpening, findWaypointSpot, largestWalkableRegion, nearestWalkable,
seamArrivalSpot, triggerableFrom, WARP_TRIGGER_SUBTILES,
} from '../src/game/level-links.ts'
import type {
BorderOpening, LinkGrid, SceneEntrance, SceneLinks, SceneWarp, SceneWaypoint, WalkableRegion,
} from '../src/game/level-links.ts'
import { findWarpTiles } from '../src/game/warp-tiles.ts'
import { encodeIndexedPng } from './png.ts'
/**
* Archive stack, in load order (later overrides earlier).
*
* `d2char.mpq` is mounted *first* so it sits at the bottom: it holds the object
* and character art (`data\global\objects\…`) but no map data, and this order
* keeps the map lookups resolving through `d2data` → `d2exp` → `Patch_D2` as the
* game does.
*/
const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const
/** Atlas page side limit (both dimensions). */
const PAGE_SIZE = 2048
/** Cells around the spawn whose frames go into the first page. */
const HOT_RADIUS_CELLS = 8
/** Where object art lives. */
const OBJECT_PREFIX = 'data\\global\\objects\\'
/**
* The player's walking speed in scene pixels, matching `act-scene.ts`.
*
* Monster speeds are expressed relative to it, so the baker has to know the
* same number the browser does. Kept as a literal on both sides rather than
* shared through a module because `act-scene.ts` is a browser entry point and
* this script must not pull it in.
*/
const PLAYER_WALK_SPEED_PX = 170
/** Where monster and town-NPC art lives. */
/**
* Mode directories tried in order when picking an object's art.
*
* Note what the object art actually *is*: under `data\global\objects` the
* archives hold **1748 DCC and 1461 COF files against just 13 DC6**. Diablo II
* draws objects with the same composite pipeline as characters — a COF that
* lists layers and frames plus the DCC files behind them — so this packer
* records each object's placement and its exact art members, and leaves the
* pixels to the `COF`/`DCC` decoder that the character art also needs. Baking a
* wrong file as if it were a DC6 would have produced silent garbage instead.
*/
const OBJECT_MODES = ['tr', 'nu', 's1', 's2', 's3'] as const
/**
* The maps to bake, derived from `Levels.txt` rather than listed by hand.
*
* `DrlgType` picks the generator, exactly as the game does (documented in the
* knowledge base and mirrored by OpenDiablo2):
*
* - **2 = preset area**: `LvlPrest` rows name fixed DS1 files, so the map is
* baked byte-for-byte — what `verify-packs.ts` diffs against the live decoder.
* - **1 = random maze** / **3 = wilderness**: no fixed layout exists. Those come
* from the generators (`src/game/maze.ts`, `src/game/wilderness.ts`) driven by
* `LvlMaze`, `LvlSub` and the `LvlPrest` room pieces, and their packs are
* marked `approximation` because the engine's own placement is hardcoded.
*/
type LevelKind = 'preset' | 'maze' | 'wilderness'
/** One map to bake. */
interface LevelJob {
readonly act: number
readonly levelId: number
readonly name: string
readonly kind: LevelKind
readonly slug: string
}
/** Filled in from `Levels.txt` once the tables are loaded. */
let LEVELS: readonly LevelJob[] = []
/**
* Turn a level name into a stable, file-friendly slug.
*
* @param name - `Levels.txt` name, e.g. `Act 1 - Tristram`.
* @returns the slug.
*/
function slugify(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
}
/** One frame's placement inside a page. */
interface Placement {
readonly page: number
readonly x: number
readonly y: number
readonly width: number
readonly height: number
}
/** One atlas page being packed. */
interface PackedPage {
readonly pixels: Uint8Array
frames: number
}
/** Frames, shelf-packed into pages of at most {@link PAGE_SIZE} squared. */
class PageBuilder {
private readonly pages: PackedPage[] = []
/** Palette the pages are encoded against. */
private readonly palette: Uint8Array
private cursorX = 0
private shelfY = 0
private shelfHeight = 0
constructor(palette: Uint8Array) {
this.palette = palette
}
/** Frames placed so far. */
get placed(): number {
return this.pages.reduce((sum, page) => sum + page.frames, 0)
}
/** The pages, with their used height. */
get pageCount(): number {
return this.pages.length
}
/**
* Place one indexed frame, opening pages as needed.
*
* Shelves keep placement trivial and predictable; frames wider than a page are
* clamped rather than rejected, because a tile bigger than 2048 px would be a
* broken library, not a reason to lose the whole map.
*
* @param frame - width, height and palette indices.
* @returns where the frame landed.
*/
add(frame: { width: number; height: number; indices: Uint8Array }): Placement {
const width = Math.max(1, Math.min(frame.width, PAGE_SIZE))
const height = Math.max(1, Math.min(frame.height, PAGE_SIZE))
if (this.cursorX + width > PAGE_SIZE) {
this.shelfY += this.shelfHeight
this.shelfHeight = 0
this.cursorX = 0
}
if (this.pages.length === 0 || this.shelfY + height > PAGE_SIZE) {
this.pages.push({ pixels: new Uint8Array(PAGE_SIZE * PAGE_SIZE), frames: 0 })
this.shelfY = 0
this.shelfHeight = 0
this.cursorX = 0
}
const page = this.pages[this.pages.length - 1]!
const placement: Placement = {
page: this.pages.length - 1, x: this.cursorX, y: this.shelfY, width, height,
}
for (let row = 0; row < height; row += 1) {
const from = row * frame.width
const to = (this.shelfY + row) * PAGE_SIZE + this.cursorX
page.pixels.set(frame.indices.subarray(from, from + width), to)
}
this.cursorX += width
this.shelfHeight = Math.max(this.shelfHeight, height)
page.frames += 1
return placement
}
/**
* Encode every page, trimming each to the rows it actually used.
*
* @param transparent - palette index to mark transparent.
* @returns file-ready PNGs, in page order.
*/
encode(transparent: number): { name: string; png: Uint8Array; width: number; height: number; frames: number }[] {
return this.pages.map((page, index) => {
const used = Math.max(1, this.usedHeight(page))
const trimmed = new Uint8Array(PAGE_SIZE * used)
for (let row = 0; row < used; row += 1) {
trimmed.set(page.pixels.subarray(row * PAGE_SIZE, (row + 1) * PAGE_SIZE), row * PAGE_SIZE)
}
return {
name: `tiles-${String(index)}.png`,
png: encodeIndexedPng({
width: PAGE_SIZE,
height: used,
pixels: trimmed,
palette: this.palette,
transparentIndex: transparent,
}),
width: PAGE_SIZE,
height: used,
frames: page.frames,
}
})
}
/**
* Height of the last written row, for trimming.
*
* @param page - the page.
* @returns the used height in rows.
*/
private usedHeight(page: PackedPage): number {
for (let row = PAGE_SIZE - 1; row >= 0; row -= 1) {
const from = row * PAGE_SIZE
for (let x = 0; x < PAGE_SIZE; x += 1) if (page.pixels[from + x] !== 0) return row + 1
}
return 1
}
}
/**
* FNV-1a over a frame's indexed pixels, so verification can compare content
* without shipping the pixels twice.
*
* @param indices - palette indices.
* @returns an 8-character hex digest.
*/
function frameHash(indices: Uint8Array): string {
let hash = 0x811c9dc5
for (const byte of indices) {
hash ^= byte
hash = Math.imul(hash, 0x01000193) >>> 0
}
return hash.toString(16).padStart(8, '0')
}
/**
* Turn a DS1 member name into a file-friendly slug.
*
* @param member - full member path.
* @returns the slug.
*/
function slugOf(member: string): string {
const base = member.split('\\').pop() ?? member
return base.replace(/\.ds1$/i, '').toLowerCase()
}
const [archiveDir = 'samples/d2', outDir = 'samples/d2-packs'] = process.argv.slice(2)
const archives = new MountedArchives()
for (const name of MOUNTS) {
archives.add(name, await MpqArchive.open(await fileSource(join(archiveDir, name))))
}
const tables: ActTables = await loadActTables(archives)
const objectsTable = parseTable(await archives.read('data\\global\\excel\\objects.txt'))
/** Same table, through the typed loader the object resolution expects. */
const objectsTableTyped = await loadObjectsTable(archives)
/**
* `MonPreset.txt` `Place` values grouped by act.
*
* The position within an act **is** the DS1 type-1 `id`, so this array must stay
* dense: dropping a row would renumber every entry after it and silently hand
* each NPC its neighbour's identity. Measured against the 1.13c drop there are
* zero empty `Place` cells in all five acts, so the guard below never fires —
* it exists to fail loudly rather than quietly misnumber if that ever changes.
*/
const presetPlaceByAct = new Map<string, string[]>()
for (const row of tables.monpreset.rows) {
const act = cell(tables.monpreset, row, 'Act')
const place = cell(tables.monpreset, row, 'Place')
if (!place) {
throw new Error(
`MonPreset.txt act ${act} has an empty Place cell; the row index is the DS1 id, `
+ 'so skipping it would shift every later NPC onto the wrong monster',
)
}
let list = presetPlaceByAct.get(act)
if (list === undefined) {
list = []
presetPlaceByAct.set(act, list)
}
list.push(place)
}
/** `monstats.txt` rows indexed by `Id` (the column `MonPreset.Place` points at). */
const statsById = new Map<string, readonly string[]>()
for (const row of tables.monstats.rows) {
const id = cell(tables.monstats, row, 'Id')
if (id) statsById.set(id, row)
}
const monstersTable = {
preset: tables.monpreset,
stats: tables.monstats,
presetPlaceByAct,
statsById,
}
/* ------------------------------------------------------------------------- *
* World connectivity
* ------------------------------------------------------------------------- */
/**
* The seed the act layout is solved with.
*
* Deliberately **not** derived from a level's seed. Which edge a seam sits on
* is a property of the pair, not of either level: if the Cold Plains rolled its
* Stony Field exit per variant, `3-var1` would put it north while `4-var2` was
* still expecting to be entered from the south, and the two copies could not be
* docked. One seed for the whole bake means all three variants of every level
* agree, and any variant can be swapped for any other at runtime.
*/
const ACT_LAYOUT_SEED = 0x5eed_2000
/** Where each act's town is, for working out which way is "up" in a dungeon. */
const ACT_TOWNS: readonly number[] = [1, 40, 75, 103, 109]
const worldGraph: WorldGraph = assignGateSides(
buildWorldGraph(parseLevelRows(tables.levels)),
ACT_LAYOUT_SEED,
)
const warpGeometry = parseWarpGeometry(tables.lvlwarp)
/**
* Distance from the nearest town, in level hops.
*
* Used for one thing: telling a dungeon's up staircase from its down one. The
* data does not say. `Vis`/`Warp` slot order is not depth order — level 20, the
* Forgotten Tower's entrance building, has its way out in slot 0 and its way
* down in slot 1, while level 9's descent is in slot 4 — so the only reliable
* signal is that the way out is the neighbour closer to town.
*/
const townDistance = ((): Map<number, number> => {
const outgoing = new Map<number, number[]>()
for (const edge of worldGraph.edges) {
const list = outgoing.get(edge.from)
if (list === undefined) outgoing.set(edge.from, [edge.to])
else list.push(edge.to)
}
const distance = new Map<number, number>()
const queue: number[] = []
for (const town of ACT_TOWNS) {
distance.set(town, 0)
queue.push(town)
}
while (queue.length > 0) {
const at = queue.shift()
if (at === undefined) break
const here = distance.get(at) ?? 0
for (const next of outgoing.get(at) ?? []) {
if (distance.has(next)) continue
distance.set(next, here + 1)
queue.push(next)
}
}
return distance
})()
/**
* A level's display name, for labelling the openings that lead to it.
*
* @param levelId - the level.
* @returns its `Levels.txt` name, or the bare id when there is no such level.
*/
function levelNameOf(levelId: number): string {
return worldGraph.levels.get(levelId)?.name ?? `level ${String(levelId)}`
}
/**
* The border seams an outdoor level must cut, as the generator wants them.
*
* @param levelId - the level being generated.
* @returns one gate per seamless edge leaving it.
*/
function gatesFor(levelId: number): PlannedGate[] {
const gates: PlannedGate[] = []
for (const edge of edgesFrom(worldGraph, levelId)) {
if (edge.kind !== 'seamless' || edge.sideFrom === null) continue
gates.push({
side: edge.sideFrom,
toLevelId: edge.to,
label: `${levelNameOf(edge.to)} Seam`,
// The Burial Grounds seam is the one place the border art has a
// dedicated opening: variant 4 is the graveyard gate, 3 the plain road.
variant: (levelId === 3 && edge.to === 17) || (levelId === 17 && edge.to === 3) ? 4 : 3,
})
}
return gates
}
/**
* Turn a graph edge and a place into a baked warp.
*
* @param edge - the edge being placed.
* @param direction - which way it goes.
* @param cellX - the anchor cell.
* @param cellY - the anchor cell.
* @param grid - the collision map, for nudging the arrival point off a wall.
* @param source - how the position was found.
* @param region - the level's main walkable region when there is a meaningful
* one, keeping the arrival point out of sealed pockets; undefined for
* generated levels, where the unstamped void would be the largest region.
* @returns the warp.
*/
function makeWarp(
edge: WorldEdge,
direction: SceneWarp['direction'],
cellX: number,
cellY: number,
grid: IsoMapScene,
source: SceneWarp['source'],
region: WalkableRegion | undefined,
): SceneWarp {
const warpId = edge.warps[0] ?? -1
const geometry = warpId < 0 ? undefined : findWarpGeometry(warpGeometry, warpId)
let x = cellToSubTile(cellX)
let y = cellToSubTile(cellY)
const fullSpan = Math.max(grid.gridWidth, grid.gridHeight)
if (region !== undefined && !triggerableFrom(grid, region, x, y, WARP_TRIGGER_SUBTILES)) {
const snapped = nearestWalkable(grid, x, y, fullSpan, region)
if (snapped !== null) {
x = snapped.x
y = snapped.y
}
}
// `OffsetX/Y` is where the engine materialises the player, relative to the
// anchor tile and usually negative so that they do not land on the trigger.
const wanted = {
x: x + (geometry?.offsetX ?? -2),
y: y + (geometry?.offsetY ?? -2),
}
const arrive = nearestWalkable(grid, wanted.x, wanted.y, fullSpan, region) ?? { x, y }
return {
toLevelId: edge.to,
warpId,
direction,
label: `${levelNameOf(edge.to)}`,
x,
y,
arriveX: arrive.x,
arriveY: arrive.y,
selectX: geometry?.selectX ?? 0,
selectY: geometry?.selectY ?? 0,
selectDX: geometry === undefined || !isClickableWarp(geometry) ? 0 : geometry.selectDX,
selectDY: geometry === undefined || !isClickableWarp(geometry) ? 0 : geometry.selectDY,
exitWalkX: geometry?.exitWalkX ?? 0,
exitWalkY: geometry?.exitWalkY ?? 0,
source,
}
}
/**
* Work out every way out of one baked level.
*
* **Warps come from the artwork.** Every map in the game, shipped or generated,
* marks its staircases with DS1 special tiles whose `style` is the `Warp0..7`
* slot — see {@link findWarpTiles} — and the slot says which edge of the graph
* the staircase is. That single rule covers presets, mazes and outdoor levels
* alike, it gives the position the original engine would have used, and it
* removes the need to guess which of a dungeon's two staircases goes up.
*
* **Seams come from the generator or the collision map.** A seamless edge has
* no marker, because walking off the edge of the Blood Moor is not a staircase.
* Outdoor levels report the openings they cut; presets have theirs recovered
* from the collision map, since their artwork is fixed and nobody wrote down
* where the gate is.
*
* The maze generator's staircase rooms are kept as a fallback for the handful
* of pieces that carry no marker.
*
* @param levelId - the level.
* @param kind - how it was built.
* @param ds1 - the assembled map, for its warp markers.
* @param grid - the baked collision map.
* @param entrances - `stats.entrances` from the wilderness generator, if any.
* @param mazeWarps - `stats.warps` from the maze generator, if any.
* @param waypointCells - waypoint objects found in the artwork, in sub-tiles.
* @returns the links, plus anything the graph wanted that could not be placed.
*/
function buildSceneLinks(
levelId: number,
kind: LevelKind,
ds1: Ds1,
grid: IsoMapScene,
entrances: readonly WildernessEntrance[],
mazeWarps: readonly MazeWarp[],
waypointCells: readonly { x: number; y: number }[],
mazeWaypoints?: readonly MazeWaypoint[],
): SceneLinks {
const outEntrances: SceneEntrance[] = []
const outWarps: SceneWarp[] = []
const unplaced: { toLevelId: number; reason: string }[] = []
const placedSeams = new Set<number>()
const placedWarps = new Set<number>()
// The part of the map the player can actually stand on.
//
// Presets are finished artwork and outdoor levels are stamped edge to edge,
// so in both the biggest connected patch of open ground is the play area.
//
// For mazes, `buildIsoMapScene` leaves unstamped void cells around the rooms
// as `blocked = 0` (see Issue #38), so the void outside the dungeon walls
// would be larger than the dungeon itself. Masking out cells that carry no
// active floor tile before running `largestWalkableRegion` isolates the true
// dungeon interior, ensuring every staircase and fallback warp lands inside
// connected rooms rather than outside the walls.
let regionGrid: LinkGrid = grid
if (kind === 'maze') {
const maskedBlocked = new Uint8Array(grid.blocked)
for (let cy = 0; cy < grid.cellsY; cy += 1) {
for (let cx = 0; cx < grid.cellsX; cx += 1) {
const cell = ds1.cells[cy]?.[cx]
const hasFloor = cell !== undefined && cell.floors.some(f => !f.hidden && f.prop1 !== 0)
if (!hasFloor) {
for (let sy = 0; sy < SUB_TILES_PER_TILE; sy += 1) {
const row = (cy * SUB_TILES_PER_TILE + sy) * grid.gridWidth + cx * SUB_TILES_PER_TILE
maskedBlocked.fill(1, row, row + SUB_TILES_PER_TILE)
}
}
}
}
regionGrid = {
cellsX: grid.cellsX,
cellsY: grid.cellsY,
gridWidth: grid.gridWidth,
gridHeight: grid.gridHeight,
blocked: maskedBlocked,
}
}
const region = largestWalkableRegion(regionGrid)
const warpEdgeTo = new Map<number, WorldEdge>()
/** `Warp0..7` slot -> the edge that slot crosses. */
const warpEdgeBySlot = new Map<number, WorldEdge>()
for (const edge of edgesFrom(worldGraph, levelId)) {
if (edge.kind !== 'warp') continue
warpEdgeTo.set(edge.to, edge)
for (const slot of edge.warpSlots) warpEdgeBySlot.set(slot, edge)
}
/** Which way a crossing leads, for the runtime's benefit. */
const here = townDistance.get(levelId) ?? Number.MAX_SAFE_INTEGER
const directionTo = (toLevelId: number): SceneWarp['direction'] =>
(townDistance.get(toLevelId) ?? Number.MAX_SAFE_INTEGER) < here ? 'up' : 'down'
// 1. The staircases the artwork declares. Authoritative for every kind of
// level, because the slot identifies the destination outright.
for (const tile of findWarpTiles(ds1)) {
const edge = warpEdgeBySlot.get(tile.slot)
// A marker for a slot this level does not use: the piece was drawn for a
// level that connects there and reused here. Ignore it rather than invent
// a destination.
if (edge === undefined) continue
// A generator can stamp the same stair piece twice; the first wins.
if (placedWarps.has(edge.to)) continue
outWarps.push(makeWarp(edge, directionTo(edge.to), tile.cellX, tile.cellY, grid, 'tile', region))
placedWarps.add(edge.to)
}
// 2. Outdoor levels: the generator already cut the seams, and knows where the
// interior presets it stamped sit.
for (const anchor of entrances) {
if (anchor.kind === 'gate' && anchor.side !== null && anchor.toLevelId >= 0) {
// The anchor the generator hands over is the border *piece*, and the
// centre of that cell is wall; on some outdoor variants the border
// block's gap is separated from the main play area by up to ~40
// sub-tiles of rock. Snapping to `region` with a 64-sub-tile radius
// places the seam trigger on the outer boundary of the main play area,
// and `seamArrivalSpot` steps inward outside the trigger radius.
const gate = nearestWalkable(grid, cellToSubTile(anchor.x), cellToSubTile(anchor.y), 64, region)
?? { x: cellToSubTile(anchor.x), y: cellToSubTile(anchor.y) }
const arrive = seamArrivalSpot(grid, gate.x, gate.y, anchor.side, region)
outEntrances.push({
toLevelId: anchor.toLevelId,
side: anchor.side,
label: anchor.label,
x: gate.x,
y: gate.y,
arriveX: arrive.x,
arriveY: arrive.y,
})
placedSeams.add(anchor.toLevelId)
continue
}
// A preset's mouth: the cave, tower or crypt that opens off this level.
const edge = anchor.toLevelId < 0 ? undefined : warpEdgeTo.get(anchor.toLevelId)
if (edge === undefined || placedWarps.has(edge.to)) continue
outWarps.push(makeWarp(edge, 'in', anchor.x, anchor.y, grid, 'tile', region))
placedWarps.add(anchor.toLevelId)
}
// 3. Mazes: staircase rooms, for pieces whose artwork carries no marker.
if (kind === 'maze') {
const leftover = [...edgesFrom(worldGraph, levelId)]
.filter(edge => edge.kind === 'warp' && !placedWarps.has(edge.to))
const queues = {
up: leftover.filter(edge => directionTo(edge.to) === 'up'),
down: leftover.filter(edge => directionTo(edge.to) === 'down'),
}
for (const room of mazeWarps) {
const edge = queues[room.direction].shift()
if (edge === undefined) continue
outWarps.push(makeWarp(edge, room.direction, room.centreX, room.centreY, grid, 'room', region))
placedWarps.add(edge.to)
}
}
// 4. Seams nobody cut: read them off the artwork's walkable border.
//
// Presets always land here, because their artwork is fixed and nobody
// wrote down where the gate is. So do the two mazes with a seamless edge
// (the Barracks opens onto the Courtyard, Lava 1 onto the Chaos
// Sanctuary), and outdoor levels whose grid is too small for the planner
// to run, like the 6x2 Kurast docks.
//
// The graph's side is usually right, but not always. The Rogue Encampment
// ships as four presets — `TownN1`, `TownE1`, `TownS1`, `TownW1` — which
// are the same camp slid to four different corners of a 57x41 canvas, so
// that the palisade opens off a different edge of the map in each. The
// graph has one side per *level*, so three variants in four would be told
// to put the gate where this particular camp has none.
//
// What distinguishes the two cases is `inset`: an opening at inset 0 is
// walkable ground running off the edge of the map, which is what a gate
// looks like, while a large inset means the scan gave up on the border and
// settled for the far end of the play area. So when the level has exactly
// one seam and some side reaches the border while the graph's side does
// not, the artwork wins and the widest such contact is the gate. With more
// than one seam there is no way to tell which border contact belongs to
// which neighbour, so the graph's sides stand.
const loneSeams = [...edgesFrom(worldGraph, levelId)]
.filter(edge => edge.kind === 'seamless' && edge.sideFrom !== null && !placedSeams.has(edge.to))
for (const edge of loneSeams) {
const wanted = edge.sideFrom!
const measured = new Map<Side, BorderOpening>()
for (const side of SIDES) {
const found = findBorderOpening(grid, side, region)
if (found !== null) measured.set(side, found)
}
const asked = measured.get(wanted)
let side: Side | null = asked === undefined ? null : wanted
let opening = asked
const atBorder = [...measured].filter(([, found]) => found.inset === 0)
if (loneSeams.length === 1 && atBorder.length > 0 && (asked === undefined || asked.inset > 0)) {
// Widest contact wins: `TownE1` grazes the north border with a one-cell
// path as well as opening its whole east flank, and the flank is the gate.
const [bestSide, bestOpening] = atBorder
.reduce((best, next) => (next[1].width > best[1].width ? next : best))
side = bestSide
opening = bestOpening
}
if (opening === undefined || side === null) {
// Nothing on the asked-for side and nothing at any border either.
const any = [...measured].sort((a, b) => a[1].inset - b[1].inset)[0]
if (any === undefined) {
unplaced.push({ toLevelId: edge.to, reason: 'every edge is solid' })
continue
}
side = any[0]
opening = any[1]
}
outEntrances.push({
toLevelId: edge.to,
side,
label: `${levelNameOf(edge.to)} Gate`,
x: opening.x,
y: opening.y,
arriveX: opening.arriveX,
arriveY: opening.arriveY,
})
placedSeams.add(edge.to)
}
// 5. Last resort. Several level types — the Act 2 sewers, the tombs, the
// Act 3 dungeons — have an empty `specials` table in `MAZE_LEVEL_TYPE_PROFILES`
// because their DRLG staircase pass has not been transcribed, so they
// stamp no stair room, and their pieces carry no marker either. Leaving
// those edges unplaced makes a level you can enter and never leave, which
// is a worse lie than an approximate staircase. Each one is spread to a
// different quarter of the map so two of them never coincide, and each is
// tagged `fallback` so nothing mistakes it for the real position.
const needFallback = [...edgesFrom(worldGraph, levelId)]
.filter(edge => edge.kind === 'warp' && !placedWarps.has(edge.to))
needFallback.forEach((edge, index) => {
// Quarter centres, in reading order, so the choice is deterministic.
const quarters = [[1, 1], [3, 1], [1, 3], [3, 3], [2, 2]] as const
const [qx, qy] = quarters[index % quarters.length]!
const wanted = {
x: Math.floor(grid.gridWidth * qx / 4),
y: Math.floor(grid.cellsY * SUB_TILES_PER_TILE * qy / 4),
}
const spot = nearestWalkable(grid, wanted.x, wanted.y, Math.max(grid.gridWidth, grid.gridHeight), region)
if (spot === null) {
unplaced.push({ toLevelId: edge.to, reason: 'no walkable ground for a fallback warp' })
return
}
outWarps.push(makeWarp(
edge,
directionTo(edge.to),
Math.floor(spot.x / SUB_TILES_PER_TILE),
Math.floor(spot.y / SUB_TILES_PER_TILE),
grid,
'fallback',
region,
))
placedWarps.add(edge.to)
})
// Anything the graph wanted and nothing produced. Portals are excluded on
// purpose: a town portal or a quest portal is conjured at run time by the
// thing that opens it, so there is nothing for the bake to cut. Seams pass 4
// already complained about are skipped, or every solid edge would be counted
// twice: once with the real reason and once with this generic one.
const reported = new Set(unplaced.map(hole => hole.toLevelId))
for (const edge of edgesFrom(worldGraph, levelId)) {
if (edge.kind === 'seamless' && !placedSeams.has(edge.to) && !reported.has(edge.to)) {
unplaced.push({ toLevelId: edge.to, reason: 'seam not cut by the generator' })
}
}
// Waypoints. The artwork wins when it has one; otherwise the level gets a
// stand-in, because `DRLGOUTDOORS_SpawnAct12Waypoint` is not implemented and
// a waypoint the network knows about but the map does not show is worse than
// an approximate position.
const waypointId = worldGraph.levels.get(levelId)?.waypoint ?? 255
const waypoints: SceneWaypoint[] = []
if (waypointId !== 255) {
const fromArt = waypointCells[0] ?? (mazeWaypoints?.[0] ? { x: mazeWaypoints[0].entityX, y: mazeWaypoints[0].entityY } : undefined)
const spot = fromArt ?? findWaypointSpot(grid, region)
if (spot !== null && spot !== undefined) {
const arrive = nearestWalkable(grid, spot.x + 2, spot.y + 2, 16, region) ?? spot
waypoints.push({
waypointId,
x: spot.x,
y: spot.y,
arriveX: arrive.x,
arriveY: arrive.y,
source: fromArt === undefined ? 'placed' : 'object',
})
}
}
return { entrances: outEntrances, warps: outWarps, waypoints, unplacedEdges: unplaced }
}
const allNames = await archives.listFiles()
{
const jobs: LevelJob[] = []
for (const row of tables.levels.rows) {
const levelId = Number(cell(tables.levels, row, 'Id'))
if (!Number.isFinite(levelId) || levelId === 0) continue
const name = cell(tables.levels, row, 'Name')
const drlg = Number(cell(tables.levels, row, 'DrlgType'))
const act = Number(cell(tables.levels, row, 'Act')) + 1
let kind: LevelKind | null = null
if (drlg === 2) kind = 'preset'
else if (drlg === 1) kind = 'maze'
else if (drlg === 3) kind = 'wilderness'
if (kind === null) continue
jobs.push({ act, levelId, name, kind, slug: `${String(levelId)}-${slugify(name)}` })
}
LEVELS = jobs
}
/**
* Decoded DT1 libraries, kept across levels.
*
* A hundred-plus levels share a few dozen level types, so decoding each library
* once instead of once per level turns the bake from repeated work into one pass.
*/
/**
* Decoded DT1 libraries, keyed by member name.
*
* Bounded on purpose: every level keeps its DT1 block bitmaps alive (each library
* decodes to tens of MB of palette indices), and an unbounded cache held 4+ GB by
* the time the bake reached act 5. Sixteen libraries is more than any single
* level type uses, so a level never re-decodes mid-level, and the memory ceiling
* stays flat across a whole 62-map bake.
*/
const libraryCache = new Map<string, Dt1>()
/** Most DT1 libraries to keep decoded at once. */
const LIBRARY_CACHE_LIMIT = 16
/**
* Decode a DT1 library, cached by member name.
*
* @param member - archive member name.
* @returns the decoded library.
*/
async function libraryOf(member: string): Promise<Dt1> {
const cached = libraryCache.get(member)
if (cached !== undefined) return cached
const decoded = decodeDt1(await archives.read(member))
libraryCache.set(member, decoded)
if (libraryCache.size > LIBRARY_CACHE_LIMIT) {
// `Map` iterates in insertion order, so the first key is the oldest entry.
const oldest = libraryCache.keys().next().value
if (oldest !== undefined && oldest !== member) libraryCache.delete(oldest)
}
return decoded
}
/** Objects.txt lookup by Id, built once. */
const objectById = new Map<number, { name: string; token: string; hp: number }>()
for (const row of objectsTable.rows) {
const id = Number(cell(objectsTable, row, 'Id'))
if (!Number.isFinite(id)) continue
objectById.set(id, {
name: cell(objectsTable, row, 'Name'),
token: cell(objectsTable, row, 'Token').toUpperCase(),
hp: Number(cell(objectsTable, row, 'HitPoints') || '0'),
})
}
/** Object members grouped by token and mode, built once. */
const objectMembers = new Map<string, Map<string, string[]>>()
const monsterMembers = new Map<string, Map<string, string[]>>()
for (const name of allNames) {
const isObj = name.toLowerCase().startsWith(OBJECT_PREFIX.toLowerCase())
const isMon = name.toLowerCase().startsWith(MONSTER_ROOT.toLowerCase())
if (!isObj && !isMon) continue
// Derive the length; a hand-counted magic number here was off by one (22 vs
// the real 21), which sliced the first letter off every monster token and
// silently broke all NPC art lookups.
const prefixLen = isObj ? OBJECT_PREFIX.length : MONSTER_ROOT.length
const rest = name.slice(prefixLen).split('\\')
if (rest.length < 3) continue
const token = (rest[0] ?? '').toUpperCase()
const mode = (rest[1] ?? '').toLowerCase()
const map = isObj ? objectMembers : monsterMembers
if (!map.has(token)) map.set(token, new Map())
const modes = map.get(token)!
if (!modes.has(mode)) modes.set(mode, [])
modes.get(mode)!.push(name)
}
const COMPONENT_PREFERENCE = ['tr', 'hd', 's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8', 'lg', 'ra', 'la', 'rh', 'lh', 'sh'] as const
function compRank(path: string): number {
const parts = path.toLowerCase().split('\\')
const comp = parts[4] ?? ''
const idx = COMPONENT_PREFERENCE.indexOf(comp as (typeof COMPONENT_PREFERENCE)[number])
return idx >= 0 ? idx : 99
}
/**
* Pick the member that stands in for an object's art.
*
* A token ships many variants (lit/unlit, per-mode, per-weapon-class); the pack
* is static art, so one deterministic pick is recorded — mode order first, then
* component preference (body layer 'tr' first), then file format (DCC before DC6)
* — and COF definition files are filtered out since they are compositing metadata,
* not sprites.
*
* @param token - object token from the lookup table.
* @param modeToken - animation mode token the engine places the object in (`NU`/`OP`/…).
* @returns the member name and how many candidates there were.
*/
function pickObjectMember(token: string, modeToken: string, baseIsMonsters = false): { member: string; candidates: number } | null {
const map = baseIsMonsters ? monsterMembers : objectMembers
const dirs = map.get(token.toUpperCase())
if (dirs === undefined) return null
const all = [...dirs.values()].flat().filter(name => {
const lower = name.toLowerCase()
return lower.endsWith('.dcc') || lower.endsWith('.dc6')
})
const candidates = all.length
if (candidates === 0) return null
const wanted = modeToken.trim().toLowerCase() === '' ? 'nu' : modeToken.trim().toLowerCase()
const order = [wanted, ...OBJECT_MODES.map(mode => mode.toLowerCase()).filter(mode => mode !== wanted)]
for (const mode of order) {
const files = all
.filter(name => {
const base = (name.split('\\').pop() ?? '').toLowerCase()
return base.includes(mode) || (mode !== 'nu' && base.includes(`lit${mode}`))
})
.sort((a, b) => {
const aDcc = a.toLowerCase().endsWith('.dcc') ? 0 : 1
const bDcc = b.toLowerCase().endsWith('.dcc') ? 0 : 1
if (aDcc !== bDcc) return aDcc - bDcc
const aRank = compRank(a)
const bRank = compRank(b)
if (aRank !== bRank) return aRank - bRank
return a.localeCompare(b)
})
if (files.length > 0) return { member: files[0]!, candidates }
}
// No file carries the mode token: fall back to any member, deterministically.
const fallback = [...all].sort((a, b) => {
const aDcc = a.toLowerCase().endsWith('.dcc') ? 0 : 1
const bDcc = b.toLowerCase().endsWith('.dcc') ? 0 : 1
if (aDcc !== bDcc) return aDcc - bDcc
const aRank = compRank(a)
const bRank = compRank(b)
if (aRank !== bRank) return aRank - bRank
return a.localeCompare(b)
})[0]
return fallback === undefined ? null : { member: fallback, candidates }
}
/** Cache decoded member frames across levels so each unique member is decoded once. */
const decodedMemberCache = new Map<string, { frame: SpriteFrame; offsetX: number; offsetY: number }>()
const lvlmaze = parseTable(await archives.read('data\\global\\excel\\LvlMaze.txt'))
const lvlsub = parseTable(await archives.read('data\\global\\excel\\LvlSub.txt'))
/** The level type row for a level row. */
function levelType(levelRow: readonly string[]): { id: string; name: string } {
const id = cell(tables.levels, levelRow, 'LevelType')
const row = tables.lvltypes.rows.find(candidate => cell(tables.lvltypes, candidate, 'Id') === id)
return { id, name: row === undefined ? '' : cell(tables.lvltypes, row, 'Name') }
}
/** The `LvlMaze` row for a level, by id then by name. */
function mazeRow(levelId: number, levelName: string): readonly string[] | undefined {
return lvlmaze.rows.find(row => Number(cell(lvlmaze, row, 'Level')) === levelId)
?? lvlmaze.rows.find(row => cell(lvlmaze, row, 'Name') === levelName)
}
const ds1Cache = new Map<string, Ds1>()
async function loadDs1(member: string): Promise<Ds1> {
const cached = ds1Cache.get(member)
if (cached !== undefined) return cached
const decoded = decodeDs1(await archives.read(member))
ds1Cache.set(member, decoded)
return decoded
}
async function rowDs1s(table: D2Table, row: readonly string[]): Promise<Ds1[]> {
const levels: Ds1[] = []
for (let slot = 1; slot <= 6; slot += 1) {
const value = cell(table, row, `File${String(slot)}`)
if (value === '' || value === '0') continue
levels.push(await loadDs1(tileMemberPath(value)))
}
return levels
}
function themeValues(table: D2Table, row: readonly string[], prefix: string): number[] {
const values: number[] = []
for (let index = 0; index < 5; index += 1) values.push(Number(cell(table, row, `${prefix}${String(index)}`)) || 0)
return values
}
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'Act 1 - Wilderness': [
'Act 1 - Wild',
'Act 1 - Town 1 Transition',
'Act 1 - DOE Entrance',
'Act 1 - Cave Entrance',
'Act 1 - Corral Fill',
'Act 1 - Fence Fill',
'Act 1 - River',
'Act 1 - Bridge',
'Act 1 - Bivouac',
'Act 1 - Pond',
'Act 1 - Swamp Fill',
'Act 1 - Stone Fill',
'Act 1 - Cottages',
'Act 1 - Fallen Camp',
'Act 1 - Camp',
'Act 1 - Cairn Stones',
'Act 1 - Inifus',
'Act 1 - Tower',
'Act 1 - Ruin',
'Act 1 - Tree Fill',
'Act 1 - Graveyard',
],
'Act 2 - Desert': ['Act 2 - Desert'],
'Act 3 - Jungle': ['Act 3 - Jungle', 'Act 3 - Clearing'],
'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'],
'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'],
'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo', 'Act 4 - Bridge'],
'Act 5 - Siege': ['Act 5 - Siege'],
'Act 5 - Barricade': ['Act 5 - Barricade'],
}
const mazePiecesCache = new Map<string, Promise<MazePiece[]>>()
function getMazePieces(levelTypeName: string, levelTypeId: string): Promise<MazePiece[]> {
void levelTypeId
const cached = mazePiecesCache.get(levelTypeName)
if (cached !== undefined) return cached
const promise = (async () => {
const pieces: MazePiece[] = []
for (const row of tables.lvlprest.rows) {
if (cell(tables.lvlprest, row, 'LevelId') !== '0' && cell(tables.lvlprest, row, 'LevelId') !== '') continue
const name = cell(tables.lvlprest, row, 'Name')
const classified = classifyMazePieceName(name, levelTypeName)
if (classified === null) continue
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
pieces.push({ name, kind: classified.kind satisfies MazePieceKind, sides: classified.sides, levels })
}
return pieces
})()
mazePiecesCache.set(levelTypeName, promise)
return promise
}
const wildernessPiecesCache = new Map<string, Promise<WildernessPiece[]>>()
function getWildernessPieces(levelTypeName: string): Promise<WildernessPiece[]> {
const cached = wildernessPiecesCache.get(levelTypeName)
if (cached !== undefined) return cached
const promise = (async () => {
const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? []
const pieces: WildernessPiece[] = []
for (const row of tables.lvlprest.rows) {
const name = cell(tables.lvlprest, row, 'Name')
if (!families.some(family => name.startsWith(family))) continue
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff|ravine/i.test(name)
pieces.push({ name, levels, border: isBorder })
}
return pieces
})()
wildernessPiecesCache.set(levelTypeName, promise)
return promise
}
const substitutionsCache = new Map<number, Promise<WildernessSubstitution[]>>()
function getSubstitutions(type: number): Promise<WildernessSubstitution[]> {
if (type < 0) return Promise.resolve([])
const cached = substitutionsCache.get(type)
if (cached !== undefined) return cached
const promise = (async () => {
const rows: WildernessSubstitution[] = []
for (const row of lvlsub.rows) {
if (Number(cell(lvlsub, row, 'Type')) !== type) continue
const file = cell(lvlsub, row, 'File')
if (file === '' || file === '0') continue
const levels = [await loadDs1(tileMemberPath(file))]
rows.push({
name: cell(lvlsub, row, 'Name'),
type,
gridSize: Number(cell(lvlsub, row, 'GridSize')) || 1,
bordType: Number(cell(lvlsub, row, 'BordType')),
dt1Mask: Number(cell(lvlsub, row, 'Dt1Mask')) || 0,
prob: themeValues(lvlsub, row, 'Prob'),
trials: themeValues(lvlsub, row, 'Trials'),
max: themeValues(lvlsub, row, 'Max'),
levels,
})
}
return rows
})()
substitutionsCache.set(type, promise)
return promise
}
const paletteCache = new Map<string, Uint8Array>()
async function getPalette(paletteName: string): Promise<Uint8Array> {
const cached = paletteCache.get(paletteName)
if (cached !== undefined) return cached
const pl2 = decodePl2(await archives.read(paletteName))
paletteCache.set(paletteName, pl2.rgb)
return pl2.rgb
}
const index: Record<string, unknown> = {
version: 1,
generated: new Date().toISOString(),
archiveDir,
pageSize: PAGE_SIZE,
palettes: {} as Record<string, number[]>,
levels: [] as unknown[],
}
for (let act = 1; act <= 5; act += 1) {
const pal = await getPalette(`data\\global\\palette\\act${String(act)}\\pal.pl2`)
;(index.palettes as Record<string, number[]>)[`act${String(act)}`] = [...pal]
}
let totalPngBytes = 0
let totalLevels = 0
let skippedArtlessSpawns = 0
let skippedMissingArtSpawns = 0
async function bakeDs1Variant(
entry: LevelJob,
levelName: string,
palette: Uint8Array,
paletteName: string,
dt1Names: readonly string[],
libraries: Dt1[],
level: Ds1,
ds1Name: string,
label: string,
seed: number,
/**
* What the generator learned while building this level.
*
* Empty for presets, which are not generated: their openings are recovered
* from the collision map instead.
*/
generatorLinks: {
readonly entrances: readonly WildernessEntrance[]
readonly mazeWarps: readonly MazeWarp[]
readonly mazeWaypoints?: readonly MazeWaypoint[]
readonly landmarks?: readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[]
} = { entrances: [], mazeWarps: [] },
): Promise<void> {
const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed)
const spawn = findIsoSpawn(scene)
const dir = join(outDir, `act${String(entry.act)}`, label)
// Frames used near the spawn load first; everything else follows.
const hot = new Set<number>()
if (spawn !== null) {
const spawnCell = cellAt(scene, spawn.x, spawn.y)
for (const draw of scene.floors) {
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex)
}
for (const draw of scene.walls) {
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex)
}
}
const firstDrawAt = new Map<number, { x: number; y: number }>()
for (const draw of [...scene.floors, ...scene.walls]) {
if (!firstDrawAt.has(draw.frameIndex)) firstDrawAt.set(draw.frameIndex, { x: draw.x, y: draw.y })
}
const bandOf = (frameIndex: number): number => {
const at = firstDrawAt.get(frameIndex)
if (at === undefined) return Number.MAX_SAFE_INTEGER
return Math.floor(at.y / 512) * 4096 + Math.floor(at.x / 768)
}
const order = scene.frames.map((_, at) => at).sort((a, b) => {
const ha = hot.has(a) ? 0 : 1
const hb = hot.has(b) ? 0 : 1
if (ha !== hb) return ha - hb
const ba = bandOf(a)
const bb = bandOf(b)
if (ba !== bb) return ba - bb
const fa = scene.frames[a]!
const fb = scene.frames[b]!
return fb.width * fb.height - fa.width * fa.height
})
const pages = new PageBuilder(palette)
const placementOf = new Map<number, Placement>()
for (const frameIndex of order) {
const frame = scene.frames[frameIndex]!
placementOf.set(frameIndex, pages.add(frame))
}
const pageFiles = pages.encode(0)
await mkdir(dir, { recursive: true })
const sha = createHash('sha256')
for (const page of pageFiles) {
await writeFile(join(dir, page.name), page.png)
sha.update(page.png)
totalPngBytes += page.png.byteLength
}
const objectPages = new PageBuilder(palette)
const objects: unknown[] = []
const npcs: unknown[] = []
/**
* Waypoint pedestals found in the artwork, in sub-tiles.
*
* Only presets have one: the outdoor and maze generators do not run the pass
* that spawns waypoints, so their levels fall back to a placed position.
*/
const waypointCells: { x: number; y: number }[] = []
const missingObjects: string[] = []
let objectsWithArt = 0
const placementByMember = new Map<string, { placement: Placement; offsetX: number; offsetY: number }>()
for (const object of level.objects) {
let resolved
try {
resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id, monstersTable)
} catch (err) {
missingObjects.push((err as Error).message)
continue
}
if (resolved.kind === 'monster') continue
if (resolved.kind === 'npc' && object.type === 1 && resolved.artless) {
skippedArtlessSpawns += 1
continue
}
const row = resolved.row
const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode, resolved.kind === 'npc')
if (pick === null && resolved.kind === 'npc' && object.type === 1) {
skippedMissingArtSpawns += 1
continue
}
const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX
const orthoY = (object.x + object.y) * ORTHO_SUB_TILE_HEIGHT + scene.originY
let objectFrame: {
page: number
x: number
y: number
width: number
height: number
offsetX: number
offsetY: number
} | null = null
if (pick !== null) {
let placed = placementByMember.get(pick.member)
if (placed === undefined) {
let art = decodedMemberCache.get(pick.member)
if (art === undefined) {
try {
const bytes = await archives.read(pick.member)
if (pick.member.toLowerCase().endsWith('.dc6')) {
const sheet = decodeDc6(bytes)
const group = sheet.groups[0]
const f = group?.frames[0]
if (f) {
art = {
frame: f,
offsetX: -16 + f.offsetX,
offsetY: 16 + (f.offsetY - f.height + 1),
}
}
} else {
const dcc = decodeDcc(bytes)
const dir = dcc.directions[0]
const f = dir?.frames[0]
if (f && dir) {
art = {
frame: f.frame,
offsetX: -16 + dir.box.left,
offsetY: 16 + dir.box.top,
}
}
}
if (art) decodedMemberCache.set(pick.member, art)
} catch (err) {
console.warn(`failed to decode object member ${pick.member}: ${(err as Error).message}`)
}
}
if (art) {
const placement = objectPages.add(art.frame)
placed = { placement, offsetX: art.offsetX, offsetY: art.offsetY }
placementByMember.set(pick.member, placed)
}
}
if (placed) {
objectFrame = {
page: placed.placement.page,
x: placed.placement.x,
y: placed.placement.y,
width: placed.placement.width,
height: placed.placement.height,
offsetX: placed.offsetX,
offsetY: placed.offsetY,
}
}
}
const list = resolved.kind === 'npc' ? npcs : objects
const resolvedName = resolved.name ?? row?.name ?? resolved.token
// The pedestal's own sub-tile, taken here because everything downstream is
// in screen pixels and cannot be converted back.
if (resolved.token.toLowerCase() === 'wp' || /waypoint/i.test(resolvedName)) {
waypointCells.push({ x: object.x, y: object.y })
}
list.push({
id: object.id,
type: object.type,
name: resolvedName,
token: resolved.token,
mode: resolved.mode === '' ? 'NU' : resolved.mode,
// `objectsTxtId` is the Objects.txt row the table points at; -1 means the
// engine picks the art straight from the table's token, with no row.
objectsTxtId: resolved.entry?.objectsTxtId ?? -1,
hp: row === null ? 0 : (objectById.get(row.id)?.hp ?? 0),
member: pick === null ? null : pick.member,
alternatives: pick === null ? 0 : pick.candidates,
// Screen point of the object's sub-tile; the sprite anchor is applied
// when a frame exists.
x: Math.round(orthoX),
y: Math.round(orthoY),
depth: (object.x + object.y) / 5,
frame: objectFrame,
})
if (pick !== null) objectsWithArt += 1
else missingObjects.push(`${resolved.token === '' ? `id ${String(object.id)}` : resolved.token} (act ${String(entry.act)}) has no art members`)
}
const objectFiles = objectPages.encode(0)
const shaObjects = createHash('sha256')
for (const page of objectFiles) {
const name = page.name.replace('tiles-', 'objects-')
await writeFile(join(dir, name), page.png)
shaObjects.update(page.png)
totalPngBytes += page.png.byteLength
}
// Collision grid as runs, so the JSON stays small without a second format.
const runs: number[][] = []
let current = scene.blocked[0] ?? 0
let count = 0
for (const value of scene.blocked) {
if (value === current) { count += 1; continue }
runs.push([current, count])
current = value
count = 1
}
runs.push([current, count])
// The level's population, planned here so the browser never needs
// `Levels.txt` or `MonStats.txt` on the pack path. The same seed the scene
// geometry used drives it, so a pack and a live mount of the same level agree
// on what lives there.
const population = planLevelMonsters(
tables,
entry.levelId,
scene.cellsX * scene.cellsY,
seed,
PLAYER_WALK_SPEED_PX,
'normal',
generatorLinks.landmarks ? { landmarks: generatorLinks.landmarks } : undefined,
)
const sceneLinks = buildSceneLinks(
entry.levelId,
entry.kind,
level,
scene,
generatorLinks.entrances,
generatorLinks.mazeWarps,
waypointCells,
generatorLinks.mazeWaypoints,
)
const sceneJson = {
version: 1,
fidelity: entry.kind === 'preset' ? 'exact' : 'approximation',
act: entry.act,
levelId: entry.levelId,
levelName,
ds1: ds1Name,
cellsX: scene.cellsX,
cellsY: scene.cellsY,
originX: scene.originX,
originY: scene.originY,
widthPx: scene.widthPx,
heightPx: scene.heightPx,
pageSize: PAGE_SIZE,
pages: pageFiles.map(page => ({ file: page.name, width: page.width, height: page.height })),
objectPages: objectFiles.map(page => ({ file: page.name.replace('tiles-', 'objects-'), width: page.width, height: page.height })),
frames: scene.frames.map(frame => [frame.width, frame.height]),
frameHash: scene.frames.map(frame => frameHash(frame.indices)),
framePlacement: scene.frames.map((_, at) => {
const place = placementOf.get(at)!
return [place.page, place.x, place.y, place.width, place.height]
}),
floors: scene.floors.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
walls: scene.walls.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
roofs: scene.roofs.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]),
objects,
npcs,
monsters: {
types: population.types,
budget: population.budget,
packs: population.packs,
},
collision: { width: scene.gridWidth, height: scene.gridHeight, runs },
spawn: spawn === null ? null : [Math.round(spawn.x), Math.round(spawn.y)],
// Everything the runtime needs to leave this level, in sub-tiles.
entrances: sceneLinks.entrances,
warps: sceneLinks.warps,
waypoints: sceneLinks.waypoints,
stats: {
floors: scene.floors.length,
walls: scene.walls.length,
roofs: scene.roofs.length,
frames: scene.frames.length,
missingTiles: scene.missingTiles,
missingRefs: scene.missingRefs,
walkable: 1 - [...scene.blocked].reduce((sum, value) => sum + value, 0) / scene.blocked.length,
objects: objects.length,
npcs: npcs.length,
monsters: population.packs.reduce((sum, pack) => sum + pack.members.length, 0),
monsterPacks: population.packs.length,
objectsWithArt,
objectsUnresolved: missingObjects,
objectsArtPending: (objects as any[]).filter(o => o.member !== null && o.frame === null).length,
dt1Libraries: dt1Names.length,
// Edges the world graph has that this copy of the level has nowhere to
// put. Reported rather than dropped: each one is a hole in the world.
unplacedEdges: sceneLinks.unplacedEdges,
},
}
const sceneBytes = new TextEncoder().encode(JSON.stringify(sceneJson))
await writeFile(join(dir, 'scene.json'), sceneBytes)
const manifest = {
level: levelName,
act: entry.act,
levelId: entry.levelId,
ds1: ds1Name,
dt1: dt1Names,
palette: paletteName,
sceneBytes: sceneBytes.byteLength,
sceneSha256: createHash('sha256').update(sceneBytes).digest('hex'),
tilePagesSha256: sha.digest('hex'),
objectPagesSha256: shaObjects.digest('hex'),
pngBytes: pageFiles.reduce((sum, page) => sum + page.png.byteLength, 0)
+ objectFiles.reduce((sum, page) => sum + page.png.byteLength, 0),
sourceArchive: MOUNTS,
}
await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 1))
;(index.levels as unknown[]).push({
act: entry.act,
levelId: entry.levelId,
kind: entry.kind,
slug: entry.slug,
label,
levelName,
ds1: ds1Name,
path: `act${String(entry.act)}/${label}`,
cells: `${String(scene.cellsX)}x${String(scene.cellsY)}`,
frames: scene.frames.length,
objects: objects.length,
npcs: npcs.length,
pages: pageFiles.length,
objectPages: objectFiles.length,
missingTiles: scene.missingTiles,
entrances: sceneLinks.entrances.length,
warps: sceneLinks.warps.length,
waypoints: sceneLinks.waypoints.length,
unplacedEdges: sceneLinks.unplacedEdges.length,
bytes: manifest.pngBytes + sceneBytes.byteLength,
})
totalLevels += 1
const bakedFramesCount = (objects as any[]).filter(o => o.frame !== null).length
const pendingCount = (objects as any[]).filter(o => o.member !== null && o.frame === null).length
console.log(
`[${String(totalLevels).padStart(3)}] act${String(entry.act)}/${label.padEnd(35)} `
+ `${String(scene.cellsX)}x${String(scene.cellsY)} `
+ `${String(scene.frames.length).padStart(3)} 图块 → ${String(pageFiles.length)} 页 PNG `
+ `(${(manifest.pngBytes / 1024).toFixed(0)} KB) + scene.json ${(sceneBytes.byteLength / 1024).toFixed(0)} KB `
+ `· 对象 ${String(objects.length)}/${String(level.objects.length)}(已接帧:${String(bakedFramesCount)},待解:${String(pendingCount)})`
+ ` · 缺失瓦片 ${String(scene.missingTiles)}`,
)
}
/**
* Restrict the bake to a few levels, for spot checks.
*
* Comma-separated, e.g. `FILTER_LEVEL=75,82`. Note that a filtered run still
* rewrites `index.json` with only the levels it baked, so the output directory
* is not a usable pack afterwards — read the `scene.json` files directly.
*/
const filterLevel = process.env.FILTER_LEVEL
const filterLevels = filterLevel !== undefined
? new Set(filterLevel.split(',').map(s => Number(s.trim())))
: null
for (const entry of LEVELS) {
if (filterLevels !== null && !filterLevels.has(entry.levelId)) continue
const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === entry.levelId)!
const paletteIndex = Number(cell(tables.levels, levelRow, 'Pal'))
const paletteName = `data\\global\\palette\\act${String(paletteIndex + 1)}\\pal.pl2`
const palette = await getPalette(paletteName)
if (entry.kind === 'preset') {
const info: LevelInfo = resolveLevel(tables, entry.levelId, entry.act)
const libraries: Dt1[] = []
for (const name of info.dt1Names) libraries.push(await libraryOf(name))
if (entry.levelId === 108) {
const pieces: PresetPiece[] = []
for (const ds1Name of info.ds1Names) {
const level = await loadDs1(ds1Name)
const pieceName = ds1Name.split('\\').pop()?.replace(/\.ds1$/i, '') ?? ds1Name
pieces.push({ name: pieceName, levels: [level] })
}
const result = generatePreset({
levelId: entry.levelId,
levelName: info.levelName,
sizeX: info.sizeX > 0 ? info.sizeX : 120,
sizeY: info.sizeY > 0 ? info.sizeY : 120,
pieces,
})
const label = `${entry.slug}-chaos`
await bakeDs1Variant(entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, result.level, 'preset:chaos-sanctuary', label, levelSeed(info.levelName))
} else {
for (const ds1Name of info.ds1Names) {
const level = await loadDs1(ds1Name)
const label = `${entry.slug}-${slugOf(ds1Name)}`
await bakeDs1Variant(entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, level, ds1Name, label, levelSeed(ds1Name))
}
}
} else if (entry.kind === 'maze') {
const type = levelType(levelRow)
const row = mazeRow(entry.levelId, entry.name)
if (row === undefined) {
console.warn(`skipping maze ${entry.name}: no LvlMaze.txt row`)
continue
}
const sectionX = Number(cell(lvlmaze, row, 'SizeX'))
const sectionY = Number(cell(lvlmaze, row, 'SizeY'))
const minRooms = Number(cell(lvlmaze, row, 'Rooms'))
const merge = Number(cell(lvlmaze, row, 'Merge'))
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
const pieces = await getMazePieces(type.name, type.id)
if (pieces.length === 0) {
console.warn(`skipping maze ${entry.name}: no maze pieces`)
continue
}
const libInfo = resolveLevelLibraries(tables, entry.levelId)
const libraries: Dt1[] = []
for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name))
for (let v = 1; v <= 3; v += 1) {
const seed = 0x5eed_0000 + entry.levelId * 10 + v
const label = `${entry.slug}-var${v}`
try {
const result = generateMaze({
levelId: entry.levelId,
levelName: entry.name,
levelTypeName: type.name,
sectionSize: sectionX,
sectionHeight: sectionY,
minRooms,
merge,
seed,
pieces,
maxCellsX: sizeX > sectionX ? sizeX : undefined,
maxCellsY: sizeY > sectionY ? sizeY : undefined,
})
await bakeDs1Variant(
entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries,
result.level, `generated:${label}`, label, seed,
{
entrances: [],
mazeWarps: (result.stats.warps ?? []) as MazeWarp[],
mazeWaypoints: (result.stats.waypoints ?? []) as MazeWaypoint[],
},
)
} catch (err) {
console.error(`failed to bake maze ${label}: ${(err as Error).message}`)
}
}
} else if (entry.kind === 'wilderness') {
const type = levelType(levelRow)
const sizeX = Number(cell(tables.levels, levelRow, 'SizeX'))
const sizeY = Number(cell(tables.levels, levelRow, 'SizeY'))
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine'))
const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme'))
const pieces = await getWildernessPieces(type.name)
if (pieces.length === 0) {
console.warn(`skipping wilderness ${entry.name}: no wilderness pieces`)
continue
}
const rows = await getSubstitutions(subType)
const shrineRows = await getSubstitutions(subShrine)
const libInfo = resolveLevelLibraries(tables, entry.levelId)
const libraries: Dt1[] = []
for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name))
// Solved once for the whole world, so all three variants cut the same
// seams on the same edges and any variant docks with any neighbour.
const gates = gatesFor(entry.levelId)
for (let v = 1; v <= 3; v += 1) {
const seed = 0x5eed_1000 + entry.levelId * 10 + v
const label = `${entry.slug}-var${v}`
try {
const result = generateWilderness({
levelId: entry.levelId,
levelName: entry.name,
levelTypeName: type.name,
sizeX,
sizeY,
subType,
subTheme: Math.max(0, subTheme),
seed,
pieces,
substitutions: rows,
shrineSubstitutions: shrineRows,
gates,
})
await bakeDs1Variant(
entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries,
result.level, `generated:${label}`, label, seed,
{
entrances: (result.stats.entrances ?? []) as WildernessEntrance[],
mazeWarps: [],
...(Array.isArray(result.stats.landmarkEntities)
? { landmarks: result.stats.landmarkEntities as readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] }
: {}),
},
)
} catch (err) {
console.error(`failed to bake wilderness ${label}: ${(err as Error).message}`)
}
}
}
}
await mkdir(outDir, { recursive: true })
const indexPath = join(outDir, 'index.json')
if (filterLevel !== undefined) {
try {
const { readFile } = await import('node:fs/promises')
const existingRaw = await readFile(indexPath, 'utf8')
const existing = JSON.parse(existingRaw) as { levels?: Array<{ label?: string }> }
if (Array.isArray(existing.levels)) {
const updatedByLabel = new Map<string, unknown>()
for (const item of index.levels as Array<{ label: string }>) {
updatedByLabel.set(item.label, item)
}
const merged: unknown[] = []
const seenLabels = new Set<string>()
for (const oldItem of existing.levels) {
if (oldItem.label && updatedByLabel.has(oldItem.label)) {
merged.push(updatedByLabel.get(oldItem.label)!)
seenLabels.add(oldItem.label)
} else {
merged.push(oldItem)
}
}
for (const [lbl, item] of updatedByLabel.entries()) {
if (!seenLabels.has(lbl)) merged.push(item)
}
index.levels = merged
}
} catch {
// No existing index.json or unreadable; write fresh index
}
}
await writeFile(indexPath, JSON.stringify(index, null, 1))
// The solved graph, written once for the whole pack. The runtime must not
// re-solve it: `assignGateSides` is seeded, but re-running it against a
// different `Levels.txt` would silently move every seam while the baked levels
// kept their old openings.
await writeFile(join(outDir, 'world-graph.json'), JSON.stringify({
version: 1,
layoutSeed: ACT_LAYOUT_SEED,
levels: [...worldGraph.levels.values()].map(level => ({
id: level.id,
act: level.act + 1,
name: level.name,
drlgType: level.drlgType,
waypoint: level.waypoint,
})),
edges: worldGraph.edges.map(edge => ({
from: edge.from,
to: edge.to,
kind: edge.kind,
sideFrom: edge.sideFrom,
sideTo: edge.sideTo,
warps: edge.warps,
warpSlots: edge.warpSlots,
source: edge.source,
})),
waypoints: [...worldGraph.waypoints.entries()].map(([id, levelId]) => ({ id, levelId })),
}, null, 1))
if (!process.env.SKIP_ENTITIES) {
const { bakeEntities } = await import('./pack-entity-assets.ts')
await bakeEntities(archiveDir, outDir)
}
console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)
console.log(`Skipped artless spawns: ${skippedArtlessSpawns}, Skipped no-art NPCs: ${skippedMissingArtSpawns}`)
console.log(`世界图:${String(worldGraph.levels.size)} 个关卡,${String(worldGraph.edges.length)} 条边,${String(worldGraph.waypoints.size)} 个传送点`)