Author: A human being.
Co-author: An artificial intelligence.
Proofreader: A human being.
Formatter: A human being.
Documentation for: Volume 02: Halls of Torment — Script for Unified Multiplier
Chapter 3 · Act III — The Cache Era (v1.2–v1.4)
One bug, found twice, fixed once as a pattern.
That is the throughline of these three versions, and it is the reason they are told as one chapter rather than three. The bug that Act I chased across eight diagnostic recordings and never fully closed was found again, from scratch, on completely different code, with a completely independent evidence trail. The second investigation produced a fix. Two versions later, that fix was carried back onto the code where the bug had first appeared. The lag between the fix and its own retrofit is as informative as the fix.
v1.2, part one: the float script
Volume 02's integer half was working. The float half did not exist yet, and the first float stat to get one was Health Regen — chosen because it refreshes constantly during play, which makes it the easiest stat in the game to test. Set a multiplier, watch the regen tick, know immediately whether the hook fired.
Floats commit through a different instruction than integers. The universal float commit is movss [rdi+168],xmm0 at HallsOfTorment.exe+F7E9F, and unlike the integer setter, one site covers it. The new hook took 17 stolen bytes and used the same bare-symbol readmem restore pattern the integer script used, and the same dispatch idea from Chapter 2 (Act II): compare rdi, and if it matches the cached container, scale the value in xmm0 before letting the original store commit it.
The sanity window for Health Regen was set to 0 through 10000, with NaN rejecting automatically because ucomiss sets the parity flag on an unordered compare — a free NaN guard that comes with the comparison rather than needing its own test. The parameter block carried a multiplier (floor 1, matching the Swordsman's base regen of 1.0), a safety clamp, an output cap of 99999, an intended-value capture, and a per-site sanity-reject counter.
Then it crashed on level exit. Twice.
v1.2, part two: the second crash investigation
The two crashes were 0xc0000005 — an access violation — with the faulting module reported as "unknown," which is what a fault inside Cheat Engine-allocated memory looks like from the outside, since the address belongs to no loaded module.
What made this investigation different from Act I's was the source of the evidence. Cheat Engine could not identify what had faulted — the crash happens inside CE-allocated code, which the OS reports as an unknown module, so the tool doing the injection is, by construction, blind to the fault it just caused. So the evidence came entirely from outside Cheat Engine: Windows Event Viewer's Application log, which records a fault offset even when the faulting module is unnamed. Act I's evidence had been video, frame-accurate and rich in context but inherently correlational; this investigation had Event Viewer fault offsets, neither rich nor contextual but exact. Both crashes' fault offsets were decoded, and both resolved to the same instruction: hop 1 of the in-hook hub walk.
That is the first dereference in the chain, reading the container pointer out of the static root slot at [HallsOfTorment.exe+396BD00]. Two independent crashes, one instruction, no ambiguity about which line of code was faulting.
The mechanism follows immediately. During teardown, the static root slot briefly holds a pointer that has been freed but remains aligned and within the canonical address range. Every property an injected fingerprint guard can cheaply check is satisfied. The pointer looks correct. Its target does not exist.
A guard had already been built for exactly this. The float hook's chain walk carried a null check, an alignment check, and a range check, with a guard-trip counter attached specifically so a crash could be attributed. The counter remained at 0 through repeated crashes. The guards did not reject the fatal pointer, because there was nothing in the pointer to reject. That counter reading 0 after a crash is the single most important measurement in this document. It is not a partial result or a suggestive correlation. It is a clean proof of impossibility: no in-hook fingerprint check can close this hole, because the disqualifying property of a freed pointer is not encoded in the pointer.
Set this beside Act I's evidence and the two investigations resolve into the same finding. The earlier arc watched Site B's Hop 2 flip from 000002CE74AC9480 to 0000000720000063 inside about 66 milliseconds while the reject counter sat flat at 62; the float investigation watched two crashes land on mov r15,[r15+1C0] with a guard counter at 0. Different code, different sites, different instrumentation, different data source. Same mechanism: a hook dereferencing a game pointer during a window where the game is dismantling the object graph out from under it.
The architectural fix
The fix is a deletion.
The chain walk was removed from the hook entirely. Not hardened. Not retried. Not guarded more carefully. Removed.
In its place, a Cheat Engine-side Lua timer running every 500 ms resolves 396BD00 → +1C0 → +10 → +4B8 with readPointer and writes the resulting container address into a ContainerCache field, at offset +20 in the parameter block. When the chain cannot be resolved — main menu, death screen, mid-transition — readPointer fails harmlessly in Lua, and the cache is written as 0 rather than left holding a previous value. Never a stale pointer.
The hook shrinks to one comparison:
rdi is the container the game is in the middle of writing to, so it is guaranteed mapped — the game is dereferencing it on the very next instruction. r14 points at an allocated parameter block that nothing else can free. The hook touches those two things and nothing else. Zero game-memory dereferences. Not zero unguarded dereferences: zero dereferences.
The phrase used at the time, and worth keeping, is crash-proof, not guarded. The distinction is the whole point. A guarded dereference is a dereference that has been made less likely to fail, and the amount less is unknown because it depends on properties of the pointer that cannot be observed. A hook with no dereferences in it cannot fail that way — not with low probability, but by construction. There is no window. There is nothing to time your bad luck against.
Two structural properties make this trade available, and both are worth naming for anyone trying to apply the pattern elsewhere. First, a failed readPointer in Cheat Engine's Lua results in an ordinary nil return, whereas a failed dereference inside injected code results in a process kill. Moving the risky operation across that boundary changes its worst-case cost from fatal to nothing. Second, a stale cache is safe here, because the hook does not follow the cached pointer — it only compares against it. A stale cached address produces, at worst, a missed scale on one write or a comparison that matches nothing. The cost of the cache being wrong for up to 500 ms is a stat that briefly does not scale. The cost of an in-hook walk being wrong for 66 ms is the game closing.
There is a performance dividend that came free and was not designed for. The old architecture walked the full hub chain on every write the setter performed, which is a lot of pointer chasing at gameplay frequency. Live testing on the v1.2 build showed level entry to be as smooth as in an untampered game, whereas the old per-write hub walk was measurable as jitter. Removing work from the hot path for safety reasons made it faster for free.
The disable dialog, and the silent-failure lesson
[DISABLE] in the float script destroys the timer and then confirms with an external showMessage — a literal "disabled cleanly" dialog. The reason is a v1-era bug: an earlier disable path used offset-form addressing, aob+09, in a context that broke the byte restore. It failed silently — the box was left unchecked, Cheat Engine reported nothing, and the original game bytes were not actually restored. Silence had meant success and also meant failure, with no way to tell which from the outside. The response was to make the success case loud, so its absence becomes diagnostic information — the mirror image of the activation convention that eventually shipped, where no dialog means success. Both are the same design instinct: never let silence be ambiguous.
Why 500 milliseconds
The timer period was not tuned experimentally, and the reasoning is short enough to state explicitly, because it is the kind of number a reader would otherwise assume is arbitrary.
The cache only needs to be fresh relative to how often a covered container is destroyed and rebuilt, not relative to how often the game writes a stat. Container lifetime changes at run boundaries, level loads, and weapon swaps — events measured in seconds, accompanied by a loading screen or menu interaction. The stat writes themselves are far more frequent but do not need a fresh cache; they need a cache that either matches or is zero. 500 ms sits comfortably within the window where a newly created container has not yet had its stats committed by the game's own recalculation pass.
The failure the period governs is therefore bounded and benign: for at most half a second after a container is created, the cache slot may still read 0, the hook's compare will not match, and the write passes through unscaled — the next refresh applies the multiplier regardless. What the period cannot cause, at any value, is a stale dereference, because the hook never dereferences the cached value. Slower would have been fine. Faster would have bought nothing.
The verdict on v1.2: no reproducible crashes across repeated level entry and exit, level entry as smooth as an untampered game, disable popup clean, and a new "Shows Cached Container Address" debug row behaving exactly as designed — a fixed nonzero address during a run, harmless flickering during teardown, zero at menus. That flicker is worth appreciating. It is the crash mechanism, rendered as a hex readout in a table row, doing no damage at all.
The v1.2 build was frozen, along with a streamlined companion script snapshot.
v1.3: scaling the cache pattern to full scope
With one float stat proving the architecture, v1.3 took the float script from 1 stat to 9: Health Regen, Movement Speed, Experience Gain, and the Zweihänder float block.
The architecture did not change. It scaled, and the way it scaled is the second appearance of the dispatch idea from Chapter 2 (Act II). The parameter blocks became a uniform 0x30-stride array of 9 records, and the hook's single compare became a compare loop — cmp rdi,[r14+20], stride 0x30, a loop counter walking the array until it matches or runs out. One Cheat Engine-side Lua cache timer resolves all 9 chains every 500 ms. Still zero game-memory dereferences inside the hook, because a loop over an owned parameter array touches only that array.
The uniform stride is what made the loop possible, and the loop is what made the stat count irrelevant. Nine records or ninety, the hook code is the same length.
A note on stat counting. One version's changelog describes the float expansion as reaching "all seven Zweihänder floats" but then names only six: Cone Angle, Attack Speed, Crit Chance, Crit Bonus, Multistrike, Knockback. Six is the count that reconciles with everything else — 3 shared floats plus 6 weapon floats gives the 9 float records the architecture describes, and 5 integer stats plus 9 floats gives the 14 stats the finished table ships. The best-guess resolution: "seven" is a miscount in the log rather than a missing stat, noted here rather than silently corrected.
Knockback's odd geometry
Eight of the nine float chains descend through hubs Volume 02 already understood. Zweihänder Knockback does not.
Knockback is reached through 1C0 → 50 → 68 → 28 → B8 → 168 — second hop 50, not the 18 that every other Zweihänder stat uses. It is not in the weapon-offense block at all. At the time, it was believed to be the first hook of a shared cross-weapon "hop-50 utility/CC container."
A note on the hop-50 theory. That belief was later disproven, outside Volume 02's own work, by live edit testing on other weapons: there is no universal hop-50 utility hub. Knockback is a per-weapon field whose second hop differs by weapon — Zweihänder at 50, Holy Scepter at 58, Shield Bash inside its own hop-20 weapon block — all converging on the same tail shape 68 → 28 → [offset] → 168. For Volume 02, the practical impact is nil because it only ever hooks the Zweihänder's chain, and that chain was correct. But the shape of the error is exactly the trap already named in the Context section: a plausible chain shape and a matching tail are not evidence of a shared container. A comparable case elsewhere had a Knockback chain resolve to a real address that silently aliased a completely unrelated stat, only to be caught because someone edited one field and watched another field move.
Knockback also produced Volume 02's one unresolved numeric disagreement.
A note on Knockback's floor. The floor was set to 30, from a decompiled source constant. The live-tested container value was 40. The discrepancy was never resolved — the decompiled-source value was chosen deliberately, and nobody proved the live-tested value wrong. That is a recorded decision, not a closed finding. Knockback was combat-confirmed the strongest way available regardless: raising the multiplier visibly increases the distance enemies are thrown, a confirmation tier that no amount of address arithmetic can substitute for.
With v1.3, Volume 02's stat coverage was complete: 5 integer stats plus 9 float stats, all 14 in-scope stats covered. The build was frozen; no reproducible crashes, and Knockback confirmed working.
v1.4: the retrofit
At this point the float script had zero in-hook dereferences, and the integer script had three hook sites full of them.
The prompt to fix that was not an architectural review. It was a user report: seldom-but-recurring random crashes, on the integer script, in normal play, not reproducible on demand — the signature of a low-probability timing window rather than a logic bug. The mechanism was no longer a mystery: it had been proven by fault-offset decoding two versions earlier, on the float side, with the guard counter reading 0. The integer script's [396BD00] → 1C0 → 10/18 → ... walks, protected by null checks only, were sitting in exactly the window that had been shown to be unclosable.
So v1.4 converted the integer script to the same CE-side cache architecture. All in-hook pointer walks were removed from all three sites; zero game-memory dereferences remained inside any hook path, in either half of the table.
The mechanics mirrored v1.3's with one offset difference worth noting for anyone reading both scripts: the integer parameter blocks became uniform 0x30-stride records for 5 stats with the container-cache qword at +28, so the integer compare is cmp rdi,[r14+28] where the float compare is cmp rdi,[r14+20]. Each of the three sites runs a 5-slot compare loop at stride 0x30 and falls into the unchanged apply code — sanity window, scale, floor, cap. Every pre-existing symbol name and relative offset was preserved, and the per-site sanity reject counters and values carried over untouched — a rewrite of the parameter memory layout that changed no symbol names and no table wiring.
A new independent Lua timer resolves the 5 integer chains every 500 ms: the stats hub 1C0 → 10 → 498/4A8/4C8 and the weapon hub 1C0 → 18 → 68 → 28 → 2C8/2E0. A nil or 0 anywhere mid-chain writes 0 to the cache, never a stale pointer. [DISABLE] destroys the timer and confirms via dialog, matching the float script exactly. Five "Shows Cached Container Address" hex debug rows were added under the integer stat sub-headers in Debug Central. Two independent 500 ms timers now ran, one per script half; the instinct to consolidate them was already visible and was deliberately deferred to v1.6.
The preflight pattern
v1.4 also introduced the pattern that eventually defines the table's activation contract. All four preflight AOB failure paths — float Site A, integer Sites A, B, and C — got a clean showMessage dialog naming the script, naming the failed site, naming the likely cause (the game was updated), and stating the outcome explicitly: the script was NOT enabled. A terse error() then aborts activation.
The reason for doing the scan twice — once in a {$lua} preflight with AOBScan, once for real with aobscanmodule — is a difference in failure behavior between the two. AOBScan in Lua returns nil on a miss and lets the caller handle it. aobscanmodule in Auto Assembler aborts the entire [ENABLE] block on a miss. The block is therefore all-or-nothing whether the preflight runs or not; what the preflight buys is a diagnosis instead of a raw abort. The user learns which of four signatures moved, which is the difference between "the table is broken" and "the table needs updating for the new build, at INT Site B."
This is the all-or-nothing activation principle, and it recurs. In v1.4, there are four separate checks in two separate scripts. In v1.6, it becomes one gate in front of one script, which is the form the published table ships in.
One property of the v1.4 conversion deserves emphasis because it is the reason the version was low-risk despite being a rewrite of three hook bodies: nothing in the table changed. Every symbol name, every relative offset within the parameter records, every sanity value, and every per-site counter carried over untouched. The table rows that had pointed at maxHealthMultiplier in v1.0 still pointed at maxHealthMultiplier in v1.4, resolving to a different address in a differently-shaped allocation, and no user-facing entry needed rewiring. The v1.1 naming convention is what made that possible, and it is why a conversion of this size could be verified by comparing behavior rather than by re-auditing dozens of table entries.
The verdict on v1.4 was brief — script looks good, approved freeze. The build was frozen with two companion script snapshots, one per script half. The last two separate companion files Volume 02 would ever produce.
What the three versions add up to
The bug was found in Act I with video and reject counters and never closed. It was found again in v1.2 with Event Viewer offsets and a guard counter, and closed in one move by deleting the risky operation instead of defending it. It was closed on the other half of the codebase in v1.4, two versions later, prompted by user-reported crashes rather than by generalizing the lesson when it was first learned.
One bug. Found twice. Fixed once as a pattern, then applied a second time to code that had needed it all along.
License: https://creativecommons.org/publicdomain/zero/1.0/