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

User avatar
WanderingNovice
Novice Hacker
Novice Hacker
Posts: 41
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0
x 39

Halls Of Torment - Cheat Scripting Practice Log

Post by WanderingNovice »


Latest Update: 15.08.26

Halls of Torment / Godot Engine 4.2 v22764619


🎯 Features

□ 📌 Steam version only
□ 📌 ⚠️ Use at your own risk (You are still learning).

Forum Format derived as fair use under the attached license below:
License: GPL-3.0
Credits: Thank you: [SeryogaSK] for the Forum Post Generator.

Beyond this point, all are original creations and follow the next attached license.


Halls of Torment / Godot Engine 4.2

Halls of Torment — Steam Store Page

Relevant Details

  • Halls of Torment was released on Steam in Early Access on May 24, 2023. Version 1.0 followed on September 24, 2024.
  • This post is meant for posterity and open discussion. Education and reference are encouraged.
  • Volume 01 documents the first scripting journey — creating a multiplier for XP Gems gain. Volume 02 expands that approach into a unified multiplier covering 14 player and weapon stats.
  • Over time, either via requests or new contributions, new cheat scripts will be 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 to 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 who 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: A chain of memory addresses that reliably leads to a value you care about, even after the game moves things around.
  • Godot Engine: The engine the game runs on — your biggest friend and biggest 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

*Compatibility: Tested against Steam build 22764619 ("HoT Fixes" update, 2026-04-14). Requires Cheat Engine 7.5 — other versions untested. Single-player only; the game has no anti-cheat and no online play to disrupt. Later game builds may work but are unverified — compare yours against the build history on SteamDB.

