Page 3 of 3

Re: Halls Of Torment - Cheat Scripting Practice Log

Posted: Thu Aug 06, 2026 4:32 pm
by WanderingNovice

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


Documentation for: Volume 02: Halls of Torment — Script for Unified Multiplier


Hestia – Leaving No Trace

The last god to speak is Hestia. She does not appear until [DISABLE], and she never gets the spotlight in disassembly screenshots, but a script that forgets her is a script that dirties the house every time it leaves. Hestia's job is to put everything back where it was: the original bytes, the symbol table, the timers, and the memory.

In concrete terms, Hestia has three obligations:

  1. Restore instructions. For each hook site, she copies the saved bytes from multiplierSite*OriginalBytes back to the AOB address that Ares or Apollo patched on enable. When she is done, the disassembly at each site is exactly what the game shipped with — same call, same cvttss2si, same mov [rdi+168],eax or movss [rdi+168],xmm0.

  2. Tidy the symbol table and timers. Every symbol Hephaestus registered — all 127 of them in the final v1.7 build — is unregistered here. That includes every per-stat parameter, every cache pointer, and the original-byte buffers themselves. Hestia also checks whether multiplierUnifiedCacheTimer still exists and, if so, destroys it and clears the Lua-side reference, so Hermes is not left walking chains for a script that no longer has any hooks.

  3. Release memory. Any regions allocated with alloc — the code caves and parameter tables — are deallocated, returning that memory to Cheat Engine's allocator. From the game's point of view, nothing changed; the hooks were always in CE-side allocated memory, and now even that is gone.

The Learner Edition sets a standard that every Volume 2-grade script has to meet: you should be able to toggle it on and off repeatedly in a live session without accumulating hooks, leaking timers, or leaving stray symbols behind. Hestia is the reason this script passes that test. If Zeus defines the rules, Athena enforces safety, Hephaestus builds the machinery, Demeter lays out the data, Ares and Apollo do the work, and Hermes delivers the pointers, Hestia is the one who makes sure that, when you are done, you can close the table, quit the game, and not wonder what you forgot to put away.

Hestia – Disable & Cleanup

At [DISABLE], Hestia works backward through everyone else's handiwork: she returns Ares' three integer doors and Apollo's float door to their saved instructions, removes the names Hephaestus published, releases Demeter's tables and the code caves, and stops Hermes' cache work. Her standard is stricter than "disabled": after the sequence completes, the game's hook sites and Cheat Engine's working state should carry no trace of the script.

Code: Select all

[DISABLE]

// Hestia: restore Ares' first door from Hephaestus' saved original bytes
multiplierSiteAAob:
readmem(multiplierSiteAOriginalBytes,22)

// Hestia: restore Ares' second door from Hephaestus' saved original bytes
multiplierSiteBAob:
readmem(multiplierSiteBOriginalBytes,15)

// Hestia: restore Ares' third door from Hephaestus' saved original bytes
multiplierSiteCAob:
readmem(multiplierSiteCOriginalBytes,15)

// Hestia: restore Apollo's float door from Hephaestus' saved original bytes
multiplierFloatSiteAAob:
readmem(multiplierFloatSiteAOriginalBytes,17)

// Hestia: unregister Max Health's per-stat symbols Hephaestus registered
unregistersymbol(maxHealthMultiplier)
unregistersymbol(maxHealthSafetyClamp)
unregistersymbol(maxHealthOutputCap)
unregistersymbol(maxHealthSanityMin)
unregistersymbol(maxHealthSanityMax)
unregistersymbol(maxHealthIntendedValue)
unregistersymbol(maxHealthSiteASanityRejectCount)
unregistersymbol(maxHealthSiteBSanityRejectCount)
unregistersymbol(maxHealthSiteCSanityRejectCount)
unregistersymbol(maxHealthContainerCache)

// Hestia: unregister the remaining integer-stat symbol families Hephaestus registered
// ... Block Strength, Defense, and the Zweihänder integer stats repeat the same family pattern.

// Hestia: unregister Health Regen's per-stat symbols Hephaestus registered
unregistersymbol(healthRegenMultiplier)
unregistersymbol(healthRegenSafetyClamp)
unregistersymbol(healthRegenOutputCap)
unregistersymbol(healthRegenSanityMin)
unregistersymbol(healthRegenSanityMax)
unregistersymbol(healthRegenIntendedValue)
unregistersymbol(healthRegenSiteASanityRejectCount)
unregistersymbol(healthRegenContainerCache)

// Hestia: unregister the remaining float-stat symbol families Hephaestus registered
// ... Movement Speed, Experience Gain, and the Zweihänder float stats repeat the same family pattern.

// Hestia: unregister Hephaestus' global backup and float-hook symbols
unregistersymbol(multiplierSiteAOriginalBytes)
unregistersymbol(multiplierSiteBOriginalBytes)
unregistersymbol(multiplierSiteCOriginalBytes)
unregistersymbol(multiplierFloatSiteAAob)
unregistersymbol(multiplierFloatSiteAOriginalBytes)

// Hestia: return code caves and Demeter's tables to CE's allocator
dealloc(multiplierSiteANewMem)
dealloc(multiplierSiteBNewMem)
dealloc(multiplierSiteCNewMem)
dealloc(multiplierStatParams)
dealloc(multiplierSiteAOriginalBytes)
dealloc(multiplierSiteBOriginalBytes)
dealloc(multiplierSiteCOriginalBytes)
dealloc(multiplierFloatSiteANewMem)
dealloc(multiplierFloatStatParams)
dealloc(multiplierFloatSiteAOriginalBytes)

// Hestia: destroy Hermes' timer if still alive
{$lua}
if not syntaxcheck then
  if multiplierUnifiedCacheTimer then
    multiplierUnifiedCacheTimer.destroy()
    multiplierUnifiedCacheTimer = nil
  end
  if multiplierIntCacheTimer then
    multiplierIntCacheTimer.destroy()
    multiplierIntCacheTimer = nil
  end
  if multiplierFloatCacheTimer then
    multiplierFloatCacheTimer.destroy()
    multiplierFloatCacheTimer = nil
  end
  showMessage('Unified Multiplier Script disabled cleanly.')
end
{$asm}

A few things to notice in shape:

  • Each readmem restores the exact byte snapshot that Hephaestus saved before Ares or Apollo replaced that site with a jump. The four lengths — 22, 15, 15, and 17 — belong to their respective stolen-instruction regions; they are not interchangeable.
  • The two complete per-stat families show the deliberate difference between the integer path and the float path: Max Health has three site-specific sanity counters, while Health Regen has one. The ellipses omit only repetitive unregistersymbol families; Hestia still removes every one in the full block.
  • The five global unregistersymbol calls are the infrastructure names outside those per-stat families: the three integer backup buffers, the float hook symbol, and the float backup buffer. They are the public names Hephaestus exposed so the rest of Cheat Engine could use them.
  • Only after the symbols are gone does Hestia return all four code caves, both of Demeter's parameter tables, and every byte-backup allocation to Cheat Engine. Deallocating the backup before its readmem replay would leave nothing safe to restore.
  • The excerpt groups the four readmem restores at the top for readability. In the full source, [DISABLE] interleaves each site's restore with the same site's unregistersymbol and dealloc calls; both orderings are semantically equivalent, provided that every readmem runs before its own buffer is deallocated.
  • The Lua tail is guarded by if not syntaxcheck then, so it runs during a real disable rather than Cheat Engine's parse-time pass. It destroys Hermes' live unified timer and also clears the two obsolete v1.4 timer names if an older table left them behind.

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


Re: Halls Of Torment - Cheat Scripting Practice Log

Posted: Fri Aug 07, 2026 10:07 am
by WanderingNovice

Small Update for Volume 2 publication:

Regrettably, I found that, due to external time constraints (allocated to the Volume 2 project scope) and internal willpower attrition (my own human limitation), I will not be able to condense what I made for Volume 2. The scope of this volume was to maximize the reader’s insight into my journey in creating the most recent iteration of the Cheat Table and the Cheat Script (v1.7 at the time of writing this).

Naturally, it is lengthy and candid. I wrote it for human readers and Artificial Intelligences, providing enough self-contained context to answer most questions. I included AI in the audience because I believe it will help expand the knowledge base for Cheat Scripting. I want a novice cheat-script writer to ask their favorite LLM how to write Cheat Engine scripts in the smoothest way possible.

If the publicly available information is well written, I will one day use someone else’s thoughtfully crafted Cheat Table for its user’s sake. I won’t have to make my own Cheat Table for that game, whatever it is. Pay-forward schemes are beautiful in a way.

Anyways, ahead will be Volume 2 chapters. Enjoy your reading!


Re: Halls Of Torment - Cheat Scripting Practice Log

Posted: Fri Aug 07, 2026 10:08 am
by WanderingNovice

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


Documentation for: Volume 02: Halls of Torment — Script for Unified Multiplier


Halls of Torment · Volume 02 · Learner Edition

Preface: What This Document Is

This is the long form of how Volume 02 of the Halls of Torment Cheat Engine work came to be. It is written for people who are already trying to build tables of their own and stalling out — script authors, script-curious lurkers, and anyone stuck watching an "editable" pointer chain go stale every time the game recalculates.

The story arrives with mistakes attached. That is deliberate. Reverse engineering is not the discipline of finding the right approach quickly; it is the discipline of noticing which wrong approach is failing, and why, before it takes the process down with it. Every act in Volume 02's history is at least partly a story of a decision that had to be reversed. The interesting part is never the decision itself. It is the shape of the evidence that forced the reversal.

A word of warning about scope. What began as a Max Health multiplier for a single character became a four-hook unified script covering fourteen stats across two type families, with a Cheat Engine-side cache timer resolving pointer chains outside the injection code, an all-or-nothing preflight, and 127 symbols to unregister on disable. That growth is not a cautionary tale — it is the honest scale of the problem. Be ready to write hundreds of lines of code ahead of where you thought you were going. The scripts that end up stable are the ones that meet the game's actual shape rather than the shape their author expected.

This document assumes some familiarity with Cheat Engine: what an AOB scan is, what a code cave is, what a pointer chain is, what an [ENABLE]/[DISABLE] block does, and enough x86-64 fluency to recognize a compare and a conditional jump. It does not teach Cheat Engine itself. It does not teach assembly from zero. It does not teach anything about Halls of Torment as a game. What it does teach — through worked example, not exhortation — is a specific inventory of techniques (AOB scans deployed two different ways, Lua-side cache timers, all-or-nothing activation contracts, symbol-audit discipline) applied against a specific engine (Godot 4.2 with its GDScript / Variant runtime), and the reasoning that produced each technique in the moment it was needed.

