Halls Of Torment - Cheat Scripting Practice Log

A context providing post for novices who wish to tackle Godot Engine with their custom scripts.

The main forum for Cheat Table database collections.


Moderator: Table Moderator

Post Reply
WanderingNovice
Cheater
Cheater
Posts: 10
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0

Halls Of Torment - Cheat Scripting Practice Log

Post by WanderingNovice »

Halls Of Torment - Steampowered Games Store Page

Halls of Torment / Godot Engine 4.2

Relevant Details:

  • Halls Of Torment is released on the online game library platform on Steam at September 24, 2024.

  • This post is meant for posterity and open discussion. Education and reference is encouraged.

  • Initially, the post will focus on one scripting journey - creating a multiplier for XP Gems gain.

  • Overtime, either via requests or new contributions, there will be new cheat scripts featured in the main Cheat Table.

  • Writing scripts for Cheat Engine use is similar to game development learning. You are dismantling software and reassembling it your liking. Take your time.

  • Lastly, just have fun. Prioritize the in-game effects that you want to modify into your desired outcome.

Here inside is some documentation for you nerds out there that want to chew on more info about the script's creation process:

Relevant Descriptions:

  • Cheat Engine: Your primary tool for modifying values or game code in memory.

  • Pointers: Set of memory addresses or code that lead to a certain address containing your desired value (usually).

  • Godot Engine: The game's engine that computes for its effects. Your biggest friend and obstacle.

  • XP Gems: The game's primary and temporary resource for determining level-ups per run.


Volume 01: Halls of Torment - Script for XP Gems Multiplier
Here below inside this spoiler container is our code for the above title:

► Show Spoiler

And here is the code with annotations:

► Show Spoiler

Then here, we have the cheat table material as referenced by this discussion:

Halls_Of_Torment_Windows_Steam_Published_Version_(Update_2026-April-14)_(XP_Gems_Multiplier).CT
XP Gems Multiplier Script + Pointers + Manipulatable Multiplier Address Field
(28.38 KiB) Downloaded 2 times

And of course, in the replies section with be individual chapters for breakdown of the script's journey. This is meant to allow google and other search engines (or aggregators like Artificial Intelligence searching algorithms) to immediately pick up on the context of the discussion in this thread. Again, this post will serve as posterity and accessibility to those who want to go further with their Cheat Engine journeys.

May we see plenty of cheat scripts proliferate everywhere! (So that I don't have to write my own for every game I want to mess around with, lol).

Last edited by WanderingNovice on Tue Jul 28, 2026 10:06 am, edited 2 times in total.

Tags:

WanderingNovice
Cheater
Cheater
Posts: 10
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0

Re: Halls Of Torment - Cheat Scripting Practice Log

Post by WanderingNovice »

Author: A human being.
Co-author: An artificial intelligence.
Proofreader: A human being.
Formatter: A human being.


PREFACE
This documentation walks through how we built a Cheat Engine
script for Halls of Torment, written with the benefit of
hindsight — including the mistakes we made and how we fixed
them. It's meant to be read start to finish by someone
following in our footsteps, not just as a reference.

TOOLS AND VERSIONS

  • Cheat Engine 7.5 (all instructions, menus, and Auto
    Assembler syntax in this documentation are specific to
    this version).
  • Target game: Halls of Torment, developed by Chasing
    Carrots, distributed on Steam.
  • Game build verified against: Build ID 22764619, "HoT Fixes
    | 2026-04-14", pushed live 14 April 2026 (per SteamDB's
    patch notes page for the game). If the game has updated
    past this build since, the pointer chain and addresses in
    this documentation should be re-checked before being
    trusted as-is.

THE GAME ENGINE
Halls of Torment is built on Godot, specifically version 4.2
(confirmed via the game's own listing on Godot's showcase
page, SteamDB's tech detection, PCGamingWiki, and a developer
talk given by Chasing Carrots' Paul Lawitzki). Most gameplay
logic runs in GDScript, with some performance-critical systems
written in C++ via GDExtension — there is no C#/Mono involved.

This matters immediately for anyone doing memory-editing work
on a Godot game: GDScript never compiles down to native x86
instructions. It runs through the engine's shared
interpreter and a generic "Variant" copy/write mechanism.
In practice, this means an instruction that writes to one
specific stat is very often the exact same instruction that
writes to dozens, or even hundreds, of unrelated fields —
there is no unique per-stat code to hook. This single fact
is the root cause behind several problems we ran into later,
and it's worth keeping in mind from the start.

PROBLEMS WE RAN INTO
The main source of trouble while working on this project was
Godot's shared-routine architecture (described above): hooking
"the write to our value" also meant hooking the write to
everything else that happens to share that instruction —
requiring extra logic just to filter for the one field we
actually cared about, the in-run pickup known as an XP Gem
that fills a temporary level-up bar.

A second, unresolved oddity also came up along the way. We
were able to successfully find and change the true underlying
value we were after, and our script does affect it correctly.
However, we also noticed a separate value address — one that
never turned up through any of our scans — that nonetheless
appeared to be tied to the game's own on-screen display (UI
element) of that same value. We don't know what this second
address actually is or what its relationship to the real value
is. Since our script works correctly as-is without touching
it, we left this unexplained and moved on rather than chasing
it down.

With that context in place, Chapter 1 begins the actual
process: finding the value in memory and tracking down a
reliable pointer to it.


WanderingNovice
Cheater
Cheater
Posts: 10
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0

Re: Halls Of Torment - Cheat Scripting Practice Log

Post by WanderingNovice »

Author: A human being.
Co-author: An artificial intelligence.
Proofreader: A human being.
Formatter: A human being.


CHAPTER 1 — FINDING THE VALUE AND FINDING THE POINTER

This chapter covers how we located the write instruction for
our target value, why we abandoned a direct code-injection
approach at that address, and how we eventually landed on a
stable pointer chain to use instead.

STEP 1 — FINDING THE WRITE INSTRUCTION
Using Cheat Engine's "Find out what writes to this address" on
our target value's current address, we found this instruction:

HallsOfTorment.exe+24CCB01 - 48 89 47 08 - mov [rdi+08],rax

But the debugger reported this same instruction firing for
over 158 different destination addresses — not just ours.
Looking at the surrounding code confirmed why:

Code: Select all

mov rax,[rbx+10]      ; load 8 bytes from (rbx+0x10) into rax
mov [rdi+10],rax      ; store rax into (rdi+0x10)
mov rax,[rbx+08]      ; load 8 bytes from (rbx+0x08) into rax
mov rsi,[rsp+38]      ; restore rsi from the stack
mov [rdi+08],rax      ; <- the write we found
mov rbx,[rsp+40]      ; restore rbx from the stack
add rsp,20            ; function epilogue
pop rdi
ret

This copies fields at +0x00, +0x08, and +0x10 from one object
to another — a generic struct/object copy, not "add value by
X" logic. That 24-byte footprint (fields at 0x00/0x08/0x10)
matches Godot's own Variant type on 64-bit builds. In short:
this is the engine's shared Variant-copy routine, used by
every GDScript value assignment in the entire game. That's why
158+ addresses hit it — it's one shared function, not 158
separate things happening to our value.

DECISION: SKIP CODE INJECTION HERE, GO STRAIGHT TO A POINTER
Patching this instruction directly would have broken every
value copy in the game. It also doesn't even do the real "add
value" math — it just moves an already-computed result into
place. So instead of hooking this address, the plan became:
find a static pointer chain straight to the value's memory
location instead, entirely independent of this shared routine.

