/** * MPQ-backed game data for the DRLG port (offline bake and tooling). * * The port reads its inputs synchronously, exactly like D2Common does from the mounted archives * (see src/game/drlg/drlg-source.ts). MPQ reads are asynchronous, so this module preloads every file * the port can open for one act and serves them from memory: * - the compiled tables `data\global\excel\.bin` ({@link DRLG_TABLE_NAMES}); * - every DS1 the tables reference (LvlPrest File1..6, LvlSub File), for all acts, because level * generation behind warps (sub_6FD77BB0 -> DRLG_InitLevel) opens presets of any level it reaches; * - the DT1 libraries of the act's level types (LvlTypes File1..32, loaded by * DRLGROOMTILE_LoadDT1FilesForRoom for activated rooms) plus the hardcoded libraries of * DRLGROOMTILE_LoadDT1FilesForRoom (DrlgRoomTile.cpp:1249-1256) and DRLG_AllocDrlg * (DrlgDrlg.cpp:47-76). * Anything else is not loaded and reading it throws (memoryDrlgSource), so a gap fails the bake * instead of silently changing the generated map. The file list is the same one * scripts/extract-d2moo-tables.ts extracts for the native oracle (the DT1 list restricted to the act), * and the reads use the game's archive priority, so the bake and the oracle consume identical bytes. */ import { decodeDt1, type Dt1 } from '../../src/formats/dt1.ts' import { cell, type D2Table } from '../../src/game/acts.ts' import type { DrlgLevelInput } from '../../src/game/drlg/drlg-map.ts' import { loadDrlgTables, type DrlgDataSource, type DrlgTableName, type DrlgTables } from '../../src/game/drlg/drlg-tables.ts' import { DRLG_TABLE_NAMES, drlgReferencedDs1Files, memoryDrlgSource, normalizeDrlgPath } from '../../src/game/drlg/drlg-source.ts' /** * DT1 libraries D2Common opens by hardcoded name (full archive paths, as the port requests them): * the three universal libraries of DRLGROOMTILE_LoadDT1FilesForRoom (Blank, InvisWal, Warp) and the * act libraries of DRLG_AllocDrlg. */ export const DRLG_HARDCODED_DT1_FILES: readonly string[] = [ 'DATA\\GLOBAL\\TILES\\Act1\\Outdoors\\Blank.dt1', 'DATA\\GLOBAL\\TILES\\Act1\\Barracks\\InvisWal.dt1', 'DATA\\GLOBAL\\TILES\\Act1\\Barracks\\Warp.dt1', 'DATA\\GLOBAL\\TILES\\Act1\\Town\\Floor.dt1', 'DATA\\GLOBAL\\TILES\\Act2\\Town\\Ground.dt1', 'DATA\\GLOBAL\\TILES\\ACT3\\Kurast\\sets.dt1', ] /** Reads one archive member (backslash path, case-insensitive), throwing when it does not exist. */ export type ArchiveReader = (mpqPath: string) => Promise export interface DrlgMpqData { /** Synchronous source over the preloaded bytes (throws for anything not preloaded). */ readonly source: DrlgDataSource /** The parsed tables (immutable; pass them to createDrlgEnv to avoid re-parsing). */ readonly tables: DrlgTables /** Archive paths of the preloaded DS1 and DT1 files. */ readonly ds1Files: readonly string[] readonly dt1Files: readonly string[] } /** * DT1 libraries of every level type of one act: the non-placeholder LvlTypes File columns of the rows * whose `Act` (D2LvlTypesTxt.nAct, 1-based as in LvlTypes.txt) is `act1Based`, plus * {@link DRLG_HARDCODED_DT1_FILES}. */ export function drlgActDt1Files(t: DrlgTables, act1Based: number): string[] { if (!Number.isInteger(act1Based) || act1Based < 1 || act1Based > 5) { throw new Error(`drlgActDt1Files: act ${act1Based} is not 1..5`) } const out = new Map() const add = (path: string): void => { const key = normalizeDrlgPath(path) if (!out.has(key)) out.set(key, path) } let rows = 0 for (const rec of t.lvlTypes) { if (rec.nAct !== act1Based) continue rows += 1 // DATATBLS_LoadLevelTypesTxt leaves names of one character or less ("0", "") unprefixed: they are // unused slots that DRLGROOMTILE_LoadDT1FilesForRoom never opens. for (const f of rec.szFile) if (f.length > 1) add(f) } if (rows === 0) throw new Error(`drlgActDt1Files: LvlTypes has no rows for act ${act1Based}`) for (const f of DRLG_HARDCODED_DT1_FILES) add(f) return [...out.values()].sort((a, b) => normalizeDrlgPath(a).localeCompare(normalizeDrlgPath(b))) } /** The compiled tables `data\global\excel\.bin` of {@link DRLG_TABLE_NAMES}. */ async function readDrlgTableBytes(read: ArchiveReader): Promise> { const tableBytes = new Map() for (const name of DRLG_TABLE_NAMES) { tableBytes.set(name, await read(`data\\global\\excel\\${name}.bin`)) } return tableBytes } /** * Load only the compiled tables: enough to map a generated level (src/game/drlg/drlg-map.ts), not to * generate one. * * @param read - archive reader with the game's priority (e.g. MountedArchives.read). */ export async function loadDrlgMpqTables(read: ArchiveReader): Promise { return loadDrlgTables(memoryDrlgSource(await readDrlgTableBytes(read), new Map())) } /** * Preload the DRLG inputs for one act from the archives. * * @param read - archive reader with the game's priority (e.g. MountedArchives.read). * @param act1Based - the act whose level types' DT1 libraries are loaded (1..5). */ export async function loadDrlgMpqData(read: ArchiveReader, act1Based: number): Promise { const tableBytes = await readDrlgTableBytes(read) const tables = loadDrlgTables(memoryDrlgSource(tableBytes, new Map())) const files = new Map() const ds1Files = drlgReferencedDs1Files(tables) const dt1Files = drlgActDt1Files(tables, act1Based) for (const path of [...ds1Files, ...dt1Files]) { const key = normalizeDrlgPath(path) if (!files.has(key)) files.set(key, await read(path)) } return { source: memoryDrlgSource(tableBytes, files), tables, ds1Files, dt1Files } } /** * SuperUniques.txt `Superunique` ids in record order: the landmark ids of SuperUnique preset units * (src/game/drlg/drlg-map.ts `DrlgMapContext.superUniqueIds`). * * The compiled table has no record for the text file's `Expansion` separator row, so the text rows are * filtered the same way; each remaining row's `hcIdx` must equal its record index and the row count * must equal the compiled table's. * * @param superuniques - SuperUniques.txt. * @param tables - the compiled tables the DRLG uses. * @returns the ids, by SuperUniques record. */ export function drlgSuperUniqueIds(superuniques: D2Table, tables: DrlgTables): string[] { const ids = superuniques.rows .filter(row => cell(superuniques, row, 'Superunique') !== 'Expansion') .map((row, at) => { const hcIdx = Number(cell(superuniques, row, 'hcIdx')) if (hcIdx !== at) throw new Error(`SuperUniques.txt record ${String(at)} has hcIdx ${String(hcIdx)}`) return cell(superuniques, row, 'Superunique').trim() }) if (ids.length !== tables.nSuperUniquesTxtRecordCount) { throw new Error(`SuperUniques.txt has ${String(ids.length)} records, superuniques.bin ${String(tables.nSuperUniquesTxtRecordCount)}`) } return ids } /** * The decoded DT1 libraries the tiles of one generated level come from, read through a synchronous * DRLG source (the same bytes the generator loaded). * * @param input - the level, from src/game/drlg/drlg-map.ts `drlgLevelInputFromDump`. * @param source - the DRLG data source the level was generated from. * @param cache - decoded libraries by normalized path, shared across levels. * @returns the libraries the level's tiles name, by normalized path. */ export function drlgLevelDt1s(input: DrlgLevelInput, source: DrlgDataSource, cache: Map): Map { const out = new Map() for (const t of input.tiles) { if (t.tile === null || out.has(t.tile.library)) continue let lib = cache.get(t.tile.library) if (lib === undefined) { lib = decodeDt1(source.readFile(t.tile.library)) cache.set(t.tile.library, lib) } out.set(t.tile.library, lib) } return out }