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

2585 lines
102 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, rename } from 'node:fs/promises'
import { createHash } from 'node:crypto'
import { join } from 'node:path'
import { isMainThread, Worker, parentPort, workerData } from 'node:worker_threads'
import { fileURLToPath } from 'node:url'
import { availableParallelism } from 'node:os'
import { performance } from 'node:perf_hooks'
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, townLevelForAct, ACT_TOWNS } 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 { frameDurationMsFromAnimSpeed } from '../src/game/animated-tiles.ts'
import { planLevelMonsters } from '../src/game/monsters.ts'
import type { IsoMapScene, IsoDraw } from '../src/game/d2map.ts'
import { SUB_TILES_PER_TILE } from '../src/game/map.ts'
import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT, OBJECT_MODE_TOKENS, ADDITIVE_OBJECT_TOKENS, objectCofMember } from '../src/game/objects.ts'
import { isWaypointObjectsTxtId, WAYPOINT_TOKENS } from '../src/game/object-lookup.ts'
import { decodeCof } from '../src/formats/cof.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'
import { act1OutdoorLevelIds, buildDrlgLevelMap, drlgLevelInputFromDump } from '../src/game/drlg/drlg-map.ts'
import type { DrlgEntrance, DrlgLevelInput } from '../src/game/drlg/drlg-map.ts'
import { dumpAct1 } from '../src/game/drlg/drlg-dump.ts'
import { createDrlgEnv } from '../src/game/drlg/drlg-source.ts'
import type { DrlgTables } from '../src/game/drlg/drlg-tables.ts'
import { WorldVariants } from '../src/game/act-variants.ts'
import type { ActLayout, VariantEntry, VariantIndex } from '../src/game/act-variants.ts'
import { drlgSuperUniqueIds, loadDrlgMpqData, loadDrlgMpqTables } from './lib/drlg-mpq-source.ts'
/**
* An entrance a generator reports: the wilderness generator's, or the DRLG adapter's (seams carry a
* span) with the display label the bake gives it.
*/
type GeneratorEntrance = WildernessEntrance | (DrlgEntrance & { readonly label: string })
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\\'
/**
* Tokens that represent persistent environmental fire and illumination objects.
* Ground truth parity (1.13c): Torches (TO), candles (A1, A2, 3o/3O), and fires
* (FX, FY, FZ, BR, BF, FL) cycle continuously in ambient illumination without clamping.
*/
export const PERSISTENT_ILLUMINATION_TOKENS = new Set([
'TO', 'A1', 'A2', '3O', '3o', 'FX', 'FY', 'FZ', 'BR', 'BF', 'FL',
])
/**
* 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'] = isMainThread
? process.argv.slice(2)
: [
(workerData as { archiveDir?: string } | undefined)?.archiveDir ?? process.argv[2] ?? 'samples/d2',
(workerData as { outDir?: string } | undefined)?.outDir ?? process.argv[3] ?? 'samples/d2-packs',
]
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
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)}`
}
/* ------------------------------------------------------------------------- *
* DRLG port (Act I outdoor levels)
* ------------------------------------------------------------------------- */
/** Copies the bake makes of every act the DRLG port generates, one game seed each. */
const DRLG_ACT_VARIANTS = 3
/**
* The game seed copy `variant` of `act` is generated from.
*
* D2 generates a whole act from one seed (DRLG_AllocDrlg -> DRLGOUTPLACE_CreateLevelConnections ->
* DRLG_InitLevel), so the seed belongs to the (act, copy) pair, never to a level. Act I's three are the
* seeds of the committed oracle fixtures (tests/fixtures/d2moo-oracle/act1-seed-5eed010{0,1,2}), so every
* baked copy is one the native D2MOO oracle generates identically.
*
* @param act - act number (1..5).
* @param variant - copy number (0-based, the index entries' `actVariant`).
* @returns the game seed.
*/
function actGameSeed(act: number, variant: number): number {
return (0x5eed_0000 + act * 0x100 + variant) >>> 0
}
/**
* The levels of an act the DRLG port generates: Act I's outdoor levels (Levels.txt DrlgType 3). The
* other acts still bake through the legacy generators.
*
* @param t - the DRLG tables.
* @param act - act number (1..5).
* @returns level ids, ascending.
*/
function drlgLevelsOf(t: DrlgTables, act: number): readonly number[] {
return act === 1 ? act1OutdoorLevelIds(t) : []
}
/** One DRLG-generated copy of an act: its index record and the adapter input of each generated level. */
interface DrlgActCopy {
readonly layout: ActLayout
readonly inputs: ReadonlyMap<number, DrlgLevelInput>
}
let drlgTablesPromise: Promise<DrlgTables> | undefined
/**
* The DRLG's compiled tables, loaded once per thread (the adapter maps a level with them).
*
* @returns the tables.
*/
function drlgTables(): Promise<DrlgTables> {
drlgTablesPromise ??= loadDrlgMpqTables(member => archives.read(member))
return drlgTablesPromise
}
/**
* Generate every copy of every DRLG act that has a level to bake, on the main thread.
*
* One DRLG run per copy, as in D2: the act's outdoor levels, their seams and the town's gate come out
* of the same run, so the copy is recorded as a whole (`index.json` `actLayouts`) and the runtime uses
* one copy per act (src/game/act-variants.ts). The whole act is generated even when a filter asks for a
* single level, because no level of it exists without the others.
*
* @param wanted - whether the bake's filters select a level.
* @returns the copies by act; acts without a selected DRLG level are absent.
*/
async function generateDrlgActs(wanted: (act: number, levelId: number) => boolean): Promise<Map<number, DrlgActCopy[]>> {
const out = new Map<number, DrlgActCopy[]>()
const t = await drlgTables()
for (let act = 1; act <= 5; act += 1) {
const levels = drlgLevelsOf(t, act)
if (!levels.some(levelId => wanted(act, levelId))) continue
const data = await loadDrlgMpqData(member => archives.read(member), act)
const town = townLevelForAct(act)
const copies: DrlgActCopy[] = []
for (let variant = 0; variant < DRLG_ACT_VARIANTS; variant += 1) {
const gameSeed = actGameSeed(act, variant)
const dump = dumpAct1(createDrlgEnv(data.source, data.tables), gameSeed, levels)
const townFile = dump.act.find(level => level.id === town)?.preset?.map?.file
if (townFile === undefined || townFile === '') {
throw new Error(`act ${String(act)} seed 0x${gameSeed.toString(16)}: the generated act picked no DS1 for its town (level ${String(town)})`)
}
copies.push({
layout: { variant, gameSeed, levels: [...levels], presets: [{ levelId: town, ds1: townFile.replace(/\//g, '\\') }] },
inputs: new Map(levels.map(levelId => [levelId, drlgLevelInputFromDump(dump, levelId, data.tables)])),
})
console.log(`DRLG act ${String(act)} copy ${String(variant)} (seed 0x${gameSeed.toString(16)}): town ${townFile}`)
}
out.set(act, copies)
}
return out
}
/**
* Check that every DRLG act copy in the (merged) index resolves the way the runtime resolves it: each
* generated level has exactly one copy per variant, the town file the copy fixed is baked, and no
* other copy of a generated level is left over (e.g. from a legacy bake merged in by a filtered run).
*
* @param index - the index about to be written.
*/
function validateActLayouts(index: VariantIndex<VariantEntry>): void {
for (const [actKey, layouts] of Object.entries(index.actLayouts ?? {})) {
const act = Number(actKey)
const generated = new Set(layouts.flatMap(layout => layout.levels))
for (const entry of index.levels) {
if (entry.act === act && entry.levelId !== undefined && generated.has(entry.levelId) && entry.actVariant === undefined) {
throw new Error(`index: ${entry.label} is a copy of DRLG level ${String(entry.levelId)} without an actVariant; bake the whole act`)
}
}
for (const layout of layouts) {
// A fresh selection per copy, pinned to it the way opening one of its levels pins it.
const variants = new WorldVariants(index)
const member = index.levels.find(entry => entry.act === act && entry.actVariant === layout.variant)
if (member === undefined) throw new Error(`actLayouts[${actKey}] copy ${String(layout.variant)}: no baked level belongs to it`)
variants.pin(member)
for (const levelId of [...layout.levels, ...layout.presets.map(preset => preset.levelId)]) {
if (variants.entryForLevel(levelId) === null) {
throw new Error(`actLayouts[${actKey}] copy ${String(layout.variant)}: level ${String(levelId)} is not baked`)
}
}
if (variants.startEntry(act) === null) throw new Error(`actLayouts[${actKey}] copy ${String(layout.variant)}: no start level`)
}
}
}
/**
* 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.
* @param subTile - the warp's exact sub-tile when the generator knows it (the
* DRLG's UNIT_TILE preset unit); the anchor cell's centre otherwise.
* @returns the warp.
*/
function makeWarp(
edge: WorldEdge,
direction: SceneWarp['direction'],
cellX: number,
cellY: number,
grid: IsoMapScene,
source: SceneWarp['source'],
region: WalkableRegion | undefined,
subTile?: { readonly x: number; readonly y: number },
): SceneWarp {
const warpId = edge.warps[0] ?? -1
const geometry = warpId < 0 ? undefined : findWarpGeometry(warpGeometry, warpId)
let x = subTile?.x ?? cellToSubTile(cellX)
let y = subTile?.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, or the
* DRLG adapter's entrances, if any.
* @param mazeWarps - `stats.warps` from the maze generator, if any.
* @param waypointCells - waypoint objects found in the artwork, in sub-tiles.
* @param mazeWaypoints - `stats.waypoints` from the maze generator, if any.
* @param options - `exactLinks`: the generator reports every seam and warp of
* the level exactly (the DRLG port). Nothing is then inferred: warp markers,
* staircase rooms, border scans and fallback warps are not used, every seam
* must carry the stretch of edge it opens on, and any disagreement with the
* world graph throws.
* @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 GeneratorEntrance[],
mazeWarps: readonly MazeWarp[],
waypointCells: readonly { x: number; y: number }[],
mazeWaypoints?: readonly MazeWaypoint[],
options: { readonly exactLinks?: boolean } = {},
): SceneLinks {
const exactLinks = options.exactLinks === true
const outEntrances: SceneEntrance[] = []
const outWarps: SceneWarp[] = []
const unplaced: { toLevelId: number; reason: string }[] = []
const notes: 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. Skipped
// when the generator reports its warps exactly: the DRLG port hands over
// the UNIT_TILE warp units D2 itself creates for them.
for (const tile of exactLinks ? [] : 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) {
const span = 'span' in anchor ? anchor.span : undefined
if (anchor.kind === 'gate' && anchor.side !== null && anchor.toLevelId >= 0 && span !== undefined) {
// A DRLG seam: the level link names the stretch of this edge that opens
// onto the neighbour (the open border block, or the whole shared edge
// for the town and the Monastery approach), so the walkable gap is
// searched there and nowhere else.
const range = { from: span.from * SUB_TILES_PER_TILE, to: span.to * SUB_TILES_PER_TILE }
const opening = findBorderOpening(grid, anchor.side, region, undefined, range)
if (opening === null) {
throw new Error(`level ${String(levelId)}: no reachable opening on the ${anchor.side} edge in tiles `
+ `${String(span.from)}..${String(span.to)} for the seam to level ${String(anchor.toLevelId)}`)
}
if (placedSeams.has(anchor.toLevelId)) {
throw new Error(`level ${String(levelId)}: two seams lead to level ${String(anchor.toLevelId)}`)
}
outEntrances.push({
toLevelId: anchor.toLevelId,
side: anchor.side,
label: anchor.label,
x: opening.x,
y: opening.y,
arriveX: opening.arriveX,
arriveY: opening.arriveY,
})
placedSeams.add(anchor.toLevelId)
continue
}
if (exactLinks && anchor.kind === 'gate') {
throw new Error(`level ${String(levelId)}: seam to level ${String(anchor.toLevelId)} has no side and span`)
}
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)
const warp = 'warp' in anchor ? anchor.warp : undefined
if (exactLinks) {
if (edge === undefined) {
throw new Error(`level ${String(levelId)}: the generator has a warp to level ${String(anchor.toLevelId)}, the world graph does not`)
}
if (placedWarps.has(edge.to)) {
throw new Error(`level ${String(levelId)}: two warps lead to level ${String(anchor.toLevelId)}`)
}
if (warp === undefined) {
throw new Error(`level ${String(levelId)}: warp to level ${String(anchor.toLevelId)} has no warp unit position`)
}
}
if (edge === undefined || placedWarps.has(edge.to)) continue
outWarps.push(makeWarp(edge, 'in', anchor.x, anchor.y, grid, 'tile', region,
warp === undefined ? undefined : { x: warp.subX, y: warp.subY }))
placedWarps.add(anchor.toLevelId)
}
if (exactLinks) {
// The generator's links and the world graph are two readings of the same
// Levels.txt rows (Vis0..7 / Warp0..7); any difference is a bug in one of
// them, never something to paper over with a guessed opening.
for (const edge of edgesFrom(worldGraph, levelId)) {
if (edge.kind === 'seamless' && !placedSeams.has(edge.to)) {
throw new Error(`level ${String(levelId)}: the world graph has a seam to level ${String(edge.to)}, the generator does not`)
}
if (edge.kind === 'warp' && !placedWarps.has(edge.to)) {
throw new Error(`level ${String(levelId)}: the world graph has a warp to level ${String(edge.to)}, the generator does not`)
}
}
for (const to of placedSeams) {
if (!edgesFrom(worldGraph, levelId).some(edge => edge.kind === 'seamless' && edge.to === to)) {
throw new Error(`level ${String(levelId)}: the generator has a seam to level ${String(to)}, the world graph does not`)
}
}
}
// 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 Harem and Basement, the
// Act 3 Spider Cavern, the Act 5 Infernal Pit — have an empty `specials`
// table in `MAZE_LEVEL_TYPE_PROFILES` because their DRLG staircase pass
// has not been transcribed (`unimplementedPassesFor` names them), 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
}
const fromName = worldGraph.levels.get(levelId)?.name ?? `level ${String(levelId)}`
const toName = worldGraph.levels.get(edge.to)?.name ?? `level ${String(edge.to)}`
notes.push(`fallback warp placed for edge ${fromName} -> ${toName}`)
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. The DRLG port's levels always
// do (DRLGOUTDOORS_SpawnAct12Waypoint puts the object in the level), so for
// them a missing or unreachable waypoint is a bug and throws. Legacy
// generators do not run that pass, so their levels get a stand-in: 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 (exactLinks && waypointId === 255 && waypointCells.length > 0) {
throw new Error(`level ${String(levelId)}: the generator placed a waypoint, Levels.txt has none`)
}
if (waypointId !== 255) {
if (exactLinks && waypointCells.length !== 1) {
throw new Error(`level ${String(levelId)}: the generator placed ${String(waypointCells.length)} waypoints for waypoint ${String(waypointId)}`)
}
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 reached = nearestWalkable(grid, spot.x + 2, spot.y + 2, 16, region)
if (exactLinks && reached === null) {
throw new Error(`level ${String(levelId)}: waypoint at ${String(spot.x)},${String(spot.y)} is not next to the main walkable region`)
}
const arrive = reached ?? 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, notes }
}
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 || levelId === 108) 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.
*
* Expanded cache limit (256): Diablo II references 242 unique DT1 files across
* all 1,651 level references. Expanding LIBRARY_CACHE_LIMIT to 256 accommodates all
* unique DT1 files without eviction across all acts and levels, eliminating 903
* redundant evictions and 677 re-decodes.
*/
const libraryCache = new Map<string, Dt1>()
/** Most DT1 libraries to keep decoded at once (accommodates all 242 unique DT1s across all levels). */
const LIBRARY_CACHE_LIMIT = 256
/**
* 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 }
}
interface DecodedMemberFrame {
frame: SpriteFrame
offsetX: number
offsetY: number
}
interface DecodedMemberArt {
frames: DecodedMemberFrame[]
}
/** Cache decoded member frames across levels so each unique member is decoded once. */
const decodedMemberCache = new Map<string, DecodedMemberArt>()
/** Cache additive blend determination for object COFs. */
const cofAdditiveCache = new Map<string, boolean>()
async function isObjectCofAdditive(token: string, modeIndex: number, baseIsMonsters: boolean): Promise<boolean> {
if (token === '') return false
const member = objectCofMember(token, modeIndex >= 0 ? modeIndex : 0, baseIsMonsters)
const cached = cofAdditiveCache.get(member)
if (cached !== undefined) return cached
try {
const bytes = await archives.read(member)
const cof = decodeCof(bytes)
const additive = cof.layers.some(l => l.drawEffect === 3 || l.transparent)
cofAdditiveCache.set(member, additive)
return additive
} catch {
cofAdditiveCache.set(member, false)
return false
}
}
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
}
/**
* The LvlPrest families the legacy outdoor generator draws its pieces from, by level type. Act I is not
* here: its outdoor levels bake only through the DRLG port ('drlg' jobs, see generateDrlgActs).
*/
const WILDERNESS_PIECE_FAMILIES: Readonly<Record<string, readonly string[]>> = {
'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
const animSpeedRaw = parseInt(cell(tables.lvlprest, row, 'Animate'), 10) || 0
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
pieces.push({
name,
kind: classified.kind satisfies MazePieceKind,
sides: classified.sides,
levels,
// `LvlPrest.KillEdge`; 1.13c trims the outer-edge room's overhang when set.
killEdge: cell(tables.lvlprest, row, 'KillEdge') === '1',
...(animSpeed !== undefined ? { animSpeed } : {}),
})
}
return pieces
})()
mazePiecesCache.set(levelTypeName, promise)
return promise
}
const BROKEN_ACT5_BARRICADE_PRESETS = new Set([
'Act 5 - Barricade Cliff Border 1',
'Act 5 - Barricade Cliff Border 4',
'Act 5 - Barricade Cliff Border 5',
'Act 5 - Barricade Ravine Border 3',
'Act 5 - Barricade Ravine Border 12',
'Act 5 - Barricade Cliff Border 4 Snow',
'Act 5 - Barricade Cliff Border 5 Snow',
'Act 5 - Barricade Cliff Border 9 Snow',
'Act 5 - Barricade Cliff Border 10 Snow',
'Act 5 - Barricade Cliff Border 12 Snow',
'Act 5 - Barricade Ravine Border 2 Snow',
'Act 5 - Barricade Ravine Border 3 Snow',
'Act 5 - Barricade Ravine Border 6 Snow',
'Act 5 - Barricade Ravine Border 7 Snow',
'Act 5 - Barricade Ravine Border 9 Snow',
'Act 5 - Barricade Ravine Border 10 Snow',
'Act 5 - Barricade Ravine Border 12 Snow',
])
const wildernessPiecesCache = new Map<string, Promise<WildernessPiece[]>>()
function getWildernessPieces(levelTypeName: string, subType?: number, levelName?: string): Promise<WildernessPiece[]> {
const isSnow = subType === 11 || (levelName !== undefined && /snow/i.test(levelName))
const cacheKey = `${levelTypeName}:${isSnow ? 'snow' : 'dirt'}`
const cached = wildernessPiecesCache.get(cacheKey)
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
if (levelTypeName === 'Act 5 - Barricade') {
if (BROKEN_ACT5_BARRICADE_PRESETS.has(name)) continue
if (isSnow && !/snow/i.test(name)) continue
if (!isSnow && /snow/i.test(name)) continue
}
const levels = await rowDs1s(tables.lvlprest, row)
if (levels.length === 0) continue
const isBorder = /border|cliff|ravine/i.test(name)
const animSpeedRaw = parseInt(cell(tables.lvlprest, row, 'Animate'), 10) || 0
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
pieces.push({ name, levels, border: isBorder, ...(animSpeed !== undefined ? { animSpeed } : {}) })
}
return pieces
})()
wildernessPiecesCache.set(cacheKey, 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
}
interface SceneJob {
readonly jobId: number
readonly entry: LevelJob
readonly type: 'preset-ds1' | 'preset-chaos' | 'maze' | 'wilderness' | 'drlg'
readonly ds1Name?: string
/** Copy number: 1-based for the legacy generators, the 0-based act copy (`actVariant`) for 'drlg'. */
readonly variant?: number
readonly label: string
readonly seed: number
/** 'drlg' jobs: the level as the main thread's DRLG run of the act copy generated it. */
readonly drlg?: { readonly gameSeed: number; readonly input: DrlgLevelInput }
}
interface SceneResult {
readonly jobId: number
readonly indexEntry: Record<string, unknown>
readonly pngBytes: number
readonly sceneBytes: number
readonly skippedArtlessSpawns: number
readonly skippedMissingArtSpawns: number
readonly missingTiles: number
readonly missingRefs: unknown
readonly logLine: string
}
async function bakeDs1Variant(
jobId: number,
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 GeneratorEntrance[]
readonly mazeWarps: readonly MazeWarp[]
readonly mazeWaypoints?: readonly MazeWaypoint[]
readonly landmarks?: readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[]
readonly notes?: readonly string[] | undefined
readonly animSpeed?: number | undefined
/** The generator reports every seam and warp exactly (see buildSceneLinks). */
readonly exactLinks?: boolean
/**
* `'layout'`: every room, tile (style, sequence, type, DT1 library) and preset unit is D2's own
* (the DRLG port); only the variant within a tile's (style, sequence) is the renderer's pick.
* Unset: the generator approximates the level.
*/
readonly fidelity?: 'layout'
} = { entrances: [], mazeWarps: [] },
animSpeedOverride?: number,
): Promise<SceneResult> {
let localSkippedArtless = 0
let localSkippedMissingArt = 0
const animSpeed = animSpeedOverride ?? generatorLinks.animSpeed
const frameDurationMs = frameDurationMsFromAnimSpeed(animSpeed)
const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed)
/**
* 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 }[] = []
for (const object of level.objects) {
try {
const resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id, monstersTable)
const resolvedName = resolved.name ?? resolved.row?.name ?? resolved.token
const isWp = isWaypointObjectsTxtId(resolved.entry?.objectsTxtId ?? resolved.row?.id)
|| WAYPOINT_TOKENS.has(resolved.token.toUpperCase())
|| resolved.token.toLowerCase() === 'wp'
|| /waypoint/i.test(resolvedName)
if (isWp) {
waypointCells.push({ x: object.x, y: object.y })
}
} catch {
// Unresolved objects handled in main loop below
}
}
const sceneLinks = buildSceneLinks(
entry.levelId,
entry.kind,
level,
scene,
generatorLinks.entrances,
generatorLinks.mazeWarps,
waypointCells,
generatorLinks.mazeWaypoints,
{ exactLinks: generatorLinks.exactLinks === true },
)
const spawn = findIsoSpawn(scene, { warps: sceneLinks.warps, entrances: sceneLinks.entrances })
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, ...scene.shadows, ...scene.walls, ...scene.roofs]) {
const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y)
if (distance <= HOT_RADIUS_CELLS) {
hot.add(draw.frameIndex)
if (draw.animatedFrames) {
for (const f of draw.animatedFrames) hot.add(f)
}
}
}
}
const firstDrawAt = new Map<number, { x: number; y: number }>()
for (const draw of [...scene.floors, ...scene.shadows, ...scene.walls, ...scene.roofs]) {
if (!firstDrawAt.has(draw.frameIndex)) firstDrawAt.set(draw.frameIndex, { x: draw.x, y: draw.y })
if (draw.animatedFrames) {
for (const f of draw.animatedFrames) {
if (!firstDrawAt.has(f)) firstDrawAt.set(f, { 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)
}
const objectPages = new PageBuilder(palette)
const objects: unknown[] = []
const npcs: unknown[] = []
const missingObjects: string[] = []
let objectsWithArt = 0
interface PlacedMemberFrame {
placement: Placement
offsetX: number
offsetY: number
}
const placementByMember = new Map<string, { frames: PlacedMemberFrame[] }>()
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) {
localSkippedArtless += 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) {
localSkippedMissingArt += 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
let animatedFrames: {
page: number
x: number
y: number
width: number
height: number
offsetX: number
offsetY: number
}[] | undefined = undefined
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]
if (group && group.frames.length > 0) {
const frames: DecodedMemberFrame[] = group.frames.map(f => ({
frame: f,
offsetX: -16 + f.offsetX,
offsetY: 16 + (f.offsetY - f.height + 1),
}))
art = { frames }
}
} else {
const dcc = decodeDcc(bytes)
const dir = dcc.directions[0]
if (dir && dir.frames.length > 0) {
const frames: DecodedMemberFrame[] = dir.frames.map(f => ({
frame: f.frame,
offsetX: -16 + dir.box.left,
offsetY: 16 + dir.box.top,
}))
art = { frames }
}
}
if (art) decodedMemberCache.set(pick.member, art)
} catch (err) {
console.warn(`failed to decode object member ${pick.member}: ${(err as Error).message}`)
}
}
if (art && art.frames.length > 0) {
const placedFrames: PlacedMemberFrame[] = art.frames.map(item => ({
placement: objectPages.add(item.frame),
offsetX: item.offsetX,
offsetY: item.offsetY,
}))
placed = { frames: placedFrames }
placementByMember.set(pick.member, placed)
}
}
if (placed && placed.frames.length > 0) {
const f0 = placed.frames[0]!
objectFrame = {
page: f0.placement.page,
x: f0.placement.x,
y: f0.placement.y,
width: f0.placement.width,
height: f0.placement.height,
offsetX: f0.offsetX,
offsetY: f0.offsetY,
}
if (placed.frames.length > 1) {
animatedFrames = placed.frames.map(f => ({
page: f.placement.page,
x: f.placement.x,
y: f.placement.y,
width: f.placement.width,
height: f.placement.height,
offsetX: f.offsetX,
offsetY: f.offsetY,
}))
}
}
}
const list = resolved.kind === 'npc' ? npcs : objects
let 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.
const isWp = isWaypointObjectsTxtId(resolved.entry?.objectsTxtId ?? row?.id)
|| WAYPOINT_TOKENS.has(resolved.token.toUpperCase())
|| resolved.token.toLowerCase() === 'wp'
|| /waypoint/i.test(resolvedName)
if (isWp) {
waypointCells.push({ x: object.x, y: object.y })
if (resolvedName === 'Dummy') {
resolvedName = 'Waypoint'
}
}
const modeToken = resolved.mode === '' ? 'NU' : resolved.mode
const modeIndex = OBJECT_MODE_TOKENS.indexOf(modeToken as any)
let frameDelta = 256
let cycleAnim = true
let sync = false
if (row !== null && modeIndex >= 0) {
const delta = row.frameDelta[modeIndex] ?? 0
if (delta > 0) frameDelta = delta
cycleAnim = (row.cycleAnim[modeIndex] ?? 1) !== 0
sync = (row.sync ?? 0) === 1
}
const tokenUpper = resolved.token.trim().toUpperCase()
if (PERSISTENT_ILLUMINATION_TOKENS.has(tokenUpper)) {
cycleAnim = true
if (frameDelta <= 0) frameDelta = 256
}
const frameDurationMs = Math.round((256 / frameDelta) * 40 * 100) / 100
const isCofAdditive = await isObjectCofAdditive(resolved.token, modeIndex, resolved.kind === 'npc')
const isAdditive = (row !== null && (row.trans === 7 || row.trans === 1)) ||
isCofAdditive ||
ADDITIVE_OBJECT_TOKENS.has(tokenUpper) ||
resolved.blendMode === 'additive'
list.push({
id: object.id,
type: object.type,
name: resolvedName,
token: resolved.token,
mode: modeToken,
// `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,
trans: row?.trans ?? 0,
blendMode: isAdditive ? 'additive' : 'normal',
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,
...(animatedFrames ? {
animatedFrames,
frameDelta,
frameDurationMs,
cycleAnim,
sync,
} : {}),
})
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)
}
// 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 serializeDraw = (draw: IsoDraw): number[] => {
if (draw.animatedFrames && draw.animatedFrames.length > 1) {
return [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY, ...draw.animatedFrames]
}
return [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]
}
const sceneJson = {
version: 1,
fidelity: entry.kind === 'preset' ? 'exact' : generatorLinks.fidelity ?? 'approximation',
act: entry.act,
levelId: entry.levelId,
levelName,
ds1: ds1Name,
...(animSpeed !== undefined ? { animSpeed } : {}),
frameDurationMs,
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(serializeDraw),
shadows: scene.shadows.map(serializeDraw),
walls: scene.walls.map(serializeDraw),
roofs: scene.roofs.map(serializeDraw),
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,
shadows: scene.shadows.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,
notes: [
...(generatorLinks.notes ?? []),
...(sceneLinks.notes ?? []),
],
},
}
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))
const indexEntry = {
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,
}
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
const logLine =
`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)}`
return {
jobId,
indexEntry,
pngBytes: manifest.pngBytes,
sceneBytes: sceneBytes.byteLength,
skippedArtlessSpawns: localSkippedArtless,
skippedMissingArtSpawns: localSkippedMissingArt,
missingTiles: scene.missingTiles,
missingRefs: scene.missingRefs,
logLine,
}
}
/**
* Execute a single scene baking task.
*
* @param job - the scene job parameters.
* @returns the bake result for deterministic index assembly.
*/
async function bakeSceneJob(job: SceneJob): Promise<SceneResult> {
const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === job.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 (job.type === 'preset-ds1') {
const info: LevelInfo = resolveLevel(tables, job.entry.levelId, job.entry.act)
const libraries: Dt1[] = []
for (const name of info.dt1Names) libraries.push(await libraryOf(name))
const level = await loadDs1(job.ds1Name!)
return await bakeDs1Variant(
job.jobId,
job.entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries,
level, job.ds1Name!, job.label, job.seed,
{ entrances: [], mazeWarps: [] },
info.animSpeed,
)
}
if (job.type === 'preset-chaos') {
const info: LevelInfo = resolveLevel(tables, job.entry.levelId, job.entry.act)
const libraries: Dt1[] = []
for (const name of info.dt1Names) libraries.push(await libraryOf(name))
const pieces: PresetPiece[] = []
for (const ds1Name of info.ds1Names) {
const level = await loadDs1(ds1Name)
const pieceName = ds1Name.split('\\').pop()?.replace(/\.ds1$/i, '') ?? ds1Name
const prestRow = tables.lvlprest.rows.find(r => {
for (let file = 1; file <= 6; file += 1) {
const f = cell(tables.lvlprest, r, `File${String(file)}`)
if (f && ds1Name.toLowerCase().includes(f.toLowerCase().replace(/\.ds1$/i, ''))) return true
}
return cell(tables.lvlprest, r, 'Name').toLowerCase().includes(pieceName.toLowerCase())
})
const animSpeedRaw = prestRow ? (parseInt(cell(tables.lvlprest, prestRow, 'Animate'), 10) || 0) : 0
const animSpeed = animSpeedRaw > 0 ? animSpeedRaw : undefined
pieces.push({ name: pieceName, levels: [level], ...(animSpeed !== undefined ? { animSpeed } : {}) })
}
const result = generatePreset({
levelId: job.entry.levelId,
levelName: info.levelName,
sizeX: info.sizeX > 0 ? info.sizeX : 120,
sizeY: info.sizeY > 0 ? info.sizeY : 120,
seed: job.seed,
pieces,
})
return await bakeDs1Variant(
job.jobId,
job.entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries,
result.level, `preset:${job.label}`, job.label, job.seed,
{ entrances: [], mazeWarps: [] },
(result.stats.animSpeed as number | undefined) ?? info.animSpeed,
)
}
if (job.type === 'maze') {
const type = levelType(levelRow)
const row = mazeRow(job.entry.levelId, job.entry.name)
if (row === undefined) {
throw new Error(`skipping maze ${job.entry.name}: no LvlMaze.txt row`)
}
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) {
throw new Error(`skipping maze ${job.entry.name}: no maze pieces`)
}
const libInfo = resolveLevelLibraries(tables, job.entry.levelId)
const libraries: Dt1[] = []
for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name))
const result = generateMaze({
levelId: job.entry.levelId,
levelName: job.entry.name,
levelTypeName: type.name,
sectionSize: sectionX,
sectionHeight: sectionY,
minRooms,
merge,
seed: job.seed,
pieces,
maxCellsX: sizeX > sectionX ? sizeX : undefined,
maxCellsY: sizeY > sectionY ? sizeY : undefined,
})
return await bakeDs1Variant(
job.jobId,
job.entry, job.entry.name, palette, paletteName, libInfo.dt1Names, libraries,
result.level, `generated:${job.label}`, job.label, job.seed,
{
entrances: [],
mazeWarps: (result.stats.warps ?? []) as MazeWarp[],
mazeWaypoints: (result.stats.waypoints ?? []) as MazeWaypoint[],
animSpeed: (result.stats.animSpeed as number | undefined) ?? (result.animSpeed as number | undefined),
},
)
}
if (job.type === 'drlg') {
const drlg = job.drlg
if (drlg === undefined || job.variant === undefined) {
throw new Error(`${job.label}: a DRLG job must carry its generated level and act copy`)
}
const t = await drlgTables()
// The libraries of the tiles D2 picked, for their (style, sequence, type) headers.
const dt1 = new Map<string, Dt1>()
for (const tile of drlg.input.tiles) {
if (tile.tile !== null && !dt1.has(tile.tile.library)) dt1.set(tile.tile.library, await libraryOf(tile.tile.library))
}
const map = buildDrlgLevelMap(drlg.input, { tables: t, dt1, superUniqueIds: drlgSuperUniqueIds(tables.superuniques, t) })
const libraries: Dt1[] = []
for (const name of map.dt1Names) libraries.push(await libraryOf(name))
const entrances: GeneratorEntrance[] = map.entrances.map(entrance => ({
...entrance,
label: entrance.kind === 'gate' ? `${levelNameOf(entrance.toLevelId)} Seam` : levelNameOf(entrance.toLevelId),
}))
const s = map.stats
const result = await bakeDs1Variant(
job.jobId,
job.entry, job.entry.name, palette, paletteName, map.dt1Names, libraries,
map.ds1, `generated:${job.label}`, job.label, job.seed,
{
entrances,
mazeWarps: [],
landmarks: map.landmarks,
exactLinks: true,
fidelity: 'layout',
notes: [
`DRLG port: act ${String(job.entry.act)} copy ${String(job.variant)}, game seed 0x${drlg.gameSeed.toString(16)}`,
`DRLG tiles: ${String(s.walls)} walls, ${String(s.floors)} floors, ${String(s.shadows)} shadows, `
+ `${String(s.withoutTile)} without a DT1 tile; units: ${String(s.objects)} objects, ${String(s.monsters)} monsters`,
],
},
)
return { ...result, indexEntry: { ...result.indexEntry, actVariant: job.variant } }
}
if (job.type === '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, subType, job.entry.name)
if (pieces.length === 0) {
throw new Error(`skipping wilderness ${job.entry.name}: no wilderness pieces`)
}
const rows = await getSubstitutions(subType)
const shrineRows = await getSubstitutions(subShrine)
const libInfo = resolveLevelLibraries(tables, job.entry.levelId)
const resolvedLibraries: Dt1[] = []
for (const name of libInfo.dt1Names) resolvedLibraries.push(await libraryOf(name))
const gates = gatesFor(job.entry.levelId)
const result = generateWilderness({
levelId: job.entry.levelId,
levelName: job.entry.name,
levelTypeName: type.name,
sizeX,
sizeY,
subType,
subTheme: Math.max(0, subTheme),
seed: job.seed,
pieces,
substitutions: rows,
shrineSubstitutions: shrineRows,
gates,
dt1Libraries: resolvedLibraries,
})
return await bakeDs1Variant(
job.jobId,
job.entry, job.entry.name, palette, paletteName, libInfo.dt1Names, resolvedLibraries,
result.level, `generated:${job.label}`, job.label, job.seed,
{
entrances: (result.stats.entrances ?? []) as WildernessEntrance[],
mazeWarps: [],
animSpeed: (result.stats.animSpeed as number | undefined) ?? (result.animSpeed as number | undefined),
...(Array.isArray(result.stats.landmarkEntities)
? { landmarks: result.stats.landmarkEntities as readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] }
: {}),
...(Array.isArray(result.stats.notes) ? { notes: result.stats.notes } : {}),
},
)
}
throw new Error(`Unknown job type: ${(job as { type: string }).type}`)
}
/**
* Enumerate all scenes to bake in deterministic order across all valid levels.
*
* @param filterLevels - optional filter set of level IDs.
* @param filterAct - optional filter for a specific act.
* @param drlgActs - the DRLG-generated act copies ({@link generateDrlgActs}); their levels bake one
* 'drlg' job per copy instead of going through the legacy wilderness generator.
* @returns ordered array of scene jobs with sequential jobIds (0..N-1).
*/
async function collectSceneJobs(
filterLevels: Set<number> | null,
filterAct: number | null,
drlgActs: ReadonlyMap<number, readonly DrlgActCopy[]>,
): Promise<SceneJob[]> {
const jobs: SceneJob[] = []
let jobId = 0
for (const entry of LEVELS) {
if (filterLevels !== null && !filterLevels.has(entry.levelId)) continue
if (filterAct !== null && entry.act !== filterAct) continue
const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === entry.levelId)!
if (entry.kind === 'preset') {
const info: LevelInfo = resolveLevel(tables, entry.levelId, entry.act)
if (entry.levelId === 108) {
for (let v = 1; v <= 3; v += 1) {
jobs.push({
jobId: jobId++,
entry,
type: 'preset-chaos',
variant: v,
label: `${entry.slug}-var${v}`,
seed: 0x5eed_1000 + entry.levelId * 10 + v,
})
}
} else {
for (const ds1Name of info.ds1Names) {
jobs.push({
jobId: jobId++,
entry,
type: 'preset-ds1',
ds1Name,
label: `${entry.slug}-${slugOf(ds1Name)}`,
seed: 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 pieces = await getMazePieces(type.name, type.id)
if (pieces.length === 0) {
console.warn(`skipping maze ${entry.name}: no maze pieces`)
continue
}
for (let v = 1; v <= 3; v += 1) {
jobs.push({
jobId: jobId++,
entry,
type: 'maze',
variant: v,
label: `${entry.slug}-var${v}`,
seed: 0x5eed_0000 + entry.levelId * 10 + v,
})
}
} else if (entry.kind === 'wilderness') {
const copies = drlgActs.get(entry.act)
if (copies !== undefined && copies.some(copy => copy.layout.levels.includes(entry.levelId))) {
for (const copy of copies) {
const input = copy.inputs.get(entry.levelId)
if (input === undefined) {
throw new Error(`act ${String(entry.act)} copy ${String(copy.layout.variant)} did not generate level ${String(entry.levelId)}`)
}
jobs.push({
jobId: jobId++,
entry,
type: 'drlg',
variant: copy.layout.variant,
label: `${entry.slug}-var${String(copy.layout.variant + 1)}`,
seed: 0x5eed_1000 + entry.levelId * 10 + copy.layout.variant + 1,
drlg: { gameSeed: copy.layout.gameSeed, input },
})
}
continue
}
const type = levelType(levelRow)
const subType = Number(cell(tables.levels, levelRow, 'SubType'))
const pieces = await getWildernessPieces(type.name, subType, entry.name)
if (pieces.length === 0) {
console.warn(`skipping wilderness ${entry.name}: no wilderness pieces`)
continue
}
for (let v = 1; v <= 3; v += 1) {
jobs.push({
jobId: jobId++,
entry,
type: 'wilderness',
variant: v,
label: `${entry.slug}-var${v}`,
seed: 0x5eed_1000 + entry.levelId * 10 + v,
})
}
}
}
// Every selected level of a generated act must bake from the DRLG copies: a level whose Levels.txt
// kind disagreed with the DRLG's DrlgType would otherwise vanish from the act without a word.
for (const [act, copies] of drlgActs) {
for (const levelId of copies[0]?.layout.levels ?? []) {
if (filterLevels !== null && !filterLevels.has(levelId)) continue
if (filterAct !== null && act !== filterAct) continue
const baked = jobs.filter(job => job.type === 'drlg' && job.entry.levelId === levelId).length
if (baked !== copies.length) {
throw new Error(`DRLG level ${String(levelId)} of act ${String(act)} has ${String(baked)} bake jobs, expected ${String(copies.length)}`)
}
}
}
return jobs
}
/**
* Write a JSON file atomically via a temporary file and rename.
*
* @param path - destination path.
* @param data - JSON-serializable object.
*/
async function writeJsonAtomic(path: string, data: unknown): Promise<void> {
const tmpPath = `${path}.tmp.${String(process.pid)}`
await writeFile(tmpPath, JSON.stringify(data, null, 1))
await rename(tmpPath, path)
}
/**
* Run scene jobs in parallel across a Node.js worker_threads pool.
*
* @param jobs - all scene baking jobs.
* @param numWorkers - worker thread count.
* @returns results array indexed by jobId.
*/
async function runWorkerPool(jobs: SceneJob[], numWorkers: number): Promise<SceneResult[]> {
return new Promise((resolve, reject) => {
const targetScript = fileURLToPath(import.meta.url)
const bootstrap = `
import { register } from 'tsx/esm/api'
register()
import(${JSON.stringify(targetScript)})
`
const results: SceneResult[] = new Array(jobs.length)
const queue = [...jobs]
let completedCount = 0
let shuttingDown = false
let isDone = false
const workers: Worker[] = []
const cleanup = () => {
if (isDone) return
isDone = true
shuttingDown = true
for (const w of workers) {
w.terminate().catch(() => {})
}
}
const abort = (err: Error) => {
if (shuttingDown) return
cleanup()
reject(err)
}
const assignNextJob = (worker: Worker) => {
if (shuttingDown) return
if (queue.length > 0) {
const nextJob = queue.shift()!
worker.postMessage({ type: 'job', job: nextJob })
} else if (completedCount === jobs.length) {
cleanup()
resolve(results)
}
}
for (let i = 0; i < numWorkers; i++) {
const worker = new Worker(bootstrap, {
eval: true,
execArgv: ['--import', 'tsx'],
workerData: { archiveDir, outDir, workerId: i + 1 },
})
worker.on('message', (msg: { type: string; result?: SceneResult; jobId?: number; label?: string; message?: string; stack?: string }) => {
if (shuttingDown) return
if (msg.type === 'ready') {
assignNextJob(worker)
} else if (msg.type === 'result') {
const res = msg.result!
results[res.jobId] = res
completedCount += 1
console.log(`[${String(completedCount).padStart(3)}/${String(jobs.length)}] ${res.logLine}`)
if (res.missingTiles > 0) {
console.log(' missingRefs:', res.missingRefs)
}
assignNextJob(worker)
} else if (msg.type === 'error') {
console.error(`\n❌ Error baking scene ${msg.label ?? ''} (jobId ${String(msg.jobId ?? '')}): ${msg.message ?? ''}`)
if (msg.stack) console.error(msg.stack)
abort(new Error(`Worker failed on scene ${msg.label ?? ''}: ${msg.message ?? ''}`))
}
})
worker.on('error', (err) => {
console.error(`\n❌ Worker thread fatal error: ${err.message}`, err.stack)
abort(err)
})
worker.on('exit', (code) => {
if (code !== 0 && !shuttingDown) {
abort(new Error(`Worker thread exited unexpectedly with code ${String(code)}`))
}
})
workers.push(worker)
}
})
}
/**
* Run scene jobs sequentially on the main thread (fallback or workers=1).
*
* @param jobs - scene baking jobs.
* @returns results array indexed by jobId.
*/
async function runSequential(jobs: SceneJob[]): Promise<SceneResult[]> {
const results: SceneResult[] = new Array(jobs.length)
let completed = 0
for (const job of jobs) {
const result = await bakeSceneJob(job)
results[job.jobId] = result
completed += 1
console.log(`[${String(completed).padStart(3)}/${String(jobs.length)}] ${result.logLine}`)
if (result.missingTiles > 0) {
console.log(' missingRefs:', result.missingRefs)
}
}
return results
}
/**
* Main coordinator entry point for act asset baking.
*/
async function main(): Promise<void> {
const filterLevel = process.env.FILTER_LEVEL
const filterLevels = filterLevel !== undefined
? new Set(filterLevel.split(',').map(s => Number(s.trim())))
: null
const filterAct = process.env.FILTER_ACT !== undefined ? Number(process.env.FILTER_ACT) : null
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]
}
await mkdir(outDir, { recursive: true })
for (let act = 1; act <= 5; act += 1) {
await mkdir(join(outDir, `act${String(act)}`), { recursive: true })
}
const drlgActs = await generateDrlgActs((act, levelId) =>
(filterLevels === null || filterLevels.has(levelId)) && (filterAct === null || act === filterAct))
const sceneJobs = await collectSceneJobs(filterLevels, filterAct, drlgActs)
if (sceneJobs.length === 0) {
console.log('No scenes matched filter criteria.')
return
}
const defaultMaxWorkers = typeof availableParallelism === 'function' ? availableParallelism() : 8
const envWorkers = process.env.MAX_WORKERS ? parseInt(process.env.MAX_WORKERS, 10) : undefined
const maxWorkers = Math.max(1, Math.min(envWorkers ?? defaultMaxWorkers, 16))
const useWorkers = maxWorkers > 1
const numWorkers = useWorkers ? Math.min(maxWorkers, sceneJobs.length) : 1
console.log(`Baking ${String(sceneJobs.length)} scenes using ${useWorkers ? `${String(numWorkers)} worker threads` : 'sequential execution'}...`)
const tStart = performance.now()
const results = useWorkers
? await runWorkerPool(sceneJobs, numWorkers)
: await runSequential(sceneJobs)
const elapsedSec = ((performance.now() - tStart) / 1000).toFixed(1)
let totalPngBytes = 0
let totalLevels = 0
let skippedArtlessSpawns = 0
let skippedMissingArtSpawns = 0
// Deterministic indexing: append results sorted strictly by jobId (0..sceneJobs.length - 1)
for (let i = 0; i < sceneJobs.length; i++) {
const res = results[i]!
;(index.levels as unknown[]).push(res.indexEntry)
totalPngBytes += res.pngBytes
totalLevels += 1
skippedArtlessSpawns += res.skippedArtlessSpawns
skippedMissingArtSpawns += res.skippedMissingArtSpawns
}
// The DRLG act copies the generated levels belong to (src/game/act-variants.ts reads them).
const actLayouts: Record<string, ActLayout[]> = {}
for (const [act, copies] of drlgActs) actLayouts[String(act)] = copies.map(copy => copy.layout)
if (Object.keys(actLayouts).length > 0) index.actLayouts = actLayouts
const indexPath = join(outDir, 'index.json')
if (filterLevel !== undefined || filterAct !== null) {
try {
const { readFile } = await import('node:fs/promises')
const existingRaw = await readFile(indexPath, 'utf8')
const existing = JSON.parse(existingRaw) as {
levels?: Array<{ label?: string }>
actLayouts?: Record<string, ActLayout[]>
}
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
}
// An act this run generated replaces its old copies whole; other acts keep theirs.
if (existing.actLayouts !== undefined) index.actLayouts = { ...existing.actLayouts, ...actLayouts }
} catch {
// No existing index.json or unreadable; write fresh index
}
}
// Outside the merge's catch-all: an act copy the runtime could not resolve must fail the bake.
validateActLayouts(index as unknown as VariantIndex<VariantEntry>)
// Atomic writes from main thread
await writeJsonAtomic(indexPath, index)
await writeJsonAtomic(join(outDir, 'world-graph.json'), {
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 })),
})
if (!process.env.SKIP_ENTITIES) {
const { bakeEntities } = await import('./pack-entity-assets.ts')
await bakeEntities(archiveDir, outDir)
}
console.log(`\n打包完成:${String(totalLevels)} 张地图,耗时 ${elapsedSec}s,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`)
console.log(`Skipped artless spawns: ${String(skippedArtlessSpawns)}, Skipped no-art NPCs: ${String(skippedMissingArtSpawns)}`)
console.log(`世界图:${String(worldGraph.levels.size)} 个关卡,${String(worldGraph.edges.length)} 条边,${String(worldGraph.waypoints.size)} 个传送点`)
}
/**
* Worker thread execution loop.
*/
function startWorker(): void {
process.on('unhandledRejection', (reason) => {
console.error('Worker unhandled rejection:', reason)
parentPort?.postMessage({
type: 'error',
jobId: -1,
label: 'unhandled-rejection',
message: String(reason),
stack: (reason as Error)?.stack,
})
})
parentPort?.on('message', async (msg: { type: string; job?: SceneJob }) => {
if (msg.type === 'job' && msg.job) {
try {
const result = await bakeSceneJob(msg.job)
parentPort?.postMessage({ type: 'result', result })
} catch (err) {
parentPort?.postMessage({
type: 'error',
jobId: msg.job.jobId,
label: msg.job.label,
message: (err as Error).message,
stack: (err as Error).stack,
})
}
} else if (msg.type === 'shutdown') {
process.exit(0)
}
})
parentPort?.postMessage({ type: 'ready' })
}
if (isMainThread) {
main().catch((err) => {
console.error('Fatal bake error:', err)
process.exit(1)
})
} else {
startWorker()
}