STEP 2 — GENERATING POINTERMAPS
A normal pointer scan on a Godot game can produce enormous
result sets (100+ GB on some 64-bit games), because Godot's
object graph is dense and Variant-wrapped almost everywhere.
A pointermap avoids this: it's a compact snapshot of "every
pointer relationship in memory right now" that Cheat Engine
can instantly cross-reference against another snapshot, rather
than re-scanning raw memory from scratch each time.

We generated 2 pointermaps first (each after a full game
restart, since restarting re-randomizes memory addresses), then
2 more — this time also switching to a different level and a
different character for extra variation. 4 total pointermaps is
a reasonable stopping point before the added benefit drops off.

STEP 3 — RUNNING THE POINTER SCAN AND NARROWING RESULTS
The initial pointer scan against our value returned 3,108
candidate paths. From there, we ran successive "Rescan memory"
passes (Memory Viewer -> Tools -> Pointer scan -> File -> Open
saved list -> Rescan memory), each time restarting the game and
re-finding the value first:

Rescan 1: 2604 Rescan 2: 2432 Rescan 3: 2301 Rescan 4: 2273

The drop-off shrank each round — a classic diminishing-returns
curve. A full PC reboot (not just a game restart) only pushed
the count down slightly further, to 2,244, confirming we'd hit
a real plateau: many of the survivors were likely legitimate
mirrored copies of the same value (a save-data copy, a runtime
copy, a UI-display copy), not coincidences that more restarts
would clear out.

A DEAD END WORTH KNOWING ABOUT: THE "ENDS WITH OFFSET 8" FILTER
Since the write instruction we found was mov [rdi+08],rax, it
seemed reasonable to filter the pointer scan for paths ending
in offset 8. This returned zero results. The reason: rdi in
that instruction isn't a simple static offset — it's a
parameter computed earlier by the caller (likely container base

  • index, since the value probably lives inside an array/
    dictionary-style stats container). That kind of relationship
    can't be expressed as a fixed additive pointer chain, so the
    filter had nothing valid to match. Lesson: a raw offset seen in
    a shared engine routine doesn't necessarily correspond to
    anything in a pointer chain — don't force it.

STEP 4 — MANUAL CANDIDATE SELECTION
Rather than continuing to rescan, we switched to sorting the
results directly: clicking the header of the last populated
"Offset N" column sorts by path length (number of hops), moving
the shortest candidates to one end. Since every result was
already anchored to the HallsOfTorment.exe module (not a heap
or thread-stack address), that filtering concern didn't apply
here.

Three short candidates stood out:

Code: Select all

"HallsOfTorment.exe"+396BC20 -> +68 -> +28 -> +5C0
"HallsOfTorment.exe"+396BD00 -> +170 -> +68 -> +28 -> +5C0
"HallsOfTorment.exe"+396BD00 -> +278 -> +68 -> +28 -> +5C0

All three share the same tail (+28 -> +5C0), and two share +68
right before it — strong evidence that this shared tail reliably
leads into the real stats structure, regardless of which route
gets there. We picked the shortest for testing (fewer hops means
fewer things that can break across a restart).

STEP 5 — VERIFYING THE CHAIN
Before trusting it, we tested:

  1. Value shown matches the actual in-game count.
  2. The value updates live in real time as it's earned in-game.
  3. It survives a full game restart (recalculates correctly with
    no manual intervention).
  4. It survives a level change and a character change — the two
    conditions we already knew could reshuffle memory layout,
    from the pointermap step above.

The chain held up: "HallsOfTorment.exe"+396BC20 -> +68 -> +28
-> +5C0
. This became our final, trusted pointer chain.

Chapter 2 picks up from this trusted pointer chain and explains
why a plain static write wasn't our final approach, and where
things went from here.

Last edited by WanderingNovice on Tue Jul 28, 2026 9:26 am, edited 1 time in total.

WanderingNovice
Cheater
Cheater
Posts: 10
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0

Re: Halls Of Torment - Cheat Scripting Practice Log

Post by WanderingNovice »

Author: A human being.
Co-author: An artificial intelligence.
Proofreader: A human being.
Formatter: A human being.


CHAPTER 2 — CONTEXTUALIZING THE WRITE INSTRUCTION

Chapter 1 ended with a trusted pointer chain resolving to our
target value's live memory address. Before writing any script,
the next step was confirming which line of code actually writes
to that address, and understanding exactly what the surrounding
code is doing — since a script that hooks the wrong line, or
misunderstands what's happening around it, will fail in subtle
ways.

CONFIRMING THE WRITE INSTRUCTION
"Find out what writes to this address," run against our
resolved pointed address, pointed back to the same instruction
already noticed in Chapter 1:

HallsOfTorment.exe+24CCB01 - 48 89 47 08 - mov [rdi+08],rax

This consistency is a good sign — it means the write instruction
we'd already identified really is the one responsible for
updating our specific value, not just a coincidentally similar
one somewhere else.

READING THE FULL WINDOW, LINE BY LINE
Here's the full surrounding code again, this time worth reading
slowly rather than skimming past the inline comments:

Code: Select all

mov rax,[rbx+10]      ; load 8 bytes from (rbx+0x10) into rax
mov [rdi+10],rax      ; store rax into (rdi+0x10)
mov rax,[rbx+08]      ; load 8 bytes from (rbx+0x08) into rax
mov rsi,[rsp+38]      ; restore rsi from the stack
mov [rdi+08],rax      ; <- the write we found
mov rbx,[rsp+40]      ; restore rbx from the stack
add rsp,20            ; function epilogue
pop rdi
ret

A few of these comments are worth unpacking rather than taking
at face value:

  • "load 8 bytes ... into rax" — the 8-byte size isn't a guess
    or something read off the raw bytes directly; it comes from
    the destination register. rax is the full 64-bit register (as
    opposed to its 32-bit sub-register eax, 16-bit ax, or 8-bit
    al). In x86, a mov into a register always moves exactly as
    many bytes as that register holds, so naming the destination
    as rax is what tells us this is a 64-bit (8-byte) read from
    memory, not a smaller one.

  • "restore ... from the stack" (the rsi and rbx lines) — this
    wording assumes a standard calling-convention pattern:
    certain registers (commonly rbx, rsi, rdi, and similar) are
    considered the caller's property, so a function that wants to
    reuse them for its own work must first push their original
    values onto the stack near the start of the function (the
    prologue), then read them back into the same registers right
    before returning (the epilogue) — putting the caller's
    original values back exactly as they were. We don't see the
    matching push instructions in this narrow window, but their
    position here — right before add rsp,20 / pop rdi / ret,
    which is a textbook epilogue — is what makes "restore" the
    correct read of these two lines rather than an ordinary load.

  • "function epilogue" (add rsp,20) — a prologue often reserves
    local stack space at function entry with something like
    sub rsp,20; the epilogue's job is to undo that before
    returning. add rsp,20 does exactly the reverse of that
    subtraction, handing the stack pointer back to where it was
    before the function was entered. Seeing this immediately
    before pop rdi and ret is the standard signature of a
    function's closing cleanup sequence, which is why it's
    labeled that way here.

WHAT THIS MEANS FOR OUR SPECIFIC VALUE
The instruction we care about, mov [rdi+08],rax, only tells us
where a write is landing (rdi+08) and what's being written
(whatever's currently in rax) — it says nothing about which
specific field is involved. That's determined entirely by
whatever value rdi happens to hold at the moment this particular
call runs, which changes depending on which of the 158+ fields
is currently being written. This is the same 158+-shared-fields
behavior flagged back in the Preface as a direct consequence of
Godot routing every GDScript value assignment through one
generic Variant-copy routine — here we're seeing exactly what
that looks like at the instruction level, and why it forces
extra filtering logic on our end rather than a clean, dedicated
write we could hook outright. In other words: this line is
generic by itself. The only way to know "is this write actually
our value" is to compare rdi+08 (the destination this specific
call is aiming at) against the target address we resolved from
our pointer chain in Chapter 1 — if they match, this is our
value's write; if not, it's some unrelated field going through
the same shared routine.

