Author: A human being.
Co-author: An artificial intelligence.
Proofreader: A human being.
Formatter: A human being.
Edit 1 (August 2, 2026): Using Markdown for formatting instead.
Edit 2 (August 5, 2026): Added a prefix for headers to associate with correct Volume.
Documentation for: Volume 01: Halls of Torment — Script for XP Gems Multiplier
Volume 1: Chapter 5 — Resilient Code via AOBScanModule and ReadMem
Chapter 4 closed with a working multiplier script that still had one open weakness: it hooked a single hard-coded address, "HallsOfTorment.exe"+24CCB01. That address is only correct for the exact build the script was written against. The moment Chasing Carrots ships a patch that shifts the executable's code layout even slightly, that literal offset goes stale, and the script fails silently — no crash, no error, just a hook that either does nothing or lands on the wrong bytes entirely. This chapter covers how the hook was made to find itself instead of trusting a fixed number, a real investigation into doing the same for the pointer chain's base offset that ended in a genuine dead end, and a byte-perfect backup mechanism that removes the last place hand-typed instructions could quietly drift out of sync with the game.
The Problem With a Hard-Coded Hook Address
A raw address like +24CCB01 means nothing on its own — it is only useful because, on one specific build, that is where two specific instructions happen to sit. Any update that recompiles or reorders the surrounding code invalidates it completely, with no warning built into the script itself. The fix isn't to guess a new address after every patch; it's to stop relying on a fixed address at all and instead search for the actual bytes of the instructions being hooked, wherever they end up living.
Fix 1 — Replacing the Hardcoded Hook With an AOB Scan
Cheat Engine's Auto Assembler provides exactly this tool: aobscanmodule scans a named module for a byte pattern (an "array of bytes," AOB) and binds whatever address it finds to a symbol, which then behaves like any other address elsewhere in the script.
Using the bytes already confirmed from earlier disassembly around the hook site:
Code: Select all
24CCB01 - 48 89 47 08 - mov [rdi+08],rax (hook target)
24CCB05 - 48 8B 5C 24 40 - mov rbx,[rsp+40]
24CCB0A - 48 83 C4 20 - add rsp,20
24CCB0E - 5F - pop rdi
24CCB0F - C3 - ret
That's a 15-byte, five-instruction chain (write, restore, stack cleanup, pop, ret), specific enough that a coincidental match elsewhere in the executable is effectively impossible. It also starts exactly at the hook target, so the scanned address IS the hook address — no extra offset math needed on top of it.
Code: Select all
aobscanmodule(hookaob,HallsOfTorment.exe,48 89 47 08 48 8B 5C 24 40 48 83 C4 20 5F C3)
aobscanmodule (rather than the plainer aobscan) restricts the search to HallsOfTorment.exe specifically, since the target is already known to live in the main executable and not a loaded library — both faster and immune to a false match turning up in a DLL. Every later reference to the hardcoded address was then replaced with hookaob: alloc(newmem,2048,hookaob) instead of alloc(newmem,2048,"HallsOfTorment.exe"+24CCB01), and a plain hookaob: label at both the ENABLE hook installation and the DISABLE restore, instead of the literal address at each spot. hookaob itself is never registered as a table symbol — it's internal plumbing, not something a player needs to see or edit.
Verification
Cheat Engine's own AOB tool, run directly on the two overwritten instructions, independently confirmed a shorter 9-byte version of the same signature (48 89 47 08 48 8B 5C 24 40) was already unique on its own — meaning the 15-byte version used here has comfortable safety margin to spare. Applying the AOB-based script and pausing execution afterward showed hookaob resolving to the same real address as before (24CCB01), the multiply still correctly encoded without the 64-bit 48 prefix from Chapter 4's fix, and the final jmp landing precisely on the original add rsp,20 instruction — confirming the 9-byte NOP coverage from Chapter 3 still holds exactly as designed.
Investigation — Why the Pointer Chain's Base Offset Stays Hardcoded
The natural next target was "HallsOfTorment.exe"+396BC20, the static base pointer at the top of the trusted pointer chain. It seemed like the same fix should apply. It doesn't, for a reason worth understanding rather than just accepting: aobscanmodule searches for byte patterns in CODE — instructions. +396BC20 is a DATA address: a fixed slot holding a pointer value that the game writes into, not an instruction that can be pattern-matched the same way. There is no "signature" to search for at a spot that never contains code in the first place.
The real technique for this case is one level indirect: find some instruction elsewhere in the game's own compiled code that genuinely reads or writes +396BC20, AOB-scan for THAT instruction's bytes (with its embedded, shifting address offset wildcarded out), then compute the real address at runtime from that instruction's own position plus its offset. That requires a live capture of a real referencing instruction, which took several attempts to get right:
- The first two attempts at "find out what accesses this address" were run against the wrong address entirely — the resolved XP counter address at the bottom of the chain (the thing
targetaddr points to after the walk), not +396BC20 itself. Both captures turned up hits that traced straight back to the script's own instructions and other ordinary game code touching that same shared field — consistent with what was already known, but not what the investigation needed.
- A third attempt correctly navigated to
+396BC20 directly and confirmed the memory viewer was looking at genuine data (the disassembler rendered nonsensical, self-contradicting "instructions" there — repeated add [rax],al runs, a jump to a bizarre far segment — the classic signature of code trying to interpret raw data bytes rather than real instructions).
- With the right address finally confirmed, "find out what accesses this address" was run again, through normal play, scene changes, and both gaining and losing XP. Nothing from the game's own code ever touched it.
Conclusion: a Genuine Dead End, Documented in Place
No in-game action was found to trigger any real game-code instruction reading +396BC20 — only the script's own compiled code ever touches it, which gives nothing to build a referencing-instruction scan around. Rather than leave this looking like an unfinished task, the offset stays hardcoded on purpose, with the reasoning written directly into the script as a comment: this particular offset is static and reliable across restarts anyway (that's exactly why the pointer chain was built on it in Chapter 1), so forcing an AOB fix here would add complexity without actually buying any real resilience.
Fix 2 — a Byte-Perfect Backup Instead of Retyped Instructions
Separately from the AOB conversion, the DISABLE section had a smaller but real fragility: it worked by hand-retyping the two original instructions (mov [rdi+08],rax / mov rbx,[rsp+40]). That happened to reassemble to byte-identical output, but only because neither instruction uses RIP-relative addressing — that's not something to keep relying on by luck. Cheat Engine's readmem directive solves this properly: it copies whatever bytes are genuinely sitting at a live address, at the exact moment the script assembles, into a buffer of your choosing — no retyping, no risk of a transcription mismatch, ever.
Code: Select all
alloc(originalbytes,9) // reserve 9 bytes to hold a live snapshot of the hook site's real bytes
originalbytes:
readmem(hookaob,9) // copy the live 9 bytes at hookaob into our buffer, right here, right now
DISABLE then pastes that exact captured snapshot straight back, instead of the retyped instructions:
Code: Select all
hookaob:
readmem(originalbytes,9) // paste the exact bytes we captured at enable-time straight back
Two ordering rules make this work, and both come from how Auto Assembler assembles a script top to bottom:
- The BACKUP block must sit before HOOK INSTALLATION in
[ENABLE], so the snapshot captures the real, untouched bytes before the jmp overwrites them.
- DISABLE must read the snapshot back out before dealloc'ing the buffer that holds it — freeing the memory first would restore from bytes that no longer exist.
Failure Point — "Invalid Address for ReadMem," a Silent Disable Failure
Adding the backup/restore pair above broke DISABLE outright: the toggle produced no visible error, but clicking it did nothing at all — no restore, no hook removal, nothing. The entire [DISABLE] section was silently aborting before any of it ran.
The cause was a missing registersymbol. hookaob, newmem, multiplier, and targetaddr all kept working fine across the enable/disable boundary without any special registration, because of how each one gets used: hookaob: in [DISABLE] is just a label position (telling the assembler "place what follows starting here"), and dealloc() resolves allocation names through its own internal bookkeeping. Neither of those needs full symbolic address resolution. readmem(address, size) is different — it needs originalbytes resolved to a real numeric address as a function ARGUMENT, and that stricter resolution path only reliably finds symbols that have actually been registered. originalbytes never was.
The Fix: Register the Symbol ReadMem Needs to Resolve
Two lines, one in each section:
Code: Select all
registersymbol(originalbytes) // required so DISABLE's readmem() can resolve this address by name
in [ENABLE], and
in [DISABLE], alongside the existing unregisters for multiplier and targetaddr. Note that this registration exists purely so readmem can find the address by name later — it is not meant for table display, unlike multiplier and targetaddr.
Verification
With the fix applied, a full enable → disable → re-enable round trip was tested. After disabling, the hook site disassembled back to a byte-for-byte match against the very first disassembly taken before any of this work began — confirming the snapshot/restore mechanism is fully reversible with nothing left dangling.
Full Resilient Script
Here is the complete script with both resilience fixes in place, shown first without comments:
Code: Select all
[ENABLE]
aobscanmodule(hookaob,HallsOfTorment.exe,48 89 47 08 48 8B 5C 24 40 48 83 C4 20 5F C3)
alloc(newmem,2048,hookaob)
alloc(multiplier,4)
alloc(targetaddr,8)
alloc(originalbytes,9)
registersymbol(multiplier)
registersymbol(targetaddr)
registersymbol(originalbytes)
label(returnhere)
label(originalcode)
label(nomatch)
label(resolvedone)
label(skipmultiply)
multiplier:
dd (int)2
targetaddr:
dq 0
originalbytes:
readmem(hookaob,9)
hookaob:
jmp newmem
nop
nop
nop
nop
returnhere:
newmem:
push rax
mov rax,["HallsOfTorment.exe"+396BC20]
test rax,rax
je resolvedone
mov rax,[rax+68]
test rax,rax
je resolvedone
mov rax,[rax+28]
test rax,rax
je resolvedone
add rax,5C0
mov [targetaddr],rax
resolvedone:
pop rax
push rbx
lea rbx,[rdi+08]
cmp rbx,[targetaddr]
pop rbx
jne nomatch
push rcx
push rdx
mov rcx,[rdi+08]
mov rdx,rax
sub rdx,rcx
cmp rdx,0
jle skipmultiply
imul edx,[multiplier]
skipmultiply:
add rcx,rdx
mov rax,rcx
pop rdx
pop rcx
nomatch:
originalcode:
mov [rdi+08],rax
mov rbx,[rsp+40]
jmp returnhere
[DISABLE]
hookaob:
readmem(originalbytes,9)
unregistersymbol(multiplier)
unregistersymbol(targetaddr)
unregistersymbol(originalbytes)
dealloc(newmem)
dealloc(multiplier)
dealloc(targetaddr)
dealloc(originalbytes)
And now the same script with every block explained:
Code: Select all
[ENABLE]
// ============================================================
// RESILIENCE — AOB SCAN FOR THE HOOK SITE
// Instead of trusting the hardcoded address 24CCB01, this scans
// the actual bytes of the hook site and its surroundings and
// binds whatever address matches to "hookaob". If a future game
// update shifts the executable's layout, this re-locates the
// same instructions automatically instead of silently hooking
// the wrong (or no) address. The pattern covers 5 instructions
// (write, restore, epilogue, pop, ret) starting exactly at our
// hook target, so hookaob IS the hook address — no offset math
// needed.
// ============================================================
aobscanmodule(hookaob,HallsOfTorment.exe,48 89 47 08 48 8B 5C 24 40 48 83 C4 20 5F C3)
// ============================================================
// SETUP
// Reserves memory for our injected code and our two editable
// values, then registers their names so the cheat table and
// the rest of this script can refer to them.
// ============================================================
alloc(newmem,2048,hookaob) // reserve 2048 bytes of memory near the scanned hook site for our injected code
alloc(multiplier,4) // reserve 4 bytes to hold our editable multiplier value
alloc(targetaddr,8) // reserve 8 bytes to hold the resolved XP counter address
alloc(originalbytes,9) // reserve 9 bytes to hold a live snapshot of the hook site's real bytes
registersymbol(multiplier) // expose "multiplier" so it can be added to the cheat table
registersymbol(targetaddr) // expose "targetaddr" for optional inspection in the cheat table
registersymbol(originalbytes) // required so DISABLE's readmem() can resolve this address by name (not for table display)
label(returnhere) // where execution resumes in the original code after our injected logic runs
label(originalcode) // marks the replayed original instructions inside newmem
label(nomatch) // where execution goes when the write isn't targeting the XP counter
label(resolvedone) // where the pointer-chain walk jumps to if it hits a null link partway through
label(skipmultiply) // where execution goes when the value change is not a gain (e.g. a decrease or reset)
// ============================================================
// DATA
// "multiplier" is what you will see and edit directly in the
// cheat table (e.g. change 2 to 10 for a 10x XP gain per gem).
// "targetaddr" is internal bookkeeping only — it always holds
// the freshly resolved memory address of the character's XP
// counter, refreshed on every hook call.
// ============================================================
multiplier:
dd (int)2 // default multiplier: doubles every genuine XP gain
targetaddr:
dq 0 // placeholder until the first successful pointer-chain resolution
// ============================================================
// BACKUP — LIVE SNAPSHOT OF THE ORIGINAL HOOK-SITE BYTES
// Reads whatever 9 bytes are actually sitting at the hook site
// right now, before we touch anything, and stores an exact copy
// in "originalbytes". DISABLE restores this literal captured
// copy rather than us retyping equivalent-looking instructions,
// so the restore is guaranteed byte-for-byte identical to
// whatever was really there — no transcription risk at all.
// ============================================================
originalbytes:
readmem(hookaob,9) // copy the live 9 bytes at hookaob into our buffer, right here, right now
// ============================================================
// HOOK INSTALLATION
// Overwrites the start of the shared Variant-write routine
// (used by 158+ fields, not just the XP counter) with a jump
// into our own code. 5 bytes for the jmp plus 4 NOPs covers
// the full 9 bytes of the two original instructions it replaces,
// so nothing downstream gets corrupted. "hookaob" is wherever
// the AOB scan above found our signature — normally the same
// address as 24CCB01, but self-locating if the game updates.
// The backup just above already captured these bytes live, so
// this overwrite is fully reversible regardless of exactly what
// these two instructions turn out to be on any given game build.
// ============================================================
hookaob:
jmp newmem
nop
nop
nop
nop
returnhere:
// ============================================================
// INJECTED LOGIC
// Runs on every single Variant write in the game (any of the
// 158+ fields sharing this routine), so everything here must
// both identify OUR field specifically and tolerate firing
// constantly, including at moments when the XP system may
// not exist yet (loading screens, menus).
// ============================================================
newmem:
// --- Step 1: re-resolve the live XP counter address ---
// Walks "HallsOfTorment.exe"+396BC20 -> +68 -> +28 -> +5C0 fresh
// on every call, so it self-corrects if the underlying container
// ever moves. Bails out early (keeping the last known-good
// address) the moment any link in the chain is null, instead of
// crashing on an invalid dereference during loading/menus.
// NOTE: +396BC20 stays a hardcoded static module offset. An
// AOB-based upgrade (same idea as "hookaob" above) was
// investigated but hit a dead end: no in-game action was found
// to trigger any real game-code instruction reading this address
// (only our own script's compiled code touched it). Since this
// offset is itself static and reliable across restarts, leaving
// it hardcoded is the correct call rather than forcing an AOB fix.
push rax // preserve the value the game is about to write; rax gets reused below
mov rax,["HallsOfTorment.exe"+396BC20] // hop 0: dereference the static base pointer
test rax,rax // check the result isn't null before going further
je resolvedone // bail out safely if it is
mov rax,[rax+68] // hop 1: apply Offset 0 and dereference
test rax,rax
je resolvedone
mov rax,[rax+28] // hop 2: apply Offset 1 and dereference
test rax,rax
je resolvedone
add rax,5C0 // hop 3: apply the final offset (no more dereferencing)
mov [targetaddr],rax // store the freshly resolved address
resolvedone:
pop rax // restore the value the game was about to write
// --- Step 2: is this write actually targeting the XP counter? ---
// The routine we hooked writes to many different fields; this
// compares the CURRENT write's destination (rdi+08) against our
// resolved XP counter address before touching anything.
push rbx
lea rbx,[rdi+08] // rbx = the destination this particular write is aiming at
cmp rbx,[targetaddr] // does it match our XP counter address?
pop rbx
jne nomatch // if not, skip straight to replaying the original write untouched
// --- Step 3: scale only the amount actually gained ---
// The field holds the running XP total, not a delta, so
// multiplying the whole value (rax) would compound the total on
// every write. Instead we recover the real delta (new total
// minus what's currently stored) and multiply only that, then
// add it back onto the untouched old total. A non-positive delta
// (the game decreasing or resetting the counter itself, rather
// than a genuine gem pickup) is left alone entirely.
// The multiply uses edx (32-bit), not rdx (64-bit): multiplier
// was only ever allocated as 4 bytes, so a 64-bit read here
// would spill into the adjacent targetaddr allocation and
// splice a chunk of a real memory address into the multiplier,
// producing an astronomically inflated result. Using the 32-bit
// register keeps the read correctly bounded to those 4 bytes.
push rcx
push rdx
mov rcx,[rdi+08] // old total, still sitting in memory before this write lands
mov rdx,rax // new total the game intended to write
sub rdx,rcx // rdx = delta = new - old (the real amount of XP being gained)
cmp rdx,0 // is this actually a gain?
jle skipmultiply // if it's zero or negative (a decrease/reset), leave it untouched
imul edx,[multiplier] // scale only the gained amount (32-bit read, matches multiplier's true size)
skipmultiply:
add rcx,rdx // old total + (possibly scaled) delta
mov rax,rcx // this is what actually gets written below
pop rdx
pop rcx
// --- Step 4: replay the original instructions ---
// These are the two real instructions our jmp overwrote; they
// must run exactly as before so the game's own logic (and the
// stack layout the function expects) stays intact.
nomatch:
originalcode:
mov [rdi+08],rax // the original write, now carrying our (possibly scaled) value
mov rbx,[rsp+40] // the second original instruction our jmp also overlapped
jmp returnhere // resume normal execution right after our hook
[DISABLE]
// ============================================================
// DISABLE
// Restores the exact bytes captured in "originalbytes" back onto
// the hook site and releases everything we allocated and
// registered. Uses "hookaob" (the scanned address) rather than
// the raw address, since the two must always match whatever the
// scan found. Restoring the literal captured snapshot instead of
// retyped instructions guarantees a perfect, byte-identical undo.
// ============================================================
hookaob:
readmem(originalbytes,9) // paste the exact bytes we captured at enable-time straight back
unregistersymbol(multiplier)
unregistersymbol(targetaddr)
unregistersymbol(originalbytes)
dealloc(newmem)
dealloc(multiplier)
dealloc(targetaddr)
dealloc(originalbytes)
What This Chapter's Work Has in Common
Every change in this chapter is about the same underlying goal: make the script correct independently of any one snapshot in time, rather than correct only for the exact build and exact moment it was written against. The AOB scan for the hook site removes dependence on a fixed address across game updates. The readmem backup removes dependence on us having transcribed two instructions correctly, across any future edit of this script. The one thing that deliberately stayed hardcoded — the pointer chain's base offset — did so only after a real, documented investigation showed there was nothing more resilient to build against; leaving it as-is was a conclusion, not a shortcut. And the registersymbol failure is a reminder that Auto Assembler resolves names differently depending on how they're used (a bare label position versus a function argument), which isn't obvious until something that looks like it should just work goes silent instead of throwing a visible error.
Chapter 6 picks up from this resilient script and covers turning it into something a future user — including future-you — can pick up and use without re-deriving any of this from scratch: table organization, in-table documentation, and small usability additions on top of what already works.
License: https://creativecommons.org/publicdomain/zero/1.0/