428 lines
16 KiB
TypeScript
428 lines
16 KiB
TypeScript
/**
|
|
* Resolving one act's town from Diablo II's own tables.
|
|
*
|
|
* The chain is three tables deep and every link is data, so nothing here is
|
|
* hard-coded per act — which is the point, because five towns with different
|
|
* sizes, different tile sets and different palettes cannot be a lookup table
|
|
* anyone maintains by hand:
|
|
*
|
|
* `Levels.txt` `Act N - Town` → level id, act, palette index, level type
|
|
* `LvlTypes.txt` level type id → `File 1..File 32` (the DT1 libraries)
|
|
* `LvlPrest.txt` level id (`LevelId`) → `File1..File6` (the DS1 variants / versions), Dt1Mask
|
|
* `data\\global\\palette\\actN\\pal.pl2` → the act's palette
|
|
*
|
|
* Two details cost real time to find and are encoded here:
|
|
*
|
|
* 1. **`LvlPrest` joins on `LevelId`, not on `Def`.** `Def` is the preset's own
|
|
* id (Act 2's town has `Def = 301`, `LevelId = 40`), so joining on the wrong
|
|
* column silently returns *other levels'* maps for four of the five acts.
|
|
* 2. **`Dt1Mask` filters the file list.** Its bits correspond to `File 1..N`;
|
|
* for all five towns the mask happens to be saturated, but a level whose mask
|
|
* is not would otherwise load libraries the map never references and index
|
|
* the rest incorrectly.
|
|
*/
|
|
import { decodeDs1 } from '../formats/ds1.ts'
|
|
import type { Ds1 } from '../formats/ds1.ts'
|
|
import { decodeDt1 } from '../formats/dt1.ts'
|
|
import type { Dt1 } from '../formats/dt1.ts'
|
|
import { decodePl2 } from '../formats/pl2.ts'
|
|
import type { Pl2 } from '../formats/pl2.ts'
|
|
import type { MountedArchives } from '../mpq/mount.ts'
|
|
|
|
/** Where the data tables live inside an archive. */
|
|
const EXCEL = 'data\\global\\excel\\'
|
|
/** Tile libraries and levels are addressed relative to this prefix. */
|
|
const TILES = 'data\\global\\tiles\\'
|
|
|
|
/** A tab-separated table with its header row. */
|
|
export interface D2Table {
|
|
/** Column names, in file order. */
|
|
readonly header: readonly string[]
|
|
/** Data rows. */
|
|
readonly rows: readonly (readonly string[])[]
|
|
}
|
|
|
|
/**
|
|
* Decode bytes as Latin-1 without touching Node APIs.
|
|
*
|
|
* The tables are ASCII, but this module is imported by the browser bundle, so
|
|
* no `Buffer` and no Node-only globals: chunks keep `String.fromCharCode` from
|
|
* blowing the argument limit on a 130 KB table.
|
|
*
|
|
* @param bytes - the bytes.
|
|
* @returns the text.
|
|
*/
|
|
function latin1(bytes: Uint8Array): string {
|
|
const chunk = 8192
|
|
let out = ''
|
|
for (let at = 0; at < bytes.length; at += chunk) {
|
|
out += String.fromCharCode(...bytes.subarray(at, Math.min(at + chunk, bytes.length)))
|
|
}
|
|
return out
|
|
}
|
|
|
|
/**
|
|
* Parse one of Diablo II's tab-separated tables.
|
|
*
|
|
* Rows end with CRLF and the last line may be empty; both are tolerated because
|
|
* `Patch_D2.mpq` and `d2data.mpq` disagree about the trailing newline.
|
|
*
|
|
* @param bytes - the decoded member.
|
|
* @returns the table.
|
|
*/
|
|
export function parseTable(bytes: Uint8Array): D2Table {
|
|
const text = latin1(bytes)
|
|
const lines = text.split(/\r?\n/).filter(line => line.length > 0)
|
|
const header = (lines.shift() ?? '').split('\t')
|
|
return { header, rows: lines.map(line => line.split('\t')) }
|
|
}
|
|
|
|
/**
|
|
* Read a column out of a row by name.
|
|
*
|
|
* @param table - the table.
|
|
* @param row - the row.
|
|
* @param column - column name.
|
|
* @returns the cell, or an empty string when the column is absent.
|
|
*/
|
|
export function cell(table: D2Table, row: readonly string[], column: string): string {
|
|
const index = table.header.indexOf(column)
|
|
return index === -1 ? '' : (row[index] ?? '')
|
|
}
|
|
|
|
/**
|
|
* The tables a level needs to be resolved and populated.
|
|
*
|
|
* The first five place and draw the level; the rest describe what lives in it.
|
|
* They are loaded together because every caller that wants one wants the level
|
|
* it belongs to as well, and because mounting the archives is the expensive
|
|
* part — a table read is a few hundred kilobytes off an already-open handle.
|
|
*/
|
|
export interface ActTables {
|
|
readonly levels: D2Table
|
|
readonly lvltypes: D2Table
|
|
readonly lvlprest: D2Table
|
|
readonly monstats: D2Table
|
|
readonly monpreset: D2Table
|
|
/** `MonStats2.txt` — body size, melee range, and which COF layers exist. */
|
|
readonly monstats2: D2Table
|
|
/** `MonLvl.txt` — the per-level scaling curve, read but not yet applied (M9). */
|
|
readonly monlvl: D2Table
|
|
/** `MonType.txt` — family equivalences, e.g. `lowundead` → `undead`. */
|
|
readonly montype: D2Table
|
|
/** `MonUMod.txt` — the elite modifiers and their shared constants. */
|
|
readonly monumod: D2Table
|
|
/** `SuperUniques.txt` — the named bosses, e.g. Bishibosh, Rakanishu. */
|
|
readonly superuniques: D2Table
|
|
/**
|
|
* `LvlWarp.txt`: the geometry of every stair, cave mouth and door.
|
|
*
|
|
* Referenced by `Levels.txt` `Warp0..7` — and only by those columns.
|
|
* `LevelWarp` is *not* an index into this table; it holds a string-table key
|
|
* for the hover text (`"To The Blood Moor"`).
|
|
*
|
|
* Rows must be keyed on `(Id, Direction)`, not on `Id` alone: ids 71, 73, 74,
|
|
* 81 and 82 each appear twice, once with `Direction = l` and once with `r`,
|
|
* for the Act 5 barricades. There is also one `Expansion` separator row with
|
|
* no usable data.
|
|
*/
|
|
readonly lvlwarp: D2Table
|
|
}
|
|
|
|
/** Everything needed to place and render one level. */
|
|
export interface LevelInfo {
|
|
/** Act number, 1..5. */
|
|
readonly act: number
|
|
/** `Levels.txt` name, e.g. `Act 1 - Town`. */
|
|
readonly levelName: string
|
|
/** `Levels.txt` id. */
|
|
readonly levelId: number
|
|
/** Palette slot from `Levels.txt` (`Pal`), 0-based. */
|
|
readonly paletteIndex: number
|
|
/** `LvlTypes.txt` name. */
|
|
readonly levelTypeName: string
|
|
/** DS1 members, full archive paths. */
|
|
readonly ds1Names: readonly string[]
|
|
/** DT1 members, full archive paths, in `File 1..N` order after the mask. */
|
|
readonly dt1Names: readonly string[]
|
|
/** `LvlPrest.Dt1Mask`, kept for reporting. */
|
|
readonly dt1Mask: number
|
|
/** Level extent in cells, from `Levels.txt`. */
|
|
readonly sizeX: number
|
|
readonly sizeY: number
|
|
/** Palette member path. */
|
|
readonly paletteName: string
|
|
/**
|
|
* `Vis0..7`: the level reachable through each warp slot, 0 when unused.
|
|
*
|
|
* Only half the world's connectivity. Outdoor levels that simply abut each
|
|
* other — town to Blood Moor, Blood Moor to Cold Plains — are absent from
|
|
* these columns entirely; see `world-graph.ts`.
|
|
*/
|
|
readonly vis: readonly number[]
|
|
/**
|
|
* `Warp0..7`: the `LvlWarp.txt` row for each slot, -1 when the slot has no
|
|
* warp tile.
|
|
*
|
|
* A slot with a `Vis` but no `Warp` is an opening the player walks through
|
|
* rather than clicks.
|
|
*/
|
|
readonly warp: readonly number[]
|
|
/** `Waypoint`, 0..38, or 255 when the level has none. */
|
|
readonly waypoint: number
|
|
/** `Position`; 1 marks a level that something teleports into. */
|
|
readonly position: number
|
|
/** `Portal`. */
|
|
readonly portal: number
|
|
}
|
|
|
|
/**
|
|
* The same shape under the name the act pages used before levels were generic.
|
|
*
|
|
* @deprecated use {@link LevelInfo}; kept so existing call sites keep reading.
|
|
*/
|
|
export type ActTown = LevelInfo
|
|
|
|
/**
|
|
* Town level IDs for Acts 1..5 in order:
|
|
* Act 1: Rogue Encampment (1)
|
|
* Act 2: Lut Gholein (40)
|
|
* Act 3: Kurast Docks (75)
|
|
* Act 4: The Pandemonium Fortress (103)
|
|
* Act 5: Harrogath (109)
|
|
*/
|
|
export const ACT_TOWNS: readonly number[] = [1, 40, 75, 103, 109]
|
|
|
|
/**
|
|
* Returns the town level ID for an act (1..5).
|
|
* Defaults to Act 5 Harrogath (109) for acts >= 5, or Act 1 Rogue Encampment (1) for acts <= 1.
|
|
*/
|
|
export function townLevelForAct(act: number): number {
|
|
if (act >= 1 && act <= 5) {
|
|
return ACT_TOWNS[act - 1]!
|
|
}
|
|
if (act <= 1) return ACT_TOWNS[0]!
|
|
return ACT_TOWNS[4]!
|
|
}
|
|
|
|
/** Turn a table-relative tile path into a member name. */
|
|
export function tileMemberPath(relative: string): string {
|
|
return `${TILES}${relative.replaceAll('/', '\\')}`
|
|
}
|
|
|
|
/**
|
|
* Load the tables from a mounted stack.
|
|
*
|
|
* @param archives - the mounted archives.
|
|
* @returns the parsed tables.
|
|
*/
|
|
export async function loadActTables(archives: MountedArchives): Promise<ActTables> {
|
|
const read = async (name: string): Promise<D2Table> => parseTable(await archives.read(`${EXCEL}${name}`))
|
|
return {
|
|
levels: await read('levels.txt'),
|
|
lvltypes: await read('lvltypes.txt'),
|
|
lvlprest: await read('lvlprest.txt'),
|
|
monstats: await read('monstats.txt'),
|
|
monpreset: await read('MonPreset.txt'),
|
|
monstats2: await read('MonStats2.txt'),
|
|
monlvl: await read('MonLvl.txt'),
|
|
montype: await read('MonType.txt'),
|
|
monumod: await read('MonUMod.txt'),
|
|
superuniques: await read('SuperUniques.txt'),
|
|
lvlwarp: await read('LvlWarp.txt'),
|
|
}
|
|
}
|
|
|
|
/** A level type's tile libraries, with the mask when the level declares one. */
|
|
export interface LevelLibraries {
|
|
/** `LvlTypes.txt` name. */
|
|
readonly levelTypeName: string
|
|
/** DT1 member names, in `File 1..N` order. */
|
|
readonly dt1Names: readonly string[]
|
|
/**
|
|
* `LvlPrest.Dt1Mask` when the level has preset rows, else `null`.
|
|
*
|
|
* Maze and wilderness levels have **no** `LvlPrest` rows at all — their fixed
|
|
* pieces are picked by the generators — so there is no mask to apply and the
|
|
* whole file list is in play.
|
|
*/
|
|
readonly dt1Mask: number | null
|
|
}
|
|
|
|
/**
|
|
* Resolve one level by its `Levels.txt` id.
|
|
*
|
|
* Everything after the level row is data: its level type names the DT1
|
|
* libraries, its `LvlPrest` rows name the DS1 files, and its `Pal` slot selects
|
|
* the act palette. Towns and dungeons differ only in which rows they hit, so one
|
|
* function covers both — the earlier town-only version could not reach
|
|
* Tristram, the Cathedral or the Throne Room at all.
|
|
*
|
|
* @param tables - the loaded tables.
|
|
* @param levelId - `Levels.txt` `Id`.
|
|
* @param act - act number to report (`Levels.txt` stores it 0-based and often
|
|
* as 0 for expansion rows, so the caller states it when it knows).
|
|
* @returns the level description.
|
|
*/
|
|
export function resolveLevel(tables: ActTables, levelId: number, act?: number): LevelInfo {
|
|
const row = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === levelId)
|
|
if (row === undefined) throw new Error(`Levels.txt has no row with Id ${String(levelId)}`)
|
|
const paletteIndex = Number(cell(tables.levels, row, 'Pal'))
|
|
const levelTypeId = cell(tables.levels, row, 'LevelType')
|
|
|
|
const typeRow = tables.lvltypes.rows.find(candidate => cell(tables.lvltypes, candidate, 'Id') === levelTypeId)
|
|
if (typeRow === undefined) throw new Error(`LvlTypes.txt has no row with Id ${levelTypeId} (level ${String(levelId)})`)
|
|
|
|
const preset = tables.lvlprest.rows.filter(candidate => Number(cell(tables.lvlprest, candidate, 'LevelId')) === levelId)
|
|
if (preset.length === 0) throw new Error(`LvlPrest.txt has no row with LevelId ${String(levelId)}`)
|
|
|
|
const dt1Mask = Number(cell(tables.lvlprest, preset[0]!, 'Dt1Mask'))
|
|
const dt1Names: string[] = []
|
|
for (let slot = 1; slot <= 32; slot += 1) {
|
|
const value = cell(tables.lvltypes, typeRow, `File ${String(slot)}`)
|
|
if (value === '' || value === '0') continue
|
|
// The mask is indexed by the slot's position in the file list, not by the
|
|
// position of the surviving entries.
|
|
if ((dt1Mask & (1 << (slot - 1))) === 0) continue
|
|
dt1Names.push(tileMemberPath(value))
|
|
}
|
|
|
|
const ds1Names: string[] = []
|
|
for (const presetRow of preset) {
|
|
for (let file = 1; file <= 6; file += 1) {
|
|
const value = cell(tables.lvlprest, presetRow, `File${String(file)}`)
|
|
if (value === '' || value === '0') continue
|
|
ds1Names.push(tileMemberPath(value))
|
|
}
|
|
}
|
|
|
|
const vis: number[] = []
|
|
const warp: number[] = []
|
|
for (let slot = 0; slot < 8; slot += 1) {
|
|
vis.push(Number(cell(tables.levels, row, `Vis${String(slot)}`) || '0'))
|
|
warp.push(Number(cell(tables.levels, row, `Warp${String(slot)}`) || '-1'))
|
|
}
|
|
|
|
return {
|
|
act: act ?? Number(cell(tables.levels, row, 'Act')) + 1,
|
|
levelName: cell(tables.levels, row, 'Name'),
|
|
levelId,
|
|
paletteIndex,
|
|
levelTypeName: cell(tables.lvltypes, typeRow, 'Name'),
|
|
ds1Names,
|
|
dt1Names,
|
|
dt1Mask,
|
|
sizeX: Number(cell(tables.levels, row, 'SizeX')),
|
|
sizeY: Number(cell(tables.levels, row, 'SizeY')),
|
|
paletteName: `data\\global\\palette\\act${String(paletteIndex + 1)}\\pal.pl2`,
|
|
vis,
|
|
warp,
|
|
waypoint: Number(cell(tables.levels, row, 'Waypoint') || '255'),
|
|
position: Number(cell(tables.levels, row, 'Position') || '0'),
|
|
portal: Number(cell(tables.levels, row, 'Portal') || '0'),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve one act's town.
|
|
*
|
|
* @param tables - the loaded tables.
|
|
* @param act - act number, 1..5.
|
|
* @returns the town description.
|
|
*/
|
|
export function resolveActTown(tables: ActTables, act: number): LevelInfo {
|
|
const row = tables.levels.rows.find(candidate => cell(tables.levels, candidate, 'Name') === `Act ${String(act)} - Town`)
|
|
if (row === undefined) throw new Error(`Levels.txt has no "Act ${String(act)} - Town" row`)
|
|
return resolveLevel(tables, Number(cell(tables.levels, row, 'Id')), act)
|
|
}
|
|
|
|
/** A resolved level with its decoded assets. */
|
|
export interface LoadedActTown {
|
|
/** The resolution result. */
|
|
readonly town: LevelInfo
|
|
/** The decoded DS1 variants / versions. */
|
|
readonly levels: readonly Ds1[]
|
|
/** The decoded DT1 libraries, in file-list order. */
|
|
readonly libraries: readonly Dt1[]
|
|
/** The act palette. */
|
|
readonly palette: Pl2
|
|
/** Total cells across the variants. */
|
|
readonly cells: number
|
|
}
|
|
|
|
/**
|
|
* Resolve and decode one act's town.
|
|
*
|
|
* @param archives - the mounted archives.
|
|
* @param tables - the loaded tables.
|
|
* @param act - act number, 1..5.
|
|
* @returns the town with assets.
|
|
*/
|
|
export async function loadActTown(
|
|
archives: MountedArchives,
|
|
tables: ActTables,
|
|
act: number,
|
|
): Promise<LoadedActTown> {
|
|
return loadLevel(archives, tables, resolveActTown(tables, act))
|
|
}
|
|
|
|
/**
|
|
* Resolve a level type's DT1 libraries without requiring preset rows.
|
|
*
|
|
* `resolveLevel` needs `LvlPrest` rows because it also wants DS1 files; the
|
|
* generators need only the tile libraries, and 101 of the game's 136 levels have
|
|
* no preset rows at all.
|
|
*
|
|
* @param tables - the loaded tables.
|
|
* @param levelId - `Levels.txt` `Id`.
|
|
* @returns the library list.
|
|
*/
|
|
export function resolveLevelLibraries(tables: ActTables, levelId: number): LevelLibraries {
|
|
const row = tables.levels.rows.find(candidate => Number(cell(tables.levels, candidate, 'Id')) === levelId)
|
|
if (row === undefined) throw new Error(`Levels.txt has no row with Id ${String(levelId)}`)
|
|
const typeRow = tables.lvltypes.rows.find(candidate => cell(tables.lvltypes, candidate, 'Id') === cell(tables.levels, row, 'LevelType'))
|
|
if (typeRow === undefined) throw new Error(`LvlTypes.txt has no row for level ${String(levelId)}`)
|
|
const preset = tables.lvlprest.rows.filter(candidate => Number(cell(tables.lvlprest, candidate, 'LevelId')) === levelId)
|
|
const dt1Mask = preset.length === 0 ? null : Number(cell(tables.lvlprest, preset[0]!, 'Dt1Mask'))
|
|
const dt1Names: string[] = []
|
|
for (let slot = 1; slot <= 32; slot += 1) {
|
|
const value = cell(tables.lvltypes, typeRow, `File ${String(slot)}`)
|
|
if (value === '' || value === '0') continue
|
|
if (dt1Mask !== null && (dt1Mask & (1 << (slot - 1))) === 0) continue
|
|
dt1Names.push(tileMemberPath(value))
|
|
}
|
|
if (cell(tables.lvltypes, typeRow, 'Name') === 'Act 5 - Barricade') {
|
|
dt1Names.push(tileMemberPath('Expansion/Siege/temptile.dt1'))
|
|
}
|
|
return { levelTypeName: cell(tables.lvltypes, typeRow, 'Name'), dt1Names, dt1Mask }
|
|
}
|
|
|
|
/**
|
|
* Decode a resolved level's assets.
|
|
*
|
|
* @param archives - the mounted archives.
|
|
* @param tables - the loaded tables (unused today; kept for symmetry with the
|
|
* resolver so callers can pass both without re-deriving).
|
|
* @param town - the resolution result.
|
|
* @returns the level with assets.
|
|
*/
|
|
export async function loadLevel(
|
|
archives: MountedArchives,
|
|
tables: ActTables,
|
|
town: LevelInfo,
|
|
): Promise<LoadedActTown> {
|
|
void tables
|
|
const levels: Ds1[] = []
|
|
for (const name of town.ds1Names) levels.push(decodeDs1(await archives.read(name)))
|
|
const libraries: Dt1[] = []
|
|
for (const name of town.dt1Names) libraries.push(decodeDt1(await archives.read(name)))
|
|
const palette = decodePl2(await archives.read(town.paletteName))
|
|
return {
|
|
town,
|
|
levels,
|
|
libraries,
|
|
palette,
|
|
cells: levels.reduce((sum, level) => sum + level.width * level.height, 0),
|
|
}
|
|
}
|