The scripts are the point. Everything else in this document — the Godot 4.2 primer, the Cheat Engine 7.5 pin, the crash investigations, the discovery of Site B — exists to make the scripts legible. Read what interests you. Skip what does not. The chapter structure follows the version history in strict order, so a reader who wants only the final architecture can start at the last chapter and work backward, and a reader who wants the story can read forward.

Cheat Engine version 7.5, Windows only. Every code path and every address in this document assumes that pin.


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


Re: Halls Of Torment - Cheat Scripting Practice Log

Posted: Fri Aug 07, 2026 10:21 am
by WanderingNovice

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


Documentation for: Volume 02: Halls of Torment — Script for Unified Multiplier


Context: The Game, the Tool, the Target

Three things were fixed before the first version was frozen, and none of them were negotiable afterward: the game and its engine, the tool and its version, and the scope boundary. Everything in Chapters 1 through 6 is a consequence of these three coordinates, so they are worth stating precisely.

The game

Halls of Torment is a Godot 4.2 game. It started development on Godot 4.0 and was upgraded partway through. The process is HallsOfTorment.exe. Gameplay logic is GDScript plus native C++ delivered through GDExtension; there is no C# and no Mono layer. Everything in this document was tested against Steam build ID 22764619, labeled "HoT Fixes" and pushed live 2026-04-14, the current public build as of 2026-08-05 when v1.7 was frozen. The game is single-player, has no anti-cheat, and has no online play to disrupt — which is why this project could publish a table at all without a long ethics preamble.

The engine choice is not trivia. It is the single largest determinant of the entire architecture, and it deserves to be spelled out before the narrative starts.

Why Godot 4.2 changes the problem

GDScript never compiles to native x86. It runs on the engine's own virtual machine, and every value assignment routes through the engine's generic Variant machinery. The practical consequence, which surfaced early and has been confirmed repeatedly, is that there is no per-stat code to hook. The instruction mov [rdi+08],rax at HallsOfTorment.exe+24CCB01 fires for 158-plus distinct destination addresses. It is engine plumbing. Right-clicking a stat in Cheat Engine and asking "find out what writes to this address" lands on a shared routine that also writes to a hundred and fifty other things, none of which are related to that stat except by the accident of being Variants.

This forecloses the naive approach and dictates the eventual one. It is not possible to hook "the Max Health write instruction," because no such instruction exists in the sense a C++ game would provide. What exists instead is a small number of shared commit instructions — points where a finished, computed value gets stored into a stat container object — and those instructions can be hooked, provided the hook can answer one question at runtime: which stat is being written right now? The identity of the field lives entirely in the register contents at the moment of the store, not in the instruction. Nothing in the disassembly ever says zweihander_damage = base * multiplier.

Two more engine consequences shaped the work. First, a conventional pointer scan on a Godot title produces result sets that are absurd by the standards of most games, because the object graph is dense with boxed Variants; "find out what accesses this address" was consistently the better first move than a brute-force pointer scan. Second, object lifetime is non-deterministic across loading screens, menus, and level transitions. A container that exists during gameplay does not exist at the campfire, and the interval where it is being torn down is a real, reachable window in which hooked code can fire. That window is the villain of Chapters 1 and 3.

The 0x18-byte Variant stride

The weapon-stat fields sit at flat 24-byte intervals: 0x18, exactly the size of a Godot Variant struct. The fields are a boxed Variant array, populated at _ready() time by the native C++ helpers createModifiedFloatValue and createModifiedIntValue in the order the weapon's own initialize_modifiers() calls them. That is why the offsets come out sequential and why they match the initialization sequence in the decompiled GDScript rather than the declaration order in any header.

For Volume 02, the stride was a labor multiplier. Once two Zweihänder fields were confirmed by scan — Damage at 2C8 and Range at 2E0 — the rest of the block could be produced by arithmetic and confirmed by live value matching, with no further scanning: Cone Angle 2F8, Attack Speed 310, Crit Chance 328, Crit Bonus 340, an unused slot at 358, Multistrike 370. That shortcut had already been proven twice on other weapons before Volume 02 needed it.

The stride is also a trap: raw address proximity is not a structural relationship. Runtime heap addresses cluster coincidentally, and shared inner steps in a chain do not guarantee a shared outer anchor. The general form of that lesson was earned outside Volume 02 — on a Holy Scepter field that resolved to a valid-looking address which silently aliased a completely different stat, caught only by a live edit test where changing one value moved both. Volume 02 inherited the caution rather than re-earning it, and it paid off exactly once, on Knockback, in Chapter 1.

The tool

Cheat Engine 7.5, and only 7.5. This is a hard pin, not a preference: every documented address, every AOB signature, every Lua call, and every .CT structural claim in this document is against 7.5 on Windows. Other versions are untested.

Four parts of Cheat Engine carried the Volume 02 work. The memory scanner and pointer scanner found the chains. The debugger — "find out what accesses/writes this address" — identified the commit instructions, and its auto-generated disassembly flowcharts mapped the code around the hook sites. The Auto Assembler injected the hooks. And the embedded Lua layer, which is the part most easily dismissed as table-automation garnish, ended up carrying the load-bearing architectural idea of the whole volume.

One more property of Cheat Engine mattered to how this project was possible at all: a .CT file is plain XML. <CheatTable> contains <CheatEntries>, which contain nested <CheatEntry> elements carrying description, address, offsets, variable type, colors, and any attached Auto Assembler script, with the <CheatCodes> block backing the Advanced Options code list separately. Every <CheatEntry> carries an ID attribute — an integer auto-assigned by Cheat Engine when the entry is created, unique within the table, and stable unless deliberately renumbered. Those IDs — called "CT IDs" throughout this document — are referenceable from hotkeys, from Lua via getMemoryRecordByID, and from dropdown link-mode entries, which is why the ID recompaction in Chapter 4 is a real operation with a verification requirement rather than a cosmetic re-sort. That the format is plain XML means a table can be inspected, diffed, and edited as text — a GUI for the parts a user would touch, and a text editor on the raw XML for the parts that are faster or safer to inspect directly, such as verifying a script body is unchanged between versions or auditing an ID list before a renumber. One trap in that XML is worth carrying: the <Offsets> list stores hops in reverse of the narrative walk order, index 0 being the offset closest to the value and the last index being the one closest to the module base. Every chain written in walk order in this document must be reversed before it matches the raw file.

The CE 7.5-only rule deserves one line of justification. Addresses, AOB signatures, Lua API surface, and .CT semantics are all version-coupled, and a table developed against a moving tool target has no reproducible baseline; pinning the tool version means that when something breaks, the game build is the only variable left.

The Lua point is worth flagging up front, because it inverts the usual expectation. Auto Assembler scripts run inside the game's address space and hook real instructions. Lua scripts run in Cheat Engine's own process, in a single shared embedded Lua state, and are not injected anywhere. Volume 02's central design move was to notice that this separation is a safety boundary and not merely an implementation detail: a readPointer that fails in Lua returns nil and costs nothing, whereas the identical dereference inside injected code, executed at the wrong microsecond, terminates the game process. Chapters 3 and 5 are largely the story of moving work across that boundary.

A short glossary of the Auto Assembler primitives used throughout this document, since these are the vocabulary of every [ENABLE]/[DISABLE] block that follows. alloc(name, size) reserves that many bytes of executable memory at load time and binds a symbol to it — the "code cave" where the hook body lives. registersymbol(name) publishes a symbol into Cheat Engine's global symbol table so .CT entries elsewhere in the table can reference it by name; unregistersymbol(name) removes it, which is why every script's [DISABLE] block ends with a list of unregisters proportional to how many symbols it declared, and why the "127 symbols unregistered on disable" figure in Chapter 3 is a real audit rather than a boast. readmem(address, size) copies bytes from that address into the assembled output at assembly time — the standard mechanism for restoring original bytes on disable without hardcoding them, and the reason the disable path can literally read "the bytes that used to be here" rather than a written-out hex string. AOBScan in Lua and aobscanmodule in Auto Assembler are two forms of the same idea, locating a byte pattern in the target process, with different failure semantics; Chapter 3 uses both deliberately and explains the choice there.

The target

Volume 02 committed to the Swordsman with the Zweihänder equipped, and to 14 stats across two type families.

Five integer stats: Max Health, Block Strength, Defense, Zweihänder Damage, Zweihänder Range. Nine float stats: Health Regen, Movement Speed, Experience Gain, and the Zweihänder float block — Cone Angle, Attack Speed, Crit Chance, Crit Bonus, Multistrike, Knockback. Five plus nine is fourteen, which is the number the table ships, the number the Usage Guide advertises, and the number every later chapter's counts must reconcile against.

The boundary was drawn where it was drawn for a reason that is easy to state and was hard to hold to. Defensive and miscellaneous stats — Max Health, Health Regen, Block Strength, Defense, Movement Speed, Experience Gain — are shared player stats and almost certainly behave identically on any of the twelve characters, since the hooked setter is universal and engine-level with no plausible mechanism by which it would work for the Swordsman's Max Health and not the Cleric's. That claim was deliberately not made outright: the published scope statement says Swordsman and Zweihänder are validated and that shared stats are "expected but unvalidated" elsewhere, because "expected" is what the evidence honestly supported and "works" is not. Chapter 4 returns to this as a deliberate under-claim.

The weapon half of the scope is genuinely weapon-specific. Field order within the hop-18 weapon-offense container is baked into each weapon's own initialize_modifiers() call order. It repeats exactly within an emitter-script family — Zweihänder and War Hammer are both MeleeEmitter-family and share the same (or identical) call order — but diverges across families. The Zweihänder offsets in this table mean nothing for a Bow, which belongs to the split projectile family where damage and crit fields live on a separate native node attached to the bullet's prototype scene entirely.

The static root

One address anchors everything that follows: HallsOfTorment.exe+396BD00. Every confirmed pointer chain in this project descends from it. The first hop, +1C0, is the shared "currently equipped weapon" container, rebuilt whenever the equipped weapon changes. This is why an unresolved ?? on a weapon field means the weapon is not currently equipped, rather than that the chain is broken.

The two chains Volume 02 lives on branch immediately below that:

  • Hub 1, the defensive hub, 396BD00 → 1C0 → 10 → [498 | 4A8 | 4B8 | 4C8] → 168 — Max Health at 498, Block Strength at 4A8, Health Regen at 4B8, Defense at 4C8, on a 0x10 stride rather than the weapon block's 0x18.
  • The weapon-offense hub, 396BD00 → 1C0 → 18 → 68 → 28 → [offset] → 168 — the Zweihänder block described above.

Two others matter in passing: Movement Speed and Experience Gain reach separate sub-objects also rooted at 396BD00, and Zweihänder Knockback reaches its container through a second hop 50 rather than 18, which is the one piece of geometry in Volume 02 that was surprising at the time. Chapter 1 covers it.

