diablo2-web/tools/d2moo-oracle/README.md

182 lines
11 KiB
Markdown

# D2MOO DRLG oracle
Native build of [D2MOO](https://github.com/ThePhrozenKeep/D2MOO)'s `D2Common` level generator
(DRLG). It is the reference the TypeScript Act I outdoor port (`src/game/drlg/`) is diffed against,
stage by stage and RNG call by RNG call.
D2MOO's source is **not** vendored. The build reads it from `D2MOO_SRC` (default `~/tmp/D2MOO`),
pinned to commit `5596f5cb6c5251a0a07c6637d26458b06099d516` (MIT license). Only this driver, the
runtime shims and the build script live in this repository.
## Build and run
```sh
# 1. Game data: compiled tables, every DS1 of LvlPrest/LvlSub, every DT1 the DRLG loads.
npx tsx scripts/extract-d2moo-tables.ts samples/d2 tools/d2moo-oracle/data
# 2. 32-bit build (needs g++ with -m32 / gcc-multilib).
./tools/d2moo-oracle/build.sh # or: OUT=/some/dir ./tools/d2moo-oracle/build.sh
# 3. Generate.
./tools/d2moo-oracle/build/d2moo-oracle --data tools/d2moo-oracle/data \
--seed 0x12345678 --levels 2,3,4,5,6,7,17,39 \
[--difficulty 0] [--isolated] [--trace out.trace] --out out.json
```
- `--seed` is the D2 **game** seed (`DRLG_AllocDrlg` `nInitSeed`). The whole act, including the
level layout of `DRLGOUTPLACE_CreateLevelConnections`, derives from it.
- `--levels` must be Act I outdoor levels (DrlgType 3).
- `--isolated` allocates a fresh act for every level. The output must equal the sequential run,
which proves that a level's generation does not depend on other levels.
- `build.sh` compiles in a temporary directory and installs the binary into `$OUT` (default
`tools/d2moo-oracle/build`) with `cp` to `d2moo-oracle.tmp` followed by `mv` over `d2moo-oracle`, so
a concurrently running oracle is never replaced by a half-written file.
- Both `build/` and `data/` are git-ignored.
The binary is 32-bit so that every D2MOO struct has the exact layout of the compiled 1.13c `.bin`
tables. `LoadBinTable` checks `count * sizeof(record) == body` for every table.
## What runs natively
The driver runs D2MOO's own code for:
- `DRLG_AllocDrlg`: the seed chain, `DRLGOUTPLACE_CreateLevelConnections` and the town.
- `DRLG_InitLevel`, then `DRLGOUTDOORS_GenerateLevel` and `DRLGOUTWILD_InitAct1OutdoorLevel`.
- Room creation.
- Room activation, mirroring `DRLGACTIVATE_InitializeRoomEx` (DrlgActivate.cpp:317-331) and
`DRLGACTIVATE_RoomEx_EnsureHasRoom` (95-115) up to `DRLGROOMTILE_AddRoomMapTiles`:
1. `DRLGROOMTILE_LoadDT1FilesForRoom`
2. `DRLGPRESET_SpawnHardcodedPresetUnits` (preset rooms only)
3. `sub_6FD77BB0`
4. `DRLGROOMTILE_InitRoomGrids`, which re-seeds the room from `dwInitSeed`.
5. `DRLGROOMTILE_AddRoomMapTiles`: the tile data of the room (the variant picks of
`DRLGROOMTILE_GetTileCache` roll the room seed), the room-seam merge with the near rooms
(`DRLGROOMTILE_GetCreateLinkedTileData` / `DRLGROOMTILE_LinkedTileDataManager`), the warps
(`DRLGROOMTILE_AddWarp`), then `DRLGOUTROOM_InitializeDrlgOutdoorRoom` or
`DRLGPRESET_AddPresetRoomMapTiles` (both build the `DRLGLOGIC_*` coord lists).
`DRLG_CreateRoomForRoomEx` (the D2ActiveRoomStrc, built by D2Game) is not run.
The outside world is provided by `src/stubs.cpp`. Its policy: anything the generator genuinely needs
is implemented with real data, and everything else **fails fast** (exit code 2).
**Real implementations:**
- Data tables: loaded in the same relative order as `DATATBLS_LoadAllTxts`. The monster tables
come first because the LvlSub DS1 loader translates monster preset units through MonPreset.
- DS1 archive reads.
- `DUNGEON_GameTileToSubtileCoords` and `DUNGEON_GameTileToClientCoords`.
- `sub_6FDAB750` and `sub_6FDAB610` (`src/path_misc.cpp`, copied from D2MOO PathMisc.cpp).
- **D2CMP tile libraries**, parsed from the real DT1 files. This is not optional:
`DRLGROOMTILE_GetTileCache` rolls the room seed iff the rarity sum of the matching tiles is > 0.
It runs inside LvlSub stamping (`sub_6FD8ACE0` → `DRLGROOMTILE_InitTileShadow`), so the tile data
advances the substitution RNG stream.
- For Act I every shadow lookup returns rarity 0, so there is no roll. This is visible in the
trace as `T 13 … 1 0`.
- The order of the returned tiles (slot order, then DT1 file order) is not verified against
D2CMP.dll. It decides which tile variant `DRLGROOMTILE_GetTileCache` picks, which `mapTiles`
dumps (`pTile`); the TypeScript shim (`src/game/drlg/drlg-d2cmp.ts`) uses the same order.
- `FOG_AllocPool` / `FOG_ReallocPool` are `calloc` / `realloc`. `FOG_ReallocPool` zero-fills the grown
part of the block (glibc `calloc` clears the whole usable size), so the entries that
`DRLGROOMTILE_ReallocRoofTileGrid` appends are deterministic. D2Common never writes `unk0x10`,
`nIntensity` and `unk0x2C` of those entries; in the game they keep whatever the pool block held.
**Modelling decisions (documented, not guessed):**
- Rooms are activated in `pFirstRoomEx` order. A preset map that spans several rooms loads its DS1
with the seed of the first activated room. That seed only matters for the randomly skipped units
of `DRLGPRESET_AddPresetUnitToDrlgMap` (Tormentor, Taintbreeder, Riftwraith, floor traps,
object 581), and none of those occur in Act I outdoor DS1s.
- The activation order also decides the room-seam merge: the room activated later edits the tiles of
the near rooms activated before it (also across level seams) and rolls their seeds.
- Activating a room initialises the levels behind its warps (`sub_6FD77BB0` → `DRLG_InitLevel`).
The sequential mode therefore generates every requested level first and activates the rooms
afterwards, so each level's RNG trace stays under its own context.
- `pDrlgRoom->pRoom` stays NULL for every room, so `D2Common_COLLISION_FirstFn_6FD41000` is a no-op
(D2Collision.cpp returns when `pRoom` is NULL). In the game a room activated earlier already has its
active room, so the seam merge of a later room also updates that room's collision grid. That does
not change the DRLG tile data or the RNG.
- Unit spawning and `DRLG_CreateRoomForRoomEx` are outside the oracle.
## Output (schema 3)
```text
{schema: 3, oracle: {d2moo, arch: "i386"}, gameSeed, difficulty, isolated,
drlg: {startSeed, seed: [low, high]},
act: [{id, drlgType, levelType, coord: [x, y, w, h], flags, seed,
outdoor?: {flags, roomData: [orth]}, preset?: {direction, map}}], // sorted by id
levels: [{id,
levelGrid: {flags, coord, grids: [grid x4], ring: [vertex], nVertices,
vertices: [vertex x24], pathStarts: [null | [[x, y, dir, flags]...] x6],
roomData: [orth], seed}, // after InitAct1OutdoorLevel, before rooms
nRooms, rooms: [{type, coord, flags, otherFlags, dt1Mask, initSeed, seed,
outdoor?: {flags, flagsEx, subType, subTheme, subThemePicked},
preset?: {prest, picked, flags, map}}], // creation order
maps: [{prest, picked, coord, file, hasInfo, units}],
levelSeedAfterRooms, warp: {n, xy},
activation: [{seed, tile, wall, floor, dirt, units, seedAfterTiles, mapTiles} | // outdoor rooms
{seed, walls: [grid], tiles: [grid], floors: [grid], shadow, units, // preset rooms
seedAfterTiles, mapTiles}]}]}
mapTiles: {nWalls, nFloors, nShadows, // pTileGrid counters
pTiles: {nWalls, nFloors, nRoofs}, // pTileGrid->pTiles counters
walls: [tile], floors: [tile], roofs: [tile],// pTiles arrays, exactly pTiles.nX entries each
links: [[bFloor, tileRef]], // pTileGrid->pMapLinks chain
warps: [[[levelId, room] | null, lvlWarpRow, bEnabled, tileRef, tileRef]], // pRoomTiles chain
units, // pPresetUnits (incl. AddWarp's UNIT_TILE units)
logical: null | {flags, nLists, indexX: grid, indexY: grid, lists: [coordList]}}
tile: [nWidth, nHeight, nPosX, nPosY, unk0x10, dwFlags, pTile, nTileType, unk0x20, unk0x24,
nRed, nGreen, nBlue, nIntensity, unk0x2C] // every D2DrlgTileDataStrc field in order
```
- The activation fields up to `units` are captured right after `DRLGROOMTILE_InitRoomGrids`;
`seedAfterTiles` is the room seed right after the room's own `DRLGROOMTILE_AddRoomMapTiles`.
- `mapTiles` is the final state after every requested room was activated: rooms activated later
rewrite tiles and preset units of the near rooms activated before them.
- References: a `D2DrlgTileDataStrc*` (`tileRef`, `unk0x20`) is `[levelId, room, "w"|"f"|"r", index]`
into that room's `pWallTiles` / `pFloorTiles` / `pRoofTiles` (rooms numbered in `pFirstRoomEx`
order, any level of the act); `pTile` is `[DT1 path (lower case, "/"-separated), tile index in that
DT1]`; `unk0x10` is an int, or `[levelId, room, "c", index]` when `DRLGLOGIC` stored a
`D2RoomCoordListStrc*` in it (index in that room's `pCoordList` chain). `lvlWarpRow` is the
LvlWarp.txt row of `pLvlWarpTxtRecord` (-1 for NULL). `coordList` is
`[nIndex, bNode, bRoomActive, box0, box1]`; `indexY` cells are 1 + the index of the list in `lists`
(0 for NULL).
- Shadow tiles live in `pRoofTiles` (`DRLGROOMTILE_CountAllTileTypes` counts them into `nRoofs`).
- `grid` is `{w, h, cells: [row-major int32]}`, or `null` when the grid was never allocated.
- `vertex` is `[x, y, direction, flags, next]`. `next` is -1 for null, 0..23 for `pVertices[i]`,
and 100+k for the k-th ring vertex.
- `orth` is `{level, dir, preset, init, box}`.
- Units are `[type, index, mode, x, y, spawned]`.
- All values are the raw packed words exactly as D2Common stores them.
## RNG trace (`--trace`)
The trace has one event per line. The TypeScript port writes the same format:
```text
I <label> <low> SEED_InitLowSeed (high seed 666)
S <label> <low> <high> SEED_SetSeeds
R <label> 0 <low>:<high> SEED_RollRandomNumber, 64-bit state after the roll (hex)
L <label> <max> <result> SEED_RollLimitedRandomNumber (max <= 0 does not roll)
P <label> 100 <result> SEED_RollPercentage
T <type> <style> <seq> <n> <sum> D2CMP_10088_GetTiles lookup: n tiles, rarity sum
# <context> driver context (drlg, L<id>, L<id>/a<room>)
```
Labels are `<context>#<n>`, assigned when a seed is initialised. The build fails if any D2MOO
object that rolls a seed was not compiled against the traced `include/D2Seed.h`.
## Defects of the previous ad-hoc runner (`~/tmp/d2moo_runner`)
Its output must not be used as a reference:
1. Outdoor rooms called `DRLGOUTPLACE_InitOutdoorRoomGrids` directly. That skipped
`DRLGROOMTILE_InitRoomGrids`' re-seed from `dwInitSeed` (D2Common.0x6FD89FA0), so every
substitution was rolled from the wrong seed.
2. `sub_6FDAB750` was stubbed to `return 0`. It drives the dirt-path search in `sub_6FD80750`, so
the dirt paths were corrupted.
3. Missing files returned a zeroed 64-byte buffer. MonPreset, Objects and SuperUniques were dummy
tables. `FOG_DisplayAssert` and `FOG_DisplayWarning` only printed.
4. It was a 64-bit build, which needed a hand-patched LvlSub layout.
5. D2CMP tile lookups were no-ops, so the shadow rarity rolls could not happen.