That comparison — and how to actually build it into a working
script — is where Chapter 3 picks up.


WanderingNovice
Cheater
Cheater
Posts: 10
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0

Re: Halls Of Torment - Cheat Scripting Practice Log

Post by WanderingNovice »

Author: A human being.
Co-author: An artificial intelligence.
Proofreader: A human being.
Formatter: A human being.


CHAPTER 3 — FROM A STATIC WRITE TO A WORKING INJECTION SCRIPT

With the pointer chain validated (Chapter 1) and the shared
write instruction understood (Chapter 2), it was finally time
to write something in Cheat Engine's Auto Assembler. This
chapter covers the basics of what a cheat script actually is,
a real mistake we made with number formatting, our first
working code injection script, and a byte-alignment failure
that corrupted the game's original code the first time we ran it.

STEP 1 — THE SIMPLEST POSSIBLE SCRIPT: A STATIC WRITE
Before touching code injection, the simplest way to prove the
pointer chain works is a one-time write straight to the
resolved address:

Code: Select all

[ENABLE]
[[["HallsOfTorment.exe"+396BC20]+68]+28]+5C0:
dd (int)999999

[DISABLE]
//nothing to restore — this only touches the XP Gems value, not instructions

Two basics worth understanding here:

  • dd stands for Define Doubleword — a directive telling the
    assembler to write 4 bytes (32 bits). Cheat Engine's directive
    family also includes db (1 byte), dw (2 bytes), and dq
    (8 bytes); dd matches a standard 32-bit integer, which is
    how our XP Gems value is stored.
  • Cheat Engine's Auto Assembler treats plain numbers as
    hexadecimal by default, everywhere — not just addresses. Our
    first attempt at this script wrote dd 999999, which actually
    wrote the hex value 0x999999 (10,066,329 in decimal), not
    999,999. Prefixing the number with (int) or # forces
    decimal interpretation instead — dd (int)999999 writes
    exactly what it looks like.

Adding this to the cheat table: open the Auto Assembler window
(Ctrl+Alt+A), use Template -> Cheat Table framework code to get
the [ENABLE] / [DISABLE] skeleton, paste the script in, then
File -> Assign to current cheat table. This creates a
checkbox-toggle entry — checking it runs [ENABLE], unchecking
it runs [DISABLE].

This confirmed the pointer chain was correct: toggling the
script on immediately rewrote our XP Gems count to whatever we
set — but only once, at the moment of toggling.

STEP 2 — WHY A STATIC WRITE ISN'T ENOUGH
A dd write only fires once, when the script is enabled. It
can't react to anything that happens afterward. What we actually
wanted was a multiplier — something that watches every future
change to our XP Gems count and scales it automatically, in real
time. That requires hooking the live write instruction again, the
same one identified in Chapters 1 and 2:

"HallsOfTorment.exe"+24CCB01 - mov [rdi+08],rax

The problem from Chapter 2 still applies: this single instruction
fires for 158+ different fields, not just XP Gems. But now we have
something we didn't have before — a validated pointer path. That
lets us add a runtime check: "only act on this write if it's
heading to our exact XP Gems address," leaving every other field
that shares this instruction completely untouched.

STEP 3 — FIRST WORKING CODE INJECTION SCRIPT
Here is the first version that worked as intended. Before
breaking it down piece by piece, here are the actual lines of
code our script was implementing, with no comments attached —
worth reading through once on its own so the annotated version
afterward reads as an explanation of these exact lines, not as
different code:

Code: Select all

[ENABLE]

alloc(newmem,2048,"HallsOfTorment.exe"+24CCB01)
alloc(multiplier,4)
alloc(targetaddr,8)
registersymbol(multiplier)
registersymbol(targetaddr)

label(returnhere)
label(originalcode)
label(nomatch)

multiplier:
dd (int)2

targetaddr:
dq 0

mov rax,["HallsOfTorment.exe"+396BC20]
mov rax,[rax+68]
mov rax,[rax+28]
add rax,5C0
mov [targetaddr],rax

"HallsOfTorment.exe"+24CCB01:
jmp newmem
returnhere:

newmem:
push rbx
lea rbx,[rdi+08]
cmp rbx,[targetaddr]
pop rbx
jne nomatch
imul eax,[multiplier]
nomatch:
originalcode:
mov [rdi+08],rax
jmp returnhere

[DISABLE]

"HallsOfTorment.exe"+24CCB01:
mov [rdi+08],rax

unregistersymbol(multiplier)
unregistersymbol(targetaddr)
dealloc(newmem)
dealloc(multiplier)
dealloc(targetaddr)

Now here is the same script again, this time with every line
and block explained:

Code: Select all

[ENABLE]

// ============================================================
// SETUP
// Reserves memory for our injected code and our two editable
// values, then registers names for them so the rest of the
// script (and the cheat table) can refer to them by name.
// ============================================================
alloc(newmem,2048,"HallsOfTorment.exe"+24CCB01)    // reserve space near the hook point for our new code to live in
alloc(multiplier,4)                                // reserve 4 bytes to hold the editable multiplier number
alloc(targetaddr,8)                                // reserve 8 bytes to hold the resolved address of our XP Gems value
registersymbol(multiplier)                         // makes "multiplier" usable as a named address in the cheat table
registersymbol(targetaddr)                         // makes "targetaddr" referenceable for cleanup later

label(returnhere)     // marks where execution resumes after our hook finishes
label(originalcode)   // marks where the original, unmodified instruction lives inside our new code
label(nomatch)        // marks the path taken when the write is NOT our XP Gems address

// ============================================================
// DATA
// "multiplier" is what you'll see and edit directly in the
// cheat table. "targetaddr" is internal bookkeeping only.
// ============================================================
multiplier:
dd (int)2          // starting multiplier value, in decimal — edit this from the cheat table later

targetaddr:
dq 0               // placeholder; overwritten with the real address the instant the script is enabled

// ============================================================
// ONE-TIME ADDRESS RESOLUTION
// Runs exactly once, the instant you enable the script. Walks
// the validated pointer path and stores the final real address
// into targetaddr, giving the hook something fixed to compare against.
// ============================================================
mov rax,["HallsOfTorment.exe"+396BC20]    // dereference the static base pointer
mov rax,[rax+68]                          // apply Offset 0 (0x68) and dereference again
mov rax,[rax+28]                          // apply Offset 1 (0x28) and dereference again
add rax,5C0                               // apply Offset 2 (0x5C0) without dereferencing — this is the actual address of our XP Gems value
mov [targetaddr],rax                      // save that resolved address for the hook to use later

// ============================================================
// HOOK INSTALLATION
// Overwrites the original instruction with a jump to our new
// code, and marks where the game continues running normally afterward.
// ============================================================
"HallsOfTorment.exe"+24CCB01:
jmp newmem         // replace the original write instruction with a jump into our own code
returnhere:        // right after the original instruction, where normal game code continues

// ============================================================
// INJECTED LOGIC
// For every value-write the game performs, check whether THIS
// write is aimed at our XP Gems address. If yes, multiply it
// first. If no, leave it completely alone.
// ============================================================
newmem:
push rbx                       // save rbx so we don't corrupt whatever the game was using it for
lea rbx,[rdi+08]               // compute the destination address this write is about to target
cmp rbx,[targetaddr]           // compare it against our known XP Gems address
pop rbx                        // restore rbx immediately after the comparison
jne nomatch                    // not a match — skip straight past the multiply step
imul eax,[multiplier]          // match found — multiply the incoming value before it gets written
nomatch:
originalcode:
mov [rdi+08],rax               // the original instruction, unchanged — writes rax to its destination
jmp returnhere                 // resume normal game execution

