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

11 KiB

D2MOO DRLG oracle

Native build of 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

# 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)

{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:

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.