/** * 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 type { IsoMapScene } from '../src/game/d2map.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 { WildernessPiece, WildernessSubstitution } from '../src/game/wilderness.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\\' /** 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, } 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 - Corral Fill', 'Act 1 - Bivouac', 'Act 1 - Pond', 'Act 1 - Swamp Fill', 'Act 1 - Stone Fill', ], 'Act 2 - Desert': ['Act 2 - Desert'], 'Act 3 - Jungle': ['Act 3 - Jungle'], '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 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 if (levelTypeName === 'Act 5 - Barricade' && name.includes('Snow')) continue const levels = await rowDs1s(tables.lvlprest, row) if (levels.length === 0) continue pieces.push({ name, levels, border: /border|cliff/i.test(name) }) } 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, ): 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[] = [] 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 list.push({ id: object.id, type: object.type, name: resolved.name ?? row?.name ?? resolved.token, 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]) 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, collision: { width: scene.gridWidth, height: scene.gridHeight, runs }, spawn: spawn === null ? null : [Math.round(spawn.x), Math.round(spawn.y)], 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, objectsWithArt, objectsUnresolved: missingObjects, objectsArtPending: (objects as any[]).filter(o => o.member !== null && o.frame === null).length, dt1Libraries: dt1Names.length, }, } 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, 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)}`, ) } const filterLevel = process.env.FILTER_LEVEL for (const entry of LEVELS) { if (filterLevel !== undefined && String(entry.levelId) !== filterLevel) 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) } 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)) 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, }) await bakeDs1Variant(entry, entry.name, palette, paletteName, libInfo.dt1Names, libraries, result.level, `generated:${label}`, label, seed) } catch (err) { console.error(`failed to bake wilderness ${label}: ${(err as Error).message}`) } } } } await mkdir(outDir, { recursive: true }) await writeFile(join(outDir, 'index.json'), JSON.stringify(index, null, 1)) console.log(`\n打包完成:${String(totalLevels)} 张地图,PNG 合计 ${(totalPngBytes / 1048576).toFixed(1)} MB,输出 ${outDir}`) console.log(`Skipped artless spawns: ${skippedArtlessSpawns}, Skipped no-art NPCs: ${skippedMissingArtSpawns}`)