Scope: Built and validated with multiple characters live (Swordsman, Sorceress, Archer, Ravaging Summoner dude, Exterminator, and the Holy Lady who uses her muscles to parry and smash the unholy. This script in version 1.0 does one thing: Inject code that would first attach a multiplier to the incoming value that the game intended to write and update XP Gems with. Super simple usage, but results may vary because not all XP Gems contribute a value of "1" per entry to your character's XP bar.

Downloads:

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 20 times
Halls-of-Torment-CheatScript-XP-Gems-Multiplier-Streamlined.txt
Text copy of the streamline version of code (XP Gems Multiplier)
(1.21 KiB) Downloaded 4 times
Halls-of-Torment-CheatScript-XP-Gems-Multiplier-Annotated.txt
Text copy of the annotated version of code (XP Gems Multiplier)
(12 KiB) Downloaded 9 times

Version History:

  • Version 1.0 (2026-07-28) — current public release.
  • Will be updated soon to reflect learnings from volume 2.

Volume 02: Halls of Torment — Unified Multiplier Script for 14 Player & Weapon Stats (Swordsman / Zweihänder)

Compatibility: Tested against Steam build 22764619 ("HoT Fixes" update, 2026-04-14). Requires Cheat Engine 7.5 — other versions untested. Single-player only; the game has no anti-cheat and no online play to disrupt. Later game builds may work but are unverified — compare yours against the build history on SteamDB.

Scope: Built and validated on the Swordsman with the Zweihänder equipped. Defensive and miscellaneous stats are shared player stats and should work on any character, but only the Swordsman is validated. The weapon block affects the Zweihänder only. Covers 14 stats: Max Health, Health Regen, Block Strength, Defense, Movement Speed, Experience Gain, and 8 Zweihänder stats (Damage, Range, Cone Angle, Attack Speed, Crit Chance, Crit Bonus, Multistrike, Knockback).

Downloads:

Halls-Of-Torment-Vol2-Scripts-(Version_1.7-Annotated).CT
Total Value Multiplier Script + Pointers + Manipulatable Multipliers, floors, and ceilings.
(206.96 KiB) Downloaded 14 times
Halls-of-Torment-CheatScript-Unified-Multiplier-Streamlined.txt
Text copy of the streamline version of code (Unified Total Multiplier)
(32.34 KiB) Downloaded 3 times
Halls-of-Torment-CheatScript-Unified-Multiplier-Annotated.txt
Text copy of the annotated version of code (Unified Total Multiplier)
(65.72 KiB) Downloaded 7 times

Version History:

  • Version 1.7 (2026-08-05) — current public release.
  • Documentation and table creation guide will follow soon.

And of course, in the replies section will be individual chapters for the breakdown of the script's journey. This is meant to allow Google and other search engines (or aggregators like Artificial Intelligence search 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.)


All published information in this thread is for common use. Attempts to hold intellectual property claims over these findings are frowned upon.
I dedicate this work to the public domain under the CC0 1.0 Universal Public Domain Dedication.
License: https://creativecommons.org/publicdomain/zero/1.0/

Last edited by WanderingNovice on Fri Aug 14, 2026 1:10 pm, edited 11 times in total.

I dedicate this work to the public domain.
You can copy, modify, distribute, and perform the work, even for commercial purposes, all without asking permission.
License: https://creativecommons.org/publicdomain/zero/1.0/


Tags:

User avatar
WanderingNovice
Novice Hacker
Novice Hacker
Posts: 41
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0
x 39

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.

Edit 1 (August 2, 2026): Using Markdown for formatting instead.
Edit 2 (August 5, 2026): Added a prefix for headers to associate with correct Volume.


Documentation for: Volume 01: Halls of Torment — Script for XP Gems Multiplier


Volume 1: 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.


License: https://creativecommons.org/publicdomain/zero/1.0/

Last edited by WanderingNovice on Wed Aug 05, 2026 5:53 am, edited 5 times in total.

I dedicate this work to the public domain.
You can copy, modify, distribute, and perform the work, even for commercial purposes, all without asking permission.
License: https://creativecommons.org/publicdomain/zero/1.0/


User avatar
WanderingNovice
Novice Hacker
Novice Hacker
Posts: 41
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0
x 39

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.

Edit 1 (August 2, 2026): Using Markdown for formatting instead.
Edit 2 (August 5, 2026): Added a header prefix to associate with the correct Volume.


Documentation for: Volume 01: Halls of Torment — Script for XP Gems Multiplier


Volume 1: 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:

PassCandidates remaining
Rescan 12,604
Rescan 22,432
Rescan 32,301
Rescan 42,273

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.


License: https://creativecommons.org/publicdomain/zero/1.0/

Last edited by WanderingNovice on Thu Aug 13, 2026 8:36 pm, edited 5 times in total.

I dedicate this work to the public domain.
You can copy, modify, distribute, and perform the work, even for commercial purposes, all without asking permission.
License: https://creativecommons.org/publicdomain/zero/1.0/


User avatar
WanderingNovice
Novice Hacker
Novice Hacker
Posts: 41
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0
x 39

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.

Edit 1 (August 2, 2026): Using Markdown for formatting instead.
Edit 2 (August 5, 2026): Added a header prefix to associate with the correct Volume.


Documentation for: Volume 01: Halls of Torment — Script for XP Gems Multiplier


Volume 1: 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.


License: https://creativecommons.org/publicdomain/zero/1.0/

Last edited by WanderingNovice on Thu Aug 13, 2026 8:37 pm, edited 5 times in total.

I dedicate this work to the public domain.
You can copy, modify, distribute, and perform the work, even for commercial purposes, all without asking permission.
License: https://creativecommons.org/publicdomain/zero/1.0/


User avatar
WanderingNovice
Novice Hacker
Novice Hacker
Posts: 41
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0
x 39

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.

Edit 1 (August 2, 2026): Using Markdown for formatting instead.
Edit 2 (August 5, 2026): Added a prefix for headers to associate with correct Volume.


Documentation for: Volume 01: Halls of Torment — Script for XP Gems Multiplier


Volume 1: Chapter 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.


License: https://creativecommons.org/publicdomain/zero/1.0/

Last edited by WanderingNovice on Wed Aug 05, 2026 5:54 am, edited 6 times in total.

I dedicate this work to the public domain.
You can copy, modify, distribute, and perform the work, even for commercial purposes, all without asking permission.
License: https://creativecommons.org/publicdomain/zero/1.0/


User avatar
WanderingNovice
Novice Hacker
Novice Hacker
Posts: 41
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0
x 39

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.

Edit 1 (August 2, 2026): Using Markdown for formatting instead.
Edit 2 (August 5, 2026): Added a prefix for headers to associate with correct Volume.


Documentation for: Volume 01: Halls of Torment — Script for XP Gems Multiplier


Volume 1: Chapter 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.

Code: Select all

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.


License: https://creativecommons.org/publicdomain/zero/1.0/

Last edited by WanderingNovice on Wed Aug 05, 2026 5:54 am, edited 4 times in total.

I dedicate this work to the public domain.
You can copy, modify, distribute, and perform the work, even for commercial purposes, all without asking permission.
License: https://creativecommons.org/publicdomain/zero/1.0/


User avatar
WanderingNovice
Novice Hacker
Novice Hacker
Posts: 41
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0
x 39

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.

Edit 1 (August 2, 2026): Using Markdown for formatting instead.
Edit 2 (August 5, 2026): Added a prefix for headers to associate with correct Volume.


Documentation for: Volume 01: Halls of Torment — Script for XP Gems Multiplier


Volume 1: Chapter 5 — Resilient Code via AOBScanModule and ReadMem

Chapter 4 closed with a working multiplier script that still had one open weakness: it hooked a single hard-coded address, "HallsOfTorment.exe"+24CCB01. That address is only correct for the exact build the script was written against. The moment Chasing Carrots ships a patch that shifts the executable's code layout even slightly, that literal offset goes stale, and the script fails silently — no crash, no error, just a hook that either does nothing or lands on the wrong bytes entirely. This chapter covers how the hook was made to find itself instead of trusting a fixed number, a real investigation into doing the same for the pointer chain's base offset that ended in a genuine dead end, and a byte-perfect backup mechanism that removes the last place hand-typed instructions could quietly drift out of sync with the game.

The Problem With a Hard-Coded Hook Address

A raw address like +24CCB01 means nothing on its own — it is only useful because, on one specific build, that is where two specific instructions happen to sit. Any update that recompiles or reorders the surrounding code invalidates it completely, with no warning built into the script itself. The fix isn't to guess a new address after every patch; it's to stop relying on a fixed address at all and instead search for the actual bytes of the instructions being hooked, wherever they end up living.

Fix 1 — Replacing the Hardcoded Hook With an AOB Scan

Cheat Engine's Auto Assembler provides exactly this tool: aobscanmodule scans a named module for a byte pattern (an "array of bytes," AOB) and binds whatever address it finds to a symbol, which then behaves like any other address elsewhere in the script.

Using the bytes already confirmed from earlier disassembly around the hook site:

Code: Select all

24CCB01 - 48 89 47 08           - mov [rdi+08],rax      (hook target)
24CCB05 - 48 8B 5C 24 40        - mov rbx,[rsp+40]
24CCB0A - 48 83 C4 20           - add rsp,20
24CCB0E - 5F                    - pop rdi
24CCB0F - C3                    - ret

That's a 15-byte, five-instruction chain (write, restore, stack cleanup, pop, ret), specific enough that a coincidental match elsewhere in the executable is effectively impossible. It also starts exactly at the hook target, so the scanned address IS the hook address — no extra offset math needed on top of it.

Code: Select all

aobscanmodule(hookaob,HallsOfTorment.exe,48 89 47 08 48 8B 5C 24 40 48 83 C4 20 5F C3)

aobscanmodule (rather than the plainer aobscan) restricts the search to HallsOfTorment.exe specifically, since the target is already known to live in the main executable and not a loaded library — both faster and immune to a false match turning up in a DLL. Every later reference to the hardcoded address was then replaced with hookaob: alloc(newmem,2048,hookaob) instead of alloc(newmem,2048,"HallsOfTorment.exe"+24CCB01), and a plain hookaob: label at both the ENABLE hook installation and the DISABLE restore, instead of the literal address at each spot. hookaob itself is never registered as a table symbol — it's internal plumbing, not something a player needs to see or edit.

Verification

Cheat Engine's own AOB tool, run directly on the two overwritten instructions, independently confirmed a shorter 9-byte version of the same signature (48 89 47 08 48 8B 5C 24 40) was already unique on its own — meaning the 15-byte version used here has comfortable safety margin to spare. Applying the AOB-based script and pausing execution afterward showed hookaob resolving to the same real address as before (24CCB01), the multiply still correctly encoded without the 64-bit 48 prefix from Chapter 4's fix, and the final jmp landing precisely on the original add rsp,20 instruction — confirming the 9-byte NOP coverage from Chapter 3 still holds exactly as designed.

Investigation — Why the Pointer Chain's Base Offset Stays Hardcoded

The natural next target was "HallsOfTorment.exe"+396BC20, the static base pointer at the top of the trusted pointer chain. It seemed like the same fix should apply. It doesn't, for a reason worth understanding rather than just accepting: aobscanmodule searches for byte patterns in CODE — instructions. +396BC20 is a DATA address: a fixed slot holding a pointer value that the game writes into, not an instruction that can be pattern-matched the same way. There is no "signature" to search for at a spot that never contains code in the first place.

The real technique for this case is one level indirect: find some instruction elsewhere in the game's own compiled code that genuinely reads or writes +396BC20, AOB-scan for THAT instruction's bytes (with its embedded, shifting address offset wildcarded out), then compute the real address at runtime from that instruction's own position plus its offset. That requires a live capture of a real referencing instruction, which took several attempts to get right:

  • The first two attempts at "find out what accesses this address" were run against the wrong address entirely — the resolved XP counter address at the bottom of the chain (the thing targetaddr points to after the walk), not +396BC20 itself. Both captures turned up hits that traced straight back to the script's own instructions and other ordinary game code touching that same shared field — consistent with what was already known, but not what the investigation needed.
  • A third attempt correctly navigated to +396BC20 directly and confirmed the memory viewer was looking at genuine data (the disassembler rendered nonsensical, self-contradicting "instructions" there — repeated add [rax],al runs, a jump to a bizarre far segment — the classic signature of code trying to interpret raw data bytes rather than real instructions).
  • With the right address finally confirmed, "find out what accesses this address" was run again, through normal play, scene changes, and both gaining and losing XP. Nothing from the game's own code ever touched it.

Conclusion: a Genuine Dead End, Documented in Place

No in-game action was found to trigger any real game-code instruction reading +396BC20 — only the script's own compiled code ever touches it, which gives nothing to build a referencing-instruction scan around. Rather than leave this looking like an unfinished task, the offset stays hardcoded on purpose, with the reasoning written directly into the script as a comment: this particular offset is static and reliable across restarts anyway (that's exactly why the pointer chain was built on it in Chapter 1), so forcing an AOB fix here would add complexity without actually buying any real resilience.

