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
--seedis the D2 game seed (DRLG_AllocDrlgnInitSeed). The whole act, including the level layout ofDRLGOUTPLACE_CreateLevelConnections, derives from it.--levelsmust be Act I outdoor levels (DrlgType 3).--isolatedallocates 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.shcompiles in a temporary directory and installs the binary into$OUT(defaulttools/d2moo-oracle/build) withcptod2moo-oracle.tmpfollowed bymvoverd2moo-oracle, so a concurrently running oracle is never replaced by a half-written file.- Both
build/anddata/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_CreateLevelConnectionsand the town. -
DRLG_InitLevel, thenDRLGOUTDOORS_GenerateLevelandDRLGOUTWILD_InitAct1OutdoorLevel. -
Room creation.
-
Room activation, mirroring
DRLGACTIVATE_InitializeRoomEx(DrlgActivate.cpp:317-331) andDRLGACTIVATE_RoomEx_EnsureHasRoom(95-115) up toDRLGROOMTILE_AddRoomMapTiles:DRLGROOMTILE_LoadDT1FilesForRoomDRLGPRESET_SpawnHardcodedPresetUnits(preset rooms only)sub_6FD77BB0DRLGROOMTILE_InitRoomGrids, which re-seeds the room fromdwInitSeed.DRLGROOMTILE_AddRoomMapTiles: the tile data of the room (the variant picks ofDRLGROOMTILE_GetTileCacheroll the room seed), the room-seam merge with the near rooms (DRLGROOMTILE_GetCreateLinkedTileData/DRLGROOMTILE_LinkedTileDataManager), the warps (DRLGROOMTILE_AddWarp), thenDRLGOUTROOM_InitializeDrlgOutdoorRoomorDRLGPRESET_AddPresetRoomMapTiles(both build theDRLGLOGIC_*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_GameTileToSubtileCoordsandDUNGEON_GameTileToClientCoords.sub_6FDAB750andsub_6FDAB610(src/path_misc.cpp, copied from D2MOO PathMisc.cpp).- D2CMP tile libraries, parsed from the real DT1 files. This is not optional:
DRLGROOMTILE_GetTileCacherolls 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_GetTileCachepicks, whichmapTilesdumps (pTile); the TypeScript shim (src/game/drlg/drlg-d2cmp.ts) uses the same order.
- For Act I every shadow lookup returns rarity 0, so there is no roll. This is visible in the
trace as
FOG_AllocPool/FOG_ReallocPoolarecalloc/realloc.FOG_ReallocPoolzero-fills the grown part of the block (glibccallocclears the whole usable size), so the entries thatDRLGROOMTILE_ReallocRoofTileGridappends are deterministic. D2Common never writesunk0x10,nIntensityandunk0x2Cof those entries; in the game they keep whatever the pool block held.
Modelling decisions (documented, not guessed):
- Rooms are activated in
pFirstRoomExorder. 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 ofDRLGPRESET_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->pRoomstays NULL for every room, soD2Common_COLLISION_FirstFn_6FD41000is a no-op (D2Collision.cpp returns whenpRoomis 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_CreateRoomForRoomExare 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
unitsare captured right afterDRLGROOMTILE_InitRoomGrids;seedAfterTilesis the room seed right after the room's ownDRLGROOMTILE_AddRoomMapTiles. mapTilesis 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'spWallTiles/pFloorTiles/pRoofTiles(rooms numbered inpFirstRoomExorder, any level of the act);pTileis[DT1 path (lower case, "/"-separated), tile index in that DT1];unk0x10is an int, or[levelId, room, "c", index]whenDRLGLOGICstored aD2RoomCoordListStrc*in it (index in that room'spCoordListchain).lvlWarpRowis the LvlWarp.txt row ofpLvlWarpTxtRecord(-1 for NULL).coordListis[nIndex, bNode, bRoomActive, box0, box1];indexYcells are 1 + the index of the list inlists(0 for NULL). - Shadow tiles live in
pRoofTiles(DRLGROOMTILE_CountAllTileTypescounts them intonRoofs). gridis{w, h, cells: [row-major int32]}, ornullwhen the grid was never allocated.vertexis[x, y, direction, flags, next].nextis -1 for null, 0..23 forpVertices[i], and 100+k for the k-th ring vertex.orthis{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:
- Outdoor rooms called
DRLGOUTPLACE_InitOutdoorRoomGridsdirectly. That skippedDRLGROOMTILE_InitRoomGrids' re-seed fromdwInitSeed(D2Common.0x6FD89FA0), so every substitution was rolled from the wrong seed. sub_6FDAB750was stubbed toreturn 0. It drives the dirt-path search insub_6FD80750, so the dirt paths were corrupted.- Missing files returned a zeroed 64-byte buffer. MonPreset, Objects and SuperUniques were dummy
tables.
FOG_DisplayAssertandFOG_DisplayWarningonly printed. - It was a 64-bit build, which needed a hand-patched LvlSub layout.
- D2CMP tile lookups were no-ops, so the shadow rarity rolls could not happen.