[DISABLE]

// ============================================================
// DISABLE
// Restores the game's original instruction exactly as it was,
// and frees all memory and names we registered.
// ============================================================
"HallsOfTorment.exe"+24CCB01:
mov [rdi+08],rax                 // puts the original, unmodified instruction back in place

unregistersymbol(multiplier)     // removes the "multiplier" name
unregistersymbol(targetaddr)     // removes the "targetaddr" name
dealloc(newmem)                  // frees the memory block that held our injected code
dealloc(multiplier)              // frees the memory that held the multiplier value
dealloc(targetaddr)              // frees the memory that held the resolved address

To make multiplier show up as its own editable row: after
assigning the script, right-click the address list, choose
Add Address Manually, type multiplier as the address (Cheat
Engine resolves registered symbols by name automatically), set
the type to 4 Bytes, and rename the row to something readable.

STEP 4 — FAILURE: THE JMP OVERWRITES MORE THAN EXPECTED
Running this script exposed a real problem. Pausing the game
first and checking the disassembly at the hook site showed a
corrupted second instruction:

Before (original code):

Code: Select all

HallsOfTorment.exe+24CCB01 - 48 89 47 08     - mov [rdi+08],rax
HallsOfTorment.exe+24CCB05 - 48 8B 5C 24 40  - mov rbx,[rsp+40]
HallsOfTorment.exe+24CCB0A - 48 83 C4 20     - add rsp,20

After (corrupted):

Code: Select all

HallsOfTorment.exe+24CCB01 - E9 FA348901     - jmp 7FF691360000
HallsOfTorment.exe+24CCB06 - 8B 5C 24 40     - mov ebx,[rsp+40]
HallsOfTorment.exe+24CCB0A - 48 83 C4 20     - add rsp,20

The cause: a jmp to a code cave is always 5 bytes (E9 plus a
4-byte offset). The first original instruction, mov [rdi+08],rax,
is only 4 bytes. Our 5-byte jmp overran it by exactly 1 byte,
eating the 48 REX prefix that belonged to the next instruction.
The surviving bytes, 8B 5C 24 40, now decoded as mov
ebx,[rsp+40]
instead of mov rbx,[rsp+40] — the same stack
slot, but loading only the 32-bit half instead of the correct
64-bit value, silently zeroing the upper 32 bits of rbx.

THE FIX: NOP PADDING AND REPLAYING BOTH INSTRUCTIONS
Since the jmp overlapped into the second instruction, both
original instructions (4 + 5 = 9 bytes total) needed to be fully
consumed at the hook site and replayed correctly inside newmem —
not just the first one. The fix pads the leftover 4 bytes with
NOPs and adds the second instruction back into originalcode:

Code: Select all

// ============================================================
// HOOK INSTALLATION
// Overwrites the original instructions with a jump to our new
// code. The jmp is 5 bytes but the two original instructions it
// overlaps total 9 bytes (4 + 5), so the remaining 4 bytes are
// padded with NOPs to avoid leaving a corrupted, partial instruction behind.
// ============================================================
"HallsOfTorment.exe"+24CCB01:
jmp newmem          // replace the original write instruction with a jump into our own code
nop                 // padding — covers the leftover byte from the first overwritten instruction
nop                 // padding — part of the second original instruction the jmp overlapped
nop                 // padding — part of the second original instruction the jmp overlapped
nop                 // padding — part of the second original instruction the jmp overlapped
returnhere:         // lands exactly on "add rsp,20", right after both replayed originals

Code: Select all

originalcode:
mov [rdi+08],rax               // original instruction 1, unchanged — writes rax (now possibly multiplied) to its destination
mov rbx,[rsp+40]                // original instruction 2, replayed in full since our jmp overwrote part of it
jmp returnhere                 // resume normal game execution

Code: Select all

[DISABLE]
"HallsOfTorment.exe"+24CCB01:
mov [rdi+08],rax                 // puts the first original instruction back in place
mov rbx,[rsp+40]                  // puts the second original instruction back in place

With the 4 NOPs added, returnhere lands exactly on add
rsp,20
, the instruction that naturally follows both replayed
originals in the untouched code — keeping everything downstream
correctly aligned. Re-checking the paused disassembly confirmed
the fix: jmp, four 90 (NOP) bytes, then add rsp,20 picking
up cleanly right where it should.

This byte-accounting lesson — always sum the lengths of every
original instruction your jmp overlaps, not just the first one —
turned out to matter again later. Chapter 4 picks up from here,
where getting the XP Gems multiplier itself to behave correctly
surfaced further complications specific to how Godot handles
values under the hood.

Last edited by WanderingNovice on Tue Jul 28, 2026 9:47 am, edited 2 times in total.

WanderingNovice
Cheater
Cheater
Posts: 10
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0

Re: Halls Of Torment - Cheat Scripting Practice Log

Post by WanderingNovice »

Author: A human being.
Co-author: An artificial intelligence.
Proofreader: A human being.
Formatter: A human being.


CHAPTER 4 — MAKING THE MULTIPLIER ACTUALLY WORK

Chapter 3 ended with a script that appeared to work: it survived
a paused-disassembly check, ran without crashing, and exposed a
multiplier value in the cheat table. This chapter is the story of
discovering that "appeared to work" and "actually works" were two
very different things — four separate failure points, each one
hiding behind the last, before the multiplier behaved correctly.

FAILURE POINT 1 — THE ADDRESS THAT NEVER GOT RESOLVED
With the multiplier exposed as a table entry, testing began at
2x, then 10x, then 100x. At first this looked like a total
success: XP Gem gains scaled up with no other stats or UI showing
any sign of interference, and even the game's natural variance in
pickup size (a deliberate design choice, not something the script
was expected to preserve) seemed to carry through cleanly.

Then the multiplier stopped mattering. XP Gems kept coming in at
what looked like their normal, unmultiplied rate no matter how
high the multiplier was set. Two hypotheses seemed plausible at
the time:

  1. The resolved address had moved — Godot's Variant containers
    (dictionaries, arrays, and similar structures) can be
    reallocated as the game runs, so an address captured once
    might no longer point at the live value later on.
  2. XP Gems were gained through more than one code path, and only
    one of those paths ran through the instruction being hooked.

The real cause turned out to be neither. Watching targetaddr
directly in the table showed it frozen at 0 — meaning the
one-time address-resolution block from Chapter 3 had never
actually run at all. The reason is a genuine Auto Assembler
mechanic worth understanding precisely: code with no explicit
label or address in front of it doesn't automatically execute
just because it appears in the script. It gets assembled as raw
bytes wherever the assembler's cursor currently sits — and unless
something in the game (or another instruction) actually jumps to
that spot, those bytes just sit there, dead. Sitting the
resolution block directly after targetaddr: dq 0 looked like it
belonged to program flow, but nothing ever pointed execution
there, so targetaddr stayed at its placeholder value forever.
Any apparent multiplier effect seen earlier was almost certainly
just the game's own natural pickup-size variance, not the script
actually firing.

THE FIX: RESOLVE THE ADDRESS INSIDE THE HOOK ITSELF
Moving the address-resolution block inside newmem guarantees it
actually runs — every single time the hook fires, right before
the comparison against targetaddr. This has a nice side effect
too: since it re-resolves fresh on every call instead of trusting
a stale one-time snapshot, it self-heals if the underlying
container ever does relocate, addressing the first hypothesis
above as a bonus rather than a separate fix.

