DRLG: fix nine bugs closing the gap to 1.10f#225
Conversation
b8c853b to
3b68918
Compare
|
Thanks for the PR, I had run similar experiments back then but only checked maps reachable from towns iirc. |
|
I'm still doing some testing, seems that I had UBER's flag set which caused most of A5 to crash and be skipped when using 1.10f mpqs. |
|
Seems like the robot found another bug, but there must be another bug somewhere that neutralizes and fixes things: undoing the bugfix (i.e., fixing the bug) makes it diverge from the game. |
Nine independent bugs in `D2Common/src/Drlg/` where the ported C++ diverges from the retail x86 binary. All nine were found by driving MOO's DRLG generator against **live retail game code running under [d2mapapi](https://github.com/jcageman/d2mapapi)** and diffing the output per-level, then reading the retail decompile in **IDA** to confirm each divergence and match the fix to the original bytes. Fixes 1-7 were nailed against **1.10f `D2Common.dll`**; fixes 8-9 are MOO reconstruction bugs cross-checked against **both** 1.10f `D2Common.dll` and 1.14d `Game.exe` (they land the last residual diffs on the 1.14d side and are byte-for-byte the same logic in both retail versions, so they need no version gate). With all nine fixes in place, MOO's DRLG output is **byte-perfect against both targets** across a 5-seed sweep `{42, 1234567, 1053646565, 987654321, 111222333}`: - **1.10f code + 1.10f data (no UBERS) vs. 1.10f d2mapapi:** 132/132 levels PERFECT every seed. - **1.14d code + 1.14d data (with UBERS) vs. 1.14d d2mapapi:** 136/136 levels PERFECT every seed - including the four Pandemonium-event "uber" levels 133-136 (Matron's Den, Forgotten Sands, Furnace of Pain, Uber Tristram). Before these fixes the sweep showed dozens of `CELL_ONLY` diffs and a handful of `PRESET_DIFF` residuals. Fixes 1-6 land Acts I-IV parity. Fix 7 lands Act V outdoor parity - it was masked earlier because a separate consumer-side setup issue (missing UBERS `Levels.bin` records) had `DUNGEON_AllocAct(ACT_V, ...)` NULL-deref before Act V DRLG code ran, so the crash-once-reachable X-scan loop bug in `DRLGOUTSIEGE_PlaceBarricadeEntrancesAndExits` was never exercised. Fixes 8-9 close the residual `CELL_ONLY` diffs that survived 1-7 on the 1.14d side. The related consumer-side UBERS build fix (so the uber levels 133-136 generate at all instead of crashing) is described in the closing note. Every fix below cites the specific retail address(es) - 1.10f, and for fixes 8-9 the 1.14d address too - so you can open the binary in your own tools and check the pseudocode. --- ## Methodology Same shape for every bug: 1. **Reproduce.** A clientless test compiles MOO's DRLG generator and, for each level (seed x difficulty x area), asks live 1.10f `D2Common.dll` through d2mapapi for the same input. We diff: - `mapRows` - subtile collision (per-cell int) - `npcs` / `objects` - preset unit sets `{id, x, y}` - `levelOrigin` / `width` / `height` - level bounding box Classification per level: `PERFECT` / `CELL_ONLY` / `PRESET_DIFF` / `STRUCTURAL`. Any non-`PERFECT` is a bug on MOO's side (d2mapapi is running the reference DLL, not our code). 2. **Bisect.** Env-gated `fprintf` traces inside MOO plus a **MinHook-based hook layer inside d2mapapi** log the same call sites on the 1.10f side (`DRLGACTIVATE_*`, `DRLGROOM_AllocRoomEx`, `SEED_RollRandomNumber`, `SpawnHardcodedPresetUnits`, `AddPresetUnitToDrlgMap`, ...). Both traces dump `pLevel->pSeed`, `pDrlgRoom->pSeed`, `wRoomsInList[]`, `fRoomStatus` and the file positions of every parsed `.ds1` unit. First divergence between the two logs pinpoints the offending function. 3. **Decompile.** Once the function is known, IDA Hex-Rays on the 1.10f address gives the pseudocode. 4. **Patch and re-sweep.** Fix, rebuild, re-run the sweep. Escalate the sweep size (5 seeds -> 100 -> 1,000) once a fix survives the smaller pool. Test hardness after this PR: - **5-seed sweep** `{42, 1234567, 1053646565, 987654321, 111222333}` x 103 levels = **515/515 PERFECT**. - **100-seed sweep** (deterministic set from C#'s `System.Random(42).Next()`) x 103 levels = **10,300/10,300 PERFECT**, zero anomalies file. - **1,000-seed sweep** currently running; no divergence observed in first ~500 seeds. --- ## Fix 1 - `DRLGACTIVATE_RoomExIdentifyRealStatus`: transition to `ROOMSTATUS_COUNT` was silently a no-op **File:** `source/D2Common/src/Drlg/DrlgActivate.cpp` **1.10f address:** `0x6FD73790` ### Symptom Rooms whose visibility ref-counts had drained back to zero kept their old `fRoomStatus`. `RoomExStatusUnset_Untile`'s `fRoomStatus == ROOMSTATUS_COUNT` check therefore never passed, `FreeRoom` never fired during propagation, and `pTileGrid` on neighbouring rooms stayed alive throughout initial DRLG generation. That in turn made `GetLinkedTileData` find pre-existing tiles on already-processed rooms and take an early-return that 1.10f never takes - visible as `CELL_ONLY` divergences on Act I wilderness levels. ### Root cause The port routed the transition through `DRLGACTIVATE_UpdateRoomExStatusImpl`, which is guarded by `if (fRoomStatus > nStatus)` (lower = higher priority). `ROOMSTATUS_COUNT = 4` is the highest value in the enum, so `X > 4` is never true - the update path is dead for exactly the transition it's meant to perform. ### Old code ```cpp DRLGACTIVATE_UpdateRoomExStatusImpl(pDrlgRoom, nFirstStatusWithRefCount); ``` ### Fix ```cpp DRLGACTIVATE_RoomExStatusUnlink(pDrlgRoom); if (nFirstStatusWithRefCount < ROOMSTATUS_COUNT) { DRLGACTIVATE_RoomExStatuslink(&pDrlgRoom->pLevel->pDrlg->tStatusRoomsLists[nFirstStatusWithRefCount], pDrlgRoom); } pDrlgRoom->fRoomStatus = nFirstStatusWithRefCount; ``` ### Evidence (1.10f `0x6FD73790`) ```c void __fastcall DRLGACTIVATE_RoomExIdentifyRealStatus(struct D2DrlgRoomStrc *pDrlgRoom) { fRoomStatus = pDrlgRoom->fRoomStatus; if ( fRoomStatus >= 4u || !pDrlgRoom->wRoomsInList[fRoomStatus] ) { // ... scan wRoomsInList[] for first non-zero slot -> v2 (v3 = slot index) if ( fRoomStatus != v2 ) { pStatusPrev = pDrlgRoom->pStatusPrev; if ( pStatusPrev ) { pStatusNext = pDrlgRoom->pStatusNext; if ( pStatusNext ) { pStatusPrev->pStatusNext = pStatusNext; pDrlgRoom->pStatusNext->pStatusPrev = pDrlgRoom->pStatusPrev; pDrlgRoom->pStatusPrev = nullptr; pDrlgRoom->pStatusNext = nullptr; } } if ( v2 < 4u ) // v2 < ROOMSTATUS_COUNT { v6 = &pDrlgRoom->pLevel->pDrlg->tStatusRoomsLists[v3]; pDrlgRoom->pStatusNext = v6; pDrlgRoom->pStatusPrev = v6->pStatusPrev; v6->pStatusPrev->pStatusNext = pDrlgRoom; v6->pStatusPrev = pDrlgRoom; pDrlgRoom->fRoomStatus = v2; } } pDrlgRoom->fRoomStatus = v2; // unconditional } } ``` Note the **unconditional** `pDrlgRoom->fRoomStatus = v2;` at the tail - even the `v2 == COUNT` path (no new list to link into) still updates the field. That is exactly what MOO was missing. --- ## Fix 2 - `DRLGACTIVATE_Update`: `for` loop that never executed **File:** `source/D2Common/src/Drlg/DrlgActivate.cpp` **1.10f address:** `0x6FD73F20` ### Symptom The client-visibility "sweep" that walks the `ClientOutOfSight` list and initialises out-of-sight rooms never ran a single iteration. ### Root cause Textbook copy-paste of the loop-head pointer into the loop-continuation predicate: both bounds are the same pointer, so the body never runs. The 1.10f binary uses a proper `do { ... } while (p != head && ...)` that starts at the head and terminates on the first return-to-head. ### Old code ```cpp D2DrlgRoomStrc* pCurRoomEx; for (pCurRoomEx = pDrlgRoomExListHead; pCurRoomEx != pDrlgRoomExListHead; // always false pCurRoomEx = pCurRoomEx->pStatusNext) { if (pCurRoomEx != &pDrlg->tStatusRoomsLists[ROOMSTATUS_CLIENT_OUT_OF_SIGHT]) { DRLGACTIVATE_RoomEx_EnsureHasRoom(pCurRoomEx, false); } if (pDrlg->nRoomsInitSinceLastUpdate != 0) { break; } } ``` ### Fix ```cpp D2DrlgRoomStrc* pCurRoomEx = pDrlgRoomExListHead; do { if (pCurRoomEx != &pDrlg->tStatusRoomsLists[ROOMSTATUS_CLIENT_OUT_OF_SIGHT]) { DRLGACTIVATE_RoomEx_EnsureHasRoom(pCurRoomEx, false); } pCurRoomEx = pCurRoomEx->pStatusNext; } while (pCurRoomEx != pDrlgRoomExListHead && pDrlg->nRoomsInitSinceLastUpdate == 0); ``` ### Evidence (1.10f `0x6FD73F20`) ```c void __stdcall DRLGACTIVATE_Update(struct D2DrlgStrc *pDrlg) { // ... v4 = pDrlg->pDrlgRoom; if ( v4 ) { v5 = pDrlg->pDrlgRoom; do { if ( (v5->dwFlags & 0x100000) == 0 && v5 != &pDrlg->tStatusRoomsLists[2] // != CLIENT_OUT_OF_SIGHT list head && !v5->pRoom ) { // ... allocate + init room, bump counters ... } v5 = v5->pStatusNext; } while ( v5 != v4 && !pDrlg->nRoomsInitSinceLastUpdate ); // <-- do/while pDrlg->pDrlgRoom = v5; } } ``` Straightforward `do { body; p = p->pStatusNext; } while (p != head && !nRoomsInitSinceLastUpdate);`. --- ## Fix 3 - `DRLGOUTPLACE_PlaceAct1245OutdoorBorders`: `bHasDirection` was sticky across vertex iterations **File:** `source/D2Common/src/Drlg/DrlgOutPlace.cpp` **1.10f address:** `0x6FD80ED7` (inside `0x6FD80E10`) ### Symptom Once one vertex in the border chain OR'd `bHasDirection = true` into `tLvlPrestPackedInfo.nPackedValue`, every subsequent iteration inherited that flag regardless of its own vertex direction. On levels whose border chain contains a non-endpoint vertex with `nDirection == 1` (L6 Black Marsh being the canonical case), later segment cells wrote the sticky `bHasDirection` flag and the river spawn refused to place - visible as `STRUCTURAL` on L6. ### Root cause The port initialised `tLvlPrestPackedInfo` **once outside the `do/while` loop**. 1.10f recomputes the equivalent (`v60 = 2*(v49 != 0) + 1`) at the top of every iteration. ### Old code ```cpp D2DrlgOutdoorPackedGrid2InfoStrc tLvlPrestPackedInfo{ 0 }; tLvlPrestPackedInfo.nUnkb00 = true; tLvlPrestPackedInfo.bHasDirection = pDrlgVertex->nDirection != 0; do { // ... loop body, no re-init here ... } while (pDrlgVertex != pLevel->pOutdoors->pVertex); ``` ### Fix ```cpp D2DrlgOutdoorPackedGrid2InfoStrc tLvlPrestPackedInfo{ 0 }; do { tLvlPrestPackedInfo.nPackedValue = 0; tLvlPrestPackedInfo.nUnkb00 = true; tLvlPrestPackedInfo.bHasDirection = pDrlgVertex->nDirection != 0; // ... rest of the loop body ... } while (pDrlgVertex != pLevel->pOutdoors->pVertex); ``` ### Evidence (1.10f `0x6FD80ED7`) `v60` is the `D2DrlgOutdoorPackedGrid2InfoStrc` union local (`tLvlPrestPackedInfo`, a bit-packed 32-bit value: bit 0 = `nUnkb00`, bit 1 = `bHasDirection`, ...). Hex-Rays does not resolve the bit-packed local so it prints the bit assignments as raw arithmetic - the annotations below decode each expression back to its named field. `v49` is the loop-local copy of `pDrlgVertex->nDirection`. ```c void __fastcall DRLGOUTPLACE_PlaceAct1245OutdoorBorders(struct D2DrlgLevelStrc *pLevel) { // ... for ( i = v5; ; v5 = i ) { DRLGVER_GetCoordDiff(pVertex, &v52, &v51); // ... v49 = *(_BYTE *)(pVertex + 8); // pDrlgVertex->nDirection nLevelType = v53->nLevelType; v60 = 2 * (v49 != 0) + 1; // <-- per-iteration re-init: // nPackedValue = 0 // nUnkb00 = 1 // bHasDirection = (v49 != 0) switch ( nLevelType ) { /* ... */ } // ... body ... if ( (struct D2DrlgVertexStrc *)pVertex == v56->pVertex ) break; } } ``` `v60 = 2 * (v49 != 0) + 1` is `(bHasDirection << 1) | nUnkb00` with `nUnkb00 = 1` - i.e. the whole packed value is re-seeded per iteration from the *current* vertex's `nDirection`. That is exactly what MOO's post-fix `tLvlPrestPackedInfo.nPackedValue = 0; nUnkb00 = true; bHasDirection = pDrlgVertex->nDirection != 0;` does. --- ## Fix 4 - `DRLGOUTPLACE_PlaceAct1245OutdoorBorders`: `bHasDirection` was set true even when both vertices had `nDirection == 0` **File:** `source/D2Common/src/Drlg/DrlgOutPlace.cpp` **1.10f address:** `0x6FD81167` (inside `0x6FD80E10`) ### Symptom Segments in wilderness borders whose current *and* next vertex both had `nDirection == 0` were writing `bHasDirection = 1` into the packed grid, where retail writes `bHasDirection = 0`. That perturbed downstream `AlterGridFlag` calls on those cells, shifting a handful of segment `LvlPrestId`s and causing per-seed `CELL_ONLY` diffs on `LEVEL_MOOMOOFARM` and other non-{BLOODMOOR, COLDPLAINS, BURIALGROUNDS} Act I wilderness levels. `BLOODMOOR / COLDPLAINS / BURIALGROUNDS` run the pre-loop that sets `pDrlgVertex->nDirection = 1` on select vertices, so at least one of each vertex pair has a non-zero direction. Other wilderness levels skip that pre-loop, so pairs with two adjacent zero-direction vertices exist - which is exactly where the bug fires. ### Root cause The port's else-branch (when `pDrlgVertex->nDirection == 0`) fell back to `pNextVertex->nDirection` for the effective `nDirection`, but then set `tLvlPrestPackedInfo.bHasDirection = true` **unconditionally** - even if `pNextVertex->nDirection` was also zero. Retail only sets that bit when the effective (short-circuited) direction is non-zero, so when both vertices are `nDirection == 0` the bit stays clear. ### Old code ```cpp nDirection = pDrlgVertex->nDirection; if (nDirection) { tLvlPrestPackedInfo.bHasDirection = true; } else { nDirection = pNextVertex->nDirection; tLvlPrestPackedInfo.bHasDirection = true; // <-- unconditional } ``` ### Fix ```cpp nDirection = pDrlgVertex->nDirection; if (nDirection == 0) { nDirection = pNextVertex->nDirection; } if (nDirection) { tLvlPrestPackedInfo.bHasDirection = true; // <-- conditional on effective != 0 } switch (pLevel->nLevelType) { case LVLTYPE_ACT1_WILDERNESS: v41 = nDirection == 0; break; // ... } ``` ### Evidence (1.10f `0x6FD81167`) `v49` is `pDrlgVertex->nDirection` at the top of the loop and is rewritten to `pNextVertex->nDirection` by the `||` short-circuit here if it was zero. `v60` is `tLvlPrestPackedInfo.nPackedValue`; `LOBYTE(v38) = v60 | 2` sets bit 1 of that packed value (`bHasDirection = true`). Crucially, that `|= 2` sits **inside** the `if` — it fires only when the short-circuit produced a non-zero effective direction. That's the retail behaviour MOO's `else` branch was missing. ```c // ... continuation of DRLGOUTPLACE_PlaceAct1245OutdoorBorders, tail block ... pVertex = (int)i; // pNextVertex if ( v49 || (v49 = *((_BYTE *)i + 8)) != 0 ) // <-- short-circuit: { // v49 stays pDrlgVertex->nDirection if != 0, v38 = v60; // else v49 = pNextVertex->nDirection. LOBYTE(v38) = v60 | 2; // bHasDirection = true, ONLY inside the if v60 = v38; // (bit 1 of nPackedValue) } switch ( v53->nLevelType ) { case 2: // LVLTYPE_ACT1_WILDERNESS v40 = v49 == 0; // reads *effective* v49 break; case 0x10: v40 = 2; break; // ACT2_DESERT case 0x1B: v40 = 3; break; // ACT4_MESA case 0x1F: v40 = sub_6FD84100(v53); break; // ACT5_BARRICADE default: v40 = -1; break; } ``` 1.14d's `Game.exe PlaceAct1245OutdoorBorders @ 0x675B60` has the same `v59 || (v59 = pNextVertex->nDirection)` short-circuit followed by a gated `v46 |= 2u`, so the two retail versions agree - no version divergence to switch on. --- ## Fix 5 - `DRLGOUTWILD_SpawnRiver`: bridge row selection had the wrong modulus **File:** `source/D2Common/src/Drlg/DrlgOutWild.cpp` **1.10f address:** `0x6FD85258` ### Symptom For outdoor levels with `OUTDOOR_RIVER | OUTDOOR_BRIDGE`, MOO tested a different (and generally wrong) set of grid rows for a valid bridge-crossing spawn cell than 1.10f. On some seeds this shifts the crossing by a row; on others it fails to find a valid cell at all - measurable as `CELL_ONLY` / `STRUCTURAL` on Act I wilderness levels that host bridges. ### Root cause MOO's modulus was `gridHeight - 1` with no `+ 1` offset. The reference formula is `(nRand + i) % (gridHeight - 2) + 1`, which produces a completely different set of visited rows. ### Old code ```cpp int nRand = SEED_RollLimitedRandomNumber(&pLevel->pSeed, pLevel->pOutdoors->nGridHeight - 2); for (int i = 0; i < pLevel->pOutdoors->nGridHeight - 2; ++i) { int nY = (nRand + i) % (pLevel->pOutdoors->nGridHeight - 1); // ... } ``` ### Fix ```cpp const int nRowRange = pLevel->pOutdoors->nGridHeight - 2; int nRand = SEED_RollLimitedRandomNumber(&pLevel->pSeed, nRowRange); for (int i = 0; i < nRowRange; ++i) { int nY = (nRand + i) % nRowRange + 1; // ... } ``` ### Evidence (1.10f `0x6FD85258`, inside `DRLGOUTWILD_SpawnRiver` @ `0x6FD850B0`) ```c void __fastcall DRLGOUTWILD_SpawnRiver(struct D2DrlgLevelStrc *pLevel, int nX) { // ... v24 = pLevel->pOutdoors; v12 = v24->nGridHeight - 2; // <-- modulus // ... seed roll into v21 = SEED_RollLimitedRandomNumber(&pLevel->pSeed, v12) v22 = 0; if ( v12 > 0 ) { while ( 1 ) { v15 = nX - 1; v16 = (int)(v21 + v22) % v12 + 1; // <-- (nRand + i) % (nGridHeight-2) + 1 // ... TestGridCellSpawnValid checks at (v15, v16) and (nX+2, v16) ... if ( ++v22 >= v12 ) return; } } } ``` `(nRand + i) % (nGridHeight - 2) + 1` - modulus is `nGridHeight - 2`, offset by `+ 1`. --- ## Fix 6 - `DRLG_OUTDOORS_GenerateDirtPath`: right-border column of the 9x9 floor grid was never written **File:** `source/D2Common/src/Drlg/DrlgOutdoors.cpp` **1.10f address:** `0x6FD7F240` (inside `0x6FD7EFE0`) ### Symptom On Cold Plains and other Act I outdoor rooms, the right-border column (`x = pDrlgCoord.nWidth`) of the floor grid stayed empty - two floor-tile cells short of the reference. Directly visible as `CELL_ONLY` diffs concentrated at the room's right edge. ### Root cause The port's loop was `nX <= nWidth`, one iteration short of 1.10f's loop, which breaks when `v14 > pDrlgCoord.nWidth + 1` - i.e. keeps going through `nX == nWidth`, one column past what MOO covered. ### Old code ```cpp for (int nX = 1; nX <= pDrlgRoom->pDrlgCoord.nWidth; ++nX) ``` ### Fix ```cpp for (int nX = 1; nX <= pDrlgRoom->pDrlgCoord.nWidth + 1; ++nX) ``` ### Evidence (1.10f `0x6FD7F240`, tail of `DRLG_OUTDOORS_GenerateDirtPath` @ `0x6FD7EFE0`) ```c void __fastcall DRLG_OUTDOORS_GenerateDirtPath(struct D2DrlgLevelStrc *pLevel, struct D2DrlgRoomStrc *pDrlgRoom) { // ... v23 = &pDrlgRoom->pDrlgCoord; if ( p_pDrlgCoord->nWidth + 1 >= 1 ) { v20 = 2; // starts at column 2 (1-based origin + 1) while ( 1 ) { // ... body reads (v20-2, v13-1)..(v20, v13+1) neighbours ... v20 = v14 + 1; // advance if ( v14 > v23->nWidth + 1 ) // <-- break when v14 > nWidth + 1 break; p_pDrlgCoord = v23; } } } ``` The break at `0x6FD7F240` is `if (v14 > pDrlgCoord.nWidth + 1) break;`, so the last iteration that runs the body has `v14 == pDrlgCoord.nWidth + 1`, which corresponds to writing at column `pDrlgCoord.nWidth`. MOO's `nX <= pDrlgCoord.nWidth` bound is one iteration short. --- ## Fix 7 - `DRLGOUTSIEGE_PlaceBarricadeEntrancesAndExits`: X-scan loops used height instead of width **File:** `source/D2Common/src/Drlg/DrlgOutSiege.cpp` **1.10f address:** `0x6FD84580` ### Symptom Act V wilderness levels (`LEVEL_BLOODYFOOTHILLS` L110, `LEVEL_FRIGIDHIGHLANDS` L111) intermittently crashed with either `EXCEPTION_ACCESS_VIOLATION` or `STATUS_HEAP_CORRUPTION` (0xC0000374) partway through Act V classify. The failure was seed-sensitive but not seed-deterministic - across 8 sequential runs of the same seed 987654321, 5 crashed at various levels between L109 and L132 (three at heap-corrupt detection during a later alloc, one at direct AV) and 3 returned 132/132 PERFECT. Same MOO code path, same input; only the transient heap layout differed. ### Root cause The first two loops in `PlaceBarricadeEntrancesAndExits` are X-scans: they walk cells along the top row (`nY = 0`) then along the second-to- last row (`nY = nGridHeight - 2`), looking for the first `bLvlLink` cell to spawn the top / bottom barricade preset on. The port bounded these loops on `pOutdoors->nGridHeight` while passing the induction variable as the X coord. Retail 1.10f bounds them on `pOutdoors->nGridWidth`. Because Act V wilderness levels are always taller than they are wide (the reproducing shape was `nGridWidth = 8`, `nGridHeight = 20`), the induction variable ran to 19 - twelve cells past the row - and computed `&pOutdoors->pGrid[2].pCellsFlags[nX + pCellsRowOffsets[nY]]` with `nX = 8..19`. `pCellsRowOffsets[nY]` still returned valid offsets, but the resulting flat index landed anywhere from just past the row into unmapped territory. The two Y-scan loops immediately after (left / right columns) were correct - they iterate the Y coord and are already bound by `nGridHeight`. The wild write's target was determined by transient heap state (ASLR + prior CRT / logger / JSON allocations), so the same seed sometimes overwrote a benign heap block (silent, test still passed), sometimes corrupted a block header (heap manager caught it at the next alloc / free as `STATUS_HEAP_CORRUPTION`), and sometimes hit an unmapped page (immediate AV). None of the outcomes were about MOO's DRLG decisions differing between runs; the DRLG path itself was fully deterministic. ### Why this wasn't caught earlier `DUNGEON_AllocAct(ACT_V, ...)` used to NULL-deref inside `sub_6FD823C0(gAct5UbersDrlgLink, ...)` because `Levels.bin` in the build being tested lacked the 1.11+ UBERS records that the D2COMMON build was compiled to reference. Every historical sweep aborted at Act V setup and only classified 103 levels/seed - Acts I-IV - so `PlaceBarricadeEntrancesAndExits` was never reached. Unblocking Act V comparison (see the header note) is what made this seven-year-old loop-bound typo observable. ### Old code ```cpp void __fastcall DRLGOUTSIEGE_PlaceBarricadeEntrancesAndExits(D2DrlgLevelStrc* pLevel) { for (int i = 0; i < pLevel->pOutdoors->nGridHeight; ++i) // <-- nGridHeight { if (DRLGOUTDOORS_GetPackedGrid2Info(pLevel->pOutdoors, i, 0).bLvlLink) { DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, i, 0, LVLPREST_ACT5_BARRICADE_EXIT_32X16, 2 * (pLevel->nLevelId == LEVEL_BLOODYFOOTHILLS) - 1, 0); break; } } for (int i = 0; i < pLevel->pOutdoors->nGridHeight; ++i) // <-- nGridHeight { if (DRLGOUTDOORS_GetPackedGrid2Info(pLevel->pOutdoors, i, pLevel->pOutdoors->nGridHeight - 2).bLvlLink) { DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, i, pLevel->pOutdoors->nGridHeight - 2, LVLPREST_ACT5_BARRICADE_ENTRANCE_32X16, 2 * (pLevel->nLevelId == LEVEL_BLOODYFOOTHILLS) - 1, 0); break; } } // Y-scan loops (unchanged) follow ... } ``` ### Fix ```cpp for (int i = 0; i < pLevel->pOutdoors->nGridWidth; ++i) // <-- nGridWidth { if (DRLGOUTDOORS_GetPackedGrid2Info(pLevel->pOutdoors, i, 0).bLvlLink) { // ... } } for (int i = 0; i < pLevel->pOutdoors->nGridWidth; ++i) // <-- nGridWidth { if (DRLGOUTDOORS_GetPackedGrid2Info(pLevel->pOutdoors, i, pLevel->pOutdoors->nGridHeight - 2).bLvlLink) { // ... } } ``` ### Evidence (1.10f `0x6FD84580`) ```c void __fastcall DRLGOUTSIEGE_PlaceBarricadeEntrancesAndExits(struct D2DrlgLevelStrc *pLevel) { int v2, v4, v5, v6, v7, v8; struct D2DrlgOutdoorInfoStrc *pOutdoors; v2 = 0; pOutdoors = pLevel->pOutdoors; if ( pOutdoors->nGridWidth > 0 ) // <-- X-scan bound by nGridWidth { while ( (DRLGGRID_GetGridEntry(&pOutdoors->pGrid[2], v2, 0) & 0x400) == 0 ) { if ( ++v2 >= pOutdoors->nGridWidth ) // nGridWidth goto LABEL_6; } DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, v2, 0, 909, // 909 = LVLPREST_ACT5_BARRICADE_EXIT_32X16 2 * (pLevel->nLevelId == 110) - 1, 0); // 110 = LEVEL_BLOODYFOOTHILLS } LABEL_6: v4 = 0; v5 = pOutdoors->nGridHeight - 2; // bottom row = nGridHeight - 2 if ( pOutdoors->nGridWidth > 0 ) // <-- X-scan bound by nGridWidth { while ( (DRLGGRID_GetGridEntry(&pOutdoors->pGrid[2], v4, v5) & 0x400) == 0 ) { if ( ++v4 >= pOutdoors->nGridWidth ) // nGridWidth goto LABEL_11; } DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, v4, v5, 908, // 908 = LVLPREST_ACT5_BARRICADE_ENTRANCE_32X16 2 * (pLevel->nLevelId == 110) - 1, 0); } LABEL_11: v6 = 0; if ( pOutdoors->nGridHeight > 0 ) // <-- Y-scan bound by nGridHeight { while ( (DRLGGRID_GetGridEntry(&pOutdoors->pGrid[2], 0, v6) & 0x400) == 0 ) { if ( ++v6 >= pOutdoors->nGridHeight ) goto LABEL_16; } DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, 0, v6, 910, // 910 = LVLPREST_ACT5_BARRICADE_EXIT_16X32 2 * (pLevel->nLevelId == 110) - 1, 0); } LABEL_16: v7 = pOutdoors->nGridWidth - 2; // right column = nGridWidth - 2 v8 = 0; if ( pOutdoors->nGridHeight > 0 ) // <-- Y-scan bound by nGridHeight { while ( (DRLGGRID_GetGridEntry(&pOutdoors->pGrid[2], v7, v8) & 0x400) == 0 ) { if ( ++v8 >= pOutdoors->nGridHeight ) return; } DRLGOUTDOORS_SpawnOutdoorLevelPresetEx(pLevel, v7, v8, 907, // 907 = LVLPREST_ACT5_BARRICADE_ENTRANCE_16X32 2 * (pLevel->nLevelId == 110) - 1, 0); } } ``` The pattern is symmetric: X-scans (loops 1 and 2 - top row / bottom row) are bounded by `nGridWidth`; Y-scans (loops 3 and 4 - left column / right column) are bounded by `nGridHeight`. The port had the two X-scans using the Y-scan bound. --- ## Fix 8 - `DRLGACTIVATE_RoomExPropagateSetStatus` / `DRLGACTIVATE_RoomSetAndPropagateStatus`: status handler re-fired on every propagation instead of once at the 0->1 ref transition **File:** `source/D2Common/src/Drlg/DrlgActivate.cpp` **1.10f address:** `0x6FD73A30` (`DRLGACTIVATE_RoomExPropagateSetStatus`) **1.14d address:** `0x61B390` (`InitNearRooms`) and `0x61B490` (`AddRoomDataActual`) ### Symptom The per-status handlers in `gRoomExSetStatus[]` (which tile / untile a room and adjust its `pTileGrid`) fired more than once for the same room+status as a status was propagated across the near-room set, instead of exactly once on the room's first reference at that status. The extra dispatches re-tiled rooms the reference leaves untouched during a given propagation, leaving stale/duplicated tile emission - the residual `CELL_ONLY` diffs that survived Fixes 1-7 on the 1.14d side (e.g. L28 Barracks at seed 1053646565). ### Root cause Retail keeps a per-room `wRoomsInList[ROOMSTATUS_COUNT]` array of cumulative reference counts, one slot per status. When a status is applied, it walks `wRoomsInList[0..nStatus]` and dispatches the status handler **only if every one of those slots is still zero** - the `0 -> 1` first-reference transition - and only then increments `wRoomsInList[nStatus]`. MOO reconstructed the gate as `nFirstStatusWithRefCount == nStatus` alone. `DRLGACTIVATE_RoomExFindFirstStatusWithRefCount` returns the first slot in `[0..]` with a non-zero count, so `== nStatus` is true in **two** distinct situations: (a) slots `[0..nStatus-1]` are empty and `[nStatus]` is *already non-zero* (a later reference - retail does NOT dispatch here), and (b) all of `[0..nStatus]` are empty (the genuine first reference - retail dispatches). The reconstruction dispatched in both, so it re-fired the handler on every propagation while the lower slots happened to be empty. The missing term is `wRoomsInList[nStatus] == 0`, which excludes case (a). ### Old code ```cpp const D2DrlgRoomStatus nFirstStatusWithRefCount = DRLGACTIVATE_RoomExFindFirstStatusWithRefCount(pNearRoom, D2DrlgRoomStatus(nStatus)); if (nFirstStatusWithRefCount == nStatus) { gRoomExSetStatus[nStatus](pNearRoom); } ``` ### Fix ```cpp const D2DrlgRoomStatus nFirstStatusWithRefCount = DRLGACTIVATE_RoomExFindFirstStatusWithRefCount(pNearRoom, D2DrlgRoomStatus(nStatus)); if (nFirstStatusWithRefCount == nStatus && pNearRoom->wRoomsInList[nStatus] == 0) { gRoomExSetStatus[nStatus](pNearRoom); } ``` (the same one-line addition applies to the sibling gate in `DRLGACTIVATE_RoomSetAndPropagateStatus`, keyed on `pDrlgRoom`.) ### Evidence (1.10f `0x6FD73A30`) `v10` is the near room (`D2DrlgRoomStrc*`), `v10 + 174` is `wRoomsInList[0]`, `a3` is `nStatus`. The `while (!*v12)` loop advances through `wRoomsInList[0], [1], ..., [nStatus]`; only when the index has passed `nStatus` (`v11 > a3`, i.e. **all** those slots were zero) does it dispatch. The `++wRoomsInList[nStatus]` sits *after* the check, so the gate sees the pre-transition state. ```c if ( (unsigned __int8)v10[172] >= (int)(unsigned __int8)a3 ) // fRoomStatus >= nStatus { v11 = 0; v12 = v10 + 174; // &wRoomsInList[0] while ( !*v12 ) // while wRoomsInList[v11] == 0 { ++v11; ++v12; if ( v11 > (unsigned __int8)a3 ) // walked [0..nStatus] all-zero { funcs_6FD73AB6[(unsigned __int8)a3](v10); // gRoomExSetStatus[nStatus] break; } } } ++*(_WORD *)&v10[2 * (unsigned __int8)a3 + 174]; // ++wRoomsInList[nStatus] (AFTER) ``` ### Evidence (1.14d `0x61B390` `InitNearRooms`, mirrored at `0x61B490` `AddRoomDataActual`) Same loop shape, `wRoomsInList` at `pRoom + 12`, `fRoomStatus` at `pRoom + 68`: ```c if ( *(unsigned __int8 *)(v11 + 68) >= (int)a3 ) // fRoomStatus >= nStatus { v12 = 0; v13 = (_WORD *)(v11 + 12); // &wRoomsInList[0] while ( !*v13 ) // while wRoomsInList[v12] == 0 { ++v12; ++v13; if ( v12 > a3 ) // walked [0..nStatus] all-zero { funcs_61B410[a3](v11); // gRoomExSetStatus[nStatus] break; } } } ++*(_WORD *)(v11 + 2 * a3 + 12); // ++wRoomsInList[nStatus] (AFTER) ``` Both retail versions gate on "all of `wRoomsInList[0..nStatus]` are zero" and increment afterwards - identical logic, so the fix is ungated. --- ## Fix 9 - `DRLGROOMTILE_AddLinkedTileData`: non-floor tiles matched any link, chaining onto the floor tile list **File:** `source/D2Common/src/Drlg/DrlgRoomTile.cpp` **1.10f address:** `0x6FD89930` (`DRLGROOMTILE_AddLinkedTileData`) **1.14d address:** `0x66E620` (`DRLGROOMTILE_AddTileData`) ### Symptom When adding a non-floor tile (wall / shadow / warp) to a room's tile grid, MOO walked the `pTileGrid->pMapLinks` list and stopped at the **first** link whose `bFloor` flag did not matter, so a non-floor tile could be chained onto the *floor* link's tile list (and vice-versa the match was too loose). The wrong link's tile list then fed the wrong tile into collision emission - `CELL_ONLY` diffs on rooms with both floor and non-floor links. ### Root cause The link-match predicate needs the link's `bFloor` to **equal** whether the incoming tile is a floor tile: `link->bFloor == (nTileType == TILETYPE_FLOOR)`. MOO's reconstruction was `(bFloor && nTileType == FLOOR) || (nTileType != FLOOR)`, whose second clause matches **any** link as soon as the tile is non-floor - so a non-floor tile grabbed whatever link came first, including the floor link. ### Old code ```cpp for (pCurLink = pDrlgRoom->pTileGrid->pMapLinks; pCurLink; pCurLink = pCurLink->pNext) { if ((pCurLink->bFloor && nTileType == TILETYPE_FLOOR) || (nTileType != TILETYPE_FLOOR)) { break; } } ``` ### Fix ```cpp for (pCurLink = pDrlgRoom->pTileGrid->pMapLinks; pCurLink; pCurLink = pCurLink->pNext) { if (pCurLink->bFloor == (nTileType == TILETYPE_FLOOR)) { break; } } ``` ### Evidence (1.10f `0x6FD89930`) `i` walks `pMapLinks` (`i[2]` = `pNext`), `*i` = `link->bFloor`, `v6` = `nTileType` (0 == `TILETYPE_FLOOR`). The two branches accept a link only when `bFloor` and "is-floor" agree, and a freshly allocated link is seeded `*v10 = (v6 == 0)` - i.e. `bFloor = (nTileType == FLOOR)`: ```c for ( i = **(_DWORD ***)(a2 + 168); i; i = (_DWORD *)i[2] ) // walk pMapLinks { if ( *i ) { if ( !v6 ) goto LABEL_11; } // bFloor: match iff nTileType == FLOOR else if ( v6 ) goto LABEL_11; // !bFloor: match iff nTileType != FLOOR } v10 = Fog_10045(a1, 12, ...); // no match -> alloc new link *v10 = v6 == 0; // bFloor = (nTileType == FLOOR) ``` ### Evidence (1.14d `0x66E620`) Byte-identical predicate, `pMapLinks` at `a1 + 84`: ```c for ( i = **(_DWORD ***)(a1 + 84); i; i = (_DWORD *)i[2] ) { if ( *i ) { if ( !a2 ) goto LABEL_11; } // a2 = nTileType else if ( a2 ) goto LABEL_11; } v9 = AllocServerMemory(".\\DRLG\\RoomTile.cpp", 0x37Bu, 0); *v9 = a2 == 0; // bFloor = (nTileType == FLOOR) ``` `if (*i) { if (!nTileType) match; } else if (nTileType) match;` is exactly the boolean equality `link->bFloor == (nTileType == FLOOR)` in both retail versions. --- ## Note (not a bug) - the UBERS build flag must be defined for the 1.14d uber levels Not a DRLG-code fix, but the prerequisite that makes the 1.14d uber-level comparison (levels 133-136) possible at all. In D2MOO, `D2_VERSION_HAS_UBERS` is derived inside `D2BuildInformation.h` (`#if D2_VERSION_MAJOR >= 1 && D2_VERSION_MINOR >= 11`), which is reached through `D2Config.h`. A build that force-includes only the narrow headers it needs (as the clientless consumer here does) never pulls in `D2Config.h`, so the macro stays undefined and **every** `#ifdef D2_VERSION_HAS_UBERS` block compiles out: the uber level-id enum entries (`LEVEL_PANDEMONIUMRUN1..FINALE`) cease to exist, and the uber routing in `DRLGOUTDOORS_GenerateLevel` (force level 134 to the Act II outdoor initializer) and `DRLGOUTDESR_InitAct2OutdoorLevel` (the `case LEVEL_PANDEMONIUMRUN2:` desert-warp preset) is stripped. Level 134 then falls through to `DRLG_GetActNoFromLevelId`'s Act V result and runs the Act V siege init, which null-derefs. The D2MOO uber code itself is correct - it keys on `LEVEL_PANDEMONIUMRUN2`, which resolves to 134 once the macro is defined, and the uber enum entries are appended (no id shift for the standard 1-132 levels). The fix is purely build configuration: define `D2_VERSION_HAS_UBERS` for the 1.14d build (or force-include `D2BuildInformation.h`). With it defined, levels 133-136 generate and match 1.14d d2mapapi 136/136 with zero cell/preset diffs. --- ## Note (not a bug) - DS1 loaders must pad past EOF Not a fix in this PR, but worth flagging for anyone driving MOO's DRLG generator against retail assets: some retail `.ds1` files declare more records in a section header than actually exist on disk. The canonical case is 1.10f's `Act1\Outdoors\Trees.ds1`, whose header says `nSubstGroups = 14` but only 13 complete records follow before EOF. MOO's DS1 parser trusts the header and reads the declared count straight out of the file buffer, so on the 14th read it walks past EOF. On the real game this is masked - Storm's allocator zero-fills tail bytes and the read returns zeros. Buffers sized to exactly the file length return uninitialised heap memory, and the garbage records perturb DRLG cell emission as CELL_ONLY diffs (L6 Black Marsh is the level where this shows up first, since it's the one that pulls Trees.ds1 as a preset). Pad DS1 buffers by 256 zero bytes past EOF and the parser behaves as it does on retail. --- ## Test results ### All nine fixes - dual-version parity (5-seed) Seeds `{42, 1234567, 1053646565, 987654321, 111222333}`, difficulty 0, each level compared against the matching-version d2mapapi: ``` 1.10f code + 1.10f data (no UBERS) vs 1.10f d2mapapi: per seed: 132/132 PERFECT (max level 132, no ubers) 5 seeds: 660/660 PERFECT CELL_ONLY=0 PRESET_DIFF=0 STRUCTURAL=0 1.14d code + 1.14d data (with UBERS) vs 1.14d d2mapapi: per seed: 136/136 PERFECT (incl uber levels 133-136) 5 seeds: 680/680 PERFECT CELL_ONLY=0 PRESET_DIFF=0 STRUCTURAL=0 ``` Every level in both configurations matches on collision cells, preset NPCs, and preset objects. The 1.10f run reaches level 132 (no ubers, correct); the 1.14d run reaches 136, adding the four Pandemonium-event levels. ### Acts I-IV parity (Fixes 1-6) The 100-seed sweep was empty (`sweep-100/anomalies.txt` has 0 lines) after Fixes 1-6. Full summary line was: ``` Total across 100 seeds: PERFECT=10300 CELL_ONLY=0 PRESET_DIFF=0 STRUCTURAL=0 (of 10300) ``` Level breakdown per seed: **103 levels** classified per seed (Acts I-IV). Act V levels were skipped by the sweep because `DUNGEON_AllocAct(ACT_V, ...)` was NULL-deref'ing on the missing UBERS `Levels.bin` records - a consumer-side setup problem, not a MOO DRLG bug. **1,000-seed sweep** (Acts I-IV configuration) completed clean: **1000/1000 seeds, all PERFECT** across 103 levels/seed = 103,000 level comparisons. Zero CELL_ONLY, zero PRESET_DIFF, zero STRUCTURAL. ### Act V parity (Fix 7) Once the consumer-side UBERS problem was worked around and Act V classification was actually reached, Fix 7 was needed to land parity on Act V outdoor levels. The 5-seed sweep after Fix 7: ``` Total across 5 seeds: PERFECT=660 CELL_ONLY=0 PRESET_DIFF=0 STRUCTURAL=0 (of 660) ``` = 132 levels/seed classified (all of Acts I-V), 5 seeds `{42, 1234567, 1053646565, 987654321, 111222333}`. Seed 987654321 was the one that reliably exercised the loop-bound bug via crashing before the fix; post-fix it hits 132/132 PERFECT across 8 repeated sequential runs (each with a fresh d2mapapi server), confirming the non-determinism is gone. A fresh **1,000-seed sweep with all seven fixes and Act V unlocked** (132 levels/seed = 132,000 comparisons) is queued to run; results will be posted as a follow-up when it completes. --- ## Attribution This investigation was carried out with **Claude Code (Anthropic Claude Opus 4.7 / 4.8)** driving the reproduction harness, the bisect hooks, the IDA queries, and the source edits.
|
Ok, this seems to fix all of the issues. |
DRLG generator: nine bugs closing the gap to retail D2Common (1.10f + 1.14d)
Nine independent bugs in
D2Common/src/Drlg/where the ported C++diverges from the retail x86 binary. All nine were found by driving
MOO's DRLG generator against live retail game code running under
d2mapapi and diffing the
output per-level, then reading the retail decompile in IDA to
confirm each divergence and match the fix to the original bytes. Fixes
1-7 were nailed against 1.10f
D2Common.dll; fixes 8-9 are MOOreconstruction bugs cross-checked against both 1.10f
D2Common.dlland 1.14dGame.exe(they land the last residualdiffs on the 1.14d side and are byte-for-byte the same logic in both
retail versions, so they need no version gate).
With all nine fixes in place, MOO's DRLG output is byte-perfect
against both targets across a 5-seed sweep
{42, 1234567, 1053646565, 987654321, 111222333}:levels PERFECT every seed.
levels PERFECT every seed - including the four Pandemonium-event
"uber" levels 133-136 (Matron's Den, Forgotten Sands, Furnace of
Pain, Uber Tristram).
Before these fixes the sweep showed dozens of
CELL_ONLYdiffs and ahandful of
PRESET_DIFFresiduals.Fixes 1-6 land Acts I-IV parity. Fix 7 lands Act V outdoor parity - it
was masked earlier because a separate consumer-side setup issue
(missing UBERS
Levels.binrecords) hadDUNGEON_AllocAct(ACT_V, ...)NULL-deref before Act V DRLG code ran, so the crash-once-reachable X-scan
loop bug in
DRLGOUTSIEGE_PlaceBarricadeEntrancesAndExitswas neverexercised. Fixes 8-9 close the residual
CELL_ONLYdiffs that survived1-7 on the 1.14d side. The related consumer-side UBERS build fix (so
the uber levels 133-136 generate at all instead of crashing) is
described in the closing note.
Every fix below cites the specific retail address(es) - 1.10f, and for
fixes 8-9 the 1.14d address too - so you can open the binary in your
own tools and check the pseudocode.
Methodology
Same shape for every bug:
Reproduce. A clientless test compiles MOO's DRLG generator and,
for each level (seed x difficulty x area), asks live 1.10f
D2Common.dllthrough d2mapapi for the same input. We diff:mapRows- subtile collision (per-cell int)npcs/objects- preset unit sets{id, x, y}levelOrigin/width/height- level bounding boxClassification per level:
PERFECT/CELL_ONLY/PRESET_DIFF/STRUCTURAL. Any non-PERFECTis a bug on MOO's side (d2mapapi isrunning the reference DLL, not our code).
Bisect. Env-gated
fprintftraces inside MOO plus aMinHook-based hook layer inside d2mapapi log the same call sites
on the 1.10f side (
DRLGACTIVATE_*,DRLGROOM_AllocRoomEx,SEED_RollRandomNumber,SpawnHardcodedPresetUnits,AddPresetUnitToDrlgMap, ...). Both traces dumppLevel->pSeed,pDrlgRoom->pSeed,wRoomsInList[],fRoomStatusand the filepositions of every parsed
.ds1unit. First divergence between thetwo logs pinpoints the offending function.
Decompile. Once the function is known, IDA Hex-Rays on the
1.10f address gives the pseudocode.
Patch and re-sweep. Fix, rebuild, re-run the sweep. Escalate the
sweep size (5 seeds -> 100 -> 1,000) once a fix survives the smaller
pool.
Test hardness after this PR:
{42, 1234567, 1053646565, 987654321, 111222333}x103 levels = 515/515 PERFECT.
System.Random(42).Next())x 103 levels = 10,300/10,300 PERFECT, zero anomalies file.
first ~500 seeds.
Fix 1 -
DRLGACTIVATE_RoomExIdentifyRealStatus: transition toROOMSTATUS_COUNTwas silently a no-opFile:
source/D2Common/src/Drlg/DrlgActivate.cpp1.10f address:
0x6FD73790Symptom
Rooms whose visibility ref-counts had drained back to zero kept their
old
fRoomStatus.RoomExStatusUnset_Untile'sfRoomStatus == ROOMSTATUS_COUNTcheck therefore never passed,FreeRoomnever fired during propagation, andpTileGridonneighbouring rooms stayed alive throughout initial DRLG generation.
That in turn made
GetLinkedTileDatafind pre-existing tiles onalready-processed rooms and take an early-return that 1.10f never takes
CELL_ONLYdivergences on Act I wilderness levels.Root cause
The port routed the transition through
DRLGACTIVATE_UpdateRoomExStatusImpl, which is guarded byif (fRoomStatus > nStatus)(lower = higher priority).ROOMSTATUS_COUNT = 4is the highest value in the enum, soX > 4isnever true - the update path is dead for exactly the transition it's
meant to perform.
Old code
DRLGACTIVATE_UpdateRoomExStatusImpl(pDrlgRoom, nFirstStatusWithRefCount);Fix
Evidence (1.10f
0x6FD73790)Note the unconditional
pDrlgRoom->fRoomStatus = v2;at the tail -even the
v2 == COUNTpath (no new list to link into) still updatesthe field. That is exactly what MOO was missing.
Fix 2 -
DRLGACTIVATE_Update:forloop that never executedFile:
source/D2Common/src/Drlg/DrlgActivate.cpp1.10f address:
0x6FD73F20Symptom
The client-visibility "sweep" that walks the
ClientOutOfSightlistand initialises out-of-sight rooms never ran a single iteration.
Root cause
Textbook copy-paste of the loop-head pointer into the loop-continuation
predicate: both bounds are the same pointer, so the body never runs.
The 1.10f binary uses a proper
do { ... } while (p != head && ...)that starts at the head and terminates on the first return-to-head.
Old code
Fix
Evidence (1.10f
0x6FD73F20)Straightforward
do { body; p = p->pStatusNext; } while (p != head && !nRoomsInitSinceLastUpdate);.Fix 3 -
DRLGOUTPLACE_PlaceAct1245OutdoorBorders:bHasDirectionwas sticky across vertex iterationsFile:
source/D2Common/src/Drlg/DrlgOutPlace.cpp1.10f address:
0x6FD80ED7(inside0x6FD80E10)Symptom
Once one vertex in the border chain OR'd
bHasDirection = trueintotLvlPrestPackedInfo.nPackedValue, every subsequent iterationinherited that flag regardless of its own vertex direction. On levels
whose border chain contains a non-endpoint vertex with
nDirection == 1(L6 Black Marsh being the canonical case), later segment cellswrote the sticky
bHasDirectionflag and the river spawn refused toplace - visible as
STRUCTURALon L6.Root cause
The port initialised
tLvlPrestPackedInfoonce outside thedo/whileloop. 1.10f recomputes the equivalent(
v60 = 2*(v49 != 0) + 1) at the top of every iteration.Old code
D2DrlgOutdoorPackedGrid2InfoStrc tLvlPrestPackedInfo{ 0 }; tLvlPrestPackedInfo.nUnkb00 = true; tLvlPrestPackedInfo.bHasDirection = pDrlgVertex->nDirection != 0; do { // ... loop body, no re-init here ... } while (pDrlgVertex != pLevel->pOutdoors->pVertex);Fix
D2DrlgOutdoorPackedGrid2InfoStrc tLvlPrestPackedInfo{ 0 }; do { tLvlPrestPackedInfo.nPackedValue = 0; tLvlPrestPackedInfo.nUnkb00 = true; tLvlPrestPackedInfo.bHasDirection = pDrlgVertex->nDirection != 0; // ... rest of the loop body ... } while (pDrlgVertex != pLevel->pOutdoors->pVertex);Evidence (1.10f
0x6FD80ED7)v60is theD2DrlgOutdoorPackedGrid2InfoStrcunion local(
tLvlPrestPackedInfo, a bit-packed 32-bit value: bit 0 =nUnkb00,bit 1 =
bHasDirection, ...). Hex-Rays does not resolve the bit-packedlocal so it prints the bit assignments as raw arithmetic - the
annotations below decode each expression back to its named field.
v49is the loop-local copy ofpDrlgVertex->nDirection.v60 = 2 * (v49 != 0) + 1is(bHasDirection << 1) | nUnkb00withnUnkb00 = 1- i.e. the whole packed value is re-seeded per iterationfrom the current vertex's
nDirection. That is exactly what MOO'spost-fix
tLvlPrestPackedInfo.nPackedValue = 0; nUnkb00 = true; bHasDirection = pDrlgVertex->nDirection != 0;does.
Fix 4 -
DRLGOUTPLACE_PlaceAct1245OutdoorBorders:bHasDirectionwas set true even when both vertices hadnDirection == 0File:
source/D2Common/src/Drlg/DrlgOutPlace.cpp1.10f address:
0x6FD81167(inside0x6FD80E10)Symptom
Segments in wilderness borders whose current and next vertex both
had
nDirection == 0were writingbHasDirection = 1into the packedgrid, where retail writes
bHasDirection = 0. That perturbeddownstream
AlterGridFlagcalls on those cells, shifting a handful ofsegment
LvlPrestIds and causing per-seedCELL_ONLYdiffs onLEVEL_MOOMOOFARMand other non-{BLOODMOOR, COLDPLAINS,BURIALGROUNDS} Act I wilderness levels.
BLOODMOOR / COLDPLAINS / BURIALGROUNDSrun the pre-loop that setspDrlgVertex->nDirection = 1on select vertices, so at least one ofeach vertex pair has a non-zero direction. Other wilderness levels
skip that pre-loop, so pairs with two adjacent zero-direction vertices
exist - which is exactly where the bug fires.
Root cause
The port's else-branch (when
pDrlgVertex->nDirection == 0) fell backto
pNextVertex->nDirectionfor the effectivenDirection, but thenset
tLvlPrestPackedInfo.bHasDirection = trueunconditionally -even if
pNextVertex->nDirectionwas also zero. Retail only sets thatbit when the effective (short-circuited) direction is non-zero, so
when both vertices are
nDirection == 0the bit stays clear.Old code
Fix
Evidence (1.10f
0x6FD81167)v49ispDrlgVertex->nDirectionat the top of the loop and isrewritten to
pNextVertex->nDirectionby the||short-circuit hereif it was zero.
v60istLvlPrestPackedInfo.nPackedValue;LOBYTE(v38) = v60 | 2sets bit 1 of that packed value(
bHasDirection = true). Crucially, that|= 2sits inside theif— it fires only when the short-circuit produced a non-zeroeffective direction. That's the retail behaviour MOO's
elsebranchwas missing.
1.14d's
Game.exe PlaceAct1245OutdoorBorders @ 0x675B60has the samev59 || (v59 = pNextVertex->nDirection)short-circuit followed by agated
v46 |= 2u, so the two retail versions agree - no versiondivergence to switch on.
Fix 5 -
DRLGOUTWILD_SpawnRiver: bridge row selection had the wrong modulusFile:
source/D2Common/src/Drlg/DrlgOutWild.cpp1.10f address:
0x6FD85258Symptom
For outdoor levels with
OUTDOOR_RIVER | OUTDOOR_BRIDGE, MOO tested adifferent (and generally wrong) set of grid rows for a valid
bridge-crossing spawn cell than 1.10f. On some seeds this shifts the
crossing by a row; on others it fails to find a valid cell at all -
measurable as
CELL_ONLY/STRUCTURALon Act I wilderness levelsthat host bridges.
Root cause
MOO's modulus was
gridHeight - 1with no+ 1offset. The referenceformula is
(nRand + i) % (gridHeight - 2) + 1, which produces acompletely different set of visited rows.
Old code
Fix
Evidence (1.10f
0x6FD85258, insideDRLGOUTWILD_SpawnRiver@0x6FD850B0)(nRand + i) % (nGridHeight - 2) + 1- modulus isnGridHeight - 2,offset by
+ 1.Fix 6 -
DRLG_OUTDOORS_GenerateDirtPath: right-border column of the 9x9 floor grid was never writtenFile:
source/D2Common/src/Drlg/DrlgOutdoors.cpp1.10f address:
0x6FD7F240(inside0x6FD7EFE0)Symptom
On Cold Plains and other Act I outdoor rooms, the right-border column
(
x = pDrlgCoord.nWidth) of the floor grid stayed empty - twofloor-tile cells short of the reference. Directly visible as
CELL_ONLYdiffs concentrated at the room's right edge.Root cause
The port's loop was
nX <= nWidth, one iteration short of 1.10f'sloop, which breaks when
v14 > pDrlgCoord.nWidth + 1- i.e. keepsgoing through
nX == nWidth, one column past what MOO covered.Old code
Fix
Evidence (1.10f
0x6FD7F240, tail ofDRLG_OUTDOORS_GenerateDirtPath@0x6FD7EFE0)The break at
0x6FD7F240isif (v14 > pDrlgCoord.nWidth + 1) break;, so the last iteration thatruns the body has
v14 == pDrlgCoord.nWidth + 1, which corresponds towriting at column
pDrlgCoord.nWidth. MOO'snX <= pDrlgCoord.nWidthbound is one iteration short.Fix 7 -
DRLGOUTSIEGE_PlaceBarricadeEntrancesAndExits: X-scan loops used height instead of widthFile:
source/D2Common/src/Drlg/DrlgOutSiege.cpp1.10f address:
0x6FD84580Symptom
Act V wilderness levels (
LEVEL_BLOODYFOOTHILLSL110,LEVEL_FRIGIDHIGHLANDSL111) intermittently crashed with eitherEXCEPTION_ACCESS_VIOLATIONorSTATUS_HEAP_CORRUPTION(0xC0000374)partway through Act V classify. The failure was seed-sensitive but not
seed-deterministic - across 8 sequential runs of the same seed 987654321,
5 crashed at various levels between L109 and L132 (three at heap-corrupt
detection during a later alloc, one at direct AV) and 3 returned 132/132
PERFECT. Same MOO code path, same input; only the transient heap layout
differed.
Root cause
The first two loops in
PlaceBarricadeEntrancesAndExitsare X-scans:they walk cells along the top row (
nY = 0) then along the second-to-last row (
nY = nGridHeight - 2), looking for the firstbLvlLinkcellto spawn the top / bottom barricade preset on. The port bounded these
loops on
pOutdoors->nGridHeightwhile passing the induction variableas the X coord. Retail 1.10f bounds them on
pOutdoors->nGridWidth.Because Act V wilderness levels are always taller than they are wide
(the reproducing shape was
nGridWidth = 8,nGridHeight = 20), theinduction variable ran to 19 - twelve cells past the row - and computed
&pOutdoors->pGrid[2].pCellsFlags[nX + pCellsRowOffsets[nY]]withnX = 8..19.pCellsRowOffsets[nY]still returned valid offsets, but theresulting flat index landed anywhere from just past the row into
unmapped territory. The two Y-scan loops immediately after (left / right
columns) were correct - they iterate the Y coord and are already bound
by
nGridHeight.The wild write's target was determined by transient heap state (ASLR
overwrote a benign heap block (silent, test still passed), sometimes
corrupted a block header (heap manager caught it at the next
alloc / free as
STATUS_HEAP_CORRUPTION), and sometimes hit anunmapped page (immediate AV). None of the outcomes were about MOO's
DRLG decisions differing between runs; the DRLG path itself was fully
deterministic.
Why this wasn't caught earlier
DUNGEON_AllocAct(ACT_V, ...)used to NULL-deref insidesub_6FD823C0(gAct5UbersDrlgLink, ...)becauseLevels.binin thebuild being tested lacked the 1.11+ UBERS records that the D2COMMON
build was compiled to reference. Every historical sweep aborted at Act
V setup and only classified 103 levels/seed - Acts I-IV - so
PlaceBarricadeEntrancesAndExitswas never reached. Unblocking Act Vcomparison (see the header note) is what made this seven-year-old
loop-bound typo observable.
Old code
Fix
Evidence (1.10f
0x6FD84580)The pattern is symmetric: X-scans (loops 1 and 2 - top row / bottom
row) are bounded by
nGridWidth; Y-scans (loops 3 and 4 - left column/ right column) are bounded by
nGridHeight. The port had the twoX-scans using the Y-scan bound.
Fix 8 -
DRLGACTIVATE_RoomExPropagateSetStatus/DRLGACTIVATE_RoomSetAndPropagateStatus: status handler re-fired on every propagation instead of once at the 0->1 ref transitionFile:
source/D2Common/src/Drlg/DrlgActivate.cpp1.10f address:
0x6FD73A30(DRLGACTIVATE_RoomExPropagateSetStatus)1.14d address:
0x61B390(InitNearRooms) and0x61B490(
AddRoomDataActual)Symptom
The per-status handlers in
gRoomExSetStatus[](which tile / untile aroom and adjust its
pTileGrid) fired more than once for the sameroom+status as a status was propagated across the near-room set,
instead of exactly once on the room's first reference at that status.
The extra dispatches re-tiled rooms the reference leaves untouched
during a given propagation, leaving stale/duplicated tile emission -
the residual
CELL_ONLYdiffs that survived Fixes 1-7 on the 1.14dside (e.g. L28 Barracks at seed 1053646565).
Root cause
Retail keeps a per-room
wRoomsInList[ROOMSTATUS_COUNT]array ofcumulative reference counts, one slot per status. When a status is
applied, it walks
wRoomsInList[0..nStatus]and dispatches the statushandler only if every one of those slots is still zero - the
0 -> 1first-reference transition - and only then incrementswRoomsInList[nStatus].MOO reconstructed the gate as
nFirstStatusWithRefCount == nStatusalone.
DRLGACTIVATE_RoomExFindFirstStatusWithRefCountreturns thefirst slot in
[0..]with a non-zero count, so== nStatusis true intwo distinct situations: (a) slots
[0..nStatus-1]are empty and[nStatus]is already non-zero (a later reference - retail does NOTdispatch here), and (b) all of
[0..nStatus]are empty (the genuinefirst reference - retail dispatches). The reconstruction dispatched in
both, so it re-fired the handler on every propagation while the lower
slots happened to be empty. The missing term is
wRoomsInList[nStatus] == 0, which excludes case (a).Old code
Fix
(the same one-line addition applies to the sibling gate in
DRLGACTIVATE_RoomSetAndPropagateStatus, keyed onpDrlgRoom.)Evidence (1.10f
0x6FD73A30)v10is the near room (D2DrlgRoomStrc*),v10 + 174iswRoomsInList[0],a3isnStatus. Thewhile (!*v12)loop advancesthrough
wRoomsInList[0], [1], ..., [nStatus]; only when the index haspassed
nStatus(v11 > a3, i.e. all those slots were zero) doesit dispatch. The
++wRoomsInList[nStatus]sits after the check, sothe gate sees the pre-transition state.
Evidence (1.14d
0x61B390InitNearRooms, mirrored at0x61B490AddRoomDataActual)Same loop shape,
wRoomsInListatpRoom + 12,fRoomStatusatpRoom + 68:Both retail versions gate on "all of
wRoomsInList[0..nStatus]arezero" and increment afterwards - identical logic, so the fix is
ungated.
Fix 9 -
DRLGROOMTILE_AddLinkedTileData: non-floor tiles matched any link, chaining onto the floor tile listFile:
source/D2Common/src/Drlg/DrlgRoomTile.cpp1.10f address:
0x6FD89930(DRLGROOMTILE_AddLinkedTileData)1.14d address:
0x66E620(DRLGROOMTILE_AddTileData)Symptom
When adding a non-floor tile (wall / shadow / warp) to a room's tile
grid, MOO walked the
pTileGrid->pMapLinkslist and stopped at thefirst link whose
bFloorflag did not matter, so a non-floor tilecould be chained onto the floor link's tile list (and vice-versa the
match was too loose). The wrong link's tile list then fed the wrong
tile into collision emission -
CELL_ONLYdiffs on rooms with bothfloor and non-floor links.
Root cause
The link-match predicate needs the link's
bFloorto equalwhether the incoming tile is a floor tile:
link->bFloor == (nTileType == TILETYPE_FLOOR). MOO's reconstructionwas
(bFloor && nTileType == FLOOR) || (nTileType != FLOOR), whosesecond clause matches any link as soon as the tile is non-floor -
so a non-floor tile grabbed whatever link came first, including the
floor link.
Old code
Fix
Evidence (1.10f
0x6FD89930)iwalkspMapLinks(i[2]=pNext),*i=link->bFloor,v6=nTileType(0 ==TILETYPE_FLOOR). The two branches accept a linkonly when
bFloorand "is-floor" agree, and a freshly allocated linkis seeded
*v10 = (v6 == 0)- i.e.bFloor = (nTileType == FLOOR):Evidence (1.14d
0x66E620)Byte-identical predicate,
pMapLinksata1 + 84:if (*i) { if (!nTileType) match; } else if (nTileType) match;isexactly the boolean equality
link->bFloor == (nTileType == FLOOR)inboth retail versions.
Note (not a bug) - the UBERS build flag must be defined for the 1.14d uber levels
Not a DRLG-code fix, but the prerequisite that makes the 1.14d
uber-level comparison (levels 133-136) possible at all. In D2MOO,
D2_VERSION_HAS_UBERSis derived insideD2BuildInformation.h(
#if D2_VERSION_MAJOR >= 1 && D2_VERSION_MINOR >= 11), which isreached through
D2Config.h. A build that force-includes only thenarrow headers it needs (as the clientless consumer here does) never
pulls in
D2Config.h, so the macro stays undefined and every#ifdef D2_VERSION_HAS_UBERSblock compiles out: the uber level-idenum entries (
LEVEL_PANDEMONIUMRUN1..FINALE) cease to exist, and theuber routing in
DRLGOUTDOORS_GenerateLevel(force level 134 to theAct II outdoor initializer) and
DRLGOUTDESR_InitAct2OutdoorLevel(the
case LEVEL_PANDEMONIUMRUN2:desert-warp preset) is stripped.Level 134 then falls through to
DRLG_GetActNoFromLevelId's Act Vresult and runs the Act V siege init, which null-derefs.
The D2MOO uber code itself is correct - it keys on
LEVEL_PANDEMONIUMRUN2, which resolves to 134 once the macro isdefined, and the uber enum entries are appended (no id shift for the
standard 1-132 levels). The fix is purely build configuration: define
D2_VERSION_HAS_UBERSfor the 1.14d build (or force-includeD2BuildInformation.h). With it defined, levels 133-136 generate andmatch 1.14d d2mapapi 136/136 with zero cell/preset diffs.
Note (not a bug) - DS1 loaders must pad past EOF
Not a fix in this PR, but worth flagging for anyone driving MOO's
DRLG generator against retail assets: some retail
.ds1files declaremore records in a section header than actually exist on disk. The
canonical case is 1.10f's
Act1\Outdoors\Trees.ds1, whose header saysnSubstGroups = 14but only 13 complete records follow before EOF.MOO's DS1 parser trusts the header and reads the declared count
straight out of the file buffer, so on the 14th read it walks past
EOF. On the real game this is masked - Storm's allocator zero-fills
tail bytes and the read returns zeros. Buffers sized to exactly the
file length return uninitialised heap memory, and the garbage records
perturb DRLG cell emission as CELL_ONLY diffs (L6 Black Marsh is the
level where this shows up first, since it's the one that pulls
Trees.ds1 as a preset).
Pad DS1 buffers by 256 zero bytes past EOF and the parser behaves as
it does on retail.
Test results
All nine fixes - dual-version parity (5-seed)
Seeds
{42, 1234567, 1053646565, 987654321, 111222333}, difficulty 0,each level compared against the matching-version d2mapapi:
Every level in both configurations matches on collision cells, preset
NPCs, and preset objects. The 1.10f run reaches level 132 (no ubers,
correct); the 1.14d run reaches 136, adding the four Pandemonium-event
levels.
Acts I-IV parity (Fixes 1-6)
The 100-seed sweep was empty (
sweep-100/anomalies.txthas 0 lines)after Fixes 1-6. Full summary line was:
Level breakdown per seed: 103 levels classified per seed (Acts
I-IV). Act V levels were skipped by the sweep because
DUNGEON_AllocAct(ACT_V, ...)was NULL-deref'ing on the missing UBERSLevels.binrecords - a consumer-side setup problem, not a MOO DRLGbug.
1,000-seed sweep (Acts I-IV configuration) completed clean:
1000/1000 seeds, all PERFECT across 103 levels/seed = 103,000
level comparisons. Zero CELL_ONLY, zero PRESET_DIFF, zero STRUCTURAL.
Act V parity (Fix 7)
Once the consumer-side UBERS problem was worked around and Act V
classification was actually reached, Fix 7 was needed to land parity
on Act V outdoor levels. The 5-seed sweep after Fix 7:
= 132 levels/seed classified (all of Acts I-V), 5 seeds
{42, 1234567, 1053646565, 987654321, 111222333}. Seed 987654321 wasthe one that reliably exercised the loop-bound bug via crashing
before the fix; post-fix it hits 132/132 PERFECT across 8 repeated
sequential runs (each with a fresh d2mapapi server), confirming the
non-determinism is gone.
A fresh 1,000-seed sweep with all seven fixes and Act V unlocked
(132 levels/seed = 132,000 comparisons) is queued to run; results
will be posted as a follow-up when it completes.
Attribution
This investigation was carried out with Claude Code (Anthropic
Claude Opus 4.7 / 4.8) driving the reproduction harness, the
bisect hooks, the IDA queries, and the source edits.