Note the terminal 168 on every chain. The final +168 is the offset the game's own store instructions write to, and verifying it against the instruction's own displacement bytes (68 01 00 000x168) rather than trusting a scan result is a standing rule, because a one-byte error like +169 still resolves to a valid address and still reads garbage. Every hook in Volume 02 sits on a mov [rdi+168],eax or a movss [rdi+168],xmm0. That repetition is not a coincidence, and it is not a design choice — it is what Godot's Variant commit path looks like from the outside.


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


Re: Halls Of Torment - Cheat Scripting Practice Log

Posted: Fri Aug 07, 2026 10:34 am
by WanderingNovice

Split source: Halls-of-Torment-Vol2-Documentation-Learner-Edition.md — Chapter 1. Strip this backreference block before posting to OCTF.

Chapter 1 · Act I — Proof of Concept (v1.0)

The starting question was narrow on purpose: how do you reliably scale one stat — Max Health — without editing memory directly, and without the edit reverting the next time the game recalculates?

Direct memory editing fails on this game for a structural reason, not a difficulty reason. Halls of Torment recomputes each stat's finished total from base plus every accumulated bonus whenever something about the build changes: a level-up, a trait pick, a weapon swap, a level load. Whatever gets written into the stat's slot is discarded at the next recalculation, because the game is not reading that slot to decide what your Max Health is. It is writing to it. Freezing the value with Cheat Engine's own freeze mechanism fights that write instead of cooperating with it, produces visible flicker, and breaks whenever the game's own logic reads the value it thinks it just stored.

The alternative is to intervene at the moment of the store. Let the game compute its total, then modify the number in flight between "computed" and "committed," and hand the game back a value it believes it produced itself. Nothing persists; nothing has to be re-frozen; every recalculation carries the multiplier forward automatically; and turning the script off restores the original bytes and lets the next natural refresh return the stat to normal. That is the entire idea of the Volume 02 table, and it was established here, in v1.0, on one stat.

Three sites for one stat

The first surprise was that Max Health does not have one write path. It has three.

The game's universal integer setter is a dispatcher: it reaches the same mov [rdi+168],eax commit through three distinct code paths, distinguished by which case of the dispatcher the caller entered. Hooking one path scales Max Health in some circumstances and not others, which is worse than not hooking at all, because partial coverage looks like a flaky script rather than an incomplete one. So the design became three hook sites — Site A, Site B, Site C — each with its own AOB signature, its own code cave, its own byte backup, and its own restore path, all sharing one set of multiplier, floor, ceiling, and sanity symbols.

The signatures, as they stand in the shipped v1.7 script and as they were established here:

Code: Select all

INT SITE A: 48 8D 97 70 01 00 00 E8 82 9A FF FF F3 0F 2C C0 89 87 68 01 00 00   (22 stolen bytes)
INT SITE B: E8 D6 10 B2 02 F3 0F 2C C0 89 87 68 01 00 00                        (15 stolen bytes)
INT SITE C: E8 3C 11 B2 02 F3 0F 2C C0 89 87 68 01 00 00                        (15 stolen bytes)

Sites B and C differ only in the relative displacement of a preceding call. They are two arms of the same dispatcher. Site A carries a longer signature because it needs more anchor bytes to be unique, and the stolen-byte count differs accordingly — a distinction that matters mechanically, because the jmp overwrite must cover the full combined length of every instruction it overlaps, not just the first. A five-byte jmp placed over a four-byte instruction eats one byte of its successor, and the classic failure mode is exactly that: a REX prefix consumed, turning mov rbx,[rsp+40] into mov ebx,[rsp+40], with symptoms that look nothing like a hook-length bug. The standing rule that follows from having been bitten by this once: count the combined length, pad with NOPs to match, and annotate which bytes in the pattern are actually hooked versus included only for uniqueness.

Site A and Site B went in first. Site C came at the end of the version, and the ordering matters for reading everything between, because every diagnostic run described below was recorded against a two-site build.

The crash-hunting subplot

The two-site build worked. Then it crashed, and the crash was not reproducible on demand. The next eight recordings were attempts to make it reproducible on demand.

The baseline recording showed the Max Health Multiplier at 10×, crash-free. A second recording captured a crash during the loading transition into Forgotten Viaduct. A third, on a more heavily instrumented build, produced the first hard artifact: Site B – Diag Hop 2 = 0000005400000073, a value that is not a pointer by any reading, captured at the exact moment of a level-load-transition crash, while Site A's strict range and unmapped reject counters both sat at 0 throughout — garbage on Site B's chain, silence on Site A's. That fixed the initial hypothesis: corruption specific to Site B's pointer chain, during level transitions.

That hypothesis was then instrumented properly: a 100 ms Lua strict-validity poller, live captures of each pointer hop into dedicated symbols, a capture of rdi at the compare, canonical-range check blocks, and two new counter families per site — a Strict Range Reject Count and a Strict Unmapped Reject Count. A control run covering 396.4 seconds and three level transitions ended in a clean manual quit rather than a crash, all four counters at 0 throughout — a clean control, and a frustrating one, since a reproduction test that does not reproduce leaves an untested variable list rather than an answer.

The untested variable at the top of that list was engine speed, which briefly turned the investigation into a story about a bug in the debugging tooling itself. Getting the speedhack to work at all had required fixing it first: the Speedhack Activator entry called setSpeed() and getSpeed(), which do not exist in Cheat Engine's Lua API — the correct functions are speedhack_setSpeed(speed) and speedhack_getSpeed(). The tell was visual: Cheat Engine's syntax highlighter rendered the old names as unrecognized plain-white identifiers. The Activator was simplified to a stateless luacall(speedhack_setSpeed(...)) pattern per [ENABLE]/[DISABLE], with [DISABLE] also unchecking Cheat Engine's own Speedhack checkbox through getMainForm().cbSpeedhack.Checked = false. A separate display-only "Speedhack Active?" readout on its own 100 ms timer confirmed, by live test, that a theory about {$lua}/{$asm} blocks being unable to share _G state across [ENABLE]/[DISABLE] was wrong — the language-mode switch and the execution-timing marker are orthogonal, and _G persists in Cheat Engine's single embedded Lua state regardless.

With the speedhack fixed, a run combining it at 3× with a rapid third dungeon re-entry crashed at roughly 78.5 seconds, on an unusually long loading screen. Site B's Hop 3 showed a garbage pointer value of 0x9 shortly before the death, while the other hops stayed plausible until the process died, and the Strict Unmapped Reject Count climbed 33 → 69 → 85 before the crash. But that run had crashed dirty, with an accidental death at Multiplier = 1 followed by a live multiplier change to 1000 in the same session — two new variables entered together, with no way to tell which one mattered.

A follow-up run isolated rapid re-entry alone: speedhack at 3×, multiplier fixed at 69 before first entry, no death. Three full dungeon entry/exit cycles, no crash, loading durations shortening on repeat entry, both reject counters at 0 throughout, Max Health scaling correct (500 × 69 = 34,500). So rapid re-entry plus speedhack, without a death and without a mid-session multiplier change, was not sufficient. The next run split the death-plus-live-edit pair the other way: reintroduce the live mid-session change (1 → 1000 at the campfire on an already-loaded character) while deliberately avoiding a death. It crashed — last fully live frame at approximately 80.2 seconds; first fully dead frame at approximately 80.4 seconds; game window replaced by desktop wallpaper; every table value flipped to ??. A genuine hard process termination.

That run's signature was different, and mattered most in hindsight: Site B's Strict Unmapped Reject Count climbed monotonically from 0 to 37 over roughly 6.4 seconds before the crash, while Hops 1 through 4 stayed rock-steady at known-good addresses the entire time — no garbage value, nothing visible in the chain at all. The conclusion drawn at the time was that the live multiplier edit combined with repeated re-entry cycles was the trigger. That hooked memory was becoming stale or invalidated across a dungeon reload boundary while the hook stayed live.

A final split-test recording covered two mini-tests in one session. First: a single re-entry after a live 1 → 1000 edit, speedhack disabled, was clean — no crash, HP scaling correctly to 500,000, the reject count climbing on the edit and the loading transition, then holding flat. Second: with speedhack enabled at 3.0× and the multiplier still at 1000, the game crashed during the first loading transition, before landing in the dungeon. The sub-second reconstruction of that crash is the most detailed artifact the arc produced: Hop 2 flipped from a clean canonical pointer, 000002CE74AC9480, to a malformed value, 0000000720000063, inside a window of roughly 66 milliseconds during the load, persisting for about 2.5 seconds before the process died — while the reject counter stayed flat at 62 across the entire corruption window, not firing on the corrupted read even though the clean test had counted 62 rejects across the same pattern. Two mechanisms fit the footage — single-hook starvation, or a write path bypassing the instrumented site — and the recording could not distinguish them. Both pointed at the same operational statement: the speedhack was the ingredient that turned a clean, guarded, 62-reject transition into a crash.

Where the hypothesis actually turned

The pivot in the investigation is the gap between the death-confounded crash and the clean-edit crash. The first crash arrived bundled with a death, so its evidence was compromised. The isolation run removed both the death and the live edit, resulting in a clean run. The next run put the live edit back and kept the death out, and crashed. That sequence is what promoted "live mid-session edit across a reload boundary" from one suspicion among several to the working mechanism, and it is a textbook example of why a confounded reproduction is worth less than a clean negative: the clean run's failure to crash is what gave the later crash its meaning.

Correlated is not causal

And then the best signal in the entire diagnostic arc turned out to be measuring the diagnostic layer itself.

The climbing Strict Unmapped Reject Count was, by that point, the project's favored early-warning indicator. It behaved exactly as an indicator should: absent throughout the clean run, climbing before each crash, consistent with a plausible mechanism, with no counterexamples. It was being treated as evidence of real pointer corruption in progress.

It was not. When the diagnostic scaffolding was finally stripped out, the closing root-cause note recorded the finding plainly: the alarming unmapped-reject counts were self-inflicted. The 100 ms Lua strict-validity poller was re-reading its own stale pointer snapshots and counting each stale read as a rejection. The hook itself re-resolves live and was never at risk from the thing the counter was counting. The counter was a real measurement of a real event — it was just an event the diagnostic layer was generating, not one the game was.

This is worth stating as the lesson of Act I rather than as a footnote, because the trap is general and easy to fall into, even when being diligent. The counter correlated with crashes, across multiple runs, in both directions, with no counterexamples in the recorded set. Correlation across several trials, with a plausible causal story, is, in most engineering contexts, exactly what is accepted as evidence. But a signal that correlates with a bug is not always evidence of that bug, and the specific way this one failed is instructive: the poller and the crash shared a common cause. Both were downstream of the same transition windows. The poller went stale during teardown, since pointers go stale during teardown; the crash happened for the same reason. Two effects of one cause look exactly like cause and effect if only one of them is being instrumented.

