diablo2-web/src/game/drlg/drlg-dump.ts

864 lines
33 KiB
TypeScript

/**
* JSON dumps of the DRLG port in the exact schema the native D2MOO oracle writes
* (tools/d2moo-oracle/src/main.cpp, schema 3; documented in tools/d2moo-oracle/README.md), plus the
* TypeScript twin of the oracle driver (`runAct1`, the single generation code path; `dumpAct1` builds the
* dump from its hooks). Diffing the two dumps stage by stage is the acceptance gate of the port
* (scripts/drlg-diff.ts, tests/drlg-act1-oracle.test.ts).
*
* Integer signedness follows the C field types, independent of how the port stores the value:
* uint32 fields are printed with `>>> 0`, int32 fields with `| 0`.
*/
import { setD2SeedTraceSink, type D2SeedStrc, type D2SeedTraceSink } from '../d2-rng.ts'
import type { D2TileLibraryEntryStrc } from './drlg-d2cmp.ts'
import {
ACT_I,
DRLGROOMFLAG_HAS_ROOM,
DRLGROOMFLAG_PRESET_UNITS_ADDED,
DRLGROOMFLAG_TILELIB_LOADED,
DRLGTYPE_MAZE,
DRLGTYPE_OUTDOOR,
DRLGTYPE_PRESET,
LEVEL_ROGUEENCAMPMENT,
outdoorCoord,
type D2DrlgCoordStrc,
type D2DrlgGridStrc,
type D2DrlgLevelStrc,
type D2DrlgLogicalRoomInfoStrc,
type D2DrlgMapStrc,
type D2DrlgOrthStrc,
type D2DrlgRoomStrc,
type D2DrlgStrc,
type D2DrlgTileDataStrc,
type D2DrlgVertexStrc,
type D2PresetUnitStrc,
type D2RoomCoordListStrc,
type DrlgEnv,
} from './drlg-types.ts'
import { DRLG_AllocDrlg, DRLG_GetActNoFromLevelId, DRLG_GetLevel, DRLG_InitLevel } from './drlg-drlg.ts'
import { setLevelGridHook } from './drlg-hooks.ts'
import { DRLGPRESET_SpawnHardcodedPresetUnits } from './drlg-preset.ts'
import { sub_6FD77BB0 } from './drlg-room.ts'
import { DRLGROOMTILE_AddRoomMapTiles, DRLGROOMTILE_InitRoomGrids, DRLGROOMTILE_LoadDT1FilesForRoom } from './drlg-roomtile.ts'
import { DATATBLS_GetLvlPrestTxtRecord } from './drlg-tables.ts'
const u32 = (v: number): number => v >>> 0
const s32 = (v: number): number => v | 0
const u8 = (v: number): number => v & 0xff
export type DumpSeed = [number, number]
export type DumpCoord = [number, number, number, number]
export interface DumpGrid { w: number; h: number; cells: number[] }
export type DumpUnit = [number, number, number, number, number, number]
export interface DumpOrth { level: number; dir: number; preset: number; init: number; box: DumpCoord | null }
export interface DumpMap { prest: number; picked: number; coord: DumpCoord; file: string; hasInfo: number; units: DumpUnit[] }
export interface DumpActLevel {
id: number
drlgType: number
levelType: number
coord: DumpCoord
flags: number
seed: DumpSeed
outdoor?: { flags: number; roomData: DumpOrth[] }
preset?: { direction: number; map: DumpMap | null }
}
/** [x, y, dir, flags, next]; next: -1 null, 0..23 pVertices[i], 100 + k the k-th ring vertex. */
export type DumpVertex = [number, number, number, number, number]
export type DumpPathVertex = [number, number, number, number]
export interface DumpLevelGrid {
flags: number
coord: DumpCoord
grids: (DumpGrid | null)[]
ring: DumpVertex[]
nVertices: number
vertices: DumpVertex[]
pathStarts: (DumpPathVertex[] | null)[]
roomData: DumpOrth[]
seed: DumpSeed
}
export interface DumpRoom {
type: number
coord: DumpCoord
flags: number
otherFlags: number
dt1Mask: number
initSeed: number
seed: DumpSeed
outdoor?: { flags: number; flagsEx: number; subType: number; subTheme: number; subThemePicked: number }
preset?: { prest: number; picked: number; flags: number; map: number }
}
// ---- activation stage (schema 3: + seedAfterTiles, mapTiles) ----
/**
* `D2DrlgTileDataStrc*`: [levelId, room index in pFirstRoomEx order, array, element index] into that room's
* `pTileGrid.pTiles` array: 'w' pWallTiles, 'f' pFloorTiles, 'r' pRoofTiles. The index may exceed the
* dumped element count (it addresses the allocation). Rooms of any level of the act can be referenced.
*/
export type DumpTileRef = [number, number, 'w' | 'f' | 'r', number]
/** `D2RoomCoordListStrc*`: [levelId, room, 'c', index in that room's `pLogicalRoomInfo.pCoordList` chain]. */
export type DumpCoordListRef = [number, number, 'c', number]
/** `D2TileLibraryEntryStrc*`: [DT1 path (lower case, '/'-separated, e.g. "data/global/tiles/act1/outdoors/blank.dt1"), tile index in that DT1]. */
export type DumpTileEntry = [string, number]
/** Every D2DrlgTileDataStrc field in struct order (D2DrlgDrlg.h:333); pointers as references. */
export type DumpTile = [
nWidth: number,
nHeight: number,
nPosX: number,
nPosY: number,
unk0x10: number | DumpCoordListRef,
dwFlags: number,
pTile: DumpTileEntry | null,
nTileType: number,
unk0x20: DumpTileRef | null,
unk0x24: number,
nRed: number,
nGreen: number,
nBlue: number,
nIntensity: number,
unk0x2C: number,
]
/** D2DrlgTileLinkStrc (pTileGrid.pMapLinks chain order). */
export type DumpTileLink = [bFloor: number, pMapTile: DumpTileRef | null]
/** D2RoomTileStrc (pRoomTiles chain order); the LvlWarp.txt record as its row index, -1 for NULL. */
export type DumpRoomTile = [
pDrlgRoom: [levelId: number, room: number] | null,
nLvlWarpTxtRecord: number,
bEnabled: number,
unk0x0C: DumpTileRef | null,
unk0x10: DumpTileRef | null,
]
/** D2RoomCoordListStrc. */
export type DumpCoordList = [nIndex: number, bNode: number, bRoomActive: number, box0: DumpCoord, box1: DumpCoord]
/** D2DrlgLogicalRoomInfoStrc. */
export interface DumpLogicalRoomInfo {
flags: number
nLists: number
indexX: DumpGrid | null
/** pIndexY cells (`(int)D2RoomCoordListStrc*`) as 1 + the index of the list in `lists`, 0 for NULL. */
indexY: DumpGrid | null
/** The pCoordList chain in order. */
lists: DumpCoordList[]
}
/**
* The room's D2DrlgTileGridStrc after every requested room has been activated (final state: a room
* activated later rewrites tiles of the near rooms activated before it, across level seams too).
*/
export interface DumpMapTiles {
/** pTileGrid counters. */
nWalls: number
nFloors: number
nShadows: number
/** pTileGrid.pTiles counters (what D2Common hands on with the room); walls/floors/roofs hold exactly these many entries. */
pTiles: { nWalls: number; nFloors: number; nRoofs: number }
walls: DumpTile[]
floors: DumpTile[]
/** Shadow tiles (DRLGROOMTILE_InitShadowTileData stores them in pRoofTiles). */
roofs: DumpTile[]
links: DumpTileLink[]
warps: DumpRoomTile[]
/** pPresetUnits in the final state (incl. the UNIT_TILE units of DRLGROOMTILE_AddWarp and door units). */
units: DumpUnit[]
logical: DumpLogicalRoomInfo | null
}
interface DumpActivationTail {
/** Room seed right after DRLGROOMTILE_InitRoomGrids. */
seed: DumpSeed
/** pPresetUnits right after DRLGROOMTILE_InitRoomGrids. */
units: DumpUnit[]
/** Room seed right after the room's own DRLGROOMTILE_AddRoomMapTiles. */
seedAfterTiles: DumpSeed
mapTiles: DumpMapTiles
}
export interface DumpOutdoorActivation extends DumpActivationTail {
tile: DumpGrid | null
wall: DumpGrid | null
floor: DumpGrid | null
dirt: DumpGrid | null
}
export interface DumpPresetActivation extends DumpActivationTail {
walls: (DumpGrid | null)[]
tiles: (DumpGrid | null)[]
floors: (DumpGrid | null)[]
shadow: DumpGrid | null
}
export type DumpActivation = DumpOutdoorActivation | DumpPresetActivation
export interface DumpLevel {
id: number
levelGrid: DumpLevelGrid
nRooms: number
rooms: DumpRoom[]
maps: DumpMap[]
levelSeedAfterRooms: DumpSeed
warp: { n: number; xy: [number, number][] }
/** One entry per room in pFirstRoomEx order (empty when activation is skipped). */
activation: DumpActivation[]
}
export interface DrlgDump {
schema: 3
oracle?: { d2moo: string; arch: string }
gameSeed: number
difficulty: number
isolated: boolean
drlg: { startSeed: number; seed: DumpSeed }
act: DumpActLevel[]
levels: DumpLevel[]
}
export function dumpSeed(s: D2SeedStrc): DumpSeed {
return [u32(s.lo), u32(s.hi)]
}
export function dumpCoord(c: D2DrlgCoordStrc): DumpCoord {
return [s32(c.nPosX), s32(c.nPosY), s32(c.nWidth), s32(c.nHeight)]
}
/** `{w, h, cells}` (row-major int32) or null when the grid was never allocated. */
export function dumpGrid(g: D2DrlgGridStrc): DumpGrid | null {
if (!g.pCellsFlags || !g.pCellsRowOffsets) return null
const cells: number[] = []
for (let y = 0; y < g.nHeight; y += 1) {
for (let x = 0; x < g.nWidth; x += 1) {
const idx = g.nCellsBase + x + g.pCellsRowOffsets[y]!
if (idx < 0 || idx >= g.pCellsFlags.length) throw new RangeError(`dumpGrid: cell (${x}, ${y}) outside the backing array`)
cells.push(s32(g.pCellsFlags[idx]!))
}
}
return { w: s32(g.nWidth), h: s32(g.nHeight), cells }
}
export function dumpPresetUnits(pUnit: D2PresetUnitStrc | null): DumpUnit[] {
const out: DumpUnit[] = []
for (let p = pUnit; p; p = p.pNext) {
out.push([s32(p.nUnitType), s32(p.nIndex), s32(p.nMode), s32(p.nXpos), s32(p.nYpos), s32(p.bSpawned)])
}
return out
}
export function dumpOrths(pOrth: D2DrlgOrthStrc | null): DumpOrth[] {
const out: DumpOrth[] = []
for (let p = pOrth; p; p = p.pNext) {
out.push({
// Level-to-level orths store the neighbour level in the union.
level: p.pLevel ? p.pLevel.nLevelId : -1,
dir: u8(p.nDirection),
preset: s32(p.bPreset),
init: s32(p.bInit),
box: p.pBox ? dumpCoord(p.pBox) : null,
})
}
return out
}
function prestFile(env: DrlgEnv, pMap: D2DrlgMapStrc): string {
if (pMap.nPickedFile < 0 || pMap.nPickedFile >= 6) return ''
return DATATBLS_GetLvlPrestTxtRecord(env.tables, pMap.nLevelPrest).szFile[pMap.nPickedFile]!.replace(/\\/g, '/')
}
export function dumpMap(env: DrlgEnv, pMap: D2DrlgMapStrc): DumpMap {
return {
prest: s32(pMap.nLevelPrest),
picked: s32(pMap.nPickedFile),
coord: dumpCoord(pMap.pDrlgCoord),
file: prestFile(env, pMap),
hasInfo: s32(pMap.bHasInfo),
units: dumpPresetUnits(pMap.pPresetUnit),
}
}
// ---- act stage ----
export function dumpActLevel(pLevel: D2DrlgLevelStrc): DumpActLevel {
const o: DumpActLevel = {
id: pLevel.nLevelId,
drlgType: pLevel.nDrlgType,
levelType: pLevel.nLevelType,
coord: dumpCoord(pLevel.pLevelCoords),
flags: u32(pLevel.dwFlags),
seed: dumpSeed(pLevel.pSeed),
}
if (pLevel.nDrlgType === DRLGTYPE_OUTDOOR && pLevel.pOutdoors) {
o.outdoor = { flags: u32(pLevel.pOutdoors.dwFlags), roomData: dumpOrths(pLevel.pOutdoors.pRoomData) }
}
if (pLevel.nDrlgType === DRLGTYPE_PRESET && pLevel.pPreset) {
o.preset = {
direction: s32(pLevel.pPreset.nDirection),
map: pLevel.pPreset.pDrlgMap ? dumpMap(pLevel.pDrlg.env, pLevel.pPreset.pDrlgMap) : null,
}
}
return o
}
export function dumpAct(pDrlg: D2DrlgStrc): DumpActLevel[] {
const levels: D2DrlgLevelStrc[] = []
for (let p = pDrlg.pLevel; p; p = p.pNextLevel) levels.push(p)
levels.sort((a, b) => a.nLevelId - b.nLevelId)
return levels.map(dumpActLevel)
}
// ---- levelGrid stage (after DRLGOUTWILD_InitAct1OutdoorLevel, before any room exists) ----
export function dumpLevelGrid(pLevel: D2DrlgLevelStrc): DumpLevelGrid {
const pOut = pLevel.pOutdoors
if (!pOut) throw new Error(`level ${pLevel.nLevelId}: no pOutdoors`)
const ring: D2DrlgVertexStrc[] = []
if (pOut.pVertex) {
const pStart = pOut.pVertex
let p: D2DrlgVertexStrc | null = pStart
do {
ring.push(p)
p = p.pNext
} while (p && p !== pStart && ring.length < 4096)
}
const ref = (p: D2DrlgVertexStrc | null): number => {
if (!p) return -1
const i = pOut.pVertices.indexOf(p)
if (i >= 0) return i
const k = ring.indexOf(p)
if (k >= 0) return 100 + k
throw new Error(`level ${pLevel.nLevelId}: vertex pointer outside pVertices and the ring`)
}
const vertex = (v: D2DrlgVertexStrc): DumpVertex => [s32(v.nPosX), s32(v.nPosY), u8(v.nDirection), s32(v.dwFlags), ref(v.pNext)]
const pathStarts: (DumpPathVertex[] | null)[] = []
for (let i = 0; i < 6; i += 1) {
const start = pOut.pPathStarts[i] ?? null
if (!start) {
pathStarts.push(null)
continue
}
const chain: DumpPathVertex[] = []
for (let p: D2DrlgVertexStrc | null = start; p; p = p.pNext) {
if (chain.length >= 4096) throw new Error(`level ${pLevel.nLevelId}: path ${i} does not terminate`)
chain.push([s32(p.nPosX), s32(p.nPosY), u8(p.nDirection), s32(p.dwFlags)])
}
pathStarts.push(chain)
}
return {
flags: u32(pOut.dwFlags),
coord: dumpCoord(outdoorCoord(pOut)),
grids: pOut.pGrid.map(dumpGrid),
ring: ring.map(vertex),
nVertices: s32(pOut.nVertices),
vertices: pOut.pVertices.map(vertex),
pathStarts,
roomData: dumpOrths(pOut.pRoomData),
seed: dumpSeed(pLevel.pSeed),
}
}
export function dumpRooms(pLevel: D2DrlgLevelStrc, maps: D2DrlgMapStrc[]): DumpRoom[] {
const mapIndex = (pMap: D2DrlgMapStrc): number => {
const i = maps.indexOf(pMap)
if (i >= 0) return i
maps.push(pMap)
return maps.length - 1
}
const out: DumpRoom[] = []
let i = 0
for (let pRoom = pLevel.pFirstRoomEx; pRoom; pRoom = pRoom.pDrlgRoomNext, i += 1) {
const r: DumpRoom = {
type: pRoom.nType,
coord: dumpCoord(pRoom.pDrlgCoord),
flags: u32(pRoom.dwFlags),
otherFlags: u32(pRoom.dwOtherFlags),
dt1Mask: u32(pRoom.dwDT1Mask),
initSeed: u32(pRoom.dwInitSeed),
seed: dumpSeed(pRoom.pSeed),
}
if (pRoom.nType === DRLGTYPE_MAZE) {
const p = pRoom.pOutdoor
if (!p) throw new Error(`level ${pLevel.nLevelId} room ${i}: maze room without pOutdoor`)
r.outdoor = { flags: s32(p.dwFlags), flagsEx: s32(p.dwFlagsEx), subType: s32(p.nSubType), subTheme: s32(p.nSubTheme), subThemePicked: s32(p.nSubThemePicked) }
} else if (pRoom.nType === DRLGTYPE_PRESET) {
const p = pRoom.pMaze
if (!p || !p.pMap) throw new Error(`level ${pLevel.nLevelId} room ${i}: preset room without map`)
r.preset = { prest: s32(p.nLevelPrest), picked: s32(p.nPickedFile), flags: u32(p.dwFlags), map: mapIndex(p.pMap) }
} else {
throw new Error(`level ${pLevel.nLevelId} room ${i}: unexpected room type ${pRoom.nType}`)
}
out.push(r)
}
return out
}
// ---- RNG trace (same line format as tools/d2moo-oracle/src/seed_traced.cpp) ----
const hex8 = (v: number): string => (v >>> 0).toString(16).padStart(8, '0')
/** Collects the RNG trace lines; `context()` mirrors D2ORACLE_SetTraceContext. */
export class DrlgTraceWriter implements D2SeedTraceSink {
readonly lines: string[] = []
private ctx = 'init'
private readonly counters = new Map<string, number>()
context(name: string): void {
this.ctx = name
this.lines.push(`# ${name}`)
}
init(seed: D2SeedStrc, kind: 'I' | 'S'): void {
const n = this.counters.get(this.ctx) ?? 0
this.counters.set(this.ctx, n + 1)
seed.label = `${this.ctx}#${n}`
this.lines.push(kind === 'I' ? `I ${seed.label} ${u32(seed.lo)}` : `S ${seed.label} ${u32(seed.lo)} ${u32(seed.hi)}`)
}
roll(seed: D2SeedStrc, kind: 'R' | 'L' | 'P', arg: number, result: number): void {
const label = seed.label ?? '?'
if (kind === 'R') this.lines.push(`R ${label} 0 ${hex8(seed.lo)}:${hex8(seed.hi)}`)
else this.lines.push(`${kind} ${label} ${s32(arg)} ${u32(result)}`)
}
/** `T <type> <style> <seq> <n> <raritySum>` (D2CMP_10088_GetTiles lookups). */
tile(nType: number, nStyle: number, nSequence: number, n: number, nRaritySum: number): void {
this.lines.push(`T ${nType} ${nStyle} ${nSequence} ${n} ${nRaritySum}`)
}
}
// ---- mapTiles (schema 3) ----
/** Pointer -> reference tables over every room of one act (pDrlg->pLevel chain, pFirstRoomEx order). */
interface DumpRefIndex {
readonly tiles: Map<D2DrlgTileDataStrc, DumpTileRef>
readonly coordLists: Map<D2RoomCoordListStrc, DumpCoordListRef>
readonly rooms: Map<D2DrlgRoomStrc, [number, number]>
}
/** Chains longer than this cannot come from D2Common (C would loop forever on a cycle). */
const MAX_DUMP_CHAIN = 1 << 20
function buildRefIndex(pDrlg: D2DrlgStrc): DumpRefIndex {
const index: DumpRefIndex = { tiles: new Map(), coordLists: new Map(), rooms: new Map() }
for (let pLevel = pDrlg.pLevel; pLevel; pLevel = pLevel.pNextLevel) {
const nLevelId = pLevel.nLevelId
let nRoom = 0
for (let pRoom = pLevel.pFirstRoomEx; pRoom; pRoom = pRoom.pDrlgRoomNext, nRoom += 1) {
index.rooms.set(pRoom, [nLevelId, nRoom])
const pTileGrid = pRoom.pTileGrid
if (pTileGrid) {
const add = (pArray: D2DrlgTileDataStrc[] | null, cArray: 'w' | 'f' | 'r'): void => {
if (!pArray) return
for (let i = 0; i < pArray.length; i += 1) {
const pTileData = pArray[i]!
if (index.tiles.has(pTileData)) throw new Error(`level ${nLevelId} room ${nRoom}: a tile data element belongs to two arrays`)
index.tiles.set(pTileData, [nLevelId, nRoom, cArray, i])
}
}
add(pTileGrid.pTiles.pWallTiles, 'w')
add(pTileGrid.pTiles.pFloorTiles, 'f')
add(pTileGrid.pTiles.pRoofTiles, 'r')
}
const pInfo = pRoom.pLogicalRoomInfo
if (pInfo) {
let k = 0
for (let p = pInfo.pCoordList; p; p = p.pNext, k += 1) {
if (k >= MAX_DUMP_CHAIN) throw new Error(`level ${nLevelId} room ${nRoom}: pCoordList chain does not terminate`)
index.coordLists.set(p, [nLevelId, nRoom, 'c', k])
}
}
}
}
return index
}
function dumpTileRef(index: DumpRefIndex, p: D2DrlgTileDataStrc | null): DumpTileRef | null {
if (!p) return null
const ref = index.tiles.get(p)
if (!ref) throw new Error('tile data pointer outside every tile array of the act (dangling?)')
return ref
}
function dumpTileEntry(pTile: D2TileLibraryEntryStrc | null): DumpTileEntry | null {
return pTile ? [pTile.szLibrary.replace(/\\/g, '/'), pTile.nIndex] : null
}
function dumpTileData(index: DumpRefIndex, t: D2DrlgTileDataStrc): DumpTile {
let unk0x10: number | DumpCoordListRef
if (typeof t.unk0x10 === 'number') {
unk0x10 = s32(t.unk0x10)
} else {
const ref = index.coordLists.get(t.unk0x10)
if (!ref) throw new Error('D2DrlgTileDataStrc::unk0x10 holds a coord list outside every pCoordList chain of the act')
unk0x10 = ref
}
return [
s32(t.nWidth),
s32(t.nHeight),
s32(t.nPosX),
s32(t.nPosY),
unk0x10,
u32(t.dwFlags),
dumpTileEntry(t.pTile),
s32(t.nTileType),
dumpTileRef(index, t.unk0x20),
s32(t.unk0x24),
u8(t.nRed),
u8(t.nGreen),
u8(t.nBlue),
u8(t.nIntensity),
s32(t.unk0x2C),
]
}
function dumpTileDataArray(index: DumpRefIndex, pArray: D2DrlgTileDataStrc[] | null, nTiles: number, szWhat: string): DumpTile[] {
if (nTiles < 0 || (nTiles > 0 && !pArray)) throw new Error(`${nTiles} ${szWhat} without an array`)
if (pArray && nTiles > pArray.length) throw new Error(`${nTiles} ${szWhat} exceed their ${pArray.length}-element allocation`)
const out: DumpTile[] = []
for (let i = 0; i < nTiles; i += 1) out.push(dumpTileData(index, pArray![i]!))
return out
}
function dumpLogicalRoomInfo(pInfo: D2DrlgLogicalRoomInfoStrc | null): DumpLogicalRoomInfo | null {
if (!pInfo) return null
const chain = new Map<D2RoomCoordListStrc, number>()
const lists: DumpCoordList[] = []
for (let p = pInfo.pCoordList; p; p = p.pNext) {
if (lists.length >= MAX_DUMP_CHAIN) throw new Error('pCoordList chain does not terminate')
chain.set(p, lists.length)
lists.push([s32(p.nIndex), s32(p.bNode), s32(p.bRoomActive), dumpCoord(p.pBox[0]), dumpCoord(p.pBox[1])])
}
// pIndexY cells hold `(int)D2RoomCoordListStrc*` in C and a 1-based handle into ppCoordListHandles here.
let indexY: DumpGrid | null = null
const g = pInfo.pIndexY
if (g.pCellsFlags && g.pCellsRowOffsets) {
const cells: number[] = []
for (let y = 0; y < g.nHeight; y += 1) {
for (let x = 0; x < g.nWidth; x += 1) {
const idx = g.nCellsBase + x + g.pCellsRowOffsets[y]!
if (idx < 0 || idx >= g.pCellsFlags.length) throw new RangeError(`pIndexY cell (${x}, ${y}) outside the backing array`)
const hCell = g.pCellsFlags[idx]!
if (hCell === 0) {
cells.push(0)
continue
}
const pList = hCell > 0 ? pInfo.ppCoordListHandles[hCell - 1] : undefined
const k = pList ? chain.get(pList) : undefined
if (k === undefined) throw new Error(`pIndexY cell (${x}, ${y}) is not a list of the room's chain`)
cells.push(k + 1)
}
}
indexY = { w: s32(g.nWidth), h: s32(g.nHeight), cells }
}
return { flags: s32(pInfo.dwFlags), nLists: s32(pInfo.nLists), indexX: dumpGrid(pInfo.pIndexX), indexY, lists }
}
function dumpRoomTiles(index: DumpRefIndex, pRoom: D2DrlgRoomStrc): DumpRoomTile[] {
const lvlWarp = pRoom.pLevel.pDrlg.env.tables.lvlWarp
const out: DumpRoomTile[] = []
for (let p = pRoom.pRoomTiles; p; p = p.pNext) {
if (out.length >= MAX_DUMP_CHAIN) throw new Error('pRoomTiles chain does not terminate')
let room: [number, number] | null = null
if (p.pDrlgRoom) {
const r = index.rooms.get(p.pDrlgRoom)
if (!r) throw new Error(`warp ${out.length} leads to a room outside the act`)
room = [r[0], r[1]]
}
let nWarp = -1
if (p.pLvlWarpTxtRecord) {
nWarp = lvlWarp.indexOf(p.pLvlWarpTxtRecord)
if (nWarp < 0) throw new Error(`warp ${out.length} has a record outside LvlWarp.txt`)
}
out.push([room, nWarp, s32(p.bEnabled), dumpTileRef(index, p.unk0x0C), dumpTileRef(index, p.unk0x10)])
}
return out
}
function dumpMapTiles(index: DumpRefIndex, pRoom: D2DrlgRoomStrc, nLevelId: number, nRoom: number): DumpMapTiles {
const where = `level ${nLevelId} room ${nRoom}`
const pTileGrid = pRoom.pTileGrid
if (!pTileGrid) throw new Error(`${where}: no pTileGrid after DRLGROOMTILE_AddRoomMapTiles`)
if (pTileGrid.pAnimTiles) throw new Error(`${where}: animated tiles (pAnimTiles) are not dumped`)
try {
const t = pTileGrid.pTiles
const links: DumpTileLink[] = []
for (let p = pTileGrid.pMapLinks; p; p = p.pNext) {
if (links.length >= MAX_DUMP_CHAIN) throw new Error('pMapLinks chain does not terminate')
links.push([s32(p.bFloor), dumpTileRef(index, p.pMapTile)])
}
return {
nWalls: s32(pTileGrid.nWalls),
nFloors: s32(pTileGrid.nFloors),
nShadows: s32(pTileGrid.nShadows),
pTiles: { nWalls: s32(t.nWalls), nFloors: s32(t.nFloors), nRoofs: s32(t.nRoofs) },
walls: dumpTileDataArray(index, t.pWallTiles, t.nWalls, 'walls'),
floors: dumpTileDataArray(index, t.pFloorTiles, t.nFloors, 'floors'),
roofs: dumpTileDataArray(index, t.pRoofTiles, t.nRoofs, 'roofs'),
links,
warps: dumpRoomTiles(index, pRoom),
units: dumpPresetUnits(pRoom.pPresetUnits),
logical: dumpLogicalRoomInfo(pRoom.pLogicalRoomInfo),
}
} catch (e) {
throw new Error(`${where}: ${e instanceof Error ? e.message : String(e)}`)
}
}
// ---- driver (TypeScript twin of tools/d2moo-oracle/src/main.cpp) ----
export interface Act1RunOptions {
readonly difficulty?: number
/** A fresh act per requested level (proves a level does not depend on the others). */
readonly isolated?: boolean
/** Receives the RNG trace; installed as the global seed trace sink for the duration of the run. */
readonly trace?: DrlgTraceWriter
/** Generate the levels but do not activate their rooms. */
readonly skipActivation?: boolean
}
/**
* Observation points of runAct1, in execution order. The structures are live: later steps keep mutating
* them (a room activated later rewrites tiles of the near rooms activated before it and rolls their
* seeds, also across level seams), so a hook must copy what it needs at that moment.
*/
export interface Act1RunHooks {
/** After DRLG_AllocDrlg. `index` 0 is Act1Run.pDrlg; isolated runs allocate one more act per further level. */
onDrlgAllocated?(pDrlg: D2DrlgStrc, index: number): void
/** After DRLG_InitLevel of a requested level. */
onLevelGenerated?(pLevel: D2DrlgLevelStrc): void
/** After DRLGROOMTILE_InitRoomGrids of a room, before DRLGROOMTILE_AddRoomMapTiles (`index`: pFirstRoomEx order). */
onRoomGridsInitialized?(pRoom: D2DrlgRoomStrc, index: number): void
/** After DRLGROOMTILE_AddRoomMapTiles of that room. */
onRoomActivated?(pRoom: D2DrlgRoomStrc, index: number): void
}
export interface Act1Run {
/** The act allocated first (the only one unless `isolated`). */
readonly pDrlg: D2DrlgStrc
/** The requested levels in request order; each level's own act is `pLevel.pDrlg`. */
readonly levels: readonly D2DrlgLevelStrc[]
}
function allocAct1(env: DrlgEnv, nSeed: number, nDifficulty: number, trace: DrlgTraceWriter | undefined): D2DrlgStrc {
trace?.context('drlg')
// D2Game allocates the act with the town level id; DRLG_AllocDrlg lays out the whole act
// (DRLGOUTPLACE_CreateLevelConnections) and generates the town.
return DRLG_AllocDrlg(env, ACT_I, nSeed, LEVEL_ROGUEENCAMPMENT, 0, nDifficulty)
}
/** Generation stage of one level: DRLG_InitLevel, everything that exists before any room is activated. */
function initLevel(pDrlg: D2DrlgStrc, nLevelId: number, trace: DrlgTraceWriter | undefined): D2DrlgLevelStrc {
trace?.context(`L${nLevelId}`)
const pLevel = DRLG_GetLevel(pDrlg, nLevelId)
if (pLevel.nDrlgType !== DRLGTYPE_OUTDOOR || DRLG_GetActNoFromLevelId(nLevelId) !== ACT_I) {
throw new Error(`level ${nLevelId} is not an Act I outdoor level`)
}
if (pLevel.pFirstRoomEx) throw new Error(`level ${nLevelId} was already generated before its own generation stage`)
DRLG_InitLevel(pLevel)
return pLevel
}
/**
* Mirrors DRLGACTIVATE_InitializeRoomEx (DrlgActivate.cpp:317-331) followed by
* DRLGACTIVATE_RoomEx_EnsureHasRoom (DrlgActivate.cpp:95-115) up to DRLGROOMTILE_AddRoomMapTiles, for every
* room in pFirstRoomEx order. DRLG_CreateRoomForRoomEx (D2Game's D2ActiveRoomStrc) is not run, so pRoom
* stays NULL (D2Common_COLLISION_FirstFn_6FD41000 is a no-op).
*/
function activateLevel(pLevel: D2DrlgLevelStrc, trace: DrlgTraceWriter | undefined, hooks: Act1RunHooks): void {
let i = 0
for (let pRoom = pLevel.pFirstRoomEx; pRoom; pRoom = pRoom.pDrlgRoomNext, i += 1) {
trace?.context(`L${pLevel.nLevelId}/a${i}`)
if (!(pRoom.dwFlags & DRLGROOMFLAG_TILELIB_LOADED)) {
DRLGROOMTILE_LoadDT1FilesForRoom(pRoom)
}
if (!(pRoom.dwFlags & DRLGROOMFLAG_PRESET_UNITS_ADDED) && pRoom.nType === DRLGTYPE_PRESET) {
DRLGPRESET_SpawnHardcodedPresetUnits(pRoom)
}
if (pRoom.pRoom || pRoom.dwFlags & DRLGROOMFLAG_HAS_ROOM) {
throw new Error(`level ${pLevel.nLevelId} room ${i}: already has an active room`)
}
if (!pRoom.nRoomsNear) {
sub_6FD77BB0(pRoom)
}
DRLGROOMTILE_InitRoomGrids(pRoom)
hooks.onRoomGridsInitialized?.(pRoom, i)
DRLGROOMTILE_AddRoomMapTiles(pRoom)
hooks.onRoomActivated?.(pRoom, i)
}
}
/**
* Runs the Act I generation exactly like the oracle driver: DRLG_AllocDrlg (layout, links, town), then
* DRLG_InitLevel of every requested level, then the activation of every room of those levels (see
* activateLevel). A non-isolated run generates every requested level before activating any room, because
* activating a room initialises the levels behind its warps (sub_6FD77BB0 -> DRLG_InitLevel); an isolated
* run allocates a fresh act per level and activates it right away.
*/
export function runAct1(
env: DrlgEnv,
nSeed: number,
levels: readonly number[],
opts: Act1RunOptions = {},
hooks: Act1RunHooks = {},
): Act1Run {
const nDifficulty = opts.difficulty ?? 0
const trace = opts.trace
if (trace) setD2SeedTraceSink(trace)
try {
let nActs = 0
const allocAct = (): D2DrlgStrc => {
const pDrlg = allocAct1(env, nSeed >>> 0, nDifficulty, trace)
hooks.onDrlgAllocated?.(pDrlg, nActs)
nActs += 1
return pDrlg
}
const generate = (pDrlg: D2DrlgStrc, nLevelId: number): D2DrlgLevelStrc => {
const pLevel = initLevel(pDrlg, nLevelId, trace)
hooks.onLevelGenerated?.(pLevel)
return pLevel
}
const pFirstDrlg = allocAct()
const generated: D2DrlgLevelStrc[] = []
if (opts.isolated) {
for (let i = 0; i < levels.length; i += 1) {
const pLevel = generate(i > 0 ? allocAct() : pFirstDrlg, levels[i]!)
generated.push(pLevel)
if (!opts.skipActivation) activateLevel(pLevel, trace, hooks)
}
} else {
for (let i = 0; i < levels.length; i += 1) generated.push(generate(pFirstDrlg, levels[i]!))
if (!opts.skipActivation) {
for (const pLevel of generated) activateLevel(pLevel, trace, hooks)
}
}
return { pDrlg: pFirstDrlg, levels: generated }
} finally {
if (trace) setD2SeedTraceSink(null)
}
}
export interface DumpAct1Options extends Act1RunOptions {
/** Stop after the act stage (the `levels` array stays empty). */
readonly actOnly?: boolean
/**
* Generate the levels but do not activate their rooms: every `activation` array stays empty.
* Only valid for comparisons that exclude the activation stage (drlg-diff --no-activation).
*/
readonly skipActivation?: boolean
}
function dumpLevelHead(pLevel: D2DrlgLevelStrc, levelGrid: DumpLevelGrid): Omit<DumpLevel, 'activation'> {
const maps: D2DrlgMapStrc[] = []
const rooms = dumpRooms(pLevel, maps)
const xy: [number, number][] = []
for (let i = 0; i < pLevel.nRoomCoords; i += 1) xy.push([pLevel.nRoom_Center_Warp_X[i]!, pLevel.nRoom_Center_Warp_Y[i]!])
return {
id: pLevel.nLevelId,
levelGrid,
nRooms: pLevel.nRooms,
rooms,
maps: maps.map(m => dumpMap(pLevel.pDrlg.env, m)),
levelSeedAfterRooms: dumpSeed(pLevel.pSeed),
warp: { n: pLevel.nRoomCoords, xy },
}
}
/** Schema-2 activation fields of a room, right after DRLGROOMTILE_InitRoomGrids. */
function dumpRoomGrids(pRoom: D2DrlgRoomStrc): Omit<DumpOutdoorActivation, 'seedAfterTiles' | 'mapTiles'> | Omit<DumpPresetActivation, 'seedAfterTiles' | 'mapTiles'> {
if (pRoom.nType === DRLGTYPE_MAZE) {
const p = pRoom.pOutdoor!
return {
seed: dumpSeed(pRoom.pSeed),
tile: dumpGrid(p.pTileTypeGrid),
wall: dumpGrid(p.pWallGrid),
floor: dumpGrid(p.pFloorGrid),
dirt: dumpGrid(p.pDirtPathGrid),
units: dumpPresetUnits(pRoom.pPresetUnits),
}
}
const p = pRoom.pMaze!
const pFile = p.pMap!.pFile!
const walls: (DumpGrid | null)[] = []
const tiles: (DumpGrid | null)[] = []
const floors: (DumpGrid | null)[] = []
for (let l = 0; l < pFile.nWallLayers; l += 1) {
walls.push(dumpGrid(p.pWallGrid[l]!))
tiles.push(dumpGrid(p.pTileTypeGrid[l]!))
}
for (let l = 0; l < pFile.nFloorLayers; l += 1) {
floors.push(dumpGrid(p.pFloorGrid[l]!))
}
return { seed: dumpSeed(pRoom.pSeed), walls, tiles, floors, shadow: dumpGrid(p.pCellGrid), units: dumpPresetUnits(pRoom.pPresetUnits) }
}
interface RoomActivationRecord {
readonly pRoom: D2DrlgRoomStrc
readonly index: number
readonly grids: ReturnType<typeof dumpRoomGrids>
seedAfterTiles: DumpSeed | null
}
/** Runs runAct1 and returns the same JSON document as the oracle driver. */
export function dumpAct1(env: DrlgEnv, nSeed: number, levels: readonly number[], opts: DumpAct1Options = {}): DrlgDump {
const levelGrids = new Map<number, DumpLevelGrid>()
const heads = new Map<D2DrlgLevelStrc, Omit<DumpLevel, 'activation'>>()
const activations = new Map<D2DrlgLevelStrc, RoomActivationRecord[]>()
// Assigned by the hooks (the casts keep TypeScript from narrowing them to `null` for good).
let drlg = null as DrlgDump['drlg'] | null
let act = null as DumpActLevel[] | null
const previousHook = setLevelGridHook(pLevel => {
levelGrids.set(pLevel.nLevelId, dumpLevelGrid(pLevel))
})
let run: Act1Run
try {
run = runAct1(env, nSeed, opts.actOnly ? [] : levels, opts, {
onDrlgAllocated(pDrlg, index) {
if (index !== 0) return
drlg = { startSeed: u32(pDrlg.dwStartSeed), seed: dumpSeed(pDrlg.pSeed) }
act = dumpAct(pDrlg)
},
onLevelGenerated(pLevel) {
const levelGrid = levelGrids.get(pLevel.nLevelId)
if (!levelGrid) throw new Error(`level ${pLevel.nLevelId}: InitAct1OutdoorLevel hook did not fire`)
heads.set(pLevel, dumpLevelHead(pLevel, levelGrid))
activations.set(pLevel, [])
},
onRoomGridsInitialized(pRoom, index) {
const records = activations.get(pRoom.pLevel)
if (!records) throw new Error(`level ${pRoom.pLevel.nLevelId}: room ${index} activated before its level was generated`)
records.push({ pRoom, index, grids: dumpRoomGrids(pRoom), seedAfterTiles: null })
},
onRoomActivated(pRoom, index) {
const records = activations.get(pRoom.pLevel)
const record = records ? records[records.length - 1] : undefined
if (!record || record.pRoom !== pRoom || record.index !== index) throw new Error(`level ${pRoom.pLevel.nLevelId} room ${index}: activation hooks out of order`)
record.seedAfterTiles = dumpSeed(pRoom.pSeed)
},
})
} finally {
setLevelGridHook(previousHook)
}
if (!drlg || !act) throw new Error('runAct1 did not report the act allocation')
const doc: DrlgDump = {
schema: 3,
gameSeed: nSeed >>> 0,
difficulty: opts.difficulty ?? 0,
isolated: opts.isolated ?? false,
drlg,
act,
levels: [],
}
// mapTiles is serialised only now: the tile data are the final state (see DumpMapTiles).
const refIndices = new Map<D2DrlgStrc, DumpRefIndex>()
for (const pLevel of run.levels) {
let index = refIndices.get(pLevel.pDrlg)
if (!index) {
index = buildRefIndex(pLevel.pDrlg)
refIndices.set(pLevel.pDrlg, index)
}
const activation: DumpActivation[] = []
for (const record of activations.get(pLevel)!) {
if (!record.seedAfterTiles) throw new Error(`level ${pLevel.nLevelId} room ${record.index}: DRLGROOMTILE_AddRoomMapTiles did not complete`)
activation.push({ ...record.grids, seedAfterTiles: record.seedAfterTiles, mapTiles: dumpMapTiles(index, record.pRoom, pLevel.nLevelId, record.index) })
}
doc.levels.push({ ...heads.get(pLevel)!, activation })
}
return doc
}