The eight Act I outdoor levels (Blood Moor 2, Cold Plains 3, Stony Field
4, Dark Wood 5, Black Marsh 6, Tamoe Highland 7, Burial Grounds 17, Moo
Moo Farm 39) are now generated by the function-by-function port of
D2MOO's D2Common DRLG in src/game/drlg, bit-exact against a native D2MOO
oracle. The legacy Act I generator (src/game/wilderness/*, the Blood Moor
branch and the Act I paths of wilderness.ts) is deleted; there is no
fallback. Acts 2-5 are unchanged.
Port (completes the kernel committed earlier):
- Tile-map stage: DRLGROOMTILE_AddRoomMapTiles with its tile-data,
linked-tile and warp helpers, DRLGPRESET_AddPresetRoomMapTiles,
DRLGOUTROOM_InitializeDrlgOutdoorRoom (drlg-outroom.ts), the
DrlgDrlgLogic.cpp parts they reach (drlg-logic.ts), D2CMP shim updates.
- drlg-dump.ts: runAct1() driver API; schema 3 dumps with per-room tile
lists. The port is now type-checked (tsconfig no longer excludes it).
Integration:
- drlg-map.ts (replaces drlg/adapter.ts): turns a generated level into
the bake's DS1-shaped canvas with exact (style, sequence, type) per
tile and per-cell DT1 masks, objects, seams from the DRLG level links,
warps from their UNIT_TILE preset units; blank cells stay blocked.
- pack-act-assets: one D2 game seed per (act, variant), a DRLG bake job
and index.json actLayouts; scripts/lib/drlg-mpq-source.ts feeds the
port from the MPQs with the oracle's file list.
- act-variants.ts: WorldVariants picks one generated copy per act (and
the Rogue Encampment DS1 that copy fixed); act-scene.ts and
verify-world-walk both use it. act-scene no longer paints grass into
the blank cells of DRLG scenes.
- world-graph: remove the invented Stony Field <-> Dark Wood seam
(gAct1WildernessDrlgLink / gAct1MonasteryDrlgLink keep them apart;
they meet only through the Underground Passage); world-placement
anchors the second group at the Monastery Gate (3000, 1000).
- level-links: findBorderOpening takes an optional edge range.
- d2map: dt1MaskAllows extracted, behaviour unchanged.
Tools and tests:
- tools/d2moo-oracle: tile-map stage and schema 3; the 4 fixtures are
regenerated (scripts/drlg-fixtures.ts).
- scripts/render-drlg-compare.ts (both dumps through the same adapter
and renderer, fails unless the XOR is empty) and
scripts/audit-levels-browser.ts (headless audit of every baked map).
- tests/drlg-act1-oracle.test.ts (all stages, all 4 fixtures) and
tests/drlg-act1-invariants.test.ts; tests and stress scripts of the
deleted generator are retired or now run on Acts 2-5 only.
- verify-world-walk: start in the town the runtime opens and floor the
spawn sub-tile like the movement collision does.
Verification:
- drlg-diff --levels 2-7,17,39 --seeds 32: 32/32 identical at every
stage; --trace-check 64/64, --isolated 32/32, difficulty 1 and 2 16/16.
- render-drlg-compare, 3 bake copies x 8 levels: 24/24 XOR empty.
- Full re-bake: 365 maps, 0 missing tiles. Browser audit: 355/365 maps,
133/136 level ids clean (112/124/129: Act 5 minions without art, as
before).
- verify:world-graph 37583 assertions; verify:world-walk 29/29;
verify:entity-packs, typecheck clean. vitest 6369 passed; the 6
failures (5 ground-item name formatting, 123-var3 waypoint) and the
verify:packs / verify:generators failures also occur at
|
||
|---|---|---|
| .. | ||
| include | ||
| src | ||
| .gitignore | ||
| README.md | ||
| build.sh | ||
README.md
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.