/** * Bake map assets out of the archives into web-native packs. * * The page can already read the real archives, but doing so costs it 38 MB and * ~11k range requests per visit (measured), because it decodes DS1/DT1/PL2 and * builds an atlas in the browser every time. This script moves all of that * offline: it walks the same code the page walks — `resolveLevel`, `decodeDs1`, * `decodeDt1`, `buildIsoMapScene`, `decodeDc6` — and writes the *result* as * indexed PNG tile pages plus one JSON scene per map. The page then decodes * nothing but PNG. * * Design decisions worth knowing: * * - **Indexed PNG, one palette per act.** Diablo II art is palette-indexed and * each act ships its own `pal.pl2`; PNG colour type 3 carries both, so the * browser's own decoder does the palette expansion on the GPU upload path. * - **Pages of at most 2048², hot frames first.** Frames used near the spawn go * into the first page(s), so the page can paint after loading one or two PNGs * instead of the whole map. * - **The scene JSON is the authority, not a re-derivation.** Draw lists are * already in painter's order and the collision grid is exactly the one the * live path produces; `scripts/verify-packs.ts` re-derives both from the MPQs * and fails if they differ by a byte. * * Usage: node scripts/pack-act-assets.ts [archive-directory] [output-directory] */ import { mkdir, writeFile } from 'node:fs/promises' import { createHash } from 'node:crypto' import { join } from 'node:path' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { MountedArchives } from '../src/mpq/mount.ts' import { cell, loadActTables, parseTable, resolveLevel, resolveLevelLibraries, tileMemberPath } from '../src/game/acts.ts' import type { ActTables, LevelInfo, D2Table } from '../src/game/acts.ts' import { decodeDs1 } from '../src/formats/ds1.ts' import type { Ds1 } from '../src/formats/ds1.ts' import { decodeDt1 } from '../src/formats/dt1.ts' import type { Dt1 } from '../src/formats/dt1.ts' import { decodePl2 } from '../src/formats/pl2.ts' import { levelSeed, buildIsoMapScene, cellAt, findIsoSpawn, ORTHO_SUB_TILE_HEIGHT, ORTHO_SUB_TILE_WIDTH } from '../src/game/d2map.ts' import { planLevelMonsters } from '../src/game/monsters.ts' import type { IsoMapScene } from '../src/game/d2map.ts' import { SUB_TILES_PER_TILE } from '../src/game/map.ts' import { loadObjectsTable, resolveDs1Object, MONSTER_ROOT } from '../src/game/objects.ts' import { decodeDcc } from '../src/formats/dcc.ts' import { decodeDc6 } from '../src/formats/dc6.ts' import type { SpriteFrame } from '../src/formats/sprite.ts' import { generateMaze, classifyMazePieceName } from '../src/game/maze.ts' import type { MazePiece, MazePieceKind } from '../src/game/maze.ts' import { generateWilderness } from '../src/game/wilderness.ts' import type { PlannedGate, WildernessEntrance, WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.ts' export interface MazeWarp { readonly room: number readonly direction: 'up' | 'down' readonly kind?: string readonly pieceName?: string readonly centreX: number readonly centreY: number } import { assignGateSides, buildWorldGraph, edgesFrom, findWarpGeometry, isClickableWarp, parseLevelRows, parseWarpGeometry, SIDES, } from '../src/game/world-graph.ts' import type { Side, WorldEdge, WorldGraph } from '../src/game/world-graph.ts' import { cellToSubTile, findBorderOpening, findWaypointSpot, largestWalkableRegion, nearestWalkable, seamArrivalSpot, triggerableFrom, WARP_TRIGGER_SUBTILES, } from '../src/game/level-links.ts' import type { BorderOpening, LinkGrid, SceneEntrance, SceneLinks, SceneWarp, SceneWaypoint, WalkableRegion, } from '../src/game/level-links.ts' import { findWarpTiles } from '../src/game/warp-tiles.ts' import { encodeIndexedPng } from './png.ts' /** * Archive stack, in load order (later overrides earlier). * * `d2char.mpq` is mounted *first* so it sits at the bottom: it holds the object * and character art (`data\global\objects\…`) but no map data, and this order * keeps the map lookups resolving through `d2data` → `d2exp` → `Patch_D2` as the * game does. */ const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const /** Atlas page side limit (both dimensions). */ const PAGE_SIZE = 2048 /** Cells around the spawn whose frames go into the first page. */ const HOT_RADIUS_CELLS = 8 /** Where object art lives. */ const OBJECT_PREFIX = 'data\\global\\objects\\' /** * The player's walking speed in scene pixels, matching `act-scene.ts`. * * Monster speeds are expressed relative to it, so the baker has to know the * same number the browser does. Kept as a literal on both sides rather than * shared through a module because `act-scene.ts` is a browser entry point and * this script must not pull it in. */ const PLAYER_WALK_SPEED_PX = 170 /** Where monster and town-NPC art lives. */ /** * Mode directories tried in order when picking an object's art. * * Note what the object art actually *is*: under `data\global\objects` the * archives hold **1748 DCC and 1461 COF files against just 13 DC6**. Diablo II * draws objects with the same composite pipeline as characters — a COF that * lists layers and frames plus the DCC files behind them — so this packer * records each object's placement and its exact art members, and leaves the * pixels to the `COF`/`DCC` decoder that the character art also needs. Baking a * wrong file as if it were a DC6 would have produced silent garbage instead. */ const OBJECT_MODES = ['tr', 'nu', 's1', 's2', 's3'] as const /** * The maps to bake, derived from `Levels.txt` rather than listed by hand. * * `DrlgType` picks the generator, exactly as the game does (documented in the * knowledge base and mirrored by OpenDiablo2): * * - **2 = preset area**: `LvlPrest` rows name fixed DS1 files, so the map is * baked byte-for-byte — what `verify-packs.ts` diffs against the live decoder. * - **1 = random maze** / **3 = wilderness**: no fixed layout exists. Those come * from the generators (`src/game/maze.ts`, `src/game/wilderness.ts`) driven by * `LvlMaze`, `LvlSub` and the `LvlPrest` room pieces, and their packs are * marked `approximation` because the engine's own placement is hardcoded. */ type LevelKind = 'preset' | 'maze' | 'wilderness' /** One map to bake. */ interface LevelJob { readonly act: number readonly levelId: number readonly name: string readonly kind: LevelKind readonly slug: string } /** Filled in from `Levels.txt` once the tables are loaded. */ let LEVELS: readonly LevelJob[] = [] /** * Turn a level name into a stable, file-friendly slug. * * @param name - `Levels.txt` name, e.g. `Act 1 - Tristram`. * @returns the slug. */ function slugify(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') } /** One frame's placement inside a page. */ interface Placement { readonly page: number readonly x: number readonly y: number readonly width: number readonly height: number } /** One atlas page being packed. */ interface PackedPage { readonly pixels: Uint8Array frames: number } /** Frames, shelf-packed into pages of at most {@link PAGE_SIZE} squared. */ class PageBuilder { private readonly pages: PackedPage[] = [] /** Palette the pages are encoded against. */ private readonly palette: Uint8Array private cursorX = 0 private shelfY = 0 private shelfHeight = 0 constructor(palette: Uint8Array) { this.palette = palette } /** Frames placed so far. */ get placed(): number { return this.pages.reduce((sum, page) => sum + page.frames, 0) } /** The pages, with their used height. */ get pageCount(): number { return this.pages.length } /** * Place one indexed frame, opening pages as needed. * * Shelves keep placement trivial and predictable; frames wider than a page are * clamped rather than rejected, because a tile bigger than 2048 px would be a * broken library, not a reason to lose the whole map. * * @param frame - width, height and palette indices. * @returns where the frame landed. */ add(frame: { width: number; height: number; indices: Uint8Array }): Placement { const width = Math.max(1, Math.min(frame.width, PAGE_SIZE)) const height = Math.max(1, Math.min(frame.height, PAGE_SIZE)) if (this.cursorX + width > PAGE_SIZE) { this.shelfY += this.shelfHeight this.shelfHeight = 0 this.cursorX = 0 } if (this.pages.length === 0 || this.shelfY + height > PAGE_SIZE) { this.pages.push({ pixels: new Uint8Array(PAGE_SIZE * PAGE_SIZE), frames: 0 }) this.shelfY = 0 this.shelfHeight = 0 this.cursorX = 0 } const page = this.pages[this.pages.length - 1]! const placement: Placement = { page: this.pages.length - 1, x: this.cursorX, y: this.shelfY, width, height, } for (let row = 0; row < height; row += 1) { const from = row * frame.width const to = (this.shelfY + row) * PAGE_SIZE + this.cursorX page.pixels.set(frame.indices.subarray(from, from + width), to) } this.cursorX += width this.shelfHeight = Math.max(this.shelfHeight, height) page.frames += 1 return placement } /** * Encode every page, trimming each to the rows it actually used. * * @param transparent - palette index to mark transparent. * @returns file-ready PNGs, in page order. */ encode(transparent: number): { name: string; png: Uint8Array; width: number; height: number; frames: number }[] { return this.pages.map((page, index) => { const used = Math.max(1, this.usedHeight(page)) const trimmed = new Uint8Array(PAGE_SIZE * used) for (let row = 0; row < used; row += 1) { trimmed.set(page.pixels.subarray(row * PAGE_SIZE, (row + 1) * PAGE_SIZE), row * PAGE_SIZE) } return { name: `tiles-${String(index)}.png`, png: encodeIndexedPng({ width: PAGE_SIZE, height: used, pixels: trimmed, palette: this.palette, transparentIndex: transparent, }), width: PAGE_SIZE, height: used, frames: page.frames, } }) } /** * Height of the last written row, for trimming. * * @param page - the page. * @returns the used height in rows. */ private usedHeight(page: PackedPage): number { for (let row = PAGE_SIZE - 1; row >= 0; row -= 1) { const from = row * PAGE_SIZE for (let x = 0; x < PAGE_SIZE; x += 1) if (page.pixels[from + x] !== 0) return row + 1 } return 1 } } /** * FNV-1a over a frame's indexed pixels, so verification can compare content * without shipping the pixels twice. * * @param indices - palette indices. * @returns an 8-character hex digest. */ function frameHash(indices: Uint8Array): string { let hash = 0x811c9dc5 for (const byte of indices) { hash ^= byte hash = Math.imul(hash, 0x01000193) >>> 0 } return hash.toString(16).padStart(8, '0') } /** * Turn a DS1 member name into a file-friendly slug. * * @param member - full member path. * @returns the slug. */ function slugOf(member: string): string { const base = member.split('\\').pop() ?? member return base.replace(/\.ds1$/i, '').toLowerCase() } const [archiveDir = 'samples/d2', outDir = 'samples/d2-packs'] = process.argv.slice(2) const archives = new MountedArchives() for (const name of MOUNTS) { archives.add(name, await MpqArchive.open(await fileSource(join(archiveDir, name)))) } const tables: ActTables = await loadActTables(archives) const objectsTable = parseTable(await archives.read('data\\global\\excel\\objects.txt')) /** Same table, through the typed loader the object resolution expects. */ const objectsTableTyped = await loadObjectsTable(archives) /** * `MonPreset.txt` `Place` values grouped by act. * * The position within an act **is** the DS1 type-1 `id`, so this array must stay * dense: dropping a row would renumber every entry after it and silently hand * each NPC its neighbour's identity. Measured against the 1.13c drop there are * zero empty `Place` cells in all five acts, so the guard below never fires — * it exists to fail loudly rather than quietly misnumber if that ever changes. */ const presetPlaceByAct = new Map() 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 /** Where each act's town is, for working out which way is "up" in a dungeon. */ const ACT_TOWNS: readonly number[] = [1, 40, 75, 103, 109] const worldGraph: WorldGraph = assignGateSides( buildWorldGraph(parseLevelRows(tables.levels)), ACT_LAYOUT_SEED, ) const warpGeometry = parseWarpGeometry(tables.lvlwarp) /** * Distance from the nearest town, in level hops. * * Used for one thing: telling a dungeon's up staircase from its down one. The * data does not say. `Vis`/`Warp` slot order is not depth order — level 20, the * Forgotten Tower's entrance building, has its way out in slot 0 and its way * down in slot 1, while level 9's descent is in slot 4 — so the only reliable * signal is that the way out is the neighbour closer to town. */ const townDistance = ((): Map => { 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 }[], ): SceneLinks { const outEntrances: SceneEntrance[] = [] const outWarps: SceneWarp[] = [] const unplaced: { toLevelId: number; reason: 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 sewers, the tombs, the // Act 3 dungeons — have an empty `specials` table in `MAZE_LEVEL_TYPE_PROFILES` // because their DRLG staircase pass has not been transcribed, so they // stamp no stair room, and their pieces carry no marker either. Leaving // those edges unplaced makes a level you can enter and never leave, which // is a worse lie than an approximate staircase. Each one is spread to a // different quarter of the map so two of them never coincide, and each is // tagged `fallback` so nothing mistakes it for the real position. const needFallback = [...edgesFrom(worldGraph, levelId)] .filter(edge => edge.kind === 'warp' && !placedWarps.has(edge.to)) needFallback.forEach((edge, index) => { // Quarter centres, in reading order, so the choice is deterministic. const quarters = [[1, 1], [3, 1], [1, 3], [3, 3], [2, 2]] as const const [qx, qy] = quarters[index % quarters.length]! const wanted = { x: Math.floor(grid.gridWidth * qx / 4), y: Math.floor(grid.cellsY * SUB_TILES_PER_TILE * qy / 4), } const spot = nearestWalkable(grid, wanted.x, wanted.y, Math.max(grid.gridWidth, grid.gridHeight), region) if (spot === null) { unplaced.push({ toLevelId: edge.to, reason: 'no walkable ground for a fallback warp' }) return } outWarps.push(makeWarp( edge, directionTo(edge.to), Math.floor(spot.x / SUB_TILES_PER_TILE), Math.floor(spot.y / SUB_TILES_PER_TILE), grid, 'fallback', region, )) placedWarps.add(edge.to) }) // Anything the graph wanted and nothing produced. Portals are excluded on // purpose: a town portal or a quest portal is conjured at run time by the // thing that opens it, so there is nothing for the bake to cut. Seams pass 4 // already complained about are skipped, or every solid edge would be counted // twice: once with the real reason and once with this generic one. const reported = new Set(unplaced.map(hole => hole.toLevelId)) for (const edge of edgesFrom(worldGraph, levelId)) { if (edge.kind === 'seamless' && !placedSeams.has(edge.to) && !reported.has(edge.to)) { unplaced.push({ toLevelId: edge.to, reason: 'seam not cut by the generator' }) } } // Waypoints. The artwork wins when it has one; otherwise the level gets a // stand-in, because `DRLGOUTDOORS_SpawnAct12Waypoint` is not implemented and // a waypoint the network knows about but the map does not show is worse than // an approximate position. const waypointId = worldGraph.levels.get(levelId)?.waypoint ?? 255 const waypoints: SceneWaypoint[] = [] if (waypointId !== 255) { const fromArt = waypointCells[0] const spot = fromArt ?? findWaypointSpot(grid, region) if (spot !== null && spot !== undefined) { const arrive = nearestWalkable(grid, spot.x + 2, spot.y + 2, 16, region) ?? spot waypoints.push({ waypointId, x: spot.x, y: spot.y, arriveX: arrive.x, arriveY: arrive.y, source: fromArt === undefined ? 'placed' : 'object', }) } } return { entrances: outEntrances, warps: outWarps, waypoints, unplacedEdges: unplaced } } const allNames = await archives.listFiles() { const jobs: LevelJob[] = [] for (const row of tables.levels.rows) { const levelId = Number(cell(tables.levels, row, 'Id')) if (!Number.isFinite(levelId) || levelId === 0) continue const name = cell(tables.levels, row, 'Name') const drlg = Number(cell(tables.levels, row, 'DrlgType')) const act = Number(cell(tables.levels, row, 'Act')) + 1 let kind: LevelKind | null = null if (drlg === 2) kind = 'preset' else if (drlg === 1) kind = 'maze' else if (drlg === 3) kind = 'wilderness' if (kind === null) continue jobs.push({ act, levelId, name, kind, slug: `${String(levelId)}-${slugify(name)}` }) } LEVELS = jobs } /** * Decoded DT1 libraries, kept across levels. * * A hundred-plus levels share a few dozen level types, so decoding each library * once instead of once per level turns the bake from repeated work into one pass. */ /** * Decoded DT1 libraries, keyed by member name. * * Bounded on purpose: every level keeps its DT1 block bitmaps alive (each library * decodes to tens of MB of palette indices), and an unbounded cache held 4+ GB by * the time the bake reached act 5. Sixteen libraries is more than any single * level type uses, so a level never re-decodes mid-level, and the memory ceiling * stays flat across a whole 62-map bake. */ const libraryCache = new Map() /** Most DT1 libraries to keep decoded at once. */ const LIBRARY_CACHE_LIMIT = 16 /** * Decode a DT1 library, cached by member name. * * @param member - archive member name. * @returns the decoded library. */ async function libraryOf(member: string): Promise { 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 } } /** Cache decoded member frames across levels so each unique member is decoded once. */ const decodedMemberCache = new Map() 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 pieces.push({ name, kind: classified.kind satisfies MazePieceKind, sides: classified.sides, levels }) } return pieces })() mazePiecesCache.set(levelTypeName, promise) return promise } const wildernessPiecesCache = new Map>() function getWildernessPieces(levelTypeName: string): Promise { const cached = wildernessPiecesCache.get(levelTypeName) if (cached !== undefined) return cached const promise = (async () => { const families = WILDERNESS_PIECE_FAMILIES[levelTypeName] ?? [] const pieces: WildernessPiece[] = [] for (const row of tables.lvlprest.rows) { const name = cell(tables.lvlprest, row, 'Name') if (!families.some(family => name.startsWith(family))) continue const levels = await rowDs1s(tables.lvlprest, row) if (levels.length === 0) continue const isBorder = levelTypeName === 'Act 1 - Wilderness' ? /\bBorder\b/i.test(name) : /border|cliff|ravine/i.test(name) pieces.push({ name, levels, border: isBorder }) } return pieces })() wildernessPiecesCache.set(levelTypeName, promise) return promise } const substitutionsCache = new Map>() 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 } 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] } let totalPngBytes = 0 let totalLevels = 0 let skippedArtlessSpawns = 0 let skippedMissingArtSpawns = 0 async function bakeDs1Variant( entry: LevelJob, levelName: string, palette: Uint8Array, paletteName: string, dt1Names: readonly string[], libraries: Dt1[], level: Ds1, ds1Name: string, label: string, seed: number, /** * What the generator learned while building this level. * * Empty for presets, which are not generated: their openings are recovered * from the collision map instead. */ generatorLinks: { readonly entrances: readonly WildernessEntrance[] readonly mazeWarps: readonly MazeWarp[] readonly landmarks?: readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] } = { entrances: [], mazeWarps: [] }, ): Promise { const scene: IsoMapScene = buildIsoMapScene(level, libraries, seed) const spawn = findIsoSpawn(scene) const dir = join(outDir, `act${String(entry.act)}`, label) // Frames used near the spawn load first; everything else follows. const hot = new Set() if (spawn !== null) { const spawnCell = cellAt(scene, spawn.x, spawn.y) for (const draw of scene.floors) { const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y) if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex) } for (const draw of scene.walls) { const distance = Math.abs(draw.cellX - spawnCell.x) + Math.abs(draw.cellY - spawnCell.y) if (distance <= HOT_RADIUS_CELLS) hot.add(draw.frameIndex) } } const firstDrawAt = new Map() for (const draw of [...scene.floors, ...scene.walls]) { if (!firstDrawAt.has(draw.frameIndex)) firstDrawAt.set(draw.frameIndex, { x: draw.x, y: draw.y }) } const bandOf = (frameIndex: number): number => { const at = firstDrawAt.get(frameIndex) if (at === undefined) return Number.MAX_SAFE_INTEGER return Math.floor(at.y / 512) * 4096 + Math.floor(at.x / 768) } const order = scene.frames.map((_, at) => at).sort((a, b) => { const ha = hot.has(a) ? 0 : 1 const hb = hot.has(b) ? 0 : 1 if (ha !== hb) return ha - hb const ba = bandOf(a) const bb = bandOf(b) if (ba !== bb) return ba - bb const fa = scene.frames[a]! const fb = scene.frames[b]! return fb.width * fb.height - fa.width * fa.height }) const pages = new PageBuilder(palette) const placementOf = new Map() for (const frameIndex of order) { const frame = scene.frames[frameIndex]! placementOf.set(frameIndex, pages.add(frame)) } const pageFiles = pages.encode(0) await mkdir(dir, { recursive: true }) const sha = createHash('sha256') for (const page of pageFiles) { await writeFile(join(dir, page.name), page.png) sha.update(page.png) totalPngBytes += page.png.byteLength } const objectPages = new PageBuilder(palette) const objects: unknown[] = [] const npcs: unknown[] = [] /** * Waypoint pedestals found in the artwork, in sub-tiles. * * Only presets have one: the outdoor and maze generators do not run the pass * that spawns waypoints, so their levels fall back to a placed position. */ const waypointCells: { x: number; y: number }[] = [] const missingObjects: string[] = [] let objectsWithArt = 0 const placementByMember = new Map() for (const object of level.objects) { let resolved try { resolved = resolveDs1Object(objectsTableTyped, entry.act, object.type, object.id, monstersTable) } catch (err) { missingObjects.push((err as Error).message) continue } if (resolved.kind === 'monster') continue if (resolved.kind === 'npc' && object.type === 1 && resolved.artless) { skippedArtlessSpawns += 1 continue } const row = resolved.row const pick = resolved.token === '' ? null : pickObjectMember(resolved.token, resolved.mode, resolved.kind === 'npc') if (pick === null && resolved.kind === 'npc' && object.type === 1) { skippedMissingArtSpawns += 1 continue } const orthoX = (object.x - object.y) * ORTHO_SUB_TILE_WIDTH + scene.originX const orthoY = (object.x + object.y) * ORTHO_SUB_TILE_HEIGHT + scene.originY let objectFrame: { page: number x: number y: number width: number height: number offsetX: number offsetY: number } | null = null if (pick !== null) { let placed = placementByMember.get(pick.member) if (placed === undefined) { let art = decodedMemberCache.get(pick.member) if (art === undefined) { try { const bytes = await archives.read(pick.member) if (pick.member.toLowerCase().endsWith('.dc6')) { const sheet = decodeDc6(bytes) const group = sheet.groups[0] const f = group?.frames[0] if (f) { art = { frame: f, offsetX: -16 + f.offsetX, offsetY: 16 + (f.offsetY - f.height + 1), } } } else { const dcc = decodeDcc(bytes) const dir = dcc.directions[0] const f = dir?.frames[0] if (f && dir) { art = { frame: f.frame, offsetX: -16 + dir.box.left, offsetY: 16 + dir.box.top, } } } if (art) decodedMemberCache.set(pick.member, art) } catch (err) { console.warn(`failed to decode object member ${pick.member}: ${(err as Error).message}`) } } if (art) { const placement = objectPages.add(art.frame) placed = { placement, offsetX: art.offsetX, offsetY: art.offsetY } placementByMember.set(pick.member, placed) } } if (placed) { objectFrame = { page: placed.placement.page, x: placed.placement.x, y: placed.placement.y, width: placed.placement.width, height: placed.placement.height, offsetX: placed.offsetX, offsetY: placed.offsetY, } } } const list = resolved.kind === 'npc' ? npcs : objects const resolvedName = resolved.name ?? row?.name ?? resolved.token // The pedestal's own sub-tile, taken here because everything downstream is // in screen pixels and cannot be converted back. if (resolved.token.toLowerCase() === 'wp' || /waypoint/i.test(resolvedName)) { waypointCells.push({ x: object.x, y: object.y }) } list.push({ id: object.id, type: object.type, name: resolvedName, token: resolved.token, mode: resolved.mode === '' ? 'NU' : resolved.mode, // `objectsTxtId` is the Objects.txt row the table points at; -1 means the // engine picks the art straight from the table's token, with no row. objectsTxtId: resolved.entry?.objectsTxtId ?? -1, hp: row === null ? 0 : (objectById.get(row.id)?.hp ?? 0), member: pick === null ? null : pick.member, alternatives: pick === null ? 0 : pick.candidates, // Screen point of the object's sub-tile; the sprite anchor is applied // when a frame exists. x: Math.round(orthoX), y: Math.round(orthoY), depth: (object.x + object.y) / 5, frame: objectFrame, }) if (pick !== null) objectsWithArt += 1 else missingObjects.push(`${resolved.token === '' ? `id ${String(object.id)}` : resolved.token} (act ${String(entry.act)}) has no art members`) } const objectFiles = objectPages.encode(0) const shaObjects = createHash('sha256') for (const page of objectFiles) { const name = page.name.replace('tiles-', 'objects-') await writeFile(join(dir, name), page.png) shaObjects.update(page.png) totalPngBytes += page.png.byteLength } // Collision grid as runs, so the JSON stays small without a second format. const runs: number[][] = [] let current = scene.blocked[0] ?? 0 let count = 0 for (const value of scene.blocked) { if (value === current) { count += 1; continue } runs.push([current, count]) current = value count = 1 } runs.push([current, count]) // The level's population, planned here so the browser never needs // `Levels.txt` or `MonStats.txt` on the pack path. The same seed the scene // geometry used drives it, so a pack and a live mount of the same level agree // on what lives there. const population = planLevelMonsters( tables, entry.levelId, scene.cellsX * scene.cellsY, seed, PLAYER_WALK_SPEED_PX, 'normal', generatorLinks.landmarks ? { landmarks: generatorLinks.landmarks } : undefined, ) const sceneLinks = buildSceneLinks( entry.levelId, entry.kind, level, scene, generatorLinks.entrances, generatorLinks.mazeWarps, waypointCells, ) const sceneJson = { version: 1, fidelity: entry.kind === 'preset' ? 'exact' : 'approximation', act: entry.act, levelId: entry.levelId, levelName, ds1: ds1Name, cellsX: scene.cellsX, cellsY: scene.cellsY, originX: scene.originX, originY: scene.originY, widthPx: scene.widthPx, heightPx: scene.heightPx, pageSize: PAGE_SIZE, pages: pageFiles.map(page => ({ file: page.name, width: page.width, height: page.height })), objectPages: objectFiles.map(page => ({ file: page.name.replace('tiles-', 'objects-'), width: page.width, height: page.height })), frames: scene.frames.map(frame => [frame.width, frame.height]), frameHash: scene.frames.map(frame => frameHash(frame.indices)), framePlacement: scene.frames.map((_, at) => { const place = placementOf.get(at)! return [place.page, place.x, place.y, place.width, place.height] }), floors: scene.floors.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]), walls: scene.walls.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]), roofs: scene.roofs.map(draw => [draw.frameIndex, draw.x, draw.y, draw.cellX, draw.cellY]), objects, npcs, monsters: { types: population.types, budget: population.budget, packs: population.packs, }, collision: { width: scene.gridWidth, height: scene.gridHeight, runs }, spawn: spawn === null ? null : [Math.round(spawn.x), Math.round(spawn.y)], // Everything the runtime needs to leave this level, in sub-tiles. entrances: sceneLinks.entrances, warps: sceneLinks.warps, waypoints: sceneLinks.waypoints, stats: { floors: scene.floors.length, walls: scene.walls.length, roofs: scene.roofs.length, frames: scene.frames.length, missingTiles: scene.missingTiles, missingRefs: scene.missingRefs, walkable: 1 - [...scene.blocked].reduce((sum, value) => sum + value, 0) / scene.blocked.length, objects: objects.length, npcs: npcs.length, monsters: population.packs.reduce((sum, pack) => sum + pack.members.length, 0), monsterPacks: population.packs.length, objectsWithArt, objectsUnresolved: missingObjects, objectsArtPending: (objects as any[]).filter(o => o.member !== null && o.frame === null).length, dt1Libraries: dt1Names.length, // Edges the world graph has that this copy of the level has nowhere to // put. Reported rather than dropped: each one is a hole in the world. unplacedEdges: sceneLinks.unplacedEdges, }, } const sceneBytes = new TextEncoder().encode(JSON.stringify(sceneJson)) await writeFile(join(dir, 'scene.json'), sceneBytes) const manifest = { level: levelName, act: entry.act, levelId: entry.levelId, ds1: ds1Name, dt1: dt1Names, palette: paletteName, sceneBytes: sceneBytes.byteLength, sceneSha256: createHash('sha256').update(sceneBytes).digest('hex'), tilePagesSha256: sha.digest('hex'), objectPagesSha256: shaObjects.digest('hex'), pngBytes: pageFiles.reduce((sum, page) => sum + page.png.byteLength, 0) + objectFiles.reduce((sum, page) => sum + page.png.byteLength, 0), sourceArchive: MOUNTS, } await writeFile(join(dir, 'manifest.json'), JSON.stringify(manifest, null, 1)) ;(index.levels as unknown[]).push({ act: entry.act, levelId: entry.levelId, kind: entry.kind, slug: entry.slug, label, levelName, ds1: ds1Name, path: `act${String(entry.act)}/${label}`, cells: `${String(scene.cellsX)}x${String(scene.cellsY)}`, frames: scene.frames.length, objects: objects.length, npcs: npcs.length, pages: pageFiles.length, objectPages: objectFiles.length, missingTiles: scene.missingTiles, entrances: sceneLinks.entrances.length, warps: sceneLinks.warps.length, waypoints: sceneLinks.waypoints.length, unplacedEdges: sceneLinks.unplacedEdges.length, bytes: manifest.pngBytes + sceneBytes.byteLength, }) totalLevels += 1 const bakedFramesCount = (objects as any[]).filter(o => o.frame !== null).length const pendingCount = (objects as any[]).filter(o => o.member !== null && o.frame === null).length console.log( `[${String(totalLevels).padStart(3)}] act${String(entry.act)}/${label.padEnd(35)} ` + `${String(scene.cellsX)}x${String(scene.cellsY)} ` + `${String(scene.frames.length).padStart(3)} 图块 → ${String(pageFiles.length)} 页 PNG ` + `(${(manifest.pngBytes / 1024).toFixed(0)} KB) + scene.json ${(sceneBytes.byteLength / 1024).toFixed(0)} KB ` + `· 对象 ${String(objects.length)}/${String(level.objects.length)}(已接帧:${String(bakedFramesCount)},待解:${String(pendingCount)})` + ` · 缺失瓦片 ${String(scene.missingTiles)}`, ) } /** * Restrict the bake to a few levels, for spot checks. * * Comma-separated, e.g. `FILTER_LEVEL=75,82`. Note that a filtered run still * rewrites `index.json` with only the levels it baked, so the output directory * is not a usable pack afterwards — read the `scene.json` files directly. */ const filterLevel = process.env.FILTER_LEVEL const filterLevels = filterLevel !== undefined ? new Set(filterLevel.split(',').map(s => Number(s.trim()))) : null for (const entry of LEVELS) { if (filterLevels !== null && !filterLevels.has(entry.levelId)) continue const levelRow = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === entry.levelId)! const paletteIndex = Number(cell(tables.levels, levelRow, 'Pal')) const paletteName = `data\\global\\palette\\act${String(paletteIndex + 1)}\\pal.pl2` const palette = await getPalette(paletteName) if (entry.kind === 'preset') { const info: LevelInfo = resolveLevel(tables, entry.levelId, entry.act) const libraries: Dt1[] = [] for (const name of info.dt1Names) libraries.push(await libraryOf(name)) for (const ds1Name of info.ds1Names) { const level = await loadDs1(ds1Name) const label = `${entry.slug}-${slugOf(ds1Name)}` await bakeDs1Variant(entry, info.levelName, palette, info.paletteName, info.dt1Names, libraries, level, ds1Name, label, levelSeed(ds1Name)) } } else if (entry.kind === 'maze') { const type = levelType(levelRow) const row = mazeRow(entry.levelId, entry.name) if (row === undefined) { console.warn(`skipping maze ${entry.name}: no LvlMaze.txt row`) continue } const sectionX = Number(cell(lvlmaze, row, 'SizeX')) const sectionY = Number(cell(lvlmaze, row, 'SizeY')) const minRooms = Number(cell(lvlmaze, row, 'Rooms')) const merge = Number(cell(lvlmaze, row, 'Merge')) const pieces = await getMazePieces(type.name, type.id) if (pieces.length === 0) { console.warn(`skipping maze ${entry.name}: no maze pieces`) continue } const libInfo = resolveLevelLibraries(tables, entry.levelId) const libraries: Dt1[] = [] for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name)) for (let v = 1; v <= 3; v += 1) { const seed = 0x5eed_0000 + entry.levelId * 10 + v const label = `${entry.slug}-var${v}` try { const result = generateMaze({ levelId: entry.levelId, levelName: entry.name, levelTypeName: type.name, sectionSize: sectionX, sectionHeight: sectionY, minRooms, merge, seed, pieces, }) await bakeDs1Variant( entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries, result.level, `generated:${label}`, label, seed, { entrances: [], mazeWarps: (result.stats.warps ?? []) as MazeWarp[] }, ) } catch (err) { console.error(`failed to bake maze ${label}: ${(err as Error).message}`) } } } else if (entry.kind === 'wilderness') { const type = levelType(levelRow) const sizeX = Number(cell(tables.levels, levelRow, 'SizeX')) const sizeY = Number(cell(tables.levels, levelRow, 'SizeY')) const subType = Number(cell(tables.levels, levelRow, 'SubType')) const subShrine = Number(cell(tables.levels, levelRow, 'SubShrine')) const subTheme = Number(cell(tables.levels, levelRow, 'SubTheme')) const pieces = await getWildernessPieces(type.name) if (pieces.length === 0) { console.warn(`skipping wilderness ${entry.name}: no wilderness pieces`) continue } const rows = await getSubstitutions(subType) const shrineRows = await getSubstitutions(subShrine) const libInfo = resolveLevelLibraries(tables, entry.levelId) const libraries: Dt1[] = [] for (const name of libInfo.dt1Names) libraries.push(await libraryOf(name)) // Solved once for the whole world, so all three variants cut the same // seams on the same edges and any variant docks with any neighbour. const gates = gatesFor(entry.levelId) for (let v = 1; v <= 3; v += 1) { const seed = 0x5eed_1000 + entry.levelId * 10 + v const label = `${entry.slug}-var${v}` try { const result = generateWilderness({ levelId: entry.levelId, levelName: entry.name, levelTypeName: type.name, sizeX, sizeY, subType, subTheme: Math.max(0, subTheme), seed, pieces, substitutions: rows, shrineSubstitutions: shrineRows, gates, }) await bakeDs1Variant( entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries, result.level, `generated:${label}`, label, seed, { entrances: (result.stats.entrances ?? []) as WildernessEntrance[], mazeWarps: [], ...(Array.isArray(result.stats.landmarkEntities) ? { landmarks: result.stats.landmarkEntities as readonly { readonly id: string; readonly tileX: number; readonly tileY: number }[] } : {}), }, ) } catch (err) { console.error(`failed to bake wilderness ${label}: ${(err as Error).message}`) } } } } await mkdir(outDir, { recursive: true }) const indexPath = join(outDir, 'index.json') if (filterLevel !== undefined) { try { const { readFile } = await import('node:fs/promises') const existingRaw = await readFile(indexPath, 'utf8') const existing = JSON.parse(existingRaw) as { levels?: Array<{ label?: string }> } if (Array.isArray(existing.levels)) { const updatedByLabel = new Map() 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 } } await writeFile(indexPath, JSON.stringify(index, null, 1)) // The solved graph, written once for the whole pack. The runtime must not // re-solve it: `assignGateSides` is seeded, but re-running it against a // different `Levels.txt` would silently move every seam while the baked levels // kept their old openings. await writeFile(join(outDir, 'world-graph.json'), JSON.stringify({ version: 1, layoutSeed: ACT_LAYOUT_SEED, levels: [...worldGraph.levels.values()].map(level => ({ id: level.id, act: level.act + 1, name: level.name, drlgType: level.drlgType, waypoint: level.waypoint, })), edges: worldGraph.edges.map(edge => ({ from: edge.from, to: edge.to, kind: edge.kind, sideFrom: edge.sideFrom, sideTo: edge.sideTo, warps: edge.warps, warpSlots: edge.warpSlots, source: edge.source, })), waypoints: [...worldGraph.waypoints.entries()].map(([id, levelId]) => ({ id, levelId })), }, null, 1)) if (!process.env.SKIP_ENTITIES) { const { bakeEntities } = await import('./pack-entity-assets.ts') await bakeEntities(archiveDir, outDir) } console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`) console.log(`Skipped artless spawns: ${skippedArtlessSpawns}, Skipped no-art NPCs: ${skippedMissingArtSpawns}`) console.log(`世界图:${String(worldGraph.levels.size)} 个关卡,${String(worldGraph.edges.length)} 条边,${String(worldGraph.waypoints.size)} 个传送点`)