Fix 2 — a Byte-Perfect Backup Instead of Retyped Instructions

Separately from the AOB conversion, the DISABLE section had a smaller but real fragility: it worked by hand-retyping the two original instructions (mov [rdi+08],rax / mov rbx,[rsp+40]). That happened to reassemble to byte-identical output, but only because neither instruction uses RIP-relative addressing — that's not something to keep relying on by luck. Cheat Engine's readmem directive solves this properly: it copies whatever bytes are genuinely sitting at a live address, at the exact moment the script assembles, into a buffer of your choosing — no retyping, no risk of a transcription mismatch, ever.

Code: Select all

alloc(originalbytes,9)          // reserve 9 bytes to hold a live snapshot of the hook site's real bytes

originalbytes:
readmem(hookaob,9)              // copy the live 9 bytes at hookaob into our buffer, right here, right now

DISABLE then pastes that exact captured snapshot straight back, instead of the retyped instructions:

Code: Select all

hookaob:
readmem(originalbytes,9)        // paste the exact bytes we captured at enable-time straight back

Two ordering rules make this work, and both come from how Auto Assembler assembles a script top to bottom:

  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.


License: https://creativecommons.org/publicdomain/zero/1.0/

Last edited by WanderingNovice on Wed Aug 05, 2026 5:54 am, edited 3 times in total.