The corrected picture is narrower and more useful than the one the counter suggested. There was never a phase where Site B's chain was gradually degrading over 6.4 seconds. There was a moment — measured at roughly 66 milliseconds in the final split test — where a pointer that the hook had every reason to trust stopped being valid.

Root cause, as finally understood

During teardown and transition windows, a pointer slot in the chain can briefly hold a freed but entirely plausible pointer. It is non-null. It is correctly aligned. It falls inside the canonical address range. It passes every fingerprint test that injected code can cheaply perform, and it points at memory that has been returned to the allocator. Dereferencing it inside a hook causes a fault, and the fault occurs in Cheat Engine-allocated code, which is why the crash report names the faulting module as "unknown."

No fingerprint guard can close that hole, because the property that makes the pointer dangerous — that its target has been freed — is not visible in the pointer's bits. This is the insight that Act III is built on, and it was already forming here, at the end of v1.0, before the mechanism had been proven with an independent forensic trail. Chapter 3 (Act III) is where it is proven and put into action.

On throwing away good tooling

A substantial amount of careful instrumentation was built during this act and then deleted: the 100 ms Lua strict-validity poller, all four hop captures per site, the rdi captures, the canonical-range check blocks, the Strict Range and Strict Unmapped reject counters, and the entire diagnostics-capture address-list subtree in the table. When the scaffolding was removed, the Max Health script went from 315 hook-code lines to 121, and the .CT went from 1343 lines to 1149.

That is not a regret. The tooling answered the question it was built to answer — it produced the freed-pointer mechanism, the frame-accurate crash windows, and, by way of its own artifact, the correlated-not-causal lesson. The cache architecture of Chapter 3 (Act III) then made the underlying question moot: once the hook performs zero game-memory dereferences, there is nothing for a validity poller to validate. Instrumentation that became unnecessary because the problem class was eliminated did its job and then correctly stopped. The one thing that survived into the shipped table is the per-stat sanity reject counter — a much cheaper, much narrower diagnostic that counts implausible values, not implausible pointers.

A note on incomplete evidence. No single hop, across any of the eight diagnostic recordings, consistently carried Site B's garbage-pointer signature — one run flagged Hop 2, another Hop 3, another showed a reject-count climb with every hop looking clean. Two competing mechanisms (single-hook starvation vs. a write path bypassing the instrumented site) both fit the footage, and the recordings could not distinguish them. That distinction never got resolved. It stopped mattering once the cache architecture in Chapter 3 (Act III) removed the dereference the ambiguity was about — a real resolution, not an explanation.

Site C, and the version's closing moves

With diagnostics retired, Site C went in: the universal integer setter's dispatcher Case 1, AOB E8 3C 11 B2 02 F3 0F 2C C0 89 87 68 01 00 00, structurally identical to Sites A and B — preflight AOB check, pointer-chain resolve with null guards, rdi filter, sanity window, multiplier, floor and cap clamps, full byte restore on disable. All three original setter code paths were now covered by the same multiplier, floor, ceiling, and sanity symbols, and a third "Tests If Max Health is Loaded" row joined the Debugging Section for Site C's sanity reject count. The streamlined script body now matched the .CT exactly — 397 lines, three sites, no diagnostics.

Note what Site C still had at this point: in-hook pointer-chain resolution with null guards. The chain walk was still inside the hook. That is the condition v1.4 eventually fixes, and it is the reason v1.4 exists at all.

A structural arrangement pass followed, verified to have changed no script logic: the Max Health script and its Live Testing Conditions nested as siblings under a shared scripts group, with Live Testing Conditions kept separate so future scripts could reuse it; a Script Controls subgroup for the multiplier family; the Debugging Section renamed to note "Load Sanity Tests"; the three ambiguous "Tests If Max Health is Loaded" rows disambiguated with Site A / Site B / Site C suffixes.

After repeated regular-gameplay sessions, the three-site Max Health Multiplier was declared stable, and the Volume Two collection was declared Version 1. A side effect noted at the time: because the hooked setter is universal, the script also benefits other characters — when a player picks a bonus-health upgrade, for instance. That observation is the seed of the scope-statement honesty problem that Chapter 4 has to resolve.

Usability came before the freeze, not after

The last substantive work in v1.0 was a usability pass, done before the version was frozen rather than as a later cleanup release. The script group was renamed to signal mechanism rather than stat, with a stat-named Max Health subfolder inside — scripts named by mechanism, stats named by subfolder, a convention that survives to v1.7. A collapsible How-to-Use guide group appeared, with numbered steps and facts. A Debug Central hub was created, stat-prefixed specifically so future scripts' debug sections could stack beside it. Description-only dropdowns were wired onto the three script knobs — multiplier, floor, ceiling presets — with bracketed ALL-CAPS tokens marking user-editable knobs, and that meant the table's labels matched the guide's text. Knob rows were renamed role-first, with commentary children explaining each, and activation signpost labels were wired so that activating a group reveals its contents.

A functional color scheme went in at the same time: gold for activation signposts, sky blue for Auto Assembler script engines, orange for script knobs, yellow for directly editable memory stats, cyan for read-only observables, green for live-testing tools, gray and silver for structural headers and commentary. It was verified against a live Cheat Engine 7.5 dark-theme screenshot rather than assumed, which caught a propagating documentation error: Cheat Engine stores colors in the .CT as BGR, not RGB, so rows historically described as "yellow FFFF00" actually render cyan. One row moved from knob orange to read-only cyan, one commentary row moved to silver, and a cosmetic open item was carried into the freeze rather than hidden — three Pointer References sub-headers still wearing script blue instead of structural gray, pending a decision.

The table was then frozen under the version-suffix naming convention that all subsequent freezes follow. v1.0 shipped one stat, three sites, a usability layer, and an unproven theory about freed pointers.


Re: Halls Of Torment - Cheat Scripting Practice Log

Posted: Fri Aug 07, 2026 11:04 am
by WanderingNovice

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


Documentation for: Volume 02: Halls of Torment — Script for Unified Multiplier


Chapter 2 · Act II — From One Stat to a Dispatch Pattern (v1.1)

v1.0 answered a question about one stat. v1.1 asked whether the answer generalized, and the honest early expectation was that it might not.

The arithmetic that forces the design

One hook per stat is the obvious extension, and it does not survive contact with the numbers. Max Health required three hook sites, because the universal integer setter reaches its commit instruction through three dispatcher paths. If each stat needs its own hooks, then Volume 02's five integer stats need fifteen sites. Every one of those sites is a separate AOB scan that can fail on a game update, a separate code cave, a separate byte backup, a separate restore path in [DISABLE], and a separate place for a length-miscount bug to hide. Fifteen sites also mean fifteen chances for a partial activation, where some hooks install and others do not, which is the worst failure mode available: the table appears to work and silently fails to cover what it claims.

The hook surface was the thing to minimize, and the insight that made that possible is a direct consequence of the engine facts in the Context section. The three sites are not Max Health's write paths. They are the universal integer setter's write paths. Every integer stat in the game commits through the same three sites, because there is only one integer setter and it is engine-level. The sites were never stat-specific — they had simply been used, so far, to scale one stat.

That reframes the problem from "find more hook sites" to "teach the existing hooks to recognize more stats." Which is a dispatch problem, and dispatch is cheap.

Dispatching on rdi

At the moment any of the three commit instructions executes, rdi holds the address of the stat container being written. That is the entire identity of the field. The instruction is generic; the register is specific.

So each hook, after the stolen bytes are replayed, compares rdi against the set of container addresses in scope. A match tells the hook which stat is currently being written, and it loads that stat's parameter block address into r14. A miss means the write belongs to something outside Volume 02's scope, and the hook falls straight through to the original code with nothing modified. Unmatched writes pass through untouched — which, given that the setter fires for every integer stat in the game across all twelve characters, is the overwhelming majority of executions.

The matched path then falls into one shared scale routine: sanity-check the incoming value against the stat's window, record the original total into the readout field, multiply by the stat's multiplier, clamp to the floor, clamp to the ceiling, and let the game's own store commit the result. One routine, parameterized entirely by whatever r14 points at. Adding a stat means adding a comparison and a parameter block, not adding code.

This is the point at which "dispatch" enters the vocabulary permanently. Every later architectural change in Volume 02 is a modification of how the dispatch table is populated, or how many hooks share it, and never a change to the idea itself. v1.3 turns the comparison chain into a strided loop. v1.4 changes where the compared addresses come from. v1.6 merges four hooks onto one dispatch surface. The pattern established here is the load-bearing structure of the finished script.

The two hubs feeding the dispatch

Five integer stats meant two different pointer chains, because Volume 02's integer stats do not live in one place.

The defensive hub (Hub 1) is reached by 396BD00 → 1C0 → 10 and then branches by offset within a 0x10-stride block: 498 for Max Health, 4A8 for Block Strength, 4C8 for Defense. Health Regen sits between them at 4B8, but Health Regen is a float and therefore invisible to the integer setter — it does not get covered until v1.2, and its presence in the same hub is the first hint that the eventual unification in v1.6 was always available.

One detail about this hub is worth recording because it caused a moment of confusion when the offsets were first mapped. The live memory order — Max Health, Block Strength, Health Regen, Defense — does not match the source declaration order in the game's native Health node, which declares StartHealth, Regen, Defense, BlockValue. Memory order reflects modifier-registration call order, not declaration order. This is the same principle that governs the weapon block's field ordering, and it means that reading a decompiled script top-to-bottom will predict the wrong offsets unless the initialize_modifiers() call sequence is read specifically.

The currently-equipped-weapon hub is reached by 396BD00 → 1C0 → 18 → 68 → 28 and then branches by offset on a 0x18 stride: 2C8 for Zweihänder Damage, 2E0 for Zweihänder Range. This is the hub that is rebuilt on weapon swap, which is why an unresolved ?? on a weapon row means the Zweihänder is not equipped rather than that anything is broken.

Both chains terminate at +168, the offset the commit instructions write. Both were walked inside the hook at this stage, with test/je null guards at each hop, in the pattern documented for Godot's non-deterministic object lifetimes. Those in-hook walks are exactly what v1.4 removes. In v1.1, they were still the mechanism, and the crashes they caused were still filed as an occasional, unexplained annoyance rather than a structural defect.

Seeding floors from decompiled source

Each stat needed a floor and a ceiling: a floor so a multiplier below 1 or a corrupt input cannot drive a stat to zero or negative, a ceiling so an aggressive preset cannot produce a value the game's own arithmetic chokes on.