FAILURE POINT 2 — COMPOUNDING GROWTH AND A CRASH ON FRESH RUNS
With the address now genuinely resolving, testing on a paused
game confirmed the injected code was running exactly where
expected. But live testing surfaced two new problems at once.

The first: XP Gem totals grew geometrically instead of scaling
linearly — 2, then 4, then 8, then 16, doubling each pickup
instead of each pickup simply being twice as large. The cause was
a mismatch between what the multiplier was scaling and what the
hooked instruction actually carries. The hooked write doesn't
carry "the amount just gained" — it carries the field's entire
running total, because this shared write routine persists full
state on every call, not a delta. Multiplying the whole total
compounds: pickup one doubles 2 into 4 and writes that back as
the new total; pickup two reads 4 back as truth, adds a new gain,
and doubles that already-inflated number again — runaway growth
that has nothing to do with the actual amount gained.

The second: the script crashed the game outright on a brand-new
run. This is where a genuine Godot-specific complication showed
up. The hook fires on every write through the shared instruction,
extremely often — and on a fresh instance, it can fire during
loading, before the container holding our value even exists yet.
Dereferencing a pointer chain through a null or not-yet-initialized
object crashes immediately. This is a real hazard of hooking a
generic, high-frequency routine in an engine where object
lifetimes aren't guaranteed to line up with when your hook first
starts firing.

THE FIX: DELTA-BASED SCALING, WITH NULL GUARDS
Two changes together fixed both problems:

  • Each hop of the pointer chain is now checked for null before
    being dereferenced further (test rax,rax / je resolvedone).
    If any link isn't ready yet, the hook simply skips re-resolving
    that one time and keeps the last known-good address, instead of
    crashing.
  • Rather than multiplying the total (rax) directly, the script
    now reads the field's current stored value (rcx), computes
    the real delta against the incoming new total (rdx = rax -
    rcx
    ), multiplies only that delta, and adds it back onto the
    untouched old total. Only the amount actually gained gets
    scaled; the running total itself is never multiplied outright.

This delta-based approach also quietly resolved something flagged
back in Chapter 3: the earlier version used eax, the 32-bit half
of rax, on the explicit assumption that XP Gem counts would
comfortably fit in 32 bits. That assumption was never actually
disproven — it simply stopped being relevant once the multiply
target moved to a full 64-bit rdx holding the computed delta,
which sidesteps the register-width question entirely rather than
resolving it.

FAILURE POINT 3 — A GUARD BUILT ON THE WRONG REASON, KEPT FOR THE
RIGHT ONE

An early pass at this script added a check to skip multiplying
whenever the computed delta was zero or negative. The reasoning
at the time was that this in-game currency could be spent and
refunded through a shop-like mechanic, and a refund would look
identical to a negative delta — so it needed to be excluded from
scaling. That reasoning turned out to rest on a mixed-up item
name: the currency actually being tracked here is XP Gems, and
XP Gems have no spend or refund path at all. They only ever add
to a running total as they're picked up.

Dropping the guard entirely once the naming mistake was caught
would have been the wrong fix, though, for a separate reason:
the hook fires on every write through a routine shared by 158+
fields, filtered only by destination address. Nothing about that
filtering guarantees the ONLY thing capable of writing to this
particular field is "add a gem's value." A new run starting and
reusing the same memory address, or some internal bookkeeping
tied to leveling that hasn't been directly observed, could both
plausibly write a smaller value back to this exact field —
neither of which has been confirmed to happen, but neither of
which has been ruled out either. If either ever does occur, a
multiplier that blindly scales every write would corrupt that
smaller value instead of leaving it alone.

THE FIX: KEEP SCALING POSITIVE DELTAS ONLY, FOR A GENERAL REASON
The original guard code didn't need to change — only what it was
for needed correcting. The check still holds: if the computed
delta is zero or negative, skip the multiply and let the write
pass through untouched. It's no longer framed as "protect against
a spend," since XP Gems can't be spent; it's framed as "protect
against any non-positive write to this field, from any source we
haven't fully verified":

Code: Select all

cmp rdx,0
jle skipmultiply
imul edx,[multiplier]
skipmultiply:

FAILURE POINT 4 — A RUNAWAY NUMBER FROM A REGISTER-WIDTH MISMATCH
With the guard's reasoning corrected and the delta-based scaling
verified stable, a fresh round of live testing turned up one more
problem: the on-screen XP Gem total exploded into an absurd
18-digit number after a single pickup (something like
"979399671887691791"), then drifted unpredictably with every gem
gained afterward — never crashing, never obviously breaking
anything structurally, just displaying nonsense.

The cause was a register-width mismatch in the multiply itself.
multiplier was allocated as exactly 4 bytes (alloc(multiplier,4)
/ dd (int)2), but the instruction reading it was
imul rdx,[multiplier] — using the full 64-bit register rdx. In
x86-64, the register you multiply into determines how many bytes
get read from memory: writing to rdx pulls a full 8-byte read
starting at that address, not the 4 bytes actually reserved. Since
targetaddr (8 bytes) sits immediately adjacent to multiplier in
memory, every multiply was silently reading 4 extra bytes belonging
to targetaddr and splicing them onto the high half of the
multiplier value — turning a clean "2" into an enormous garbage
number, and explaining why the result drifted between pickups
instead of staying consistent (it was reading whatever targetaddr
currently held).

THE FIX: MATCH THE REGISTER WIDTH TO THE ALLOCATION
One line: swap the 64-bit register for its 32-bit alias, so the
read stays correctly bounded to the 4 bytes multiplier actually
owns.

imul edx,[multiplier]

edx is the lower 32 bits of rdx; writing to the 32-bit
sub-register automatically zeroes the upper 32 bits of the full
register, so nothing downstream (add rcx,rdx) needs to change —
it now just receives a correctly-sized value instead of a corrupted
one. Confirmed after the fix: a paused disassembly showed the
multiply encoded without the 48 REX.W prefix (proof it's now the
32-bit form), and live testing showed the total scaling sanely at
both 2x and 10x with no runaway growth.

FULL CORRECTED SCRIPT
Here are the actual lines of code implementing all four fixes,
shown first without comments:

Code: Select all

[ENABLE]

alloc(newmem,2048,"HallsOfTorment.exe"+24CCB01)
alloc(multiplier,4)
alloc(targetaddr,8)
registersymbol(multiplier)
registersymbol(targetaddr)

label(returnhere)
label(originalcode)
label(nomatch)
label(resolvedone)
label(skipmultiply)

multiplier:
dd (int)2

targetaddr:
dq 0

"HallsOfTorment.exe"+24CCB01:
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]

"HallsOfTorment.exe"+24CCB01:
mov [rdi+08],rax
mov rbx,[rsp+40]

unregistersymbol(multiplier)
unregistersymbol(targetaddr)
dealloc(newmem)
dealloc(multiplier)
dealloc(targetaddr)

And now the same script with every block explained:

Code: Select all

[ENABLE]

// ============================================================
// 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,"HallsOfTorment.exe"+24CCB01)   // reserve 2048 bytes of memory near the 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 target address
registersymbol(multiplier)                          // expose "multiplier" so it can be added to the cheat table
registersymbol(targetaddr)                          // expose "targetaddr" for optional inspection in the cheat table

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 our value
label(resolvedone)    // where the pointer-chain walk jumps to if it hits a null link partway through
label(skipmultiply)   // where execution goes when the write is not a genuine gain (zero or negative delta)

// ============================================================
// DATA
// "multiplier" is what you will see and edit directly in the
// cheat table. "targetaddr" is internal bookkeeping only — it
// always holds the freshly resolved memory address of our
// value, refreshed on every hook call.
// ============================================================
multiplier:
dd (int)2      // default multiplier: doubles every genuine gain

