← The Esoteric Path

Five wrong addresses, then a watchpoint

Each guess about which function was responsible encoded a belief. The thing that finally worked encoded nothing at all.

This mod keeps a single town portal that follows you, instead of leaving one behind at every cast. Doing that means stopping the game from destroying the old one, which means knowing exactly which code destroys it — not approximately, and not by reputation.

Static disassembly produced five answers over several days. All five were wrong. What is worth writing down is not that they were wrong, but that each was wrong in a different way, and that one of them had the correct address.

Before any of them, a thirty-second experiment: leave town by waypoint rather than through the portal, and watch what happens. That discriminated between the competing explanations and killed the level-teardown theory outright. It cost half a minute and it saved a day of reading the wrong function.

The five

Every offset below is an RVA into D2Game or D2Common unless stated. See the note at the foot on why none of them is written as an absolute.

  1. D2Common #10066 — AllocDrlgDelete

    Breakpointed at its import thunk, rva 0xA48A, verified as jmp dword ptr [IAT] against the static import table at 0xF82F0 so there was no doubt the breakpoint was on the real thing. Zero hits across a full cast, town, and return trip.

    A published decompilation says the paired delete calls this twice. This build never calls it here at all. The decompilation was of a different version, and a decompilation describes a structure.

  2. D2Common #10102 — ToggleHasPortalFlag

    The ordinal route. Exactly one call site exists in the whole of D2Game, at 0xA490E, so it looked unambiguous. Then the disassembly turned out to be percentage arithmetic — a result multiplied by 160, the 0x51EB851F reciprocal that compilers emit for divide-by-one-hundred, clamps at 0x80 and 0x100 — and it consumes a return value.

    A void toggle(room, flag) cannot be that function. The ordinal resolved perfectly; it resolved to something else. Ordinals do not transfer between versions.

  3. DeletePlayerTownPortal, rva 0x386F0 — and all four of its callers

    Instrumented at 0x5D06F, 0xAF4C2 (the cast path), 0xE210F and 0xF1225, every counter zeroed, then a complete cycle played. All five read zero. The function with the exactly correct name is not on the return path.

    Doug's own notes from years earlier recommend NOPping precisely this call — and they were right, for the goal he had then. Removing a portal is not the same problem as keeping one. A correct note can be a wrong answer to a question it never asked.

  4. OperateFn slot 15, rva 0x6BCB0

    This one was read out of the live dispatch table at 0x107BBC rather than guessed from a header, so the address was certainly, verifiably right. The breakpoint was armed correctly. Zero hits.

    Entering a portal in this build is not an object operate — you walk into it. The address was right and the hypothesis was still wrong, which is the failure mode that no amount of care about addresses will catch.

  5. The sweep for cmp <mem>, 0x3C

    Not a wrong address — a defective filter, and the most instructive of the five. The search required a memory operand and returned four sites, which felt like coverage. Allowing register operands as well returns twenty-three: nineteen of them were invisible to the search that had supposedly covered them.

    None of the twenty-three is the exclusion being hunted anyway. The best-scoring candidate is string-length arithmetic — the constant nearby is 0x104, which is MAX_PATH. A search that returns results is not evidence of coverage. Its filter is a hypothesis too, and it is the one nobody writes down.

Watch the data instead

Every one of the five encoded a belief about who was responsible. Watching the object itself encodes nothing:

  1. Breakpoint the cast path just past the object-creation call, at 0xAF4ED. The new unit's pointer arrives in a stack out-parameter — confirmed rather than assumed, because 0xAF514 immediately reads [obj+0x0C] from it.
  2. Read the layout off the live object rather than off a header:
    +0x00  dwType     = 2          UNIT_OBJECT
    +0x04  dwClassId  = 0x3B       the town portal
    +0x0C  dwUnitId
    +0x10  dwMode
    
    freed:  dwType = 6,  dwUnitId = 0xFFFFFFFF
    That last line is the whole trick. Once you know what a freed unit looks like, you can recognise the event without knowing who caused it.
  3. Set a hardware write watchpoint on the struct. It caught the writer on the first attempt: D2Common rva 0x33990, executing mov [esi], 6 then mov [esi+0x0C], -1.
  4. Read the call chain straight off the stack — 0x06A78D into 0x06A330, 0x06D55F into 0x0E20A0, and 0x0E21C5 into the free.

A watchpoint on the data cannot be defeated by guessing the wrong module. The five function-level guesses each had to name a suspect before they could be set. This one would have worked identically if the culprit had turned out to be one of the other modifications running in the same process rather than the game itself — and at the outset, that was genuinely still open.

The general form: when you are hunting a state change, instrument the state, not the code you suspect of changing it. The suspect list is where the error lives.

What travels between versions, and what does not

The paired-delete theory behind the first wrong turn came from a language model reasoning over a public decompilation project, and it deserves its credit: it described this build's structure correctly, down to the two linked units held at [edi] and [edi+4]. That part checked out against the live process.

Not one of its addresses or ordinals transferred. That is the durable lesson and it is not specific to this game: structure survives a version change; addresses and ordinals do not. Take the shape from the decompilation and measure the numbers yourself.

Two notes from years earlier

The reverse engineering this mod rests on is Doug's own, done from scratch long before any of the work described here. Those notes are now indexed alongside everything else, and twice during this investigation they outranked the newer material.

One of them reads, in full, next to a two-byte jump:

6FD7D324  EB 14   JMP SHORT
# this jump destroys all towns, to restore towns use JA

That settled a dispute in which a later “correction” claimed the address was unpatched. The correction had been produced by diffing against a reference binary chosen by matching file size — which turned out to be another copy of the modified library. The note was right; the correction was wrong, and was reverted before it reached this site.

A second note independently confirms two addresses that were measured separately, years apart, by different means. Neither measurement knew about the other until they were compared.

First-party reverse engineering outranking a scraped address table, demonstrated rather than asserted. It is the reason the corpus grades its sources.

On the addresses

Everything above is an RVA. These DLLs load at fixed bases on a stock install, so an absolute address usually works — which is exactly what makes them dangerous. An RVA is the only form that survives a different patch level, a rebased load, or another modification in the stack moving one of them, and absolutes carried between versions are the largest single source of the kind of wrong turn described above. Where an absolute appears, it is quoted verbatim from a note that recorded it that way.

None of it is taken on faith. The findings behind this page are in the public corpus with their evidence and their trust grade attached, including the ones that were disproven:

finding.d2game_portal_delete_not_statically_reachable
finding.paired_portal_delete_site_113c
finding.ordinals_do_not_transfer_110f_to_113c
finding.portal_free_path_113c_live_captured

They are greppable by id in symbols.jsonl, or searchable here. Five wrong answers are recorded next to the right one on purpose — a corpus that only keeps its successes is not measuring anything.