The floors were seeded from the Swordsman's actual base stats as recovered by decompiling the game's .pck archive: Block Strength 5, Defense 10, Zweihänder Damage 100, Zweihänder Range 126. That last number reflects a unit conversion that recurs throughout this project — the raw internal value is the displayed value multiplied by 18. Range's ceiling was set to 90000, which is 5000 displayed times 18, and the other new ceilings were set to 99999. Max Health's floor was left at 1, a deliberate decision carried over from v1.0 rather than an oversight; 500 was the Swordsman's original Max Health total and the number every diagnostic run standardized on, but the floor stayed at 1.

The ×18 constant deserves a note. It began as an observation about Movement Speed, where raw divided by 18 gives displayed meters per second exactly across all twelve characters, and it recurred independently on Holy Scepter's Range, where raw 162 divided by 18 gives the displayed 9.0 m. Two unrelated stat types — a speed and a distance — sharing a conversion factor is enough to treat 18 as an engine-wide constant rather than a per-field coincidence, and the standing guidance became: on any raw value that does not match its UI display one-to-one, try dividing by 18 before assuming anything more exotic. Zweihänder Range's floor of 126 is that guidance being applied rather than rediscovered.

The naming convention that outlived everything

All existing maxHealth* symbol names were preserved unchanged, and 36 new symbols followed the identical pattern: blockStrength*, defense*, zweihanderDamage*, zweihanderRange*. Each stat gets the same suffix family — Multiplier, SafetyClamp, OutputCap, SanityMin, SanityMax, IntendedValue, per-site SanityRejectCount, and later ContainerCache.

This looks like bookkeeping, and it is actually the reason the next five versions were cheap. Because the symbol names are mechanical, every subsequent restructuring — the 0x30-stride parameter arrays in v1.3 and v1.4, the merged parameter space in v1.6 — could preserve every pre-existing symbol name and relative offset while changing the memory layout underneath them. Table entries consistently pointed to the same registered symbols across three architectural rewrites. Nothing in the user-facing table had to be re-wired because the internals moved. The <stat>* convention established in v1.1 is present, unmodified, in the v1.7 annotated script's label declarations.

The table side of v1.1 mirrored the same discipline: four new gold stat folders modeled exactly on the existing Max Health section, and the Debug Central nest renamed with per-stat gray sub-headers holding all fifteen sanity-reject test rows — five stats times three sites.

The verdict

The build was gameplay-tested, declared stable and working correctly, and frozen with a companion streamlined script snapshot.

Five times the coverage of v1.0 at zero additional hook surface. It is the highest-leverage single change in the whole volume, and it produced no new failure modes — which, in a document with this many crash investigations, is worth saying out loud.


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


Re: Halls Of Torment - Cheat Scripting Practice Log

Posted: Fri Aug 07, 2026 11:11 am
by WanderingNovice

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


Documentation for: Volume 02: Halls of Torment — Script for Unified Multiplier


Chapter 3 · Act III — The Cache Era (v1.2–v1.4)

One bug, found twice, fixed once as a pattern.

That is the throughline of these three versions, and it is the reason they are told as one chapter rather than three. The bug that Act I chased across eight diagnostic recordings and never fully closed was found again, from scratch, on completely different code, with a completely independent evidence trail. The second investigation produced a fix. Two versions later, that fix was carried back onto the code where the bug had first appeared. The lag between the fix and its own retrofit is as informative as the fix.

v1.2, part one: the float script

Volume 02's integer half was working. The float half did not exist yet, and the first float stat to get one was Health Regen — chosen because it refreshes constantly during play, which makes it the easiest stat in the game to test. Set a multiplier, watch the regen tick, know immediately whether the hook fired.

Floats commit through a different instruction than integers. The universal float commit is movss [rdi+168],xmm0 at HallsOfTorment.exe+F7E9F, and unlike the integer setter, one site covers it. The new hook took 17 stolen bytes and used the same bare-symbol readmem restore pattern the integer script used, and the same dispatch idea from Chapter 2 (Act II): compare rdi, and if it matches the cached container, scale the value in xmm0 before letting the original store commit it.

