# Hot-swapping BH's config at runtime _Measured against `C:\Diablo II\BH 1.5s2-1.13c\BH.dll` (753,664 B, 2023-07-03, ImageBase `0x10000000`, DYNAMIC_BASE set). **Every address here is an RVA.** BH relocates — observed at `0x69660000` and `0x730A0000` in different sessions. An absolute BH address is always wrong._ Binary identity, from the CodeView record at file offset `0x97520`: **slashdiablo-maphack**. No public source was used for any address in this document. BH is open source, and the public tree is useful for names and shape, but it is a different revision — everything below was disassembled from this binary and independently re-verified by an adversarial pass. **Status: shipped and confirmed working in-game.** `src/bh_config.c`, esoteric.dll v1.45. --- ## 1. THE ANSWER **BH cannot reload its own config, and nothing in it ever will.** `Config::Parse` has exactly one call site, and the chain from it terminates in `DllMain`'s `DLL_PROCESS_ATTACH`. No watcher thread, no reload command, no menu item, no second parse anywhere in the image. Writing bytes into `BH.cfg` and waiting for BH to notice can never work; the swap has to call the parser itself. **And calling the parser is not enough.** This is the part that cost a full test cycle: item names do not come from the config map. They come from a compiled rule list that `Config::Parse` never touches. --- ## 2. THE MISTAKE, RECORDED IN FULL The first working version re-pointed the filename, called `Config::Parse`, and re-applied the **Maphack** module. It logged: ``` [CFG] Maphack re-applied (instance 862E14AC, found 4 modules into the key list). [CFG] re-pointed BH config 'BH.cfg' -> 'BH-Level.cfg'; parse OK, maphack re-apply OK (NO files written) ``` Every clause of that is true. Nothing visible changed. Three independent errors stacked: 1. **Wrong module.** BH's module named `Maphack` is the **minimap/visual layer** — Show Monsters, Infravision, Show Missiles, Force Light Radius, Remove Weather, Remove Shake, Reveal Map, Display Lvl Names. It owns no item filtering. The item filter is the **Item** module. 2. **Wrong structure.** Even the right module would not have been enough alone: the rules are compiled into three globals, and only `InitializeItemRules` rebuilds them. 3. **Wrong enumeration.** The module walk used the **key-handler** list (`manager+0x34`), which holds only modules that registered a key binding — four in a live session. A module without a key binding is unreachable that way, and Item is one. **The lesson worth keeping:** "the call succeeded" is not "the change took effect." Re-applying configuration is only meaningful against the structure that is actually read at draw time. Find that structure first, then work backwards to whatever rebuilds it. --- ## 3. THE ITEMDISPLAY PIPELINE ``` 0x000281C5 *outName = rule->action.name the write, UNCONDITIONAL 0x000BA150 rules[all] std::vector the superset 0x000BA118 rules[desc] 0x000BA174 rules[blank] 0x00028670 InitializeItemRules __cdecl, ZERO args, bare ret at 0x28925 0x000231F0 Item::LoadConfig the ONLY caller of 0x28670 in the image 0x0009787C Item vtable, slot 0 = Item::LoadConfig 0x0008F430 "ItemDisplay", pushed from exactly ONE site: 0x000286A6 ``` The rule vectors are **append-only** — push_backs at `0x28880` / `0x2889D` / `0x288C1`, cleared by nothing except the atexit destructors. Building twice without a teardown doubles the list, and evaluation is order-sensitive, so the stale rules sit in front. **Blanking is not a special case.** An empty `ItemDisplay` value compiles to a rule whose `action.name` is empty, and `0x281C5` writes it unconditionally. That is why ``` ItemDisplay[hp1]: ``` erases the potion's name both on the ground and in a vendor tooltip. Both surfaces converge on BH's single 6-byte patch at **D2Client RVA `0x00092366`**, inside the common tail of `GetItemName` (`0x000914F0`) — eleven internal jumps converge there and eight call sites cover both surfaces, so they cannot diverge through BH. Rule objects are **leaked by design**. There is no `Rule` destructor anywhere in BH; it leaks them at shutdown too. Roughly 100–250 KB per switch. Freeing them would mean destroying polymorphic `Condition` objects whose vtable is unmapped — not worth a crash. --- ## 4. THE PARSE DEFECT Latent in BH, invisible in normal use because BH parses exactly once. ``` 0x0000AAB0 Config::Parse bool __thiscall(void*), ECX=this, bare ret at 0xADE7 result in AL ONLY -- upper 24 bits of EAX are the basic_ios dtor's leftovers 0x0000ABDB range-erase of the map at Config+0x1C 0x0000AD7B push_back into the ordered-key vector at Config+0x2C the ONLY instruction in the whole function that touches it. NEVER CLEARED. 0x0000B8A0 Config::ReadMapList -- the only feeder of InitializeItemRules. Walks that vector, resolving each key through std::map::operator[] (0x0000BC10), which INSERTS AN EMPTY STRING ON A MISS. ``` So on a second parse, every `ItemDisplay` key present in the **old** file but absent from the new one comes back **as an empty value** — a blank-name rule — and because the vector is ordered and append-only, those stale blanks precede the new file's rules. The switch would appear to half-work in a way that reads like a rule-matching bug. Fix: destroy `Config+0x2C` before re-parsing, using BH's own `~vector` at `0x00046820`. Its `_Tidy` (`0x00004380`) deallocates then zeroes `_Myfirst/_Mylast/_Myend`, leaving the vector valid, empty and immediately reusable by `push_back`. `Config+0x00` is the filename `std::string`; Parse joins it with the directory global at `0x000A99AC`, which already ends in a backslash (appended at `BH::Startup` `0x345D–0x3467`), so the filename must be bare. --- ## 5. THE MODULE MANAGER ``` 0x000A9974 ModuleManager* -- a POINTER, not the object. Reading it as the object walks zero nodes; that is how the error first surfaced. manager+0x00 std::map -- ALL 9 modules manager+0x14..+0x44 thirteen per-event handler lists manager+0x34 the KEY list; node +0x04 = next, +0x08 = module manager+0x10 0x00011180 -- the list "lock", which is AN EMPTY FUNCTION 0x0003ED20 manager ctor; the object is operator new(0x48) at 0x3473 0x0003D2F0 the registrar: operator new(0x10) per node, node ctor 0x3E7B0, push-front 0x0003EC90 key thunk, `mov eax,[ecx] / jmp [eax+0x28]` -- 0x28/4 = slot 10, which independently confirms Maphack vtable slot 10 (0x354F0) as its key handler ``` **There is no locking in BH.** Anything that mutates state BH's render path reads must run on the same thread as that render path. ### Module ownership, by config key | Module | Code region | vtable | slot 0 | Owns | |---|---|---|---|---| | **Maphack** | `0x335D0–0x35C84` | `0x97E24` | `0x337B0` | Show Monsters, Infravision, Show Missiles, Force Light Radius, Remove Weather, Remove Shake, Reveal Map, Display Lvl Names | | **Item** | `0x231C0–0x24Bxx` | `0x9787C` | `0x231F0` | Advanced Item Display, Shorten Item Names, Show Ethereal, Show Sockets, Show iLvl, Alt Item Style, Color Mod, Show Rune Numbers, **ItemDisplay** | The name is BH's own and it is misleading: to drop map features without losing the minimap, clear the individual keys in the `.cfg` — do not unregister the module. Maphack slot 0 is **not idempotent** — it unconditionally `operator new`s a `0x30` and a `0x70` object and overwrites `this+0x18C` without freeing the old one. --- ## 6. THE SHIPPED SEQUENCE All five calls rebased on `GetModuleHandleA("BH.dll")`. | # | Target | RVA | Convention | ECX | Notes | |---|---|---|---|---|---| | 1 | `~vector` | `0x46820` | `__thiscall`, bare ret | `Config+0x2C` | clear the stale key vector **first** | | 2 | *re-point the filename* | — | our own writes | — | see below | | 3 | `Config::Parse` | `0xAAB0` | `__thiscall`, bare ret | `Config` | result in **AL** only | | 4 | `~vector` ×3 | `0x31930` | `__thiscall`, bare ret | `0xBA150`, `0xBA118`, `0xBA174` | **after** the parse, so a failed parse leaves the working set intact | | 5 | `InitializeItemRules` | `0x28670` | `__cdecl`, **no args** | — | ECX never read | **Step 2.** `Config+0x00` is an MSVC10 `std::string`: 16-byte **inline** buffer, `_Mysize` at `+0x10`, `_Myres` at `+0x14`. Save 16 bytes, `memset`, `memcpy` the new name, set `_Mysize`, and **leave `_Myres` at 15**. Layout proven, not assumed — `size()` `0xBB30` returns `[this+0x10]`; `_Myptr` `0x49E0` does `cmp [eax+0x14],0x10 / jb`; `_Tidy` `0x4250` resets `[ecx+0x14]` to `0xF`. Names over 15 chars would need a real heap block from BH's own `operator new` (IAT `0x8A2B8`) — **refuse instead**. All five of Doug's files fit; `BH-leveling.cfg` is exactly 15. **`Item::LoadConfig` (`0x231F0`) is deliberately NOT called.** It is not idempotent — at `0x23BD2` it `operator new`s a `0x30` config-menu category into `Settings+0x78` (`*(base+0xA9960)`) plus ~9 more entries, so a second call duplicates BH's in-game settings menu and leaks. Calling the rule builder directly costs the Item **toggles** (Show Sockets, Show iLvl, Color Mod) not following a switch. Item **display rules** do follow, and those are the filter. ### Gates — all read-only; any failure refuses rather than proceeding - **Relocation-aware build fingerprint**: byte at `0x286A6` is `0x68`, the push operand equals `base+0x8F430`, and that address reads `"ItemDisplay"`. Proves both the build **and** that the rebasing arithmetic is right. - **`BH::Startup` self-test**: `*(base+0xA9964) == base+0xADC08`, written from `Item::GetToggles` (`0x2FF0`). Note `0xA9964` is `&Toggles`, **not** the Item module instance — an earlier reading that said otherwise would have passed `&Toggles` as `this`. - Every code target begins `0x55` — catches a 5-byte JMP detour from another loader. - `VirtualQuery` on every pointer; vector headers must satisfy `first <= last <= end`, `span % stride == 0`, `count < 200000`, buffer committed. - Refuse if the rule list is empty **and has never been populated** — that means "Advanced Item Display" was off at BH startup, and building rules would switch on behaviour BH intends to be off. ### Thread rule Step 4 empties vectors the render path iterates, and BH has no locks. Safe **only** because the whole sequence runs on D2's main thread — the caller is inside the frame-end UI run at D2Client `0x6FB73D91`. The thread id is logged once so the assumption is checkable. **Never call it from another thread.** ### Honest limit on "writes nothing" `Parse` opens the file through `_Fiopen` with openmode 3 (`in|out`) and `_SH_DENYNO` — an `"r+"` handle. It issues **no write**: the only stream imports on that path are `getline` (`0x8A188`) and `ios_base::eof` (`0x8A194`). Contents and mtime are untouched, verified by size and timestamp across switches. Narrowing the handle itself would mean patching BH's startup. The switch is **session-only**: nothing on disk changes, so a restart returns to `BH.cfg`. --- ## 7. TOOLCHAIN: THE FAULT GUARD Two traps, both hit, both producing a handler that **looks installed and does nothing**. **`__try` is unavailable.** It compiles under `-fms-extensions`, then the assembler rejects it: ``` error: assembler label 'L__ehtable$call_bad' can not be undefined error: assembler label 'Lcall_bad$parent_frame_offset' can not be undefined ``` Clang's i386 SEH codegen is incomplete for the MinGW target. A single trivial function may slip through `-c`; anything real does not. **A hand-rolled `fs:[0]` frame gets silently disarmed.** The classic technique works — link a record whose handler rewrites the faulting `CONTEXT`'s `Eip`/`Esp`/`Ebp` to resume at a label in the caller and returns `ExceptionContinueExecution`. But if nothing in the C source branches to that label, clang proves the block unreachable, **deletes it, and folds `&&label` to the literal 1**: ```asm movl $1, -36(%ebp) # recover_eip ``` The handler then resumes at address `0x00000001` and every fault is fatal — the exact opposite of the intent, with no diagnostic. Keep the block reachable: ```c static volatile DWORD g_never = 0; ... if (g_never) goto *(void *)(uintptr_t)f.recover_eip; ``` The volatile read forces the test, which keeps the indirect branch, which keeps the block, which makes the label address real (`movl $Ltmp2, ...`). Restore `fs:[0]` **inside the handler** before returning, not at the recovery label — the callee may have installed its own SEH records on stack that is about to be discarded. **Test it rather than trusting it.** `src/bh_guard.c` was proven in a standalone harness covering a null-`this` deref, a deep fault after a 512-byte frame is established, a call to address 4 in both conventions, and a call into non-executable data — each verified caught, with ESP compared before and after and a known-good call repeated afterwards. --- ## 8. WHAT IS STILL UNKNOWN | Unknown | Cheapest measurement | |---|---| | Whether the live BH image matches the on-disk bytes at the five targets | the `0x55` prologue check covers the common case; log the first 8 bytes on the first switch for certainty | | Exact leak per switch | log `rules[all]` and compare private bytes across ten switches | | Whether `Rule+0x10` (`Action.stopProcessing`) makes evaluation first-match-wins | disassemble `0x27AE0` and the loop exit at `0x27AC6` — static, no game | | How to switch a config whose name exceeds 15 chars | allocate via BH's `operator new` (IAT `0x8A2B8`), store at `Config+0x00`, set `_Myres` to size−1. **Untested — do not ship without a test cycle.** |