targetaddr:
dq 0           // placeholder until the first successful pointer-chain resolution

// ============================================================
// HOOK INSTALLATION
// Overwrites the start of the shared Variant-write routine
// (used by 158+ fields, not just ours) 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.
// ============================================================
"HallsOfTorment.exe"+24CCB01:
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 our value's container
// may not exist yet (loading screens, menus).
// ============================================================
newmem:

// --- Step 1: re-resolve the live target address ---
// Walks the validated pointer chain 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 or menus.
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 our value? ---
// The routine we hooked writes to many different fields; this
// compares the CURRENT write's destination (rdi+08) against our
// resolved 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 resolved 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 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 is left
// alone entirely — not because this value can be spent (it can't;
// XP Gems only ever add), but because nothing guarantees this
// shared write routine is only ever fed a gain here, and a
// blind multiply would corrupt any smaller value that does land
// on this field for a reason we haven't directly observed.
// 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 (see Failure Point 4). 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 amount actually gained, if any)
cmp rdx,0               // is this actually a gain?
jle skipmultiply        // if it's zero or negative, 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 two original instructions at the hook site and
// releases everything we allocated and registered.
// ============================================================
"HallsOfTorment.exe"+24CCB01:
mov [rdi+08],rax
mov rbx,[rsp+40]

unregistersymbol(multiplier)
unregistersymbol(targetaddr)
dealloc(newmem)
dealloc(multiplier)
dealloc(targetaddr)

WHAT THIS CHAPTER'S FAILURES HAVE IN COMMON
All four failure points trace back to the same root habit worth
naming plainly: assuming a script does what it looks like it does
without actually testing that assumption against live behavior.
A one-time block that looks like setup code but never executes,
a total that looks like a delta but isn't, a guard whose correct
justification wasn't the one it was originally written under, and
an instruction that reads correctly in isolation but silently
overruns its own allocation once the actual bytes involved are
traced through — none of these were visible from reading the
script alone. Each one only surfaced through deliberate, structured
testing and questioning: watching values directly in the table,
testing from a genuinely fresh game state, double-checking an
assumption about the tracked item itself rather than taking an
earlier label at face value, and watching the actual displayed
number instead of assuming a one-line multiply was self-evidently
correct.

One thing this script still doesn't address: it hooks a single
hard-coded address, "HallsOfTorment.exe"+24CCB01, which will
break the moment the game is updated and that address shifts.
Chapter 5 covers how we made the hook resilient to exactly that.


WanderingNovice
Cheater
Cheater
Posts: 10
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0

Re: Halls Of Torment - Cheat Scripting Practice Log

Post by WanderingNovice »

Author: A human being.
Co-author: An artificial intelligence.
Proofreader: A human being.
Formatter: A human being.


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:

  1. The BACKUP block must sit before HOOK INSTALLATION in [ENABLE],
    so the snapshot captures the real, untouched bytes before the
    jmp overwrites them.
  2. 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

Code: Select all

unregistersymbol(originalbytes)

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.


WanderingNovice
Cheater
Cheater
Posts: 10
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0

Re: Halls Of Torment - Cheat Scripting Practice Log

Post by WanderingNovice »

Author: A human being.
Co-author: An artificial intelligence.
Proofreader: A human being.
Formatter: A human being.


CHAPTER 6 — MAKING THE TABLE AND SCRIPT USER-FRIENDLY

Chapter 5 closed with a script that is correct and resilient: it
survives game updates via an AOB-scanned hook, it restores itself
byte-for-byte via a live snapshot, and it documents its own one
deliberate exception (the hardcoded base offset) in place. None of
that helps much if the table it lives in is unreadable six months
from now, or if sharing the raw script with someone else hands
them 60-odd lines of assembly with no idea what it does or how to
use it. This chapter is not about another bug — it's about closing
that gap. Four additions, each solving a different half of "usable
by someone who isn't you, right now, mid-project":

  1. A summary comment at the top of the script itself.
  2. A Group Header in the cheat table, for organization at a glance.
  3. A free-text Notes block on the table, for anyone who never opens
    the code at all.
  4. A companion Lua script so the table can find the game process
    on its own.

1. A TOP-OF-SCRIPT SUMMARY COMMENT
If the raw script is ever copied out of the table on its own —
pasted into a forum post, sent in a message, opened later in the
standalone Auto Assembler editor — the table's own organization
(headers, notes) doesn't travel with it. Only the text of the
script itself does. So the very first thing anyone sees needs to
carry the whole picture in a few lines.

Text placed before the first [ENABLE] marker is treated as a
comment and safely ignored by Cheat Engine's assembler — this is
an established pattern, not a guess; official Cheat Engine
templates commonly open with an attribution line the same way
before their first section marker.

Code: Select all

// ============================================================
// HALLS OF TORMENT — XP GEM MULTIPLIER
// Summary of Script intention: hooks the shared Variant-write
// routine used by 158+ game fields, filters for writes landing
// on the live XP counter address (resolved via a 3-hop
// pointer chain), and scales only the positive delta by
// "multiplier" (editable live in the cheat table). Since XP
// Gems add their value directly onto this counter, scaling the
// counter's gains effectively multiplies every XP Gem picked
// up, speeding up in-run Level Ups. AOB-scanned and
// self-restoring, so it's safe to enable/disable repeatedly and
// self-relocates if a game update shifts the executable's code
// layout.
// ============================================================

This block is already the first thing at the top of the canonical
script from here on — nothing else in the script needed to change
for it to work.

2. A GROUP HEADER IN THE CHEAT TABLE
This is a table-organization feature, not something typed into
the Auto Assembler script — it lives in Cheat Engine's own address
list GUI:

  1. Right-click empty space below your entries in the main Cheat
    Engine window and choose Create Header.
  2. Double-click its description and give it a self-explanatory
    name, e.g. === Halls of Torment — XP Gem Multiplier ===.
  3. Drag the multiplier script entry onto the header so it becomes
    a nested child (it will indent slightly underneath).
  4. Right-click the header and open Group Config, then tick
    "Activating this entry activates its children" and
    "Deactivating this entry deactivates its children" if the
    header's own checkbox should enable/disable everything nested
    under it at once. This pays off most once more related entries
    exist later (a hotkey toggle, a read-only debug address showing
    the live resolved pointer, and so on) — they all live under one
    clearly-labeled banner instead of scattered loose rows.
  5. Optionally right-click and choose Change Color to make the
    header visually stand out if the table grows further.

Under the hood, this is exactly what Cheat Engine writes into the
.CT file's XML when "Create Header" is used — shown here purely
for reference, not something to hand-edit:

Code: Select all

<CheatEntry>
  <ID>0</ID>
  <Description>"=== Halls of Torment — XP Gem Multiplier ==="</Description>
  <LastState/>
  <GroupHeader>1</GroupHeader>
  <CheatEntries>
    <CheatEntry>
      <ID>1</ID>
      <Description>"XP Gem Multiplier"</Description>
      <VariableType>Auto Assembler Script</VariableType>
      <AssemblerScript>[ENABLE]
...your script...
[DISABLE]
...
</AssemblerScript>
    </CheatEntry>
  </CheatEntries>
</CheatEntry>

The <GroupHeader>1</GroupHeader> tag plus the nested
<CheatEntries> block is what the GUI action produces on your
behalf — you never need to write this by hand.

  1. A NOTES BLOCK FOR THE TABLE ITSELF
    The summary comment from Section 1 only helps someone who opens
    the script text. Cheat Engine's table also has its own free-text
    Notes field (Table Properties → Notes), separate from the Auto
    Assembler code, meant for exactly this kind of plain-language
    context. It doesn't need // marks since it isn't code — just the
    same divider style used everywhere else in this project, for
    consistency and readability:

Code: Select all

============================================================
HALLS OF TORMENT — XP GEM MULTIPLIER
============================================================

WHAT THIS DOES
Hooks the shared Variant-write routine used by 158+ in-game
fields, isolates the live XP counter (the value that fills up
from every XP Gem pickup and drives in-run Level Ups), and
multiplies only the amount actually gained on each write by an
editable "multiplier" value. Non-gain writes (decreases or
resets) are left untouched, so nothing else about the counter's
normal behavior changes.

TARGET
- Pointer chain: "HallsOfTorment.exe"+396BC20 -> +68 -> +28 -> +5C0
- Hook site: AOB-scanned
  (48 89 47 08 48 8B 5C 24 40 48 83 C4 20 5F C3),
  self-relocates automatically if a game update shifts the
  executable's code layout.

VERIFIED AGAINST
Build ID 22764619 — "HoT Fixes | 2026-04-14"
(pushed live 14 April 2026, per SteamDB:
https://steamdb.info/patchnotes/22764619/)
If Halls of Torment has since updated past this build, re-check
that the pointer chain and AOB pattern still resolve correctly
before trusting the multiplier's output.

HOW TO USE
1. Enable this entry's checkbox in the cheat table.
2. Edit "multiplier" (default: 2) to whatever multiplier you
   want each XP Gem's effective value to be scaled by.
3. Disable the checkbox at any time to cleanly restore the
   original game code byte-for-byte — safe to toggle repeatedly.

SAFETY NOTES
- The scaling multiply is a 32-bit signed operation (imul edx),
  so an extremely high multiplier combined with a very large
  single gem pickup could in theory overflow. Keep the
  multiplier at a modest value (low double digits or under) to
  stay well clear of that ceiling.
- All hook-site bytes are captured live at enable-time and
  restored exactly on disable — no permanent changes are ever
  made to the game's files on disk.

The "VERIFIED AGAINST" entry is worth explaining, since it's easy
to skip past: it records the exact game build this table was last
confirmed against, sourced directly from SteamDB's public patch
history, so a future reader (including future-you) has an
immediate way to tell whether the game has moved on since this was
last checked, without having to re-derive everything from scratch
to find out.

  1. A COMPANION LUA SCRIPT: AUTO-ATTACH
    Every script so far has assumed Cheat Engine is already attached
    to HallsOfTorment.exe. Making that happen automatically is a
    genuinely different feature of Cheat Engine, not an extension of
    anything written so far — worth understanding clearly rather than
    just pasting code:

  2. Everything in this project up to now is Auto Assembler: x86
    assembly, injected directly into the game's own memory, living
    inside a script's [ENABLE]/[DISABLE] blocks.

  3. Attaching to a process is handled by Lua, Cheat Engine's separate
    automation/scripting language. It runs inside Cheat Engine
    itself, not injected into the game, and lives in its own
    dedicated pane — the Cheat Table Lua Script editor, opened with
    Ctrl+Alt+L on the main Cheat Engine window — entirely separate
    from the Auto Assembler editor used for every script so far.

Cheat Engine also has a zero-code option for this, worth knowing
about even though it doesn't travel with the table: Edit → Settings
→ General Settings → "Automatically attach to processes named" →
HallsOfTorment.exe (also check "Even autoattach when another
process has already been selected" so it re-attaches automatically
after a crash/restart). This lives in Cheat Engine's own settings,
not inside the .CT file, so it only helps on this specific
machine and won't come along if the table is shared or opened
elsewhere.

For an attach behavior that does travel with the table itself, two
Lua approaches were written as reference, saved as a companion
file rather than committed permanently into the table (there was
no immediate need to wire one in yet, so both are kept on hand for
whenever that need arises):

Code: Select all

-- ============================================================
-- OPTION 1: SIMPLE ONE-LINER
-- Queues the process name onto Cheat Engine's own internal
-- auto-attach list. CE checks this list continuously in the
-- background, so it will attach the moment the game is
-- running -- even if the game isn't open yet when the table
-- is loaded. This is literally what CE generates for its own
-- exported trainers.
-- ============================================================
getAutoAttachList().add("HallsOfTorment.exe")

Code: Select all

-- ============================================================
-- OPTION 2: TIMER-BASED, WITH A RETRY LIMIT
-- Polls the running process list on an interval until it finds
-- HallsOfTorment.exe, attaches to it, then destroys the timer
-- so it stops polling once the job is done. Gives up after a
-- fixed number of attempts instead of polling forever, unlike
-- Option 1's list-based approach which just keeps checking
-- indefinitely.
-- ============================================================
PROCESS_NAME = "HallsOfTorment.exe"

local autoAttachTimer = nil          -- holds the timer object once created
local autoAttachInterval = 1000      -- how often to check, in milliseconds
local autoAttachTicks = 0            -- how many times we've checked so far
local autoAttachMaxTicks = 30        -- give up after ~30 seconds (30 x 1000ms)

-- ------------------------------------------------------------
-- Runs once per tick: checks if the game is running yet, and
-- either attaches (then stops) or gives up after too many tries.
-- ------------------------------------------------------------
local function autoAttachTick(timer)
  if autoAttachMaxTicks > 0 and autoAttachTicks >= autoAttachMaxTicks then
    timer.destroy()                  -- stop polling, game never showed up
    return
  end

  if getProcessIDFromProcessName(PROCESS_NAME) ~= nil then
    timer.destroy()                  -- stop polling, we found it
    openProcess(PROCESS_NAME)        -- attach Cheat Engine to the game
  end

  autoAttachTicks = autoAttachTicks + 1
end

autoAttachTimer = createTimer(getMainForm())   -- attach the timer to CE's own main window
autoAttachTimer.Interval = autoAttachInterval
autoAttachTimer.OnTimer = autoAttachTick

Option 1 is the simplest possible version and matches what Cheat
Engine itself generates for exported trainers. Option 2 trades
that simplicity for a hard timeout (useful if the game is never
actually launched, so it doesn't poll forever) and a natural place
to later hook an onOpenProcess callback that could auto-enable
the multiplier script the instant attachment succeeds — a genuine
possibility for later, not something built here. One practical
note either way: Cheat Engine asks for permission before running a
table's embedded Lua on load by default, since Lua can do more than
assembly injection — worth knowing that prompt exists rather than
assuming either script would run silently.

WHAT DIDN'T MAKE IT IN, ON PURPOSE
Two more polish ideas came up alongside these four and were
deliberately left undone rather than forgotten:

  • Hotkeys to toggle the script or bump the multiplier up/down —
    a real usability win, but not built, since nothing about the
    script needed it to function correctly.
  • A code-level guard against the 32-bit multiply's overflow
    ceiling — currently only a documented caveat in the Notes block
    above (Section 3), not an actual runtime check. Enforcing it in
    code would mean deciding what should happen at the ceiling
    (clamp the value? skip the multiply entirely for that one write?)
    without a real scenario yet showing that ceiling is ever
    actually at risk of being reached in normal play.

Leaving both out is a scope decision, documented here for
posterity, not an oversight — anyone picking this project back up
later has the full context for why they're missing, and can decide
for themselves whether either is worth adding.

Chapter 7 closes out this documentation with what was learned
across every chapter, the specific ways Godot's engine shaped every
decision made along the way, and where this project stands now.


WanderingNovice
Cheater
Cheater
Posts: 10
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0

Re: Halls Of Torment - Cheat Scripting Practice Log

Post by WanderingNovice »

Author: A human being.
Co-author: An artificial intelligence.
Proofreader: A human being.
Formatter: A human being.


CHAPTER 7 — CONCLUSION, LESSONS LEARNED, AND GODOT'S FINGERPRINTS
Six chapters ago, this project started from a single in-game
pickup — the XP Gem — and a blank cheat table. What exists now is
a working, resilient, and reasonably well-documented multiplier
for that value: a script that survives game updates, restores
itself byte-for-byte, and explains itself in three different
places depending on how someone eventually encounters it. This
closing chapter does three things: recaps what was actually built,
pulls together every place Godot's own architecture directly
shaped a decision along the way, and is honest about the one
thing this project never fully resolved.

WHAT WE ACTUALLY BUILT

  • A pointer chain resolving the live XP counter's address fresh on
    every hook call: "HallsOfTorment.exe"+396BC20 -> +68 -> +28 ->
    +5C0, chosen from thousands of pointer-scan candidates and
    verified across restarts, a level change, and a character
    change.
  • A hook on the shared Variant-write routine at that address,
    filtering for writes landing specifically on our resolved
    address out of the 158+ fields that pass through the same
    instruction.
  • Delta-based scaling — multiplying only the amount actually
    gained on a write, not the running total — with a guard against
    any non-positive write from a source we haven't fully verified.
  • A register-width-correct multiply (32-bit edx, matching
    multiplier's true 4-byte allocation) after an earlier 64-bit
    version quietly corrupted the result with neighboring memory.
  • An AOB-scanned hook site that re-locates itself if a future game
    update shifts the executable's layout, plus a live byte-snapshot
    backup/restore in place of hand-retyped restore instructions.
  • A cheat table wrapped around all of that: a top-of-script
    summary comment, a Group Header for organization, a Notes block
    with usage instructions and the exact game build it was verified
    against, and a companion auto-attach reference script.

Verified against Build ID 22764619, "HoT Fixes | 2026-04-14" (per
SteamDB's patch notes for the game). If Halls of Torment has
updated past this build by the time this is read, the pointer
chain and AOB pattern should be re-checked before being trusted
as-is — exactly the caveat already carried in the Preface and the
table's own Notes block.

GODOT'S FINGERPRINTS ON THIS PROJECT
Almost every real difficulty in this project traces back to one
architectural fact, flagged as early as the Preface: GDScript
never compiles to native x86 instructions. It runs through Godot's
own interpreter and a single, generic Variant copy/write
mechanism shared by every value assignment in the entire game.
Four separate places where that fact directly shaped what we had
to do:

  • The write instruction we found in Chapter 1
    (mov [rdi+08],rax) fired for over 158 different destination
    addresses, not just ours — because it is Godot's shared
    Variant-copy routine, not dedicated code for our value. This is
    the reason the multiplier script needed an explicit address
    comparison at all (Chapter 2, Chapter 3's Step 2) instead of
    being able to hook a clean, dedicated instruction outright.
  • A normal pointer scan on a Godot game can produce enormous
    result sets — 100+ GB on some 64-bit titles — because Godot's
    object graph is dense and Variant-wrapped almost everywhere.
    This is exactly why Chapter 1 used pointermaps (compact,
    reusable snapshots) instead of repeatedly re-scanning raw memory
    from scratch.
  • The "ends with offset 8" dead end in Chapter 1 exists because
    rdi in the shared routine isn't a fixed static offset — it's a
    parameter computed by the caller, most likely container base
    plus index. A shared engine routine's incidental register
    arithmetic doesn't necessarily correspond to anything expressible
    as a fixed pointer chain, and forcing that assumption cost real
    time before it was abandoned.
  • Every failure point in Chapter 4 and every resilience fix in
    Chapter 5 exists downstream of the same root cause: because the
    hook has to sit on a routine shared by 158+ fields rather than
    code written specifically for our value, every later refinement
    (the delta guard, the register width, the AOB scan) was about
    making that one shared hook behave correctly and durably for
    just our field, without disturbing the other 157.

THE LOOSE THREAD WE DIDN'T CHASE
The Preface flagged one unresolved oddity up front: a separate
memory address, never surfaced by any of our scans, that appeared
tied to the game's own on-screen display of the XP counter — with
no confirmed explanation of what it actually is or how it relates
to the real value our script correctly multiplies. Looking back at
Chapter 1 with the benefit of hindsight, there's a plausible (but
never confirmed) candidate for what that might be: when the
pointer-scan candidate count plateaued around 2,244 results after
diminishing returns from repeated restarts, the working theory at
the time was that many of the survivors were legitimate mirrored
copies of the same value — a save-data copy, a runtime copy, and a
UI-display copy were all named as possibilities. That UI-display
copy is a reasonable candidate for the address the Preface
describes, but this was never actually tested or confirmed against
it directly, so it stays exactly what it's always been: an
educated guess, not an answer. Recorded here plainly rather than
smoothed over, since our script works correctly without ever
needing to resolve it, and a future reader deserves to know this
is genuinely open rather than quietly settled.

LESSONS LEARNED, CHAPTER BY CHAPTER

  • Finding the value and the pointer (Chapter 1): the shortest
    pointer-scan candidate isn't automatically "the" chain until
    it's actually verified across a restart, a level change, and a
    character change — and a filter that assumes a shared routine's
    local register math maps onto a fixed offset can silently return
    nothing, for a real architectural reason, not a mistake in how
    the filter was written.
  • Contextualizing the write instruction (Chapter 2): disassembly
    comments describing "restore," "epilogue," and similar concepts
    are inferences drawn from an instruction's position in the code,
    not facts printed directly on the line — worth reading slowly
    rather than taking at face value, especially before hooking
    anything nearby.
  • First scripts and code injection (Chapter 3): a fixed-length
    jmp needs NOP padding sized to the full combined length of
    every instruction it overlaps, not just the one instruction it's
    conceptually "replacing" — a mismatch here corrupts real code
    silently rather than crashing outright.
  • Making the multiplier actually work (Chapter 4): a script that
    merely doesn't crash can still hide multiple independent
    failures behind "looks like it works" — compounding math, a
    guard whose justification didn't match the actual system it was
    protecting, and a register-width mismatch that produces
    confidently wrong numbers with no error at all. Each only
    surfaced through deliberate live testing, not by reading the
    script.
  • Resilient code via AOBscanmodule and Readmem (Chapter 5):
    hardcoded addresses and hand-retyped restore instructions are
    both silent, delayed failure risks rather than obvious ones — but
    resilience isn't automatically worth chasing everywhere; the base
    pointer offset stayed hardcoded on purpose once a real
    investigation confirmed there was nothing to build an AOB scan
    around.
  • Making the table and script user-friendly (Chapter 6): a script
    only its author can read is only half finished, because the
    script text, the cheat table's organization, and the table's
    Notes field each travel differently and reach different future
    readers — the same context has to be repeated in more than one
    place on purpose, not left to live in only one.

WHERE THIS STANDS NOW
The XP Gem Multiplier works, is resilient to game updates within
the limits documented in Chapter 5, restores itself cleanly every
time it's disabled, and is organized and annotated well enough
that picking this project back up later — or handing it to someone
else — shouldn't require re-deriving anything from scratch. The
one open question, the UI-display address mystery, is written down
rather than buried, exactly where a future continuation of this
work would need to start if it's ever worth chasing further.


WanderingNovice
Cheater
Cheater
Posts: 10
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0

Re: Halls Of Torment - Cheat Scripting Practice Log

Post by WanderingNovice »

In the next volume, we will try to tackle making scripts for other values such as:

  • Current Health Points

  • Maximum Health Points

  • Attack Speed

  • Movement Speed

  • Timer

  • Damage Values for Main Weapon

  • Damage Values for Abilities

  • Critical Chance

  • Multistrike

  • Others that can be messed around with to create our unique power fantasy dream.


Post Reply