← The Esoteric Path

Swapping a config that was never meant to reload

Two findings that have nothing to do with Diablo II, arrived at by taking a loot filter apart.

The loot filter that runs alongside this mod reads its configuration exactly once, during DllMain. Its parser has one call site and the chain from it terminates at process attach. There is no watcher thread, no reload command, no menu item, no second parse anywhere in the image. Editing the file and waiting for it to be noticed cannot work, and no amount of patience will change that.

So the swap has to call the parser directly. That much was straightforward. What follows is the part that was not.

“The call succeeded” is not “the change took effect”

The first working version re-pointed the filename, called the parser, and re-applied the module it looked like it should. It logged this:

[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 errors were stacked underneath it, and each one alone would have been enough:

  1. The module named Maphack is the minimap and visual layer — show monsters, infravision, light radius, reveal map. It owns no item filtering at all. The name was doing the reasoning.
  2. Even the right module would not have sufficed. Item names are read at draw time from a compiled rule list, and only the routine that builds that list can change it. The config map the parser fills is not what the renderer reads.
  3. The module walk used the key-handler list, which holds only modules that registered a key binding — four of nine in a live session. A module without a key binding is invisible to that enumeration, and the item filter is one.

The lesson is not about loot filters. Re-applying configuration only means something against the structure that is actually read at the moment of use. Find that structure first, then work backwards to whatever rebuilds it. A log line that reports success is reporting that a function returned, which is a much smaller claim than it looks.

The optimiser disarmed the fault handler

Calling into a third-party binary at runtime wants a guard, so that a wrong address produces a refusal instead of taking the process down. On this toolchain that turned out to have two traps, and both produce a handler that looks installed and does nothing.

Structured exception handling is unavailable

__try compiles under -fms-extensions and then the assembler rejects it — clang's i386 SEH codegen is incomplete for this target. A trivial function may slip through; anything real does not.

A hand-rolled frame gets deleted

The classic technique still works: link a record onto fs:[0] whose handler rewrites the faulting context to resume at a label, and return continue execution. But if nothing in the C source ever branches to that label, the compiler proves the block unreachable, removes it, and folds the label's address to the literal 1:

movl $1, -36(%ebp)        # recover_eip

The handler then resumes execution at address 0x00000001. Every fault becomes fatal — the precise opposite of the intent, with no warning at compile time and nothing visibly wrong at the call site. The guard is present, initialised, and inverted.

The fix is to keep the block reachable on paper, so that the label survives to have an address at all:

static volatile DWORD g_never = 0;
...
if (g_never) goto *(void *)(uintptr_t)f.recover_eip;

The volatile read forces the test, the test keeps the indirect branch, the branch keeps the block, and the label becomes real. One more detail earned the hard way: restore fs:[0] inside the handler before returning, not at the recovery label — the callee may have installed its own records on stack that is about to be discarded.

It was then tested rather than trusted: a null dereference, a deep fault after a 512-byte frame, calls to address 4 in two conventions, and a call into non-executable data. Each verified caught, stack pointer compared before and after, and a known-good call repeated afterwards to prove the guard had not eaten the process on its way past.

On the addresses

Every offset here is an RVA. That binary sets DYNAMIC_BASE and relocates — it has been observed at two different load addresses in the same week. An absolute address written down from one session is wrong in the next, and someone would act on it.

None of it came from published source. It was disassembled from the binary as shipped, 753,664 bytes, and re-verified adversarially afterwards. That is the difference between a table copied from somewhere and a measurement, and it is the reason the findings above include the one that was wrong.