diablo2-web/tools/d2moo-oracle
troytt b6fc2512d4 feat(drlg): generate Act I outdoor levels with the 1:1 D2MOO port (#455)
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 bad6806.

Test pins updated, measured on the new bake: SceneEntrance 172 -> 166
and world-graph edges 286 -> 284 (the removed seam), seamless pairs
27 -> 26 and placement pairs 19 -> 18; full-spectrum floors / walls /
PNGs 1008898 / 237542 / 1390 (the old pins matched no bake of the
current code).

TAG=agy
CONV=a4f9163c-a011-4161-b169-7f361b1ed57e
2026-09-27 15:20:41 +00:00
..
include feat(drop): embed MonStats and SuperUniques with 1.13c multi-diff TC parity (Fixes #410, #411) 2026-09-25 13:44:35 +00:00
src feat(drlg): generate Act I outdoor levels with the 1:1 D2MOO port (#455) 2026-09-27 15:20:41 +00:00
.gitignore feat(drop): embed MonStats and SuperUniques with 1.13c multi-diff TC parity (Fixes #410, #411) 2026-09-25 13:44:35 +00:00
README.md feat(drlg): generate Act I outdoor levels with the 1:1 D2MOO port (#455) 2026-09-27 15:20:41 +00:00
build.sh feat(drlg): generate Act I outdoor levels with the 1:1 D2MOO port (#455) 2026-09-27 15:20:41 +00:00

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
  • --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.