I dedicate this work to the public domain.
You can copy, modify, distribute, and perform the work, even for commercial purposes, all without asking permission.
License: https://creativecommons.org/publicdomain/zero/1.0/


User avatar
WanderingNovice
Novice Hacker
Novice Hacker
Posts: 41
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0
x 39

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.

Edit 1 (August 2, 2026): Using Markdown for formatting instead.
Edit 2 (August 5, 2026): Added a prefix for headers to associate with correct Volume.


Documentation for: Volume 01: Halls of Torment — Script for XP Gems Multiplier


Volume 1: Chapter 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.

3. 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.

4. 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:

  • 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.
  • 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.


License: https://creativecommons.org/publicdomain/zero/1.0/

Last edited by WanderingNovice on Wed Aug 05, 2026 5:54 am, edited 4 times in total.

I dedicate this work to the public domain.
You can copy, modify, distribute, and perform the work, even for commercial purposes, all without asking permission.
License: https://creativecommons.org/publicdomain/zero/1.0/


User avatar
WanderingNovice
Novice Hacker
Novice Hacker
Posts: 41
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0
x 39

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.

Edit 1 (August 2, 2026): Using Markdown for formatting instead.
Edit 2 (August 5, 2026): Added a prefix for headers to associate with correct Volume.


Documentation for: Volume 01: Halls of Torment — Script for XP Gems Multiplier


