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.
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.
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.
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.
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.
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.
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.
Every one of the five encoded a belief about who was responsible. Watching the object itself encodes nothing:
0xAF4ED. The
new unit's pointer arrives in a stack out-parameter — confirmed rather than assumed,
because 0xAF514 immediately reads [obj+0x0C] from it.+0x00 dwType = 2 UNIT_OBJECT +0x04 dwClassId = 0x3B the town portal +0x0C dwUnitId +0x10 dwMode freed: dwType = 6, dwUnitId = 0xFFFFFFFFThat 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.
D2Common rva 0x33990, executing
mov [esi], 6 then mov [esi+0x0C], -1.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.
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.
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.
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.