The sanity window for Health Regen was set to 0 through 10000, with NaN rejecting automatically because ucomiss sets the parity flag on an unordered compare — a free NaN guard that comes with the comparison rather than needing its own test. The parameter block carried a multiplier (floor 1, matching the Swordsman's base regen of 1.0), a safety clamp, an output cap of 99999, an intended-value capture, and a per-site sanity-reject counter.

Then it crashed on level exit. Twice.

v1.2, part two: the second crash investigation

The two crashes were 0xc0000005 — an access violation — with the faulting module reported as "unknown," which is what a fault inside Cheat Engine-allocated memory looks like from the outside, since the address belongs to no loaded module.

What made this investigation different from Act I's was the source of the evidence. Cheat Engine could not identify what had faulted — the crash happens inside CE-allocated code, which the OS reports as an unknown module, so the tool doing the injection is, by construction, blind to the fault it just caused. So the evidence came entirely from outside Cheat Engine: Windows Event Viewer's Application log, which records a fault offset even when the faulting module is unnamed. Act I's evidence had been video, frame-accurate and rich in context but inherently correlational; this investigation had Event Viewer fault offsets, neither rich nor contextual but exact. Both crashes' fault offsets were decoded, and both resolved to the same instruction: hop 1 of the in-hook hub walk.

Code: Select all

mov r15,[r15+1C0]

That is the first dereference in the chain, reading the container pointer out of the static root slot at [HallsOfTorment.exe+396BD00]. Two independent crashes, one instruction, no ambiguity about which line of code was faulting.

The mechanism follows immediately. During teardown, the static root slot briefly holds a pointer that has been freed but remains aligned and within the canonical address range. Every property an injected fingerprint guard can cheaply check is satisfied. The pointer looks correct. Its target does not exist.

A guard had already been built for exactly this. The float hook's chain walk carried a null check, an alignment check, and a range check, with a guard-trip counter attached specifically so a crash could be attributed. The counter remained at 0 through repeated crashes. The guards did not reject the fatal pointer, because there was nothing in the pointer to reject. That counter reading 0 after a crash is the single most important measurement in this document. It is not a partial result or a suggestive correlation. It is a clean proof of impossibility: no in-hook fingerprint check can close this hole, because the disqualifying property of a freed pointer is not encoded in the pointer.

Set this beside Act I's evidence and the two investigations resolve into the same finding. The earlier arc watched Site B's Hop 2 flip from 000002CE74AC9480 to 0000000720000063 inside about 66 milliseconds while the reject counter sat flat at 62; the float investigation watched two crashes land on mov r15,[r15+1C0] with a guard counter at 0. Different code, different sites, different instrumentation, different data source. Same mechanism: a hook dereferencing a game pointer during a window where the game is dismantling the object graph out from under it.

The architectural fix

The fix is a deletion.

The chain walk was removed from the hook entirely. Not hardened. Not retried. Not guarded more carefully. Removed.

In its place, a Cheat Engine-side Lua timer running every 500 ms resolves 396BD00 → +1C0 → +10 → +4B8 with readPointer and writes the resulting container address into a ContainerCache field, at offset +20 in the parameter block. When the chain cannot be resolved — main menu, death screen, mid-transition — readPointer fails harmlessly in Lua, and the cache is written as 0 rather than left holding a previous value. Never a stale pointer.

The hook shrinks to one comparison:

Code: Select all

cmp rdi,[r14+20]

rdi is the container the game is in the middle of writing to, so it is guaranteed mapped — the game is dereferencing it on the very next instruction. r14 points at an allocated parameter block that nothing else can free. The hook touches those two things and nothing else. Zero game-memory dereferences. Not zero unguarded dereferences: zero dereferences.

The phrase used at the time, and worth keeping, is crash-proof, not guarded. The distinction is the whole point. A guarded dereference is a dereference that has been made less likely to fail, and the amount less is unknown because it depends on properties of the pointer that cannot be observed. A hook with no dereferences in it cannot fail that way — not with low probability, but by construction. There is no window. There is nothing to time your bad luck against.

Two structural properties make this trade available, and both are worth naming for anyone trying to apply the pattern elsewhere. First, a failed readPointer in Cheat Engine's Lua results in an ordinary nil return, whereas a failed dereference inside injected code results in a process kill. Moving the risky operation across that boundary changes its worst-case cost from fatal to nothing. Second, a stale cache is safe here, because the hook does not follow the cached pointer — it only compares against it. A stale cached address produces, at worst, a missed scale on one write or a comparison that matches nothing. The cost of the cache being wrong for up to 500 ms is a stat that briefly does not scale. The cost of an in-hook walk being wrong for 66 ms is the game closing.

There is a performance dividend that came free and was not designed for. The old architecture walked the full hub chain on every write the setter performed, which is a lot of pointer chasing at gameplay frequency. Live testing on the v1.2 build showed level entry to be as smooth as in an untampered game, whereas the old per-write hub walk was measurable as jitter. Removing work from the hot path for safety reasons made it faster for free.

The disable dialog, and the silent-failure lesson

[DISABLE] in the float script destroys the timer and then confirms with an external showMessage — a literal "disabled cleanly" dialog. The reason is a v1-era bug: an earlier disable path used offset-form addressing, aob+09, in a context that broke the byte restore. It failed silently — the box was left unchecked, Cheat Engine reported nothing, and the original game bytes were not actually restored. Silence had meant success and also meant failure, with no way to tell which from the outside. The response was to make the success case loud, so its absence becomes diagnostic information — the mirror image of the activation convention that eventually shipped, where no dialog means success. Both are the same design instinct: never let silence be ambiguous.

Why 500 milliseconds

The timer period was not tuned experimentally, and the reasoning is short enough to state explicitly, because it is the kind of number a reader would otherwise assume is arbitrary.

The cache only needs to be fresh relative to how often a covered container is destroyed and rebuilt, not relative to how often the game writes a stat. Container lifetime changes at run boundaries, level loads, and weapon swaps — events measured in seconds, accompanied by a loading screen or menu interaction. The stat writes themselves are far more frequent but do not need a fresh cache; they need a cache that either matches or is zero. 500 ms sits comfortably within the window where a newly created container has not yet had its stats committed by the game's own recalculation pass.

The failure the period governs is therefore bounded and benign: for at most half a second after a container is created, the cache slot may still read 0, the hook's compare will not match, and the write passes through unscaled — the next refresh applies the multiplier regardless. What the period cannot cause, at any value, is a stale dereference, because the hook never dereferences the cached value. Slower would have been fine. Faster would have bought nothing.

The verdict on v1.2: no reproducible crashes across repeated level entry and exit, level entry as smooth as an untampered game, disable popup clean, and a new "Shows Cached Container Address" debug row behaving exactly as designed — a fixed nonzero address during a run, harmless flickering during teardown, zero at menus. That flicker is worth appreciating. It is the crash mechanism, rendered as a hex readout in a table row, doing no damage at all.

The v1.2 build was frozen, along with a streamlined companion script snapshot.

v1.3: scaling the cache pattern to full scope

With one float stat proving the architecture, v1.3 took the float script from 1 stat to 9: Health Regen, Movement Speed, Experience Gain, and the Zweihänder float block.

The architecture did not change. It scaled, and the way it scaled is the second appearance of the dispatch idea from Chapter 2 (Act II). The parameter blocks became a uniform 0x30-stride array of 9 records, and the hook's single compare became a compare loop — cmp rdi,[r14+20], stride 0x30, a loop counter walking the array until it matches or runs out. One Cheat Engine-side Lua cache timer resolves all 9 chains every 500 ms. Still zero game-memory dereferences inside the hook, because a loop over an owned parameter array touches only that array.

The uniform stride is what made the loop possible, and the loop is what made the stat count irrelevant. Nine records or ninety, the hook code is the same length.

A note on stat counting. One version's changelog describes the float expansion as reaching "all seven Zweihänder floats" but then names only six: Cone Angle, Attack Speed, Crit Chance, Crit Bonus, Multistrike, Knockback. Six is the count that reconciles with everything else — 3 shared floats plus 6 weapon floats gives the 9 float records the architecture describes, and 5 integer stats plus 9 floats gives the 14 stats the finished table ships. The best-guess resolution: "seven" is a miscount in the log rather than a missing stat, noted here rather than silently corrected.

Knockback's odd geometry

Eight of the nine float chains descend through hubs Volume 02 already understood. Zweihänder Knockback does not.

Knockback is reached through 1C0 → 50 → 68 → 28 → B8 → 168 — second hop 50, not the 18 that every other Zweihänder stat uses. It is not in the weapon-offense block at all. At the time, it was believed to be the first hook of a shared cross-weapon "hop-50 utility/CC container."

A note on the hop-50 theory. That belief was later disproven, outside Volume 02's own work, by live edit testing on other weapons: there is no universal hop-50 utility hub. Knockback is a per-weapon field whose second hop differs by weapon — Zweihänder at 50, Holy Scepter at 58, Shield Bash inside its own hop-20 weapon block — all converging on the same tail shape 68 → 28 → [offset] → 168. For Volume 02, the practical impact is nil because it only ever hooks the Zweihänder's chain, and that chain was correct. But the shape of the error is exactly the trap already named in the Context section: a plausible chain shape and a matching tail are not evidence of a shared container. A comparable case elsewhere had a Knockback chain resolve to a real address that silently aliased a completely unrelated stat, only to be caught because someone edited one field and watched another field move.

Knockback also produced Volume 02's one unresolved numeric disagreement.

A note on Knockback's floor. The floor was set to 30, from a decompiled source constant. The live-tested container value was 40. The discrepancy was never resolved — the decompiled-source value was chosen deliberately, and nobody proved the live-tested value wrong. That is a recorded decision, not a closed finding. Knockback was combat-confirmed the strongest way available regardless: raising the multiplier visibly increases the distance enemies are thrown, a confirmation tier that no amount of address arithmetic can substitute for.

With v1.3, Volume 02's stat coverage was complete: 5 integer stats plus 9 float stats, all 14 in-scope stats covered. The build was frozen; no reproducible crashes, and Knockback confirmed working.

v1.4: the retrofit

At this point the float script had zero in-hook dereferences, and the integer script had three hook sites full of them.

The prompt to fix that was not an architectural review. It was a user report: seldom-but-recurring random crashes, on the integer script, in normal play, not reproducible on demand — the signature of a low-probability timing window rather than a logic bug. The mechanism was no longer a mystery: it had been proven by fault-offset decoding two versions earlier, on the float side, with the guard counter reading 0. The integer script's [396BD00] → 1C0 → 10/18 → ... walks, protected by null checks only, were sitting in exactly the window that had been shown to be unclosable.

So v1.4 converted the integer script to the same CE-side cache architecture. All in-hook pointer walks were removed from all three sites; zero game-memory dereferences remained inside any hook path, in either half of the table.

The mechanics mirrored v1.3's with one offset difference worth noting for anyone reading both scripts: the integer parameter blocks became uniform 0x30-stride records for 5 stats with the container-cache qword at +28, so the integer compare is cmp rdi,[r14+28] where the float compare is cmp rdi,[r14+20]. Each of the three sites runs a 5-slot compare loop at stride 0x30 and falls into the unchanged apply code — sanity window, scale, floor, cap. Every pre-existing symbol name and relative offset was preserved, and the per-site sanity reject counters and values carried over untouched — a rewrite of the parameter memory layout that changed no symbol names and no table wiring.

A new independent Lua timer resolves the 5 integer chains every 500 ms: the stats hub 1C0 → 10 → 498/4A8/4C8 and the weapon hub 1C0 → 18 → 68 → 28 → 2C8/2E0. A nil or 0 anywhere mid-chain writes 0 to the cache, never a stale pointer. [DISABLE] destroys the timer and confirms via dialog, matching the float script exactly. Five "Shows Cached Container Address" hex debug rows were added under the integer stat sub-headers in Debug Central. Two independent 500 ms timers now ran, one per script half; the instinct to consolidate them was already visible and was deliberately deferred to v1.6.

The preflight pattern

v1.4 also introduced the pattern that eventually defines the table's activation contract. All four preflight AOB failure paths — float Site A, integer Sites A, B, and C — got a clean showMessage dialog naming the script, naming the failed site, naming the likely cause (the game was updated), and stating the outcome explicitly: the script was NOT enabled. A terse error() then aborts activation.

The reason for doing the scan twice — once in a {$lua} preflight with AOBScan, once for real with aobscanmodule — is a difference in failure behavior between the two. AOBScan in Lua returns nil on a miss and lets the caller handle it. aobscanmodule in Auto Assembler aborts the entire [ENABLE] block on a miss. The block is therefore all-or-nothing whether the preflight runs or not; what the preflight buys is a diagnosis instead of a raw abort. The user learns which of four signatures moved, which is the difference between "the table is broken" and "the table needs updating for the new build, at INT Site B."

This is the all-or-nothing activation principle, and it recurs. In v1.4, there are four separate checks in two separate scripts. In v1.6, it becomes one gate in front of one script, which is the form the published table ships in.

One property of the v1.4 conversion deserves emphasis because it is the reason the version was low-risk despite being a rewrite of three hook bodies: nothing in the table changed. Every symbol name, every relative offset within the parameter records, every sanity value, and every per-site counter carried over untouched. The table rows that had pointed at maxHealthMultiplier in v1.0 still pointed at maxHealthMultiplier in v1.4, resolving to a different address in a differently-shaped allocation, and no user-facing entry needed rewiring. The v1.1 naming convention is what made that possible, and it is why a conversion of this size could be verified by comparing behavior rather than by re-auditing dozens of table entries.

The verdict on v1.4 was brief — script looks good, approved freeze. The build was frozen with two companion script snapshots, one per script half. The last two separate companion files Volume 02 would ever produce.

What the three versions add up to

The bug was found in Act I with video and reject counters and never closed. It was found again in v1.2 with Event Viewer offsets and a guard counter, and closed in one move by deleting the risky operation instead of defending it. It was closed on the other half of the codebase in v1.4, two versions later, prompted by user-reported crashes rather than by generalizing the lesson when it was first learned.

One bug. Found twice. Fixed once as a pattern, then applied a second time to code that had needed it all along.


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


Re: Halls Of Torment - Cheat Scripting Practice Log

Posted: Fri Aug 07, 2026 11:24 am
by WanderingNovice

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


Documentation for: Volume 02: Halls of Torment — Script for Unified Multiplier


Chapter 4 · Act IV — Designing for the Player, Not Just the Pointer (v1.5)

v1.5 is a table-only release. Both script bodies are byte-identical to v1.4, verified as such, and no streamlined snapshots were produced because there was nothing new to snapshot. Not one instruction changed.

That fact is the reason this chapter exists as its own act rather than being folded into a version that also moved code. The Volume 02 arc alternates between architecture versions and interface versions, and the alternation was not accidental. A version that changes hook assembly and a version that changes table structure fail in completely different ways, get tested completely differently, and want completely different review attention. Mixing them means that when something breaks, it is unclear whether the cause was in the code or the presentation. Keeping them separate means a "no script logic changed" version can be verified with a byte comparison, which is a far stronger guarantee than a careful read-through.

The alternation also changes what a version means to a reader who was not present. "v1.5 changed no code" is a claim that anybody holding both snapshots can verify in one command, and it converts a version from a bundle of edits into a single reviewable statement. A reader auditing this history can skip v1.5 and v1.7 entirely if they care only about behavior, and read only v1.5 and v1.7 if they care only about presentation. That legibility is worth more than the small release overhead it incurs.

The problem the table had

By v1.4, the table was correct, and it was overwhelming.

Fourteen stats, each with a multiplier, a floor, a ceiling, a sanity minimum, a sanity maximum, an intended-value readout, and per-site reject counters. Gold folders per stat, gray sub-headers per group, a Debug Central hub with a growing set of cached-address readouts, and a How-to-Use guide. Everything a power user could want, arranged in a tree that a first-time downloader has to navigate before doing the one thing they actually came to do, which is turn one number from 1 to 10.

The failure mode here is not confusion. It is abandonment. A user who opens a table, sees forty rows of clamps and counters, and cannot immediately identify where the multiplier goes, closes the file. Every diagnostic row that made the table trustworthy also made it less approachable, and the two goals are genuinely in tension: the counters and clamps are what make the script safe, and they are also noise to somebody who just wants more health.

The two-tier answer

Both script nests were reorganized under two headers. The integer script's structure was designed for a would-be user with ease-of-navigation principles in mind; the float script's structure was mirrored to match it.

Simplified View is a single Script Controls group holding exactly one row per stat — 5 rows on the integer script, 9 on the float script — each pointing directly at that stat's registered multiplier symbol. Nothing else. No floors, no ceilings, no sanity windows, no counters. One row, one number, per stat.

Detailed View is a hide-children group holding the original per-stat folders, moved intact. Not rebuilt, not trimmed: moved. Everything a v1.4 user knew how to find is still exactly where it was, one level deeper. That includes small things like the Knockback folder's "Raw value = Displayed Range × 18" note, which stayed with its stat rather than being relocated to a general documentation row.

The rows were renamed in the same pass, and the rename is a small piece of honesty work rather than cosmetics. "Multiplies Current Total - <stat>" replaced the older phrasings, because what the hooks actually scale is the endpoint arithmetic — the finished total the game computed and is about to write to +168, inclusive of every in-game bonus already applied. Not the base stat. Not the upgrade multipliers. The total. Users who assume they are editing a base value will form wrong expectations about how the multiplier interacts with their build, and the row name is the cheapest available place to correct that assumption.

All four view headers were set to gray AAAAAA, consistent with the table's convention: blue for script headers, gold for stat folders, gray for organizational and utility headers, orange for multiplier leaves. The color system from v1.0 was now old enough that new structure could simply inherit it.

The cascade

Simplified View's value comes from its header, not its rows.

The Script Controls group header includes three Cheat Engine memory-record options: moRecursiveSetValue, moActivateChildrenAsWell, and moDeactivateChildrenAsWell. It also carries a description-only dropdown headed [DEFAULT] with presets 1, 2, 3, 4, 5, 10, 50, 100, and 1000. Selecting a preset in the header sets that value on every stat row beneath it in a single action, and activating or deactivating the header propagates to its children.

At the design level, that is the whole mechanic: one control that writes through to a set of leaves, using Cheat Engine's own recursion options rather than any Lua glue. The leaf rows carry the same preset list under a [MULTIPLIER] heading, so a user who wants one stat at 100 and everything else at 2 does the cascade first and then overrides the one row. The headers also carry an inline usage hint pointing to the Value column, because a dropdown attached to a header is not self-evident as a bulk-set control, and nothing in Cheat Engine's UI signals that it is.

What belongs here is the shape of the decision and its consequence, not the Auto Assembler syntax that makes it work: a single general-purpose cascade over all stats is powerful and imprecise. It cannot express "double my defensive stats" without also touching the weapon block. v1.6 fixes that, and the fix is a direct response to using this version's cascade in practice.

Retiring the speedhack

The Live Testing Conditions section — comprising the group, the Speedhack Activator, and the Speedhack Active Readout — was retired in this version. Debugging phase done.

This closes a subplot that ran through the whole first act. The Activator was fixed during the crash-hunting investigation of Chapter 1 (Act I), when its calls to the nonexistent setSpeed() and getSpeed() were replaced with speedhack_setSpeed() and speedhack_getSpeed(), then simplified to a stateless luacall form, with a companion "Speedhack Active?" readout polling the live engine speed on a 100 ms timer so recordings could visually confirm the applied multiplier at any frame. That readout earned its place — in one diagnostic run it showed 3 at the exact moment of entering the dungeon that triggered the crash, establishing the speedhack's role rather than assuming it.

Retiring it is a maturity signal rather than a loss: the speedhack was never a Volume 02 stat, only instrumentation for compressing dungeon re-entry cycles and stressing transition timing, and shipping it in a public table would mean shipping and documenting a feature that exists to make the game easier to break. It remains recoverable from the v1.0 through v1.4 snapshots for anyone reproducing the diagnostic conditions — the correct place for it, in the history rather than the release. The general pattern the Reflection returns to is that tools built to investigate a problem should be evaluated against the problem, not kept simply because they work. Act I's pollers and hop captures were deleted when the cache architecture made their question moot; the speedhack was deleted when the crash investigations closed. Both deletions shrank the artifact without losing anything the frozen snapshots do not preserve.

The v1.5 build was frozen and backed up. No script snapshot, because the scripts had not moved.


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


Re: Halls Of Torment - Cheat Scripting Practice Log

Posted: Fri Aug 07, 2026 11:36 am
by WanderingNovice

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


Documentation for: Volume 02: Halls of Torment — Script for Unified Multiplier


Chapter 5 · Act V — Unification (v1.6)

v1.6 is where the table becomes one thing.

Two scripts became one script. Two cache timers became one timer. Two disable paths became one. Four independent preflight checks became one activation gate. Two separately ordered stat lists were combined into a single order that matches the game's own interface. And the table gained its first self-documentation, which turns out to be the bridge to v1.7's publication work.

Every architectural decision from Chapters 2 and 3 survives intact into this version. The hook assembly, symbol names, floors and ceilings, and sanity windows are unchanged from v1.4 and v1.5. The change is that they now all live in a single [ENABLE] block.

The merge

The integer script's three hooks and the float script's one hook were combined into a single table entry. The separate float script entry was retired. One activation now arms all 14 Volume 02 stats.

The merge was structurally cheap for a specific reason worth naming, because it is the payoff of the v1.4 retrofit. After v1.4, the two scripts had identical architecture: uniform 0x30-stride parameter records, a cmp rdi,[r14+offset] dispatch loop, a 500 ms Cheat Engine-side Lua cache timer, no in-hook dereferences, byte-exact restore on disable, and a showMessage preflight on every site. They differed in stat count, in one parameter-block offset (+28 for the integer records, +20 for the float records), and in whether the scaling arithmetic ran in general-purpose registers or SSE registers. That is not a merge of two designs. It is a merge of two instances of one design.

Had this merge been attempted at v1.3 — with the float script on the cache architecture and the integer script still walking chains inside its hooks — it would have meant combining two genuinely different safety models into one script, and the result would have been a script whose crash behavior depended on which half of it fired. The v1.4 retrofit is what made v1.6 a consolidation instead of a rewrite. The two versions are more tightly coupled than their numbering suggests.

The parameter space merged the same way the code did: two allocated tables, one of 5 integer records and one of 9 float records, both on the uniform 0x30 stride, now living in one script's allocation set with one deallocation list. The two container-cache offsets stayed different — +28 on integer records, +20 on float records — rather than being normalized, since normalizing them would have meant editing every hook's compare instruction and every timer write for no functional gain. Preserving an inconsistency that costs nothing is often cheaper and safer than tidying it.

The tradeoff, stated as a choice

The halves can no longer be enabled separately. That is a behavioral regression, and it was made deliberately.

Before v1.6, integer stats could be run without float stats, or the reverse. There are real uses for that: isolating which half caused an anomaly, or scaling only weapon damage while leaving movement untouched. After v1.6, activation is all fourteen or nothing.

What was bought with it: one checkbox instead of two, one activation to explain in the documentation, one disable path to get right, one set of failure modes, and the guarantee that "the script is on" describes one state instead of four. For a table whose next version would be handed to strangers, that simplification is worth more than the flexibility it costs, because per-stat control still exists. Every stat's multiplier can be left at 1, which is exactly equivalent to not hooking it, minus the ability to observe the difference. What is genuinely lost is the ability to disable a hook, not the ability to disable a stat.

This is recorded here as a choice rather than a cleanup, so that anyone reading the v1.7 table and wondering why they cannot enable half of it has the reasoning, and so that anyone forking it knows the split is recoverable from the v1.5 snapshot.

One gate, four signatures

The all-or-nothing preflight seeded in v1.4 reaches its final form here. All four signatures — integer Sites A, B, and C, and float Site A — are AOB-verified in a single {$lua} block before any memory is written anywhere. Any miss produces a showMessage that names the exact failing site, followed by an error() that unwinds the entire [ENABLE] section.

The activation contract this creates is the one the Usage Guide teaches: no dialog on activation means success. Four signatures verified, four hooks installed, fourteen stats armed. A dialog means the game build does not match this table and nothing was written to memory, which is a much stronger and more useful statement than "activation failed."

The preflight block also guards against Cheat Engine's own syntax-check pass with an if not syntaxcheck then wrapper, so the scans only run on real activation rather than every time the script is validated in the editor.

One timer

The two legacy 500 ms timers were replaced by one, resolving all 14 container chains per tick — the defensive hub, the weapon hub, Knockback's hop-50 chain, and the shared-container chains for Movement Speed and Experience Gain — with the same discipline as before: nil or 0 anywhere mid-chain writes 0 into that stat's cache slot, so a hook can never compare against, let alone follow, a stale pointer.

The consolidation contains a detail that is worth more than the consolidation itself. The new timer defensively destroys the two legacy timers if it finds them. It also self-destructs if it detects that the script's symbols are gone.

Both behaviors reflect migration thinking and arise from how people actually use cheat tables. A user upgrading from an older version to v1.6 does not restart Cheat Engine between tables. They open the new table while the old one's Lua state is still live in the same embedded interpreter, possibly with a timer from the previous table still ticking against symbols that no longer exist. Cheat Engine's Lua state persists across table loads — the same _G persistence property that the Chapter 1 (Act I) speedhack investigation confirmed by live test. Without the defensive destruction, the upgrade path produces an orphaned timer that either does nothing or throws errors on every tick, and the user experiences that as the new table being broken.

The self-destruct-on-missing-symbols check is the same idea pointed at the table's own future: if the script is disabled or the table is closed, the timer stops rather than persisting as a leak.

One teardown

A single [DISABLE] handles everything: four byte-restores from the readmem backups, 127 symbol unregisters, 10 deallocations, the timer destruction, and the "disabled cleanly" confirmation dialog.

127 symbols is a number that exists only because of the naming convention established in v1.1. Fourteen stats times a mechanical suffix family, plus per-site counters, plus the site symbols themselves. Because the names were generated by pattern rather than chosen individually, the unregister list could be written and audited by pattern too. A table with 127 hand-named symbols and no convention would have shipped with an incomplete unregister list. The symptom would be symbol collisions the next time the table was loaded in the same Cheat Engine session. This failure presents as a mysterious activation error in a later session and has nothing obviously to do with the disable path.

The cascade redesign

v1.5's Simplified View cascade lived on a single header and set every stat at once. That is imprecise in a way that becomes obvious the first time someone wants to raise their defensive stats without also multiplying their Zweihänder's crit chance.

The redesign moved the preset dropdown and the recursive-set-value options off the general Script Controls header — which became a plain organizational header — and onto three new category headers: Defensive Values, Miscellaneous, and the Zweihänder weapon group.

Each header's preset now affects only its own rows and cannot affect a stat in another category. Each carries a short hint in the form "Set all N at once ---> [Column = Value]". All 28 multiplier rows — 14 in Simplified View plus 14 in Detailed View, aliasing the same symbols — had their idle dropdown item renamed from [MULTIPLIER] to [ DEFAULT = 1 ].

That rename is worth a sentence. [MULTIPLIER] labels the control. [ DEFAULT = 1 ] states the current effect. A user seeing [MULTIPLIER] in a value column has to work out whether the stat is being modified; a user seeing [ DEFAULT = 1 ] knows it is not. The same information, expressed as a state rather than a category, removes a whole class of "is this on?" questions before they can be asked.

The category split is a direct lesson from v1.5's coarser cascade. A control whose blast radius is larger than any real intent will eventually be used by accident, and the fix is not a warning label — it is a smaller blast radius. Three headers provide more structure than one, and that structure maps onto how users actually think about their build.

The navigation overhaul

Stat order now matches the in-game UI everywhere: Max Health, Health Regen, Block Strength, Defense, then Movement Speed and Experience Gain, then the 8 Zweihänder stats, in Simplified View, Detailed View, and Debug Central alike. The prior ordering was an artifact of implementation history — integer stats grouped together because they arrived in v1.1, float stats grouped together because they arrived in v1.2 and v1.3 — meaningful to whoever wrote the script and meaningless to everyone else. The game-UI order interleaves the two type families (Max Health is an integer, Health Regen is a float, and they sit adjacent because the status screen places them adjacent), so sorting by data type leaked an internal implementation detail into the user's navigation. Unification removed the last reason to keep doing it.

Two section-banner containers were added under the master header: a soft-green "Main Content" banner holding the unified script first, and a soft-magenta "Advanced Users Section" holding Pointer References and Debug Central last. This is an information-hierarchy decision aimed at two audiences at once. The green banner is the whole table for a newcomer: activate the script, set a multiplier, play. The magenta banner — the pointer reference rows and diagnostic readouts — is safe to ignore entirely, and the Usage Guide says so in as many words. Nothing was removed for the newcomer's benefit, and nothing was hidden from the power user; the advanced material was simply moved behind a boundary that says "you do not need this." The pointer-reference headers were always expanded, since a reference table that hides its contents behind a click does not serve as a reference.

Self-documentation appears

The table's Comments gained two new sections in this version, "HOW THIS TABLE WORKS" and "COLOR LEGEND," along with a version-stamp row and a Simplified View caveat note, and the in-table guide was rewritten for the unified two-view structure. This is a small change with an outsized role in the arc: it is the first time the table tries to explain itself to somebody who was not present while it was built. Up to v1.5, the documentation lived in the surrounding project, and the table was a working file; a version stamp and a color legend embedded in the .CT are artifacts for a reader who has only the .CT. v1.7's publication-grade Comments block is a direct continuation of this, not a new idea.

Testing and the loose ends

The build was tested in-game as a full pass: activation clean; all 19 debug rows resolved; category cascades flipping only their own rows and nothing else; values aliasing correctly across Simplified and Detailed views; disable clean. The aliasing check matters more than it sounds — the two views point to the same registered symbols, so a value set in one must appear in the other, a property of the wiring rather than something either view can verify on its own. The two per-half streamlined script files were consolidated into a single unified streamlined script, and the table in this version reached 238 total entries.

Everything became one thing. What remained was to make it presentable to strangers.


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


Re: Halls Of Torment - Cheat Scripting Practice Log

Posted: Fri Aug 07, 2026 11:47 am
by WanderingNovice

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


Documentation for: Volume 02: Halls of Torment — Script for Unified Multiplier


Chapter 6 · Act VI — Publication (v1.7)

v1.7 is a table-only release. The unified script body is byte-identical to v1.6; no streamlined snapshot was needed, and no hook, symbol, floor, ceiling, or sanity window changed. The stated goal was narrow and specific: a public, self-contained table that anyone can download and use without access to any surrounding project materials.

Everything in this version follows from taking that sentence seriously. A stranger downloading a .CT file cold has the file and nothing else. They do not have a way to ask a question. Every piece of context they need has to be either in the file or in a document shipped alongside it.

ID recompaction, and verifying before refactoring

All 238 entries were renumbered sequentially in document order. The Expand/Collapse toggle keeps ID 0; the rest run 1 through 237 with no gaps; Cheat Engine's next auto-assigned ID is 238.

The reason to do this is cosmetic on the surface and structural underneath. Six versions of additions had left the ID space scattered, running past 369 with holes in it. Anybody reading the raw .CT XML to understand the structure has to hold two orderings in their head at once: document order and ID order. Recompaction collapses them into one.

The reason it was safe is that it was verified as such first, and that verification is the part worth recording. Cheat Engine IDs are referenceable from several places, and a renumber breaks any reference it does not know about. Three checks ran before a single ID moved: no hotkey links pointing at IDs, no getMemoryRecordByID calls anywhere in the table's Lua, and no dropdown link-mode entries referencing other records by ID. All three came back clean, which is what made the renumber a formatting change rather than a gamble.

That sequence generalizes into a small case study. The refactor was trivial to perform and would have been extremely difficult to debug had it broken something, because a broken ID reference does not produce an error at load — it produces a control that silently stops working, only to be discovered later by a user who assumes they are using it wrong. The cost of the three checks was minutes. The cost of skipping them was a class of bug that is nearly invisible from the inside. ID recompaction is a standing step of every freeze in this project, along with the verification that precedes it.

The Comments block, and why the order is the order

The table's Comments became the table's manual. The reading order was chosen deliberately, and it is the order a stranger needs rather than the order the material was written in:

  1. Compatibility — game and build date, the Cheat Engine 7.5 requirement, the single-player note.
  2. Quick Start — three steps.
  3. Scope statement — what is validated and what is not.
  4. Troubleshooting — the signature dialog, ?? values, event-driven stat refresh, the ×18 raw units.
  5. Known Limitations — all-or-nothing activation, floor and ceiling clamps, and the fact that the script scales computed totals only.
  6. Internals — the stat map, "How this table works," the color legend.
  7. License footer.

The ordering principle is that each block should answer the question the reader is currently holding. Compatibility comes first because the first way this table can fail is by being the wrong table for the build, and finding that out after five minutes of setup is worse than finding it out immediately. Quick Start comes second because most readers want three steps and nothing more. Scope comes third, before troubleshooting, because "does this cover my character?" is a scope question that users otherwise mistake for a bug. Troubleshooting comes before Known Limitations because a reader with a symptom is more urgent than a reader with a question about design. Internals come sixth because they are interesting and nobody needs them to use the table. The license comes last because it is a fact, not an instruction.

Note what is not first: the architecture. The most intellectually interesting content in the table — the cache timer, the four hook sites, the dispatch loop — is in position six of seven. That placement was intentional and mildly uncomfortable to make.

The scope statement as a deliberate under-claim

The published scope statement says the table is built and validated on the Swordsman with the Zweihänder equipped, that defensive and miscellaneous stats are shared player stats and should work on any character, and that only the Swordsman is validated.

The shared stats are believed to work everywhere. The hooked setter is universal and engine-level, and there is positive evidence in that direction from v1.0 onward: because the setter is shared, the script demonstrably affects other characters' health in situations like picking a bonus-health upgrade. There is no plausible mechanism by which the same three integer commit sites would cover the Swordsman's Max Health and miss the Archer's.

That was not claimed outright, because it was never tested. Every address in this project was confirmed by a live session on the actual game, and no live session was ever run on another character for these stats. "Expected but unvalidated" is exactly what the evidence supports, and it is the phrasing that shipped.

This is a deliberate under-claim, and it is the right trade for a public artifact. An over-claim that turns out to be true costs nothing and teaches the reader that the document's claims are aspirational. An over-claim that turns out to be false costs the reader a broken run and the document all its remaining credibility. A stated boundary between "validated" and "expected" lets a reader on the Cleric decide for themselves, with accurate information about what they are deciding.

The weapon half of the scope is not an under-claim at all — it is a hard boundary. The Zweihänder offsets are specific to the Zweihänder's own initialize_modifiers() call order. Field order repeats within an emitter-script family but not across families, and the projectile-weapon family does not even keep damage and crit fields in the emitter's container. A Bow user is not looking at an untested case; they are looking at the wrong container.

The license

The Volume 2 table is released into the public domain under CC0 1.0 Universal. No attribution is required.

The stated reasoning was two words: "public property." A tool for a single-player game with no anti-cheat, built to be read as much as run, published to a community that shares tables freely, with the goal that other people can take the cache-timer pattern and the store-site hooking approach and use them without asking. CC0 is the license that expresses that with the least ceremony, and it is the only license this document restates.

Final consistency work

Two categories of closing work, both mechanical, both necessary.

Stat-map corrections. The stat map in the table's Comments had drifted from the table's actual coverage across six versions of changes. Two stats considered at some point but never shipped were removed from the map. Zweihänder Knockback was added, having been implemented back in v1.3 without the documentation catching up. And "Crit Damage" was renamed "Crit Bonus" to match the lexicon the rest of the table already used, since the game's own display uses "Crit Bonus" and a table that names a stat two different things in two places generates support questions forever. That drift is worth naming as a general condition: documentation embedded inside an artifact drifts from the artifact unless something forces a reconciliation, and nothing forced it until the publication pass found three separate inconsistencies in one block of text.

Version-string sweep. The version-stamp row was updated to 1.7, the Comments section headers were bumped, and the sweep confirmed zero references to "1.6" remained anywhere in the file — boring, and load-bearing, since a public file that identifies itself as the previous version makes every downstream bug report ambiguous about which build it came from.

The v1.7 build was frozen.

The publication set

The finished publication set ships six files alongside this document, all byte-verified against each other:

  • Halls-Of-Torment-Vol2-Scripts-(Version_1.7-Annotated).CT — the annotated edition, with line-by-line commentary embedded in both script bodies, strip-verified functionally identical to the frozen v1.7 table.
  • Halls-of-Torment-Vol2-CheatScript-Unified-Multiplier-Streamlined.txt — the raw script body, extracted byte-identical from the frozen v1.7 table.
  • Halls-of-Torment-Vol2-CheatScript-Unified-Multiplier-Annotated.txt — the commented twin of the streamlined script.
  • Halls-of-Torment-Vol2-CheatScript-Compact-View-Toggle-Annotated.txt — the UI toggle, shipping annotated-only; judged small enough to need no streamlined copy.
  • Halls-of-Torment-Vol2-Documentation-Usage-Guide.md — "Tested against" pinned to Steam build 22764619 ("HoT Fixes", 2026-04-14).
  • Halls-of-Torment-OCT-Documentation-Forum-Post-Template.md — a Markdown-based BBCode-accent template for the forum post.

The two-file convention visible in that list — a streamlined artifact and an annotated twin for every code deliverable, with the annotated version strip-verified against the streamlined one — is a standing rule rather than a Volume 2 invention. The verification direction matters: the annotated file is checked to be functionally identical to the shipped one, not the reverse, so commentary can never silently become a behavioral difference. A related standing rule explains an apparent inconsistency in the file set: frozen .CT files stay unchanged, and post-freeze details (like a later build-number pin) live in external docs rather than a re-frozen table, because a frozen version is a historical fact.

The set was one document short. It could tell a reader how to use the table and what each line of the script does, but it could not explain why the hook contains no pointer dereferences, what the climbing reject counter turned out to be, or why the halves cannot be enabled separately. That is the document you are reading.


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