Volume 1: Chapter 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.


License: https://creativecommons.org/publicdomain/zero/1.0/

Last edited by WanderingNovice on Wed Aug 05, 2026 5:54 am, edited 4 times in total.

I dedicate this work to the public domain.
You can copy, modify, distribute, and perform the work, even for commercial purposes, all without asking permission.
License: https://creativecommons.org/publicdomain/zero/1.0/


User avatar
WanderingNovice
Novice Hacker
Novice Hacker
Posts: 41
Joined: Sat Jul 25, 2026 7:40 am
Answers: 0
x 39

Re: Halls Of Torment - Cheat Scripting Practice Log

Post by WanderingNovice »

Volume 2 Teaser

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

Defensive Values (In-game values that affect survival)

  • Max Health
  • Block Strength
  • Health Regen
  • Defense

Miscellaneous (In-game values that affect gameplay mechanics)

  • Movement Speed
  • Experience Gain

Weapon Attributes (In-game values that affect the Swordsman's main weapon)

  • Zweihänder - Damage
  • Zweihänder - Range
  • Zweihänder - Cone Angle
  • Zweihänder - Attack Speed
  • Zweihänder - Crit Chance
  • Zweihänder - Crit Bonus
  • Zweihänder - Multistrike
  • Zweihänder - Knockback

Others (Possibly fun to mess around with if I managed to find them once volume 2 is published)

  • In-game timer.
  • Gold gained per level.
  • Damage dealt counter (The particular arithmetic that contributes to quests).

I dedicate this work to the public domain.
You can copy, modify, distribute, and perform the work, even for commercial purposes, all without asking permission.
License: https://creativecommons.org/publicdomain/zero/1.0/


Post Reply