diablo2-web/scripts/lib/drlg-mpq-source.ts

166 lines
7.8 KiB
TypeScript

/**
* 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\<name>.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<Uint8Array>
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<string, string>()
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\<name>.bin` of {@link DRLG_TABLE_NAMES}. */
async function readDrlgTableBytes(read: ArchiveReader): Promise<Map<DrlgTableName, Uint8Array>> {
const tableBytes = new Map<DrlgTableName, Uint8Array>()
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<DrlgTables> {
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<DrlgMpqData> {
const tableBytes = await readDrlgTableBytes(read)
const tables = loadDrlgTables(memoryDrlgSource(tableBytes, new Map()))
const files = new Map<string, Uint8Array>()
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<string, Dt1>): Map<string, Dt1> {
const out = new Map<string, Dt1>()
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
}