/** * 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, 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' 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() 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() 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 => { const outgoing = new Map() 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() const queue: number[] = [] for (const town of ACT_TOWNS) { distance.set(town, 0) queue.push(town) } while (queue.length > 0) { const at = queue.shift() if (at === undefined) break const here = distance.get(at) ?? 0 for (const next of outgoing.get(at) ?? []) { if (distance.has(next)) continue distance.set(next, here + 1) queue.push(next) } } return distance })() /** * A level's display name, for labelling the openings that lead to it. * * @param levelId - the level. * @returns its `Levels.txt` name, or the bare id when there is no such level. */ function levelNameOf(levelId: number): string { return worldGraph.levels.get(levelId)?.name ?? `level ${String(levelId)}` } /** * The border seams an outdoor level must cut, as the generator wants them. * * @param levelId - the level being generated. * @returns one gate per seamless edge leaving it. */ function gatesFor(levelId: number): PlannedGate[] { const gates: PlannedGate[] = [] for (const edge of edgesFrom(worldGraph, levelId)) { if (edge.kind !== 'seamless' || edge.sideFrom === null) continue gates.push({ side: edge.sideFrom, toLevelId: edge.to, label: `${levelNameOf(edge.to)} Seam`, // The Burial Grounds seam is the one place the border art has a // dedicated opening: variant 4 is the graveyard gate, 3 the plain road. variant: (levelId === 3 && edge.to === 17) || (levelId === 17 && edge.to === 3) ? 4 : 3, }) } return gates } /** * Turn a graph edge and a place into a baked warp. * * @param edge - the edge being placed. * @param direction - which way it goes. * @param cellX - the anchor cell. * @param cellY - the anchor cell. * @param grid - the collision map, for nudging the arrival point off a wall. * @param source - how the position was found. * @param region - the level's main walkable region when there is a meaningful * one, keeping the arrival point out of sealed pockets; undefined for * generated levels, where the unstamped void would be the largest region. * @returns the warp. */ function makeWarp( edge: WorldEdge, direction: SceneWarp['direction'], cellX: number, cellY: number, grid: IsoMapScene, source: SceneWarp['source'], region: WalkableRegion | undefined, ): SceneWarp { const warpId = edge.warps[0] ?? -1 const geometry = warpId < 0 ? undefined : findWarpGeometry(warpGeometry, warpId) let x = cellToSubTile(cellX) let y = cellToSubTile(cellY) const fullSpan = Math.max(grid.gridWidth, grid.gridHeight) if (region !== undefined && !triggerableFrom(grid, region, x, y, WARP_TRIGGER_SUBTILES)) { const snapped = nearestWalkable(grid, x, y, fullSpan, region) if (snapped !== null) { x = snapped.x y = snapped.y } } // `OffsetX/Y` is where the engine materialises the player, relative to the // anchor tile and usually negative so that they do not land on the trigger. const wanted = { x: x + (geometry?.offsetX ?? -2), y: y + (geometry?.offsetY ?? -2), } const arrive = nearestWalkable(grid, wanted.x, wanted.y, fullSpan, region) ?? { x, y } return { toLevelId: edge.to, warpId, direction, label: `${levelNameOf(edge.to)}`, x, y, arriveX: arrive.x, arriveY: arrive.y, selectX: geometry?.selectX ?? 0, selectY: geometry?.selectY ?? 0, selectDX: geometry === undefined || !isClickableWarp(geometry) ? 0 : geometry.selectDX, selectDY: geometry === undefined || !isClickableWarp(geometry) ? 0 : geometry.selectDY, exitWalkX: geometry?.exitWalkX ?? 0, exitWalkY: geometry?.exitWalkY ?? 0, source, } } /** * Work out every way out of one baked level. * * **Warps come from the artwork.** Every map in the game, shipped or generated, * marks its staircases with DS1 special tiles whose `style` is the `Warp0..7` * slot — see {@link findWarpTiles} — and the slot says which edge of the graph * the staircase is. That single rule covers presets, mazes and outdoor levels * alike, it gives the position the original engine would have used, and it * removes the need to guess which of a dungeon's two staircases goes up. * * **Seams come from the generator or the collision map.** A seamless edge has * no marker, because walking off the edge of the Blood Moor is not a staircase. * Outdoor levels report the openings they cut; presets have theirs recovered * from the collision map, since their artwork is fixed and nobody wrote down * where the gate is. * * The maze generator's staircase rooms are kept as a fallback for the handful * of pieces that carry no marker. * * @param levelId - the level. * @param kind - how it was built. * @param ds1 - the assembled map, for its warp markers. * @param grid - the baked collision map. * @param entrances - `stats.entrances` from the wilderness generator, if any. * @param mazeWarps - `stats.warps` from the maze generator, if any. * @param waypointCells - waypoint objects found in the artwork, in sub-tiles. * @returns the links, plus anything the graph wanted that could not be placed. */ function buildSceneLinks( levelId: number, kind: LevelKind, ds1: Ds1, grid: IsoMapScene, entrances: readonly WildernessEntrance[], mazeWarps: readonly MazeWarp[], waypointCells: readonly { x: number; y: number }[], mazeWaypoints?: readonly MazeWaypoint[], ): SceneLinks { const outEntrances: SceneEntrance[] = [] const outWarps: SceneWarp[] = [] const unplaced: { toLevelId: number; reason: string }[] = [] const notes: string[] = [] const placedSeams = new Set() const placedWarps = new Set() // 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() /** `Warp0..7` slot -> the edge that slot crosses. */ const warpEdgeBySlot = new Map() for (const edge of edgesFrom(worldGraph, levelId)) { if (edge.kind !== 'warp') continue warpEdgeTo.set(edge.to, edge) for (const slot of edge.warpSlots) warpEdgeBySlot.set(slot, edge) } /** Which way a crossing leads, for the runtime's benefit. */ const here = townDistance.get(levelId) ?? Number.MAX_SAFE_INTEGER const directionTo = (toLevelId: number): SceneWarp['direction'] => (townDistance.get(toLevelId) ?? Number.MAX_SAFE_INTEGER) < here ? 'up' : 'down' // 1. The staircases the artwork declares. Authoritative for every kind of // level, because the slot identifies the destination outright. for (const tile of findWarpTiles(ds1)) { const edge = warpEdgeBySlot.get(tile.slot) // A marker for a slot this level does not use: the piece was drawn for a // level that connects there and reused here. Ignore it rather than invent // a destination. if (edge === undefined) continue // A generator can stamp the same stair piece twice; the first wins. if (placedWarps.has(edge.to)) continue outWarps.push(makeWarp(edge, directionTo(edge.to), tile.cellX, tile.cellY, grid, 'tile', region)) placedWarps.add(edge.to) } // 2. Outdoor levels: the generator already cut the seams, and knows where the // interior presets it stamped sit. for (const anchor of entrances) { if (anchor.kind === 'gate' && anchor.side !== null && anchor.toLevelId >= 0) { // The anchor the generator hands over is the border *piece*, and the // centre of that cell is wall; on some outdoor variants the border // block's gap is separated from the main play area by up to ~40 // sub-tiles of rock. Snapping to `region` with a 64-sub-tile radius // places the seam trigger on the outer boundary of the main play area, // and `seamArrivalSpot` steps inward outside the trigger radius. const gate = nearestWalkable(grid, cellToSubTile(anchor.x), cellToSubTile(anchor.y), 64, region) ?? { x: cellToSubTile(anchor.x), y: cellToSubTile(anchor.y) } const arrive = seamArrivalSpot(grid, gate.x, gate.y, anchor.side, region) outEntrances.push({ toLevelId: anchor.toLevelId, side: anchor.side, label: anchor.label, x: gate.x, y: gate.y, arriveX: arrive.x, arriveY: arrive.y, }) placedSeams.add(anchor.toLevelId) continue } // A preset's mouth: the cave, tower or crypt that opens off this level. const edge = anchor.toLevelId < 0 ? undefined : warpEdgeTo.get(anchor.toLevelId) if (edge === undefined || placedWarps.has(edge.to)) continue outWarps.push(makeWarp(edge, 'in', anchor.x, anchor.y, grid, 'tile', region)) placedWarps.add(anchor.toLevelId) } // 3. Mazes: staircase rooms, for pieces whose artwork carries no marker. if (kind === 'maze') { const leftover = [...edgesFrom(worldGraph, levelId)] .filter(edge => edge.kind === 'warp' && !placedWarps.has(edge.to)) const queues = { up: leftover.filter(edge => directionTo(edge.to) === 'up'), down: leftover.filter(edge => directionTo(edge.to) === 'down'), } for (const room of mazeWarps) { const edge = queues[room.direction].shift() if (edge === undefined) continue outWarps.push(makeWarp(edge, room.direction, room.centreX, room.centreY, grid, 'room', region)) placedWarps.add(edge.to) } } // 4. Seams nobody cut: read them off the artwork's walkable border. // // Presets always land here, because their artwork is fixed and nobody // wrote down where the gate is. So do the two mazes with a seamless edge // (the Barracks opens onto the Courtyard, Lava 1 onto the Chaos // Sanctuary), and outdoor levels whose grid is too small for the planner // to run, like the 6x2 Kurast docks. // // The graph's side is usually right, but not always. The Rogue Encampment // ships as four presets — `TownN1`, `TownE1`, `TownS1`, `TownW1` — which // are the same camp slid to four different corners of a 57x41 canvas, so // that the palisade opens off a different edge of the map in each. The // graph has one side per *level*, so three variants in four would be told // to put the gate where this particular camp has none. // // What distinguishes the two cases is `inset`: an opening at inset 0 is // walkable ground running off the edge of the map, which is what a gate // looks like, while a large inset means the scan gave up on the border and // settled for the far end of the play area. So when the level has exactly // one seam and some side reaches the border while the graph's side does // not, the artwork wins and the widest such contact is the gate. With more // than one seam there is no way to tell which border contact belongs to // which neighbour, so the graph's sides stand. const loneSeams = [...edgesFrom(worldGraph, levelId)] .filter(edge => edge.kind === 'seamless' && edge.sideFrom !== null && !placedSeams.has(edge.to)) for (const edge of loneSeams) { const wanted = edge.sideFrom! const measured = new Map() 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; otherwise the level gets a // stand-in, because `DRLGOUTDOORS_SpawnAct12Waypoint` is not implemented and // a waypoint the network knows about but the map does not show is worse than // an approximate position. const waypointId = worldGraph.levels.get(levelId)?.waypoint ?? 255 const waypoints: SceneWaypoint[] = [] if (waypointId !== 255) { const fromArt = waypointCells[0] ?? (mazeWaypoints?.[0] ? { x: mazeWaypoints[0].entityX, y: mazeWaypoints[0].entityY } : undefined) const spot = fromArt ?? findWaypointSpot(grid, region) if (spot !== null && spot !== undefined) { const arrive = nearestWalkable(grid, spot.x + 2, spot.y + 2, 16, region) ?? spot waypoints.push({ waypointId, x: spot.x, y: spot.y, arriveX: arrive.x, arriveY: arrive.y, source: fromArt === undefined ? 'placed' : 'object', }) } } return { entrances: outEntrances, warps: outWarps, waypoints, unplacedEdges: unplaced, 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() /** 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 { 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() 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>() const monsterMembers = new Map>() 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() /** Cache additive blend determination for object COFs. */ const cofAdditiveCache = new Map() async function isObjectCofAdditive(token: string, modeIndex: number, baseIsMonsters: boolean): Promise { 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() async function loadDs1(member: string): Promise { 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 { const levels: Ds1[] = [] for (let slot = 1; slot <= 6; slot += 1) { const value = cell(table, row, `File${String(slot)}`) if (value === '' || value === '0') continue levels.push(await loadDs1(tileMemberPath(value))) } return levels } function themeValues(table: D2Table, row: readonly string[], prefix: string): number[] { const values: number[] = [] for (let index = 0; index < 5; index += 1) values.push(Number(cell(table, row, `${prefix}${String(index)}`)) || 0) return values } const WILDERNESS_PIECE_FAMILIES: Readonly> = { 'Act 1 - Wilderness': [ 'Act 1 - Wild', 'Act 1 - Town 1 Transition', 'Act 1 - DOE Entrance', 'Act 1 - Cave Entrance', 'Act 1 - Corral Fill', 'Act 1 - Fence Fill', 'Act 1 - River', 'Act 1 - Bridge', 'Act 1 - Bivouac', 'Act 1 - Pond', 'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', 'Act 1 - Cottages', 'Act 1 - Fallen Camp', 'Act 1 - Camp', 'Act 1 - Cairn Stones', 'Act 1 - Inifus', 'Act 1 - Tower', 'Act 1 - Ruin', 'Act 1 - Tree Fill', 'Act 1 - Graveyard', ], 'Act 2 - Desert': ['Act 2 - Desert'], 'Act 3 - Jungle': ['Act 3 - Jungle', 'Act 3 - Clearing'], 'Act 3 - Kurast': ['Act 3 - Burst', 'Act 3 - Burbs', 'Act 3 - Clearing', 'Act 3 - Slums', 'Act 3 - Metro', 'Act 3 - Travincal', 'Act 3 - Bridge'], 'Act 4 - Mesa': ['Act 4 - Mesa', 'Act 4 - Fortress', 'Act 4 - Pits', 'Act 4 - Bridge'], 'Act 4 - Lava': ['Act 4 - Lava', 'Act 4 - Diablo', 'Act 4 - Bridge'], 'Act 5 - Siege': ['Act 5 - Siege'], 'Act 5 - Barricade': ['Act 5 - Barricade'], } const mazePiecesCache = new Map>() function getMazePieces(levelTypeName: string, levelTypeId: string): Promise { 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>() function getWildernessPieces(levelTypeName: string, subType?: number, levelName?: string): Promise { 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 = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /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>() function getSubstitutions(type: number): Promise { 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() async function getPalette(paletteName: string): Promise { 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' readonly ds1Name?: string readonly variant?: number readonly label: string readonly seed: number } interface SceneResult { readonly jobId: number readonly indexEntry: Record 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 WildernessEntrance[] 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 } = { entrances: [], mazeWarps: [] }, animSpeedOverride?: number, ): Promise { 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, ) 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() 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() 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() 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() 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' : '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 { 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 === '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. * @returns ordered array of scene jobs with sequential jobIds (0..N-1). */ async function collectSceneJobs( filterLevels: Set | null, filterAct: number | null, ): Promise { 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 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, }) } } } 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 { 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 { 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 { 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 { 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 = { version: 1, generated: new Date().toISOString(), archiveDir, pageSize: PAGE_SIZE, palettes: {} as Record, 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)[`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 sceneJobs = await collectSceneJobs(filterLevels, filterAct) 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 } 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 }> } if (Array.isArray(existing.levels)) { const updatedByLabel = new Map() for (const item of index.levels as Array<{ label: string }>) { updatedByLabel.set(item.label, item) } const merged: unknown[] = [] const seenLabels = new Set() for (const oldItem of existing.levels) { if (oldItem.label && updatedByLabel.has(oldItem.label)) { merged.push(updatedByLabel.get(oldItem.label)!) seenLabels.add(oldItem.label) } else { merged.push(oldItem) } } for (const [lbl, item] of updatedByLabel.entries()) { if (!seenLabels.has(lbl)) merged.push(item) } index.levels = merged } } catch { // No existing index.json or unreadable; write fresh index } } // 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() }