Brightness, reading palette, text size, comments and reading sound.Adds page background, hover cards, button corners, code colours, width and layout.Everything, including diagram styling, motion, inline code, depth and fine details.
Reading sound Off
Sound starts off. Play when you want it.
Original sounds, released into the public domain under CC0.
Margin comments
Hide author and reader comments without deleting them.
Page brightness
Lesson text size
Reading palette
More options
Reader finish
Modern adds semantic colours and refined controls. Soft depth adds frost and shadows.
Page background
Hover cards
Definitions, examples, tips, and lesson summaries that open over words and links. Off also removes the underlines.
Chat button
Which corner the button for asking questions about the book sits in. Top corners stop short of the lesson list and contents; at the bottom right it sits under the Notes button. It takes the colour of the chapter you are reading.
Notes button
Where the 📝 Notes button sits on lessons. Hidden leaves a thin tab on the edge; Alt + N brings it back.
Settings panel
Popover opens under the button. Side sheet slides in from the right with a dimmed page behind it.
Sidebar and header contrast
Original keeps the dark lesson list and header. Unified tints them to match the page and palette.
Reading width
How wide the lesson text column is. Standard is about 70 characters a line.
Table of contents
Light or dark for the “On this page” panel on the right, whatever the page is.
Lesson spacing
Code brightness
Syntax colours
Moving effects
System respects reduced motion while still advancing steps. On Play enables moving effects when you start a diagram. Off keeps manual steps.
Fine-tuning
Depth
Flat is the original. Soft adds faint shadows to diagram boxes, cards and buttons and lifts them slightly on hover.
Inline code
Classic keeps the badge look. Soft quiets the background and border for paragraphs full of keywords.
Floating buttons
Docked shows the Notes and chat buttons normally. Minimal makes them small icons that fade until you point at them.
Gradients
Use flat colours for headings, cards and diagram backgrounds.
Diagram background
Changes diagram surfaces; labels and role colours remain readable.
Diagram boxes
Heading rectangles
Diagram label backgrounds
Diagram frames
Diagram size
Page grid
Time per step
Semantic colours
Ligatures
What the colours mean
Terms, inputs, and data
Code and processing
Results and tips
Cautions
These colours follow your theme; their meanings stay the same. Data tables and diagram explanations use the information colour; code panels use processing. The lesson header and navigation identify its chapter. Correct answers are green; errors are red.
Alt + T palette · Alt + D page · Alt + C code · Alt + S syntax · Alt + H semantic · Alt + L ligatures · Alt + N lesson list · Alt + O on this page
14. Versioned Evidence, State, and Integrity — print chapter
Game Hacking Academy
Chapter 14: Versioned Evidence, State, and Integrity
The build manifest identifies which executable is present. It does not tell
us whether an old player layout still describes that executable. That second
question needs evidence from the running objects and the code that uses them.
An old field offset can still point into mapped memory after an update, which
makes a broken tool look plausible. To recover the layout, compare C++ object
shape, vtables, container headers, pointer chains, and x86-64 field accesses
against the fingerprint of the exact build. Independent clues matter more than
one address that happens to return a reasonable number.
If one build reads health from [rcx+0x138], the number 0x138 is true only
for that build. The complete statement is:
In this exact build, a method receiving the player object in rcx reads a
four-byte health value at offset 0x138.
An update can recompile the class with a field added, removed, or reordered,
and the same offset then lands on something else. An offset therefore belongs
to one exact build, and it has to be checked again after every update before it
is used.
The target invariant is:
A layout is used only on the build and the object it was recovered from, and
every field read is bounded and checked for a sensible value.
Unpacked, that is four separate demands. Use an offset only on the exact build
you proved it on. Use it only on an object you have confirmed is the one you
meant. Never read past the end of that object. And check that the value coming
back makes sense before believing it — a four-byte read that succeeds is not
the same thing as a four-byte read that returned health.
One memory scan cannot settle a structure: a four-byte 100 at some offset fits
health, ammunition, or a coincidence equally well. Static access patterns,
runtime value transitions, object identity, and relationships among fields each
rule out different wrong answers, so a layout is accepted only where they
agree.
An inserted field shifts only later fields. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
old health offset = 8; position offset = 0
insert selected bytes before health
new health offset = 8 + inserted_bytes
compare the old offset with the new access
recover the offset from the health writer
keep earlier fields and build identity separate
Result: health is at+12, position stays at +0. Adding the same shift to every old offset would corrupt the earlier position access.
Suppose several methods access one candidate object:
Offset
Width
Access pattern
Runtime observation
Candidate meaning
0x08
8
indirect call through loaded pointer
points into read-only module data whose entries point into executable code
vtable pointer
0x30
12
three adjacent float loads
changes with movement
position vector
0x48
4
float compare against zero
changes with view rotation
yaw or pitch candidate
0x138
4
subtract, clamp, store
follows damage and healing
health candidate
0x13C
4
upper bound for previous field
stable per entity class
max-health candidate
The relationship 0 <= health <= max_health is stronger than “both numbers
look reasonable.” The clamp that stores health at 0x138 reads its upper bound
from 0x13C, so one instruction sequence ties the two candidates together and
each supports the other.
Health and max health: layout and the relation between themTwo four-byte fields sit side by side. A clamp keeps one value no larger than the other.
1Two four-byte fields at 0x138 and 0x13C
Object bytes
0x1380x13F
health0x138 to 0x13B
max health0x13C to 0x13F
Health occupies 0x138 to 0x13B and max health 0x13C to 0x13F, in this exact build only: recheck after an update.
2A clamp relates their values
Values
0
≤
H
≤
M
With H for health and M for max health, the clamp keeps 0 ≤ H ≤ M.
All 2 steps shown
Two four-byte fields sit side by side. A clamp keeps one value no larger than the other.
Two objects can have similar bytes but different roles. Validate identity with
several signals:
vtable pointer falls inside the module’s expected read-only data section, and
several entries reached through it fall inside executable code;
entity ID matches the manager entry used to reach the object;
generation changes when the slot is reused;
position is finite and within broad world bounds;
health relationships hold;
repeated methods use the same object pointer.
A shape check says bytes resemble a player. An identity check says this
is the player object reached through the expected ownership path in this
snapshot.
LayoutV42 is metadata in the analysis tool, not a struct read directly from
the game. Its usize fields are candidate byte offsets for one exact build.
The separate Confidence value records how much independent evidence supports
each candidate.
#[derive(Debug,Clone,Copy)]
structLayoutV42 {
position: usize,
health: usize,
max_health: usize,
entity_id: usize,
generation: usize,
}
#[derive(Debug,Clone,Copy,PartialEq,Eq)]
enumConfidence {
Candidate,
Repeated,
CrossValidated,
}
Generated metadata stores the confidence level beside each numeric offset,
together with the accesses and value transitions that earned it, so a
Candidate never becomes a trusted field just because it has a convenient
name.
A recovered layout is easier to judge after reading one whose source is known.
Lesson 4.10 numbered a toy guard’s states for a
C++ layout. Here is the whole structure. The guard’s Rust lab,
npc_brain_lab.rs, cannot show one itself,
because its Rust types use Rust’s default layout, which lets the compiler order
fields however it likes. In C++, the fields of a plain structure sit in memory
in the order they are declared:
Every float and uint32_t takes 4 bytes, so the offsets count up in fours:
0, 4, 8, 12 (0x0C), and 16 (0x10). The state is stored as a single byte at
20, which is 0x14 (1 × 16 + 4). The next field is 4 bytes wide, and its
alignment requires it to start at a multiple of 4. Bytes 21, 22, and 23 are
not, so the compiler leaves them as padding, and seen_for starts at 24, which
is 0x18 (1 × 16 + 8). search_ticks follows at 28, 0x1C (1 × 16 + 12), and
target_id at 32, 0x20 (2 × 16). The structure ends at 36 bytes, 0x24
(2 × 16 + 4).
Here is the guard at the end of tick 5 of
Lesson 4.10’s run, mid-chase. It has seen
the player on ticks 1 to 5, so seen_for is 5, and chase steps of 1 on ticks 3,
4, and 5 have taken it to (3, 0), still facing east. Its health is still the 100
that the lab gives every new guard:
+0x00 x3.0
+0x04 y0.0
+0x08 facing x1.0
+0x0C facing y0.0
+0x10 health100
position
facing, a unit vector
Bytes 0x00 to 0x13 of the toy layout at tick 5: where the guard stands, which way it faces, and its health.
+0x14 state1
+0x15 padding· · ·
+0x18 seen_for5
+0x1C search_ticks0
+0x20 target_id7
one state byte, three padding bytes
timers
who it hunts
Bytes 0x14 to 0x23. The highlighted byte is the state machine's current state, 1 for Chase. The target id 7 is a made-up entity number for the player.
The target field is an id rather than a pointer. Some games store a raw pointer
to the target object instead, and that pointer goes stale when the target is
destroyed and its memory reused, the problem that generation-checked handles in
Lesson 3.10 solve.
A byte’s 8 bits give 2⁸ = 256 different values, 0 to 255, but only 0 to 4 mean
anything to the guard’s code. Write 9 into the state byte of a toy program that
uses the lab’s five-state guard, and the result depends on how the compiler built the
switch statement that picks a branch for each state. Compilers often turn a
switch over small numbers into a table of jump targets, one code address per
value. If the compiled code checks the range first, 9 goes to a default branch.
If it indexes the table without a check, it reads whatever bytes lie past the
table’s end as an address, jumps there, and usually crashes.
The guard’s state is an enum, a type whose values come from a fixed list of
names. In the Rust lab, safe code cannot store 9 in a GuardState at all. Rust
treats an enum holding a value outside its list as undefined behavior: the
language promises nothing about what the program then does, so safe code is
never allowed to create one. A tool that reads this byte from another process
therefore decodes it as a plain integer first and accepts it only when it is
one of the five known values, the kind of check that
rejecting impossible layouts
applies to every field.
Suppose the guard’s 36 bytes came from a C++ build whose source you do not
have, and you watched them through
the demo run. Each line of the layout above
is then a claim, and each needs its own check. The run settles some claims and
cannot reach others:
Claim
Check
What the demo run showed
+0x14 is the state
it changes exactly when the guard’s behavior changes
0, then 1 from tick 3 (chase), 2 on tick 7 (attack), 3 from tick 8 (search), 0 from tick 13: settled
+0x18 is seen_for
it rises by 1 per tick in sight, and drops to 0 when sight is lost
1 to 7 on ticks 1 to 7, then 0: settled
+0x1C is search_ticks
it counts while the guard searches
0 to 4 on ticks 8 to 12: settled
+0x10 is health
it falls by the damage when the guard is hit
100 on every tick, but nothing hit the guard: still open
+0x15 to +0x17 are padding
no instruction reads or writes them
not checked yet
The health line shows the limit of any run: it settles only what it made
happen. The demo never damaged the guard, so it shows only that +0x10 held
100. The Confidence value above sums up exactly this evidence, one field at a
time.
Predict first
Through all 16 ticks of the demo, bytes +0x15 to +0x17 read 00 00 00.
Does that settle the padding claim?
Answer: No: it needs a check that no instruction touches them, in every state. Padding means bytes that no instruction uses. Access breakpoints, which fire on any read or write, that never fire on 0x15 to 0x17 settle it, but only for the states the run reached.
The demo reached Patrol, Chase, Attack and Search, never Flee. A run where the
guard is hurt down to 25 health or less, the point at which it flees, tests the
health claim and the padding claim in that last state at the same time.
fingerprint the new executable and symbols that remain available;
locate the same behavioral function through call relationships or a
carefully versioned pattern;
collect member accesses again instead of adding a constant to every offset;
replay the same controlled game transitions;
rebuild the offset table;
compare field relationships, not just individual values;
create a new layout record and keep the old record immutable;
run captured-snapshot regression tests for both builds.
As a flow:
Step 3 deserves emphasis, because the tempting shortcut is to diff two known
offsets and add that difference to all the others. Layouts do not move like
that. A field inserted in the middle shifts everything after it and nothing
before it, and alignment can absorb part of the shift:
0x30position
0x48yaw
0x138health
0x13Cmax_health
0x150entity_id
Build 41: five fields at their original offsets.
0x30position
0x48yaw
0x50team (new)
0x140health
0x144max_health
0x158entity_id
Build 42: inserting team at 0x50 pushes every later field down by 8 bytes. position and yaw, both before the insertion, do not move at all.
Nothing here moved by one constant. Two fields did not move at all, one
appeared, and the rest shifted by the size of the insertion. A tool that added
+8 everywhere would read position eight bytes into the wrong place and
report numbers that still look like perfectly plausible coordinates.
Compiler optimization can split, inline, reorder, or eliminate accesses. A
missing old pattern does not prove the underlying concept disappeared.
A reader should validate the whole requested field range before interpreting
bytes. Use checked addition for base + offset, enforce maximum object size,
and reject unreadable page crossings. Decode into local values; do not create a
reference whose lifetime pretends remote bytes belong to the current process.
Validation should reject:
non-finite vectors;
health > max_health when the game model forbids it;
a vtable pointer outside expected read-only module data, or initial entries
that do not point into an expected executable code section;
duplicate live entity IDs in one snapshot;
generation changes during a multi-field read;
an unsupported build rather than falling back to the nearest known layout.
Layout: the byte positions and interpretation of fields in an object.
Shape check: validation that bytes have a plausible structure.
Identity check: validation that the object is the intended instance.
Generation: a counter distinguishing reuse of the same storage slot.
Cross-validation: confirming a field with independent checks, such as an
access pattern, a value transition, and a range relation, that all agree.
Build fingerprint: stable data used to identify one exact binary version.
An old offset is a hypothesis about a new build, not a migrated layout. Recover
the new object’s fields from its access patterns and relationships, check its
shape and identity, and keep the evidence beside each confidence score. That
record shows which uncertainty remains when the build changes again.
Build identity and a recovered layout tell us what a read would mean. They
do not grant permission to perform it. The next lesson follows the access
rights on the Windows handle that lets a tool reach another process.
A call logger should not mean “break on every call in the whole process.” That would be noisy and slow.
Choose:
one module;
one executable section;
one known caller range or target function;
one small time window;
a maximum number of events.
This scope is not just a performance setting. It gives the log a meaning. “These calls happened while one turn ended inside this verified module range” is a result you can reproduce. “The process called thousands of things” is mostly noise.
remember the address of the instruction that should run afterward;
move the instruction pointer to the called function.
On 32-bit x86, a common direct call begins with opcode E8 followed by a signed 32-bit relative displacement. The CPU adds that displacement to the address immediately after the call. A disassembler performs this calculation and exposes the final target.
Predict first
At 0x00401000 the five bytes are E8 4B 00 00 00: the opcode E8, then the displacement 0x0000004B stored lowest byte first.
Which address does this call go to?
Answer: 0x00401050. The next instruction is at 0x00401000 + 5 = 0x00401005, and 0x00401005 + 0x4B = 0x00401050. The call also pushes 0x00401005, so ret comes back there.
Where a five-byte call goesE8 is followed by a signed 32-bit displacement, lowest byte first. Change the call’s address or the displacement.
Bytes, then the CPU’s arithmetic
The five bytes at 0x00401000
Opcode, then displacement
0x004010000x00401004
E8call
4B×1
00×256
00×256²
00×256³
What the CPU computes
Next instruction
0x00401000 + 5 = 0x00401005
Displacement
0x0000004B = +75 (0x4B)top bit clear: zero or positive
Target
0x00401005 + 0x4B = 0x00401050
Return address pushed
0x00401005ret resumes here
The CPU measures from the end of the call, 0x00401005, not from 0x00401000. Adding +75 (0x4B) gives 0x00401050, and 0x00401005 is pushed so ret can come back.
An indirect call such as call eax or call [ecx+8] gets its destination from a register or memory at runtime. Static bytes alone do not reveal one final target, so the first course logger deliberately records only direct calls.
A relative call starts measuring after itself. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
call_site = 0x1000; length = 5
next_ip = call_site + length
target = next_ip + signed_displacement
push next_ip as the return address
instruction_pointer = target
ret later resumes at saved next_ip
Result: call enters 0x100F and a matching return resumes at 0x1005. The saved return is independent of the signed displacement.
The logger does not place a breakpoint on every byte equal to E8. That byte may appear inside an immediate value or data. Instead, iced-x86 walks complete instructions and reports their flow-control kind.
NearBranch16 and NearBranch32 are the direct relative call forms used in this 32-bit target. Indirect calls need runtime register or memory values and are a separate feature.
Decoder::with_ip needs both the bytes and their live starting address. The address lets each Instruction report a correct ip() and resolve a relative target. decoder.can_decode() stops at the end of the bounded slice. flow_control() == FlowControl::Call identifies call-like control flow, while the operand-kind check narrows that set to direct near branches.
The lesson uses decode() for readability. The complete binary reuses one Instruction with decode_out, which avoids constructing a new value on every loop iteration. That optimization does not change the algorithm.
an in-process instrumentation hook for a practice program.
Start with one entry breakpoint while learning the state machine. The completed course tool expands to verified direct calls in readable executable regions, excludes known noisy sites, deduplicates addresses, and applies a hard 2,000-breakpoint cap. More instrumentation multiplies state and performance cost.
A software breakpoint changes the first byte of an instruction to 0xCC, the one-byte int3 instruction. When the CPU executes it, Windows pauses the thread and reports EXCEPTION_BREAKPOINT to the debugger. The original E8 must be remembered so the real call can still execute and can later be restored.
0x00401000CC
0x004010014B
0x0040100200
0x0040100300
0x0040100400
The same 5 bytes with the software breakpoint installed. Only the first byte changes, from E8 to CC (int3). The saved original E8 is what lets the logger restore the real call before it executes.
One breakpoint hit is not enough. If the debugger immediately writes 0xCC back at the same address and continues, the thread hits it forever. The logger temporarily restores the call and single-steps exactly one instruction:
At the breakpoint event, Windows reports the address of int3. The logger:
confirms the address belongs to its breakpoint map;
restores the original E8 byte;
opens the event thread and reads its CONTEXT;
rewinds EIP to the call address;
sets bit 8 of EFlags, the trap flag;
records that this thread is stepping this call;
continues the event.
The CPU now executes the real call. Because the trap flag is set, Windows pauses after that one instruction with EXCEPTION_SINGLE_STEP. At this moment EIP contains the call destination. The logger clears the flag, reinstalls 0xCC at the call site, removes the thread from the stepping map, and prints the source and destination.
The stepping map is keyed by thread ID because several game threads can hit different breakpoints. A single global “currently stepping” variable would mix their state.
When the single-step pause reveals a call’s destination, copy only the facts
needed to answer “who called where, on which thread, and when?” The writer
can format this small owned record after the target is running again:
#[derive(Debug)]
structCallEvent {
timestamp_micros: u128,
thread_id: u32,
call_site: usize,
target: usize,
stack_pointer: usize,
}
Do not automatically dump the entire stack or arbitrary pointed-to memory. Record only what the current experiment requires.
The event is a snapshot of facts already available while the thread is paused. call_site and target explain control flow; thread_id separates timelines; stack_pointer helps compare call contexts without copying unknown stack contents.
The debug loop and the log writer run at different speeds. A bounded channel
holds at most 1,024 copied events; when it fills, the debugger can count a
dropped event instead of leaving the game paused while disk output catches
up:
The debugger thread uses try_send, which fails at once when the channel is full. A bounded channel creates backpressure: it gives the producer a fixed amount of space instead of allowing logs to consume memory forever. Losing a counted event is better than holding every target thread suspended while a disk writer catches up. Lesson 14.6 compares other overload policies for the same bounded queue.
Symbols can be slow or unavailable. A module-relative address is still useful:
wesnoth.exe + 0x1B4D00
Address resolution is delayed because the debug-event loop has a deadline: every target thread remains stopped until ContinueDebugEvent. Expensive formatting, symbol lookup, and file I/O belong on a background worker after the minimal register facts have been copied.
The paused thread supplied the addresses; the background writer now records
them with a target fingerprint and explicit column names. In this example,
one row says that thread 4108 reached a call site at time 15,234
microseconds and the call resolved to the listed target:
The version header says how to parse the columns, and the target hash binds the log to one binary so later analysis does not mix runs from different builds. Hexadecimal addresses keep the debugger’s notation. Lesson 14.6 extends the same versioned schema with sequence numbers and correlation IDs that join the events of one action.
These conditions form the logger’s safety envelope. The event cap bounds memory and terminal output. The time cap bounds disruption. The drop threshold detects a writer that cannot keep up. A restoration failure stops the experiment because continuing would leave the target executing altered code whose ownership is uncertain.
breakpoints maps each call address to its original byte. A BTreeMap also restores in deterministic address order, which makes diagnostics repeatable. stepping maps a thread ID to the call it is temporarily executing. A HashMap gives direct keyed lookup when the single-step event arrives.
The types prevent two different facts from being blurred together: “this address is patched” and “this thread is between restore and re-arm.”
WaitForDebugEvent returns one pending event. Until the debugger calls ContinueDebugEvent, target threads remain stopped. Therefore every successful wait must lead to exactly one continue, including error paths.
Some event variants transfer handles to the debugger. The logger closes process, thread, and image-file handles after reading the active union member selected by dwDebugEventCode. It does not guess which union field is valid.
The AttachGuard is the last line of cleanup. If ordinary error propagation leaves the event loop, Drop attempts to detach. Breakpoint restoration still happens explicitly first because detaching a process whose code still contains course 0xCC bytes would be unsafe.
RAII — resource acquisition is initialization, the habit of tying cleanup to a value’s lifetime — restores bytes and closes handles.
First prove the logger against a small test target whose source is available. Then run the real capstone against one narrow course target: in Wesnoth 1.14.9, log calls around 0x009B4D00 while ending one turn. The optional build reference lists an AssaultCube and an Urban Terror target for the same logger.
Set a limit such as 1,000 events or five seconds. The output should show real module-relative callers and targets from the selected game. Compare the log with x64dbg, then detach and verify every breakpoint byte was restored.
The compiled course tool chooses the Wesnoth main module, asks VirtualQueryEx for readable executable regions, decodes them as x86 with iced-x86, and installs at most 2,000 verified E8 call breakpoints. It excludes two known noisy call sites from the historical lab. When a call fires, it restores E8, rewinds EIP, sets the trap flag, executes the real call once, records the destination EIP, reinserts 0xCC, and continues.
Exercise a small Wesnoth path, then press End. The tool restores every installed call byte before detaching. The full code is call_logger.rs; shared memory and region enumeration are in process.rs. The optional build reference gives an order for reading the full source.
use std::{ffi::c_void, mem::size_of};use anyhow::{Context, Result};use windows::Win32::{ Foundation::ERROR_NO_MORE_FILES, System::{ Diagnostics::{ Debug::{FlushInstructionCache, ReadProcessMemory, WriteProcessMemory}, ToolHelp::{ CreateToolhelp32Snapshot, MODULEENTRY32W, Module32FirstW, Module32NextW, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPMODULE, TH32CS_SNAPMODULE32, TH32CS_SNAPPROCESS, }, }, Memory::{ MEM_COMMIT, MEMORY_BASIC_INFORMATION, PAGE_EXECUTE, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS, PAGE_PROTECTION_FLAGS, PAGE_READONLY, PAGE_READWRITE, PAGE_WRITECOPY, VirtualProtectEx, VirtualQueryEx, }, SystemInformation::{ IMAGE_FILE_MACHINE, IMAGE_FILE_MACHINE_I386, IMAGE_FILE_MACHINE_UNKNOWN, }, Threading::{ IsWow64Process2, OpenProcess, PROCESS_ACCESS_RIGHTS, PROCESS_QUERY_INFORMATION, PROCESS_VM_OPERATION, PROCESS_VM_READ, PROCESS_VM_WRITE, }, },};use super::OwnedHandle;#[derive(Clone, Debug)]pub struct ProcessEntry { pub id: u32, pub name: String,}#[derive(Clone, Copy, Debug)]pub struct MemoryRegion { pub base: usize, pub size: usize, pub readable: bool, pub writable: bool, pub executable: bool,}#[derive(Debug)]pub struct Process { handle: OwnedHandle, id: u32, name: String,}fn wide_text(buffer: &[u16]) -> String { let end = buffer .iter() .position(|unit| *unit == 0) .unwrap_or(buffer.len()); String::from_utf16_lossy(&buffer[..end])}fn no_more_files(error: &windows::core::Error) -> bool { error.code() == ERROR_NO_MORE_FILES.to_hresult()}impl Process { /// Lists every running process with ToolHelp. pub fn list() -> Result<Vec<ProcessEntry>> { // SAFETY: no borrowed pointers are passed; the returned snapshot is owned below. let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }?; let snapshot = OwnedHandle::from_raw(snapshot)?; let mut raw = PROCESSENTRY32W { dwSize: size_of::<PROCESSENTRY32W>() as u32, ..Default::default() }; // SAFETY: `raw` is correctly sized and writable for the duration of the call. unsafe { Process32FirstW(snapshot.raw(), &mut raw) }?; let mut processes = Vec::new(); loop { processes.push(ProcessEntry { id: raw.th32ProcessID, name: wide_text(&raw.szExeFile), }); // SAFETY: `raw` remains a valid PROCESSENTRY32W output buffer. match unsafe { Process32NextW(snapshot.raw(), &mut raw) } { Ok(()) => {} Err(error) if no_more_files(&error) => break, Err(error) => return Err(error.into()), } } Ok(processes) } pub fn find(name: &str) -> Result<ProcessEntry> { Self::list()? .into_iter() .find(|entry| entry.name.eq_ignore_ascii_case(name)) .with_context(|| format!("process {name:?} is not running")) } pub fn open_by_name(name: &str, allow_write: bool) -> Result<Self> { let entry = Self::find(name)?; Self::open(entry, allow_write) } pub fn open(entry: ProcessEntry, allow_write: bool) -> Result<Self> { let mut access: PROCESS_ACCESS_RIGHTS = PROCESS_QUERY_INFORMATION | PROCESS_VM_READ; if allow_write { access |= PROCESS_VM_OPERATION | PROCESS_VM_WRITE; } Self::open_with_access(entry, access) } pub fn open_with_access(entry: ProcessEntry, access: PROCESS_ACCESS_RIGHTS) -> Result<Self> { // SAFETY: the PID is a value; inheritance is disabled; `OwnedHandle` owns the result. let handle = unsafe { OpenProcess(access, false, entry.id) }?; Ok(Self { handle: OwnedHandle::from_raw(handle)?, id: entry.id, name: entry.name, }) } pub fn is_32_bit(&self) -> Result<bool> { let mut process_machine = IMAGE_FILE_MACHINE::default(); let mut native_machine = IMAGE_FILE_MACHINE::default(); // SAFETY: both output pointers remain valid for the call. unsafe { IsWow64Process2( self.handle.raw(), &mut process_machine, Some(&mut native_machine), )?; } Ok(process_machine == IMAGE_FILE_MACHINE_I386 || (process_machine == IMAGE_FILE_MACHINE_UNKNOWN && native_machine == IMAGE_FILE_MACHINE_I386)) } pub fn id(&self) -> u32 { self.id } pub fn name(&self) -> &str { &self.name } pub fn raw_handle(&self) -> windows::Win32::Foundation::HANDLE { self.handle.raw() } pub fn read_exact(&self, address: usize, output: &mut [u8]) -> Result<()> { let mut bytes_read = 0_usize; // SAFETY: `output` is writable for its length. Windows validates the remote range. unsafe { ReadProcessMemory( self.handle.raw(), address as *const c_void, output.as_mut_ptr().cast(), output.len(), Some(&mut bytes_read), )?; } anyhow::ensure!(bytes_read == output.len(), "short read at {address:#x}"); Ok(()) } pub fn read_bytes(&self, address: usize, count: usize) -> Result<Vec<u8>> { let mut bytes = vec![0_u8; count]; self.read_exact(address, &mut bytes)?; Ok(bytes) } pub fn read_u32(&self, address: usize) -> Result<u32> { let bytes: [u8; 4] = self .read_bytes(address, 4)? .try_into() .expect("the read length is exactly four"); Ok(u32::from_le_bytes(bytes)) } pub fn read_f32(&self, address: usize) -> Result<f32> { Ok(f32::from_bits(self.read_u32(address)?)) } pub fn write_exact(&self, address: usize, bytes: &[u8]) -> Result<()> { let mut bytes_written = 0_usize; // SAFETY: `bytes` is readable for its length. Windows validates the remote range. unsafe { WriteProcessMemory( self.handle.raw(), address as *const c_void, bytes.as_ptr().cast(), bytes.len(), Some(&mut bytes_written), )?; } anyhow::ensure!(bytes_written == bytes.len(), "short write at {address:#x}"); Ok(()) } pub fn write_u32(&self, address: usize, value: u32) -> Result<()> { self.write_exact(address, &value.to_le_bytes()) } /// Writes machine code, flushes the CPU instruction cache, and restores protection. pub fn write_code(&self, address: usize, bytes: &[u8]) -> Result<()> { let mut old = PAGE_PROTECTION_FLAGS::default(); // SAFETY: Windows validates the target range and writes `old` before returning. unsafe { VirtualProtectEx( self.handle.raw(), address as *const c_void, bytes.len(), PAGE_EXECUTE_READWRITE, &mut old, )?; } let write_result = self.write_exact(address, bytes); if write_result.is_ok() { // SAFETY: the process handle is live and the written range is the one above. unsafe { FlushInstructionCache( self.handle.raw(), Some(address as *const c_void), bytes.len(), )?; } } let mut ignored = PAGE_PROTECTION_FLAGS::default(); // SAFETY: this restores the protection returned by the first call. let restore_result = unsafe { VirtualProtectEx( self.handle.raw(), address as *const c_void, bytes.len(), old, &mut ignored, ) }; write_result?; restore_result?; Ok(()) } pub fn module(&self, wanted_name: &str) -> Result<(usize, usize)> { // Include SNAPMODULE32 so a 64-bit tool can list a 32-bit game module. // SAFETY: the PID is valid and the returned snapshot is owned below. let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, self.id) }?; let snapshot = OwnedHandle::from_raw(snapshot)?; let mut raw = MODULEENTRY32W { dwSize: size_of::<MODULEENTRY32W>() as u32, ..Default::default() }; // SAFETY: `raw` is correctly sized and writable. unsafe { Module32FirstW(snapshot.raw(), &mut raw) }?; loop { if wide_text(&raw.szModule).eq_ignore_ascii_case(wanted_name) { return Ok((raw.modBaseAddr as usize, raw.modBaseSize as usize)); } // SAFETY: `raw` remains a valid output buffer. match unsafe { Module32NextW(snapshot.raw(), &mut raw) } { Ok(()) => {} Err(error) if no_more_files(&error) => break, Err(error) => return Err(error.into()), } } anyhow::bail!("module {wanted_name:?} was not found in {}", self.name) } pub fn regions(&self, start: usize, end: usize) -> Result<Vec<MemoryRegion>> { let mut current = start; let mut regions = Vec::new(); while current < end { let mut raw = MEMORY_BASIC_INFORMATION::default(); // SAFETY: `raw` is a valid output structure; Windows validates the address. let returned = unsafe { VirtualQueryEx( self.handle.raw(), Some(current as *const c_void), &mut raw, size_of::<MEMORY_BASIC_INFORMATION>(), ) }; if returned == 0 { break; } let protection = raw.Protect; let blocked = protection.contains(PAGE_GUARD) || protection.contains(PAGE_NOACCESS); let readable = !blocked && raw.State == MEM_COMMIT && [ PAGE_READONLY, PAGE_READWRITE, PAGE_WRITECOPY, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, ] .iter() .any(|flag| protection.contains(*flag)); let writable = readable && [ PAGE_READWRITE, PAGE_WRITECOPY, PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, ] .iter() .any(|flag| protection.contains(*flag)); let executable = !blocked && [ PAGE_EXECUTE, PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, ] .iter() .any(|flag| protection.contains(*flag)); let base = raw.BaseAddress as usize; let size = raw.RegionSize; regions.push(MemoryRegion { base, size, readable, writable, executable, }); let next = base.checked_add(size).context("memory map overflowed")?; anyhow::ensure!(next > current, "memory map did not advance"); current = next; } Ok(regions) }}
This first complete version writes compact lines directly to the console. The bounded-channel design above is the next performance step when you add file output; it is not required to run or reproduce the actual call logger.
Executed calls appearing in a Wesnoth traceSilent demonstration · 15 seconds
The original call-logger recording shows the running game producing trace output. Each observed call requires a breakpoint hit, execution of the saved call, a single-step event, and re-arming; bytes discovered in a static scan have not necessarily executed.
One trace lists what ran while it recorded, but not which calls the action
caused: most of them belong to drawing, input and timers that run anyway. To
separate the two, fix the scenario first (the build, the save, the logging
window and the event cap) and record it twice without changing anything. Then
record it once more with a single change, such as recruiting one unit before
ending the turn.
The two unchanged runs show how far the counts move on their own. A function
the game calls once per frame runs as many times as frames fit in the window,
and that depends on timing, so even identical runs disagree. A count in the
changed run points at the change only when it falls outside the range the
unchanged runs produced.
Predict first
Two runs that only end the turn called wesnoth.exe+0x0E7710 212 and 219 times. A third run that first recruits one unit called it 215 times.
Does recruiting change how often +0x0E7710 runs?
Answer: The counts cannot tell: 215 lies inside the 212 to 219 of the identical runs. A count points at the change only when it falls outside what unchanged runs produce. 215 is inside, so this function may or may not take part in recruiting, and these counts cannot separate the two.
Two unchanged runs, then one changeCall counts for four logged targets in wesnoth.exe. Runs A and A′ repeat the same scenario; run B adds one recruit.
1Fix the scenario before any run
The same for every run
Build
wesnoth.exe, its SHA-256 in the log header
Start
the same save: turn 3, four units on the map
Window
5 seconds from the first click, at most 1,000 events
The action
Runs A and A′
end the turn
Run B
recruit one unit, then end the turn
Every run uses the same build, the same save and the same logging window. Only the action differs, and only in run B.
2Two unchanged runs give the spread
Calls per target in runs A and A′
wesnoth.exe+0x0E7710
212A
219A′
212 to 219spread
wesnoth.exe+0x1F0C80
4A
4A′
4spread
wesnoth.exe+0x2A1C30
1A
1A′
1spread
wesnoth.exe+0x3C5E40
0A
0A′
0spread
Runs A and A′ did the same thing, yet +0x0E7710 ran 212 and then 219 times: 219 − 212 = 7 calls of difference from timing alone, because the number of frames that fit in 5 seconds varies.
3Read run B against the spread
Run B compared with the spread
wesnoth.exe+0x0E7710
212 to 219A and A′
215B
inside: no evidence
wesnoth.exe+0x1F0C80
4A and A′
5B
outside: a candidate
wesnoth.exe+0x2A1C30
1A and A′
1B
inside: no evidence
wesnoth.exe+0x3C5E40
0A and A′
1B
outside: a candidate
Only two targets leave the spread: +0x1F0C80 ran 5 times against 4, and +0x3C5E40 ran once against never. Those are the two places to break during the next recruit.
4One guess to test next
+0x1F0C80
Counts
4 calls with 4 units
5 calls with 5 units
Guess
called once for each unit when a turn ends
Test
break on it in the next run: each hit should receive a different unit
The save had four units and run B had five, and +0x1F0C80 ran 4 and then 5 times. Once per unit at the end of a turn fits, but it stays a guess until a breakpoint shows each call handling a different unit.
All 4 steps shown
Call counts for four logged targets in wesnoth.exe. Runs A and A′ repeat the same scenario; run B adds one recruit.
Wrong bitness, module range, region permissions, or decoder input
Breakpoints install but never fire
The chosen action did not execute those sites, or the scope is wrong
Repeated hit at one address
EIP was not rewound or the call was re-armed too early
Unhandled single-step event
The thread ID was absent from the stepping map
Target stays paused
A WaitForDebugEvent result did not reach one matching continue
Target crashes after detach
Original bytes were not completely restored
This table is why the state machine is explained explicitly: the symptom tells you which transition to inspect instead of encouraging random breakpoint changes.
These notes help when you reproduce the logger on another course game or read
its complete source. The breakpoint cycle and bounded record above do not
depend on them.
Optional build reference: other capstone targets
AssaultCube 1.2.0.2: log entries to the internal print function at 0x00419880 while names are drawn;
Urban Terror 4.3.4: log the render path around the memory-wall hook at 0x0052D2FD for one local frame window.
Optional build reference: read the complete source in six passes
The full call_logger.rs is longer than the scanner because it owns a live state machine. Read it in this order:
Constants and guards — caps, trap flag, known exclusions, detach cleanup.
Thread context helpers — open only the event thread and get or set its suspended register state.
Static discovery — copy executable regions and let iced-x86 return verified call instructions.
Breakpoint transaction — save E8, install 0xCC, and roll back already-installed sites on partial failure.
Exception state machine — pair each breakpoint event with a later per-thread single-step event.
Event loop and shutdown — continue every event once, close transferred handles, restore bytes, and detach.
Do not begin at main and dive into every Windows name. First locate these six responsibilities; then read the helper used by the phase you are studying.
The live tools in this chapter reported different parts of one process:
modules, page regions, threads, and API results. The kernel lessons explained
why those observations pass through controlled services. A dump brings
selected process data together at one capture moment, so a later investigation
can ask which module and thread were involved even after the process is gone.
A live debugger reads a running process. A dump file saves selected process state so it can be inspected later. A minidump may include:
basic process and operating-system information;
loaded and recently unloaded modules;
thread records and selected thread context;
stack memory needed to reconstruct calls;
exception information when captured during a crash.
The word “mini” describes a selectable subset, not a harmless text log. Dumps can contain file paths, arguments, stack data, and other private values. Treat them like diagnostic data, not screenshots.
The dump type decides which optional streams are saved. That is why a missing
heap object or exception record may mean “not captured,” not “never existed.”
A dump records one chosen momentThe dump holds the state at its capture point. It does not hold how the program got there.
1One chosen instant, saved
Timeline
Time
earlier history
capture point
later
Saved in the dump
↓ saved from that instant
Records
modules
threads
stacks
Something chooses one capture point; the dump keeps module, thread and stack records as they were then.
2Earlier history is not in the dump
Timeline
Time
earlier history: not saved
capture point
later
Needs another source
Where history lives
logs
tracing
How the state came about needs logs or tracing, which a dump does not contain.
3A missing stream may simply not have been captured
Saved in the dump
Records
modules
threads
stacks
Not captured
a stream that was not requested
If a dump lacks something, it may not have been asked for, which is not proof the thing did not exist.
All 3 steps shown
The dump holds the state at its capture point. It does not hold how the program got there.
A reproducible crash often disappears when you attach a debugger and change timing. A dump saves the process state close to the failure without a debugger attached, and from that file alone you can answer:
Which thread failed?
Which module contained the instruction pointer?
What calls led to that point?
Which game and DLL versions were loaded?
Did the crash occur inside the course patch, the game, or a system DLL?
A dump does not record every earlier event. Combine it with logs or an ETW trace when the history matters.
MiniDumpWriteDump can accept a process handle and PID. This course program deliberately uses GetCurrentProcess and GetCurrentProcessId, so it can dump only itself through this code path. It does not search for a game PID or request another process handle.
It also avoids MiniDumpWithFullMemory. A normal dump plus thread and unloaded-module information is enough to learn the workflow without copying every private page in the process.
In the source, that choice is the dump_type value. Each optional stream has its own bit, and | sets several bits at once:
bits 15–120001
bits 11–80000
bits 7–40010
bits 3–00000
bit 12, thread info
bit 5, unloaded modules
bit 1, full memory, left 0
dump_type in binary. MiniDumpWithThreadInfo is 0x1000 = 4,096 = 2^12, so it is bit 12. MiniDumpWithUnloadedModules is 0x20 = 32 = 2^5, bit 5. MiniDumpNormal is 0: it sets no bit and names the baseline dump. Together they make 0x1020 = 4,096 + 32 = 4,128. MiniDumpWithFullMemory would be 0x2, bit 1, and this lab leaves it clear.Complete lab source: self_minidump.rs
#[cfg(windows)]fn main() -> anyhow::Result<()> { use std::{fs::OpenOptions, os::windows::io::AsRawHandle, path::PathBuf}; use anyhow::{Context, ensure}; use windows::Win32::{ Foundation::HANDLE, System::{ Diagnostics::Debug::{ MiniDumpNormal, MiniDumpWithThreadInfo, MiniDumpWithUnloadedModules, MiniDumpWriteDump, }, Threading::{GetCurrentProcess, GetCurrentProcessId}, }, }; let output = std::env::args_os() .nth(1) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from("gha-self.dmp")); ensure!( !output.exists(), "refusing to overwrite {}", output.display() ); let file = OpenOptions::new() .write(true) .create_new(true) .open(&output) .with_context(|| format!("could not create {}", output.display()))?; let file_handle = HANDLE(file.as_raw_handle()); // SAFETY: these calls take no pointers and return identifiers for this process. let (process, process_id) = unsafe { (GetCurrentProcess(), GetCurrentProcessId()) }; let dump_type = MiniDumpNormal | MiniDumpWithThreadInfo | MiniDumpWithUnloadedModules; // SAFETY: both handles belong to this process, the file stays open during // the call, and no optional exception/user/callback pointer is supplied. unsafe { MiniDumpWriteDump( process, process_id, file_handle, dump_type, None, None, None, )?; } drop(file); println!("Wrote {}", output.display()); println!("The dump contains this lab process, not another process."); println!("Open it with: windbgx -z {}", output.display()); Ok(())}#[cfg(not(windows))]fn main() { eprintln!("This self-dump lab must run on Windows.");}
File owns a Windows file handle. AsRawHandle borrows the numeric handle so the Windows API can use it. The File must stay alive until MiniDumpWriteDump returns.
That is why drop(file) appears after the Windows call. Closing it first would leave file_handle as a stale number.
The first command builds the small dumper in the Windows VM. The second gives
it an output path; create_new(true) makes a repeat run fail safely rather
than replace the first capture. Open the resulting file in the next step.
Install WinDbg from Microsoft, then open the file from the UI or run:
Terminal window
windbgx -z .\gha-self-first.dmp
These first commands answer different questions:
!analyze -v summarize an exception if the dump contains one
lm list loaded modules and their address ranges
~ list threads
~* k show a stack trace for every thread
r show registers for the selected thread
The self-dump is captured during a normal function call, so it does not contain a crash exception. That is useful: you can learn module, thread, register, and stack navigation without deliberately causing undefined behavior.
A stack trace is a chain of function frames. The top frame is where the selected thread was when captured. Frames below it are callers that led there.
topinside MiniDumpWriteDump
self_minidump::main
Rust runtime start-up
C runtime start-up
bottomWindows thread start-up in kernel32 and ntdll
where the thread was at capture
callers, each waiting for the frame above it to return
A simplified k listing for the thread that wrote the self-dump, newest call on top. Read from the bottom up, it replays the calls in the order they happened: Windows starts the thread, the C and Rust runtimes start up, main runs, and main calls MiniDumpWriteDump. A real listing splits each box into several frames.
Names may be missing when matching symbols are unavailable. In that case, WinDbg may show a module plus an offset:
self_minidump+0x1234
The offset is still useful when paired with the exact build hash and module base: it names the byte 0x1234 past the start of self_minidump.exe as loaded, and in one exact build that is one specific instruction.
Do not corrupt a running game’s memory just to obtain a dump. Instead, make a tiny separate test program that crashes on purpose, enable Windows Error Reporting local dumps for that one executable in a disposable VM, and inspect the result.
Not every Rust failure is a crash that Windows Error Reporting can see. Returning an error from main prints it and exits with code 1. A panic under Rust’s default unwinding strategy is caught by the runtime, which prints the message and exits with code 101. Both are ordinary exits, so there is nothing to dump. A panic in a program built with panic = "abort" is different: it ends in a fail-fast exception, code 0xC0000409, which Windows Error Reporting does capture, and !analyze then reports that exception rather than an access violation.
A small dump may omit heap data needed to inspect a particular object.
A dump records one instant, not the earlier event timeline.
Unresolved symbols make stacks harder to name.
Optimized code may inline functions or keep values only in registers.
A module path and timestamp help, but a trusted hash is stronger build identity.
Start small. Capture more data only when a specific debugging question requires it, and keep diagnostic files with the exact build record and test logs they describe.
Chapter 12 uses that same build and module evidence to ask which DLL actually
loaded, which API contract was resolved, and where a process-level view ends.
It then follows requests into device drivers and distinguishes process virtual
addresses from the physical-memory mappings underneath them.
A snapshot captures a calculation at one moment. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
memory = 75; register = 80
choose the capture moment
if after store: memory = register
copy selected memory and register state
save this captured state
explain what this snapshot cannot establish
Result: dump memory=75, register=80. A memory-only view would omit the temporary result; this snapshot alone cannot reconstruct the preceding event history.
Final values: moment = before; live_memory = 75; live_register = 80; saved_memory = 75; saved_register = 80; saved = true.
Read every step of the lesson’s example
Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.
moment = before.
Step 2, line 1. The calculation’s temporary result has not yet been stored.
moment = before; live_memory = 75; live_register = 80.
Step 3, line 2. Capture at the selected boundary.
moment = before; live_memory = 75; live_register = 80.
Step 4, line 3. The store has not executed when capture begins.
moment = before; live_memory = 75; live_register = 80.
Step 5, line 4. Copy the selected state into the diagnostic file.
Step 7, line 6. Result: dump memory=75, register=80. A memory-only view would omit the temporary result; this snapshot alone cannot reconstruct the preceding event history.
Page-table translation can produce an address and still answer the wrong question. You may have the wrong CR3, a stale object pointer, a different build, or a capture taken between two related updates.
None of those mistakes makes the walk fail. A wrong CR3 is still a valid page-table root for some other address space, a stale pointer can still land on mapped memory, and another build’s object still has bytes where the old layout expects fields. A successful walk means only that the page tables in the file lead somewhere; each stage below checks, in a different way, that the somewhere is the object you meant:
A capture file is only bytes; nothing inside it says where it came from. The facts needed to interpret it travel beside it, in a provenance record that covers:
who created it and how it was captured;
source machine or virtual machine;
operating-system and game build;
CPU architecture and paging mode;
capture tool and settings;
UTC capture time;
physical address ranges included or omitted;
CR3 source and the process it belongs to;
SHA-256 digest of the unchanged file.
Keep the original read-only and run experiments on a working copy, so a mistake can never alter the only copy. If the original’s digest stops matching the record, stop: every translation made from the file assumed the recorded bytes, so none of them can be trusted until the change is explained.
Three independent checks catch different mistakes:
the capture translator’s physical result;
a debugger or hypervisor translation for the same snapshot;
semantic structure checks on the resulting bytes.
The second translator catches a bug in the first. The structure checks catch the case both translators share: a correct translation of the wrong thing.
For an object model, semantic checks might include:
vptr points into the expected module’s read-only section;
several vtable slots point into executable sections;
health falls within the game’s valid range;
position components are finite floating-point values;
name length and termination are bounded;
container begin/end/capacity relationships make sense;
handles resolve with matching generations.
Here is how several of those checks land on one small object, a toy 64-bit player layout:
+0x00vptr
+0x08health
+0x0Cx
+0x10y
+0x14z
+0x18begin
+0x20end
+0x28capacity
in module .rdata
0 to max
finite floats, no NaN or infinity
item list, begin ≤ end ≤ capacity
A 48-byte PlayerLayout: an 8-byte vtable pointer, a 4-byte health value, three 4-byte floats, and three 8-byte container pointers, so 8 + 4 + 3 × 4 + 3 × 8 = 48 = 3 × 16 = 0x30 bytes, and PlayerLayout::SIZE is 48. Each brace is a check on different bytes.
A single field can look right by accident: plenty of unrelated memory holds a small integer that could pass for health. Four checks on different bytes, each failing in its own way, rarely all pass at a wrong address. Try the four candidates below; only the intended player passes every check.
Four checks on one decoded playerEach candidate is 48 bytes decoded with the same PlayerLayout. A health of 0 to 100 and a vptr inside the game’s .rdata are expected.
Freed memory, filled with zeros
Decoded with PlayerLayout
Field (offset)
0x0vptr (+0x00)
0health (+0x08)
0, 0, 0x, y, z (+0x0C)
0x0 ≤ 0x0 ≤ 0x0begin, end, capacity (+0x18)
Checks on different bytes
Each check
fails: vptr in .rdata
passes: 0 to 100
passes: finite floats
passes: begin ≤ end ≤ capacity
Verdict
reject: 1 of 4 checks fail
3 of 4 checks pass by accident. One failing check is enough to reject the bytes.
A successful walk still needs the intended object. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
walk the saved page tables
check the recorded capture build
check the selected object identity
check field relationships
accept only when all evidence agrees
report translated versus validated
Result: all selected fixture checks agree; return a labelled snapshot.
The capture layer understands physical ranges and page tables. The layout layer understands fields and invariants. The presentation layer prints a PlayerSnapshot.
This separation keeps an object-layout mistake from corrupting translation code and makes each layer independently testable.
Modern systems use an IOMMU and operating-system policy to restrict DMA-capable devices. On supported Windows systems, Kernel DMA Protection can isolate peripherals and block unsafe access, especially around externally accessible buses.
For defensive validation:
check Windows Security or System Information for the reported protection state;
keep firmware, Windows, and device drivers updated;
use trusted devices and cables;
lock or shut down a machine before leaving it unattended;
follow organizational policy for external PCIe/Thunderbolt devices;
investigate unexpected DMA-capable hardware.
Do not disable Secure Boot, virtualization-based protections, an IOMMU, or Kernel DMA Protection to make an experiment easier. A blocked path is a successful defense, not a translation failure.
Our capture workflow makes several promises: the hash names the bytes being
read, CR3 names the intended address space, bounds protect every physical
read, and the decoded object passes its game-specific invariants. A successful
page-table walk alone guarantees none of the other promises. A bypass in
the wider sense is the same mismatch: a protection checked one signal while
the unwanted effect arrived by another path. The table transfers the capture
lesson to the controls studied in Chapter 13:
Defensive gap
What the control wrongly assumed
Safer lab test
Coverage gap
Every execution, input, and memory path passes the point it observes
Send labeled events through toy paths and compare logs
Identity gap
The name, signer, PID, or handle it trusted cannot change
Restart/rebuild the toy target and verify identity is re-evaluated
Time gap
Trusted state stays the same between the check and the use
Pause between validation and use, mutate a fixture, and expect rejection
Parser gap
Producer and consumer agree about lengths and encodings
Feed a corpus of bounded malformed fixtures into both parsers
Privilege gap
Requests reaching a higher-privilege component are already safe to accept
Exercise a fake broker with least-privilege test tokens
Telemetry gap
A blocked action needs no event of its own
Assert that every allow, deny, timeout, and parse failure has an event
Recovery gap
A crash cannot leave hooks, input, bytes, or handles in a bad state
Inject worker errors and verify reverse-order cleanup
An assumption becomes testable once it is stated as an invariant, a condition
that must hold every time. For example, an offline tool may promise: “a local
fixture write is accepted only when the build matches, the range is expected,
the old bytes match, and observation-only mode is off.” The sketch below is a
policy check over a toy LabState and WriteRequest, not a function that
writes to a process. It returns one named reason for each refusal:
Each ? is a separate exit, so each broken assumption produces its own named denial, and a test can target exactly one of them:
The byte comparison runs last, immediately before the toy tool would act. It
shortens a time gap; it does not make a remote read and write atomic. A tool
that needs that guarantee must arrange exclusive ownership or have the owner
of the state validate and apply the request as one operation.
The defensive lesson is to make assumptions explicit, require several
independent signals, keep permissions narrow, and fail closed with useful
telemetry. Chapter 13 applies the same checklist to game state, integrity
checks, hooks, and decision traces.
Those actions are unnecessary for learning page tables, memory models, object patterns, obfuscation, encryption, or forensic validation. The offline lab teaches the computer science directly and remains reproducible for every reader.
Here is the capture chapter’s sequence in one place. Each step either records
the identity needed by the next one or checks a claim made by an earlier step:
Choose an open-source game or toy program you can rebuild with symbols.
Reconstruct one class or component from the stripped build.
Locate its constructors, destructors, vtable, containers, and ownership patterns.
Create an offline snapshot or synthetic fixture.
Translate one virtual address with the capture reader.
Decode only bounded fields into a typed snapshot.
Validate the snapshot with several invariants.
Check the reconstruction against the debug symbols or source.
Turn every discovered assumption into a unit test.
Store or delete the capture according to its data-handling rules.
The capture path ends with one validated snapshot and a record of its limits.
Chapter 13 turns that evidence into a question about valid game state: whichever path
observed or changed the value, which state must remain valid and which
boundary actually enforces it?
Lesson 14.5
Game State and Invariants
A Lua script, a controller input, and a network message can all ask a game to
change health. Lesson 1.3 used the rule 0 <= health <= max_health to decide
whether a player record made sense. Now imagine one of those requests arrives
when health is 5 and asks for 20 damage. The game should produce health 0,
not -15, and its displayed health bar should eventually agree. This chapter
asks where that rule is enforced, which copy of health the game trusts, and
how to notice when one route around the rule still changes the game.
The earlier chapters established how to recover an object layout and keep a
live hook valid. Advanced game hacking now asks what the observed behavior
means: record the decision and effect, then inspect integrity and environment
checks and encoded values. The final control cases ask whether each check
protects the authoritative state it claims to protect.
Four pieces of evidence keep this question answerable: the value and the
address temporarily holding it, the exact game build, a timestamped sequence
of state changes, and whether the tool observed state or changed it. The
following model connects those pieces before looking for a control gap.
An invariant is a statement that must be true whenever the system is in a
valid state. Examples include:
ammunition never becomes negative;
an entity ID refers to at most one live entity in a snapshot;
a patch is applied only to the exact build and bytes it was made for;
a rendering hook is either fully installed or fully removed;
a read-only observer never changes the game state.
An invariant does not live at one address. The health bound
0 <= health <= max_health can be enforced by a clamp instruction, depend on a
max_health field, and be reported by a log line, all at the same time. Each of
those is a place where the rule is kept or reported, and a bug in one of them
leaves the rule itself unchanged: a state with health = 120 and
max_health = 100 is invalid whichever of the three failed to catch it.
Predict first
A damage routine guards health with max(new health, 0), and the same routine applies heals as positive changes. Health is 90 and max_health is 100.
A heal of 30 arrives. What health does the game store?
Answer: 120, which breaks the invariant. 90 + 30 = 120, and max(120, 0) = 120 because 120 is not below 0. The rule 0 ≤ health ≤ 100 is now false, and the health bar copies 120 as well.
Apply one health change, then keep 0 ≤ health ≤ max_healthThe game adds the change to health, then its control decides what is stored. Damage is a negative change.
Request, control, accepted state
The request, then the control
health + change
90health now
+ 30heal
= 120before the control
Rule applied
max(120, 0) = 120
Accepted state
Stored health (the deciding copy)
120
0 ≤ 120 ≤ 100: broken
↓ the display follows
Health bar
shows 120copies the accepted value
90 + 30 = 120. max(120, 0) only raises values below 0, and 120 is not below 0, so it passes unchanged. 120 breaks 0 ≤ health ≤ 100, and the health bar copies the broken value.
Values needed to describe the system at one moment
health = 48, mode = Alive
Transition
An event and rule that move state to another valid state
5 damage changes health from 48 to 43
Invariant
A property required of every valid state or transition
0 <= health <= max_health
A control keeps an invariant true, usually by refusing or limiting a transition
before it takes effect. A damage routine that stops health at 0 instead of
letting it go negative is a control. A detector reports that an invariant
may already have failed, such as a scan that logs any entity whose health
exceeds its maximum. The two are separate pieces of code, so they fail
separately: a detector can be noisy while the control still works, or quiet
while the control is incomplete.
A bypass is a path that reaches an outcome without satisfying the
control, the code that was supposed to guard it. In a game, the mismatch is
often between a convenient proxy and the real rule:
Proxy that gets checked
Property that actually matters
Menu button is enabled
Engine policy permits the state change
One field’s checksum matches
The complete object relation is current and valid
One environment signal is absent
The requested command satisfies game rules
An old object still has a plausible address
The identity and generation still match
The analysis pattern is always the same: state the promised rule, trace the
branch that claims to enforce it, follow every path to the effect, and look for
a path where the proxy and the real property disagree. Later lessons apply that
pattern to integrity checks, anti-debug behavior, encoded values, and requests
that cross game or system boundaries. The hook and layout contracts remain
the foundation that makes those observations trustworthy.
A game keeps several copies of the same fact. The simulation’s entity object
holds health, the health bar keeps its own copy for drawing, a script receives
one in a snapshot, and in a networked game the server keeps a record while each
client keeps a prediction of it. Exactly one copy is authoritative: the one
whose value decides what happens next. In a single-player game that is usually
the simulation object. In a multiplayer game with an authoritative server it is
the server’s record, and each client’s copy is a prediction that the server can
overwrite.
A control protects only the copy it runs against. Code that validates the
health bar’s copy and then applies damage to the simulation object has checked
one copy and changed another; Lesson 14.7 calls this the wrong source of truth.
Server authority is the same rule at network scale: the server validates
every client request against its own record, because anything a client
computed can be altered on the client’s machine before it is sent.
The copies hold the same number most of the time, so reading a matching value
does not say which copy was read. Timing does. The authoritative copy changes
first and decides the next transition; every other copy catches up afterward,
on its own schedule.
Suppose an entity can be alive, downed, or removed. A state diagram lists every
allowed transition:
The diagram raises a question that one memory sample cannot answer: does the
stored health field change before or after the Alive -> Downed transition?
A sample shows one moment. A trace of timestamped events shows the order.
A trace is a list of events in the order they were logged, each saying what
changed, when, and which observer saw it. Typed fields let a program check the
trace:
#[derive(Debug,Clone,Copy,PartialEq,Eq)]
enumMode {
Alive,
Downed,
Removed,
}
#[derive(Debug,Clone,PartialEq,Eq)]
structTraceEvent {
sequence: u64,
tick: u64,
source: &'staticstr,
entity_id: u32,
event: Event,
}
#[derive(Debug,Clone,PartialEq,Eq)]
enumEvent {
HealthRead { value: i32 },
HealthWrite { before: i32, after: i32 },
ModeChanged { before: Mode, after: Mode },
}
sequence gives log order. tick connects the event to the game clock.
source names the observer that produced the event. Because an event carries
typed fields rather than a sentence, a test can ask directly whether a
HealthWrite ending at 0 comes before the ModeChanged to Downed for the
same entity. A short trace for entity 7:
Health reaches 0 at tick 900 and the mode changes in the same tick. The health
bar, which draws from a copy, shows 0 only at tick 901, one tick later.
State, transition, and display are different observations. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
entity.health = 6; state = Alive
apply damage without going below zero
if health is zero: enter Downed
choose whether the display has refreshed
copy health to the display when refreshed
report both the state and observed copy
Result: health 0, state Downed, display 0. Similar values do not make these observations the same event.
Final values: damage = 6; refresh = yes; health = 0; state = Downed; display = 0.
Read every step of the lesson’s example
Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.
damage = 6; refresh = yes.
Step 2, line 2. Damage changes the stored health.
damage = 6; refresh = yes; health = 0.
Step 3, line 3. The transition tests the new health, rather than the old display.
damage = 6; refresh = yes; health = 0; state = Downed.
Step 4, line 4. A display can still contain an earlier value.
damage = 6; refresh = yes; health = 0; state = Downed; display = 6.
Step 5, line 5. Refresh copies the current health.
damage = 6; refresh = yes; health = 0; state = Downed; display = 0.
Step 6, line 6. Result: health 0, state Downed, display 0. Similar values do not make these observations the same event.
damage = 6; refresh = yes; health = 0; state = Downed; display = 0.
Watch it happenA changing entity and the later observation of its display copy
Entity 7 health changes from 6 to 0 in event 41 at tick 900. Event 42 changes Alive to Downed in the same tick. Event 43 observes the health-bar copy at 0 at tick 901. The initial display-copy value is left unspecified.
Read the steps
Entity 7 has recorded before-values health 6 and mode Alive. The health-bar copy has not yet been observed in this trace.
Sequence 41 at tick 900 records health changing from 6 to 0. The health value and its six filled units become zero.
Sequence 42 records Alive changing to Downed in the same tick. This is a separate typed event for the same entity.
At tick 901, sequence 43 observes health 0 in the drawing copy. The display observation follows the simulation observations by one tick.
The journal keeps entity identity, event type, sequence and tick. It shows the display delay. Log order alone does not prove that one event caused the next.
The state changes and the journal follow the exact three-event trace. Sequence records order; causation needs additional evidence.
Log order is not cause. Event 41 appearing before event 42 fits the damage
causing the transition, but it also fits both events sharing one cause, or two
observers that flushed their output at different times. Lesson 14.6 adds
correlation IDs, which tie together the events of one action even when log
order misleads.
Health is an easy starting example because its valid range is visible. The
same questions transfer to a rendering hook, though the fields are different:
Question
Health update
Hook installation
What is the state?
Health 48, maximum 100
Original bytes, trampoline, and callback ownership
What transition is requested?
Apply 5 damage
Install a detour
What must stay true?
Health remains between 0 and 100
A call sees a complete original path or a complete hook path
Where is the control?
The damage routine before changing simulation health
Version check, instruction preparation, and coordinated publication before calls enter the detour
What can a detector report?
A snapshot finds health outside the bound
A byte or lifecycle check finds an incomplete installation
The analogy has a limit: health is one value, while a hook changes executable
bytes that other threads may be using. Lesson 8.10 handles that concurrency.
The transferable method is to name the state, permitted transition, invariant,
control, and observable failure before choosing an implementation.
Invariant: a property required of every valid state or transition.
Control: logic intended to preserve an invariant.
Detector: logic that reports a possible invariant failure.
Authoritative state: the one copy of a value whose contents decide what
happens next.
Server authority: a design in which the server’s record is authoritative
and every client request is validated against it.
Trace: events listed in the order they were logged, each with its time
and source.
The useful model is state changing through transitions under an invariant. A
control must guard the authoritative copy when a transition takes effect; a
detector can report a violation but does not enforce that rule. Lesson 14.7
uses this distinction to examine what an integrity check actually covers.
Lesson 14.6
Telemetry as Evidence of Game Behavior
An invariant names what a game must keep true. To know whether a control
actually enforced it, we need evidence of the input, decision, and effect in
the same run. A useful trace follows that path without recording more state
than the question requires.
Suppose the game rejects an action just before a health value changes. Those
two events are close in time, but proximity does not prove the rejection caused
the change. Structured events, a monotonic clock, thread IDs, and a bounded
queue let us reconstruct the state-machine path without confusing correlation
with causation.
Most logs turn out to be useless at exactly the moment you need them. They
record that something happened without recording why it was allowed or refused,
so you end up staring at a line that says write failed and guessing.
Useful telemetry lets you follow one game action the whole way through: what
came in, what the code decided, why it decided that, and what actually changed
as a result.
The rule to design against:
Every guarded operation leaves behind enough evidence to reconstruct four
things — which target it touched, what it decided, why, and what happened —
without dumping secrets or unbounded blobs into the log.
Those four are worth naming separately, because a log missing any one of them
cannot answer the question you will eventually put to it:
What it records
The question it answers later
identity
which process, build, and object was this?
decision
was it allowed or denied?
reason
which specific check produced that answer?
outcome
did the effect actually happen?
Drop the reason and you can see that something was refused but never why. Drop
the outcome and you cannot tell an allowed operation from a completed one.
Design events around the decision pipeline:
The same correlation ID connects the stages without pretending they happened
on one thread.
That field earns its place the moment two actions overlap, which on a render
thread is most of the time. Here are four events from one run, in the order
they reached the log:
Read that stream the obvious way — pair each effect with the decision just
before it — and everything looks correct. The effect at sequence 43 sits right
after an allowed decision, so nothing stands out, and the log appears to show
two successful operations.
Now pair by correlation ID instead. The effect at 43 belongs to action 7, and
action 7 was denied. Something changed state after a refusal, which is the
exact contradiction this lesson exists to catch, and the naive reading walked
straight past it.
Matching correlation IDs across log rowsFour rows, two correlation IDs. Following one ID through the log shows what happened to that request.
1Four rows in the order they were logged
41
denied
corr 7
42
allowed
corr 8
43
state changed
corr 7
gen 13
44
state changed
corr 8
gen 14
Each row has a sequence number, an event, a correlation ID and sometimes a generation.
2Correlation 7 changed state after being denied
41
denied
corr 7
43
state changed
corr 7
gen 13
Rows 41 and 43 share ID 7, so they are one request: denied, yet changed at generation 13. Correlation 8 shows the expected order, allowed then changed.
All 2 steps shown
Four rows, two correlation IDs. Following one ID through the log shows what happened to that request.
Nothing was missing from the log. The evidence was complete, and ordering alone
was still enough to reach the wrong conclusion, because two actions interleaved
and the reader assumed they had not. This is why the join in a detection rule
is written on the correlation ID rather than on adjacency.
The diagram becomes two Rust types below. GameEvent stores the fields common
to every observation; EventKind carries the data specific to a snapshot,
decision, effect, or dropped-event report. A consumer can order events by
sequence and join one request’s path by correlation_id.
Optional: practise the named operations and their inputs with a few whole-token blanks.
Use a monotonic clock for duration and ordering inside one run. Wall-clock time
is useful for humans but can jump. sequence detects missing or reordered
events. A schema version allows the reader to reject fields it cannot
interpret.
A render hook cannot block forever to preserve logs. Use a bounded queue and
choose a policy:
drop newest and increment a counter;
drop oldest and retain the recent window;
sample low-priority events while preserving decisions and failures;
write a compact overflow marker once per interval.
Never hide loss. An analysis interval containing dropped decision events has
lower evidentiary confidence.
A render hook cannot block forever to preserve logs, so use a bounded queue with a deliberate overflow policy, and never hide how many events were dropped. The interactive version needs JavaScript; the lesson text around it explains the same idea.
Save the smallest event fixture that demonstrates the failure:
SnapshotBuilt generation=12
Decision allowed=false reason=ValueOutOfRange
EffectFinished changed=true generation=13
The repair test passes only when the effect remains unchanged after the denied
decision. Keep a separate test confirming that a valid command still changes
state; a repair that disables everything is not correct.
Telemetry: structured observations emitted by a running system.
Correlation ID: an identifier connecting events from one logical action.
Reason code: a stable bounded explanation for a decision.
Normalization: replacing unstable details so trace structure can be
compared.
Detection rule: a precise query identifying evidence of an invariant
failure.
Regression trace: a saved minimal event sequence used to test a repair.
Versioned events and correlation IDs connect a decision to its eventual
effect. Contradictory pairs reveal a broken invariant; false-positive and
false-negative counts show whether the rule works in ordinary use. The
smallest failing trace becomes a regression test, while valid behavior must
continue to pass.
Lesson 14.7
Integrity Checks and Their Boundaries
Imagine a game displaying a green integrity indicator while an inventory item
has already changed. A hash proves something about the byte range and moment
it covered; a snapshot may be only a copy of the state the game actually uses.
Tracing the state transition and the check’s decision tells us whether the
indicator describes the authoritative value.
An integrity check answers a narrow question such as, “Do these bytes equal the
expected bytes?” It does not automatically prove that the whole process, file,
object, or later operation is unchanged.
Every integrity check has five boundaries, and a passing result means nothing
outside them:
Boundary
Question to answer
Bytes
Exactly which bytes are included?
Time
When are they measured, and how long is the result reused?
Identity
Which build, object, file, or module does the result describe?
Consumer
Which operation trusts the result?
Failure
What state is reached when verification cannot complete?
The target invariant for this lesson is:
A sensitive toy operation proceeds only when verification covers the same
object, the required fields, and the state used by that operation.
Follow the decision, not only the checksum function
The important path begins with canonical state and ends at the effect:
A result that is cached, copied to another object, or checked far from the
effect adds edges to this path. Each added edge is a place where the result can
stop describing the object, and the moment, it was computed for.
The checksum includes every field used to identify the record and interpret
health. The range invariant is checked separately because a matching checksum
does not make an impossible value meaningful.
For the record used in the examples below, player 7 with 80 of 100 health at
generation 3, the checksum works out like this:
rotate_left(7) moves every bit of the 32-bit word seven places toward the
high end, and any bit pushed off the top re-enters at the bottom. For a small
number nothing reaches the top. 100 is 1100100 in binary, seven bits wide
because it is less than 2⁷ = 128, so after moving seven places its highest bit
sits at position 6 + 7 = 13, far below bit 31. The rotation is then exactly a
multiplication by 2⁷. The same reasoning turns 3.rotate_left(13) into
3 × 2¹³. The total, 37,673, is far below 2³² (about 4.3 billion), so none of the
wrapping operations actually wraps.
player_id7
health80
max_health100
generation3
checksum37,673
inputs to covered_checksum
stored result
verify recomputes a checksum from the first four fields and compares it with the fifth. A field left out of the braced range could change without the comparison noticing.
A correct earlier decision can become stale. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
record = generation 17, health 80
verify and save checked_generation 17
optionally update record to generation 18
compare checked generation with current
consume the decision only when current
report whether the effect was permitted
Result: the current decision permits the fixture effect.
A check covers health but ignores max_health or player_id. The bytes can
be internally consistent while describing the wrong entity or an impossible
range.
Watch it happen to the record above. Suppose covered_checksum had been written
without the max_health term — an easy omission, since the field is rarely the
one being updated. For player 7 that shorter sum is 217 + 80 + 24,576 = 24,873,
and 24,873 is what the record stores. Now change max_health from 100 to 60
and recompute nothing:
Predict first
The record stores the shorter checksum, 217 + 80 + 24,576 = 24,873. Then max_health drops from 100 to 60, and nothing is recomputed.
When verify recomputes the shorter checksum, does it still equal the stored 24,873?
Answer: Yes: it is still 217 + 80 + 24,576 = 24,873. None of the three terms reads max_health, so the recomputed sum equals the stored one. verify still refuses this record, but only because its second clause finds 80 > 60.
Change a field, then run verifyThe record was sealed as player 7, health 80, max_health 100, generation 3. verify recomputes the checksum from the record now and also tests health ≤ max_health.
Recompute, compare, decide
Recompute from the record now
Terms
217player_id × 31
+ 80health
not coveredmax_health 60
+ 24,576generation 3 × 8,192
↓ add the terms
Compare
24,873recomputed now
=
24,873stored when sealed
verify: both clauses, then the result
Clauses
checksum matches
80 ≤ 60: no
Result
verify refuses: OutOfRange
217 + 80 + 24,576 = 24,873, equal to the stored 24,873, because max_health is not in the sum. 80 > 60. verify refuses with OutOfRange.
The checksum is doing its job correctly. It is reporting that every byte it
covers is unchanged, and that is true. The record is still impossible, because
the damage was done to a field the check never looked at.
The complete covered_checksum catches the same edit. With max_health = 60
its third term becomes 60 × 128 = 7,680, so the sum becomes
217 + 80 + 7,680 + 24,576 = 32,553, which no longer equals the stored 37,673.
This is why verify has two clauses rather than one. The checksum answers “did
the covered bytes change?” and the range test answers “is this record
possible?”, and neither question implies the other. A record can pass one and
fail the other in both directions, so a check that asks only the first will
accept states the game’s own rules forbid.
The program verifies generation 17, the object changes to generation 18, and a
later operation trusts the old true. This is a time-of-check/time-of-use
problem.
The result was correct when it was computed. It went stale because nothing tied
it to the generation it described. A consumer that compares the result’s
generation with the record’s current generation refuses it at the last step.
This local function is easy to reason about because the check and effect share
one exclusive borrow. Across process or thread boundaries, use versioned
snapshots, expected-byte comparisons, or a command sent to the owner of the
state; a remote read followed by a remote write is not atomic.
Integrity: confidence that state still satisfies its required relation.
Coverage: the exact data and conditions examined by a check.
Canonical state: the state the game actually uses to decide an effect.
Freshness: whether a check result still describes the state being consumed.
TOCTOU: a gap in which state changes between checking and using it.
Postcondition: a property that must hold after an operation completes.
An integrity result describes only the bytes it covered, the identity and time
of that check, and the consumer that acts on it. A missing field or stale result
breaks the link to the guarded effect. The mutation test above exposes missing
coverage; the repair puts verification where the current state becomes an
effect.
Lesson 14.8
Anti-Debug Signals and Responses
A pause at a breakpoint may change a timer reading without changing any game
rule. A debugger event is likewise evidence about the environment, not a
verdict about the player’s actions. We will use threads, timers, and a small
state machine to keep the signal, decision, and response separate.
An anti-debug routine is often described as a bag of checks. That view hides
the important part: what state does each observation cause, and which game
behavior changes afterward?
Model it as a protocol with inputs, memory, and outputs. For a deliberately
small local fixture:
This diagram exposes questions a single if debugger_present cannot answer:
Does one sample cause a permanent decision?
Is there a second observation before behavior changes?
Can a slow frame, breakpoint, virtual machine, or overloaded computer produce
the same signal?
Is the response visible in telemetry?
Does the game still enforce its real state invariants?
The target invariant is:
Environmental observations may annotate or pause the experiment, but game
state validity is enforced independently at the state-changing boundary.
These two Rust enums make the distinction visible in the type system. A
Signal stores one observation; an Assessment names what the detector can
reasonably conclude from it. Neither type grants permission to change game
state.
#[derive(Debug,Clone,Copy,PartialEq,Eq)]
enumSignal {
ObserverFlag(bool),
SampleDelayMicros(u64),
UnexpectedBreakpointEvent,
}
#[derive(Debug,Clone,Copy,PartialEq,Eq)]
enumAssessment {
Ordinary,
NeedsAnotherSample,
NeedsDiagnosis,
}
A signal is measured data. An assessment interprets one or more signals.
A response is an action such as recording a diagnostic event or refusing a
specific invalid command. Combining all three into one hidden boolean makes it
hard to discover false positives and impossible to explain the decision.
What one signal, a repeat and an explanation call forThe decisions follow the evidence. Explaining a signal never replaces validation at the state-changing boundary.
1One unusual fixture sample
Evidence, then next state
one unusual sampleseen
NeedsAnotherSamplenext state
A single odd sample is not enough to conclude anything.
2The same signal repeats
Evidence, then next state
a repeated signalseen
NeedsDiagnosisnext state
A repeat makes it worth finding out why.
3The signal is explained: back to Baseline
Evidence, then next state
an explained signalseen
Baselinenext state
still validate every change
Explaining a signal does not check what the game accepts. Validation still happens where state changes.
All 3 steps shown
The decisions follow the evidence. Explaining a signal never replaces validation at the state-changing boundary.
The state machine above is deliberately abstract, which risks leaving the
impression that an “observer signal” is one vague thing. It is not. A Windows
program can take the signal from several genuinely different sources, and they
differ in what they prove, which is why a signal and the assessment drawn from
it are kept apart.
Things the loader wrote down. When Windows starts a process for a debugger,
it records that fact inside the process itself. The Process Environment Block
carries a BeingDebugged byte at offset 0x02, and IsDebuggerPresent is
little more than a read of it. The same structure’s NtGlobalFlag field gains
heap-checking bits, and the process heap ends up with different Flags and
ForceFlags values than it otherwise would. Nothing here measured anything.
The loader simply noted what it was asked to do, and the program is reading the
note.
Bookkeeping the kernel keeps. A debugged process has a debug object
associated with it, and NtQueryInformationProcess will report on that
association when asked for ProcessDebugPort or ProcessDebugObjectHandle.
Again a record, held somewhere else.
Its own code.Lesson 2.4 showed that a software breakpoint replaces a byte
with 0xCC. A checksum over its own instructions notices that change.
Thread context. A hardware breakpoint changes no code bytes, so a checksum
misses it entirely. It lives in the CPU’s debug registers — DR0 through DR3
holding addresses, DR7 holding enable and condition bits — and those
registers are part of a thread’s saved context. A debugger can inspect a
suspended target thread with GetThreadContext. Windows explicitly says a
call on the current running thread can return success with an invalid context;
the return code alone is not evidence of its live debug-register values.
That pair is worth sitting with. Neither kind of breakpoint is invisible, and
neither is universally visible; they leave traces in different places. A
program checking only its bytes never sees a hardware breakpoint, and one
checking only its debug registers never sees a software breakpoint.
Who handles an exception first. A program can raise an exception on purpose
and see whether its own handler runs. If something else took it, something else
is attached.
The surrounding machine. Parent process, window titles, the module list.
Sort these by what they would establish and the picture changes. The first two
are records the operating system keeps, and a determined observer can change
what those records say. The middle two are the program noticing traces of your
tools inside its own address space and thread state. The last one is about the
machine, not about this process, and can be true with nothing attached at all.
None of them establishes that a game rule was broken. That is why environment
signals stay separate from validating game state, and why the invariant at the
top belongs at the effect boundary rather than anywhere in this list.
Suppose code measures how long a tiny operation takes. A large gap might come
from a breakpoint, but also from preemption, power management, virtualization,
page faults, logging, or a busy machine.
Numbers make the difficulty concrete. Time the same trivial operation ten
thousand times on an idle machine and you might record:
median 1.2 us
99th percentile 8.0 us
maximum 412.0 us <- one scheduler preemption, nothing unusual
Now try to pick a threshold. Set it at 10 microseconds and ordinary background
activity trips it several times a minute. Set it above 412 and you have written
a rule that almost nothing will ever trigger, including the condition you meant
to catch. The distribution of “ordinary but unlucky” overlaps the distribution
of “actually paused,” so no single number separates them cleanly.
Ordinary timings of one tiny operation overlap with the timings of a real pause, so a threshold of 10 microseconds is tripped by background activity and one above 412 microseconds almost never fires. The interactive version needs JavaScript; the lesson text around it explains the same idea.
Repetition gives stronger evidence than one sample, but does not reveal intent.
Twenty slow samples from the same code path could reflect a persistent load,
power policy, or an attached debugger. That is why the state machine above
asks for another sample and then a diagnosis rather than declaring a game-rule
violation from a timing number.
Use distributions rather than one magic threshold:
Measurement
Useful interpretation
Median
Typical sample duration
95th or 99th percentile
Ordinary tail latency
Maximum
One extreme event; inspect rather than generalize
Repeated outliers
Evidence that the environment changed
Game invariant failure
Evidence that game state became invalid
Establish a baseline on the same fixture, retain raw bounded samples, and
report the threshold and sample count. A threshold without its distribution is
not a reproducible result.
Given a measured delay and the recorded 99th-percentile limit from a clean
run, this function returns Ordinary at or below that limit and
NeedsAnotherSample above it. The event record keeps the input and reason so
a later test can reconstruct the decision.
The event tells a test what was measured and why it was classified. The next
sample can confirm or reject the first interpretation. No branch silently
changes unrelated game behavior.
backward from the branch to find the signals and stored history;
forward from the branch to find every response and shared sink;
across clean runs to estimate ordinary variation;
across one controlled change, such as the same fixture run with a
breakpoint set, to see which transitions differ;
through failures to determine whether the response is fail-open,
fail-closed, or merely diagnostic.
Name the response precisely. “The game detects a debugger” is weaker than
“after two samples above the recorded p99, the fixture enters Diagnosing and
emits reason timing_outlier; no simulation state changes.”
Assessment: an interpretation based on one or more signals.
Response: behavior chosen after an assessment.
Baseline: recorded ordinary behavior used for comparison.
False positive: an ordinary condition classified as suspicious.
False negative: the target condition occurs without the expected signal.
Fail-open / fail-closed: whether uncertainty permits or refuses an effect.
An anti-debug signal is a measurement, not a game-rule decision. The state
machine gives that signal an assessment and a response; timing distributions
and ordinary-tool tests expose false positives. Whatever response it chooses,
the actual game effect still needs its own state validation.
A game does not have to store health as the obvious integer 125. It might XOR the value with a key, rotate the bits, split it across fields, or store both a value and a check value.
This is obfuscation: it makes a value less obvious. If the running program can decode it, an analyst can usually trace that decoding logic too. Obfuscation is not the same as encryption, and neither one automatically makes a program fair or secure.
Predict first
Health is 125, but the game stores 125 XOR 90. A memory scan for 125 finds nothing.
Which number does memory hold?
Answer: 39. 125 is 0111 1101 and 90 is 0101 1010. XOR puts a 1 only where the two bits differ, giving 0010 0111 = 32 + 4 + 2 + 1 = 39.
Encode one byte with XOR, then decode itXOR compares the value and the key bit by bit: 1 where they differ, 0 where they match. The same key applied again flips the same bits back.
125 XOR 90 = 39
Value 125
bit 7bit 0
0
1
1
1
1
1
0
1
Key 90
0
1
0
1
1
0
1
0
↓ XOR: 1 where the bits differ
Stored: 39
0
0
1
0
0
1
1
1
↓ XOR with the same key
Decoded: 39 XOR 90 = 125
0
1
1
1
1
1
0
1
Memory holds 39, so a scan for 125 finds nothing. XOR with 90 again gives back 125.
We use the book’s own toy type. Do not use this lesson to defeat protection in a third-party competitive game.
Searching memory for the number 125 may fail because that representation never exists for long. A better question is:
Which instruction produces the value that the health bar displays or the damage calculation reads?
In a debug build, place a breakpoint near the rendering or gameplay function, then step backward through the data flow. A small decode might compile into operations like:
moveax, [rcx+28h] ; read encoded field
roreax, 7; undo a left rotation
xoreax, edx; remove the key
Follow edx too. A transform without its key is only half the formula.
That last property is the important one: decoding the encoded value must recover the starting value for many inputs, not only one lucky example.
Follow the loop’s 100 case with its key 0xA1B2_C3D4. The changing numbers
below are all 32-bit values; the final result must return to the starting one:
Undo representation changes in reverse order. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
value = selected_value
mixed = value XOR key
stored = rotate_left(mixed, rotation)
read the same stored bits
unmixed = rotate_right(stored, rotation)
decoded = unmixed XOR key
Result: decoded 100, matching the original 100. Reversibility does not establish integrity.
Watch it happenXOR then rotate, and the same steps undone in reverse
A row of 32 bits starts as the value 100. XOR with the key 0xA1B2C3D4 flips the bits above the key’s ones, giving 0xA1B2C3B0. Rotating left by seven carries the first seven bits round to the end, giving 0xD961D850. Rotating right by seven and XORing with the key again return the bits to 100.
Read the steps
The value 100 is 0x00000064 as 32 bits. The key, 0xA1B2C3D4, sits below it. encode will XOR with the key and then rotate; decode has to undo those two steps in the opposite order.
XOR with the key: wherever the key has a 1, the bit above it flips. 100 XOR 0xA1B2C3D4 is 0xA1B2C3B0.
Rotate left by seven: the first seven bits, 1010000, are carried round to the other end. The stored value is 0xD961D850, which looks nothing like 100.
To decode, undo the rotation first: rotate right by seven, and the same seven bits go back round. That recovers 0xA1B2C3B0.
Then XOR with the same key again. The same bits flip back, and the result is 0x00000064: the original 100.
Encoding applies XOR then rotation; decoding reverses the order. This reversible toy representation is not a security guarantee.
The lab also stores a toy tag calculated from the encoded value and key. When one bit changes, the tag no longer agrees:
The code makes the two exits explicit. It recomputes the tag from the stored
encoded value and the supplied key; a mismatch returns IntegrityError
without decoding. A match permits the inverse transformation and returns the
logical value in Ok.
This tag is educational, not cryptographically secure. An attacker who understands make_tag can calculate a matching tag. The useful lesson is structural: if a changed value is immediately rejected, look for another field or function that validates it.
Some programs change a key when a level loads, an object is created, or a value is written. Reading the same field at several moments shows which events change the key:
Moment
Logical value
Encoded bytes
Suspected key source
spawn
125
E9 88 …
constructor argument
damage
100
75 84 …
same object field
reload
125
different
new session state
Do not assume a value has “random encryption” merely because its bytes change. The cause may be an ordinary per-object key, a pointer-relative cookie, a checksum, or unrelated neighboring data.
The full implementation lives in advanced-memory-labs/src/obfuscation.rs, with a small executable in src/bin/obfuscation_lab.rs:
Terminal window
cargo run --manifest-path advanced-memory-labs/Cargo.toml --bin obfuscation_lab
cargo test --manifest-path advanced-memory-labs/Cargo.toml obfuscation
The demo prints a transformed value, decodes it, flips one bit, and shows the integrity failure. Its tests prove both the round trip and the failure case.
✅ XOR can be one reversible step; security depends on a complete, reviewed construction and key handling.
❌ “The value changed, so my offset is wrong.”
✅ The representation or key may have changed.
❌ “One successful decode proves the formula.”
✅ Test several values, sessions, and objects.
❌ “An integrity field is impossible to reproduce.”
✅ First determine whether it is a checksum, a keyed MAC (message authentication code, which needs a secret key), or an authenticated-encryption tag.
The next lesson returns to game text and its byte encoding. For the deeper
distinction between checksums, signatures, encryption, and authenticated
formats, continue to Chapter 9’s integrity and protection lessons.
Lesson 14.10
Obfuscated Values and Reversible Transforms
A value that looks random in a scan may be a familiar number after a few
reversible operations. Here we follow hexadecimal values through XOR, bit
rotation, and wrapping arithmetic, using breakpoints and an instruction trace
to check each step instead of guessing from one memory snapshot.
An encoded field often looks like noise in a memory viewer, because the bits
being stored are not the number the game displays. That on its own tells you
almost nothing about what is being done to it.
Five words get used as though they were interchangeable here, and they are not:
Word
What it is actually for
Needs a secret?
compression
making the bytes smaller
no
serialization
arranging values for storage or sending
no
obfuscation
making the bytes awkward to read
no
authentication
proving nobody altered the bytes
yes
encryption
hiding the bytes from anyone without the key
yes
Only the last two involve a secret. Mistaking obfuscation for encryption sends
you hunting for a key that was never there; mistaking encryption for
obfuscation wastes days trying to spot a pattern in output designed to have
none.
The rule your decoder has to satisfy:
Decoding an encoded value returns exactly the original value, and any
integrity check travelling with it is verified before the decoded value is
used for anything.
The second half is the part people skip. A value that decodes cleanly is not
the same as a value nobody tampered with — decoding will happily produce a
plausible-looking number from bytes somebody edited.
Trace both directions:
The write path often reveals the forward transform. The read path reveals its
inverse and the order of operations.
Obfuscation and encryption answer different questions
Read encode left to right. XOR combines the value and key; rotation moves the
bit positions. To invert it, reverse both the order and each operation:
rotate right by the same count;
XOR with the same key because x ^ k ^ k == x.
This “reverse order, invert each step” rule also helps when disassembly shows a
longer composition.
XOR, rotate, store: and back againChange the value, the key or the rotation and follow the bytes. Every step is reversible, so decoding recovers the value.
Encode, store and decode
Encode
Value 100 as a 32-bit number
00
00
00
64
↓ XOR each bit with the key
XOR with the key 0x5A3C96E1
5A
3C
96
85
↓ rotate left 7
Rotate left 7: the high 7 bits wrap to the low end
1E
4B
42
AD
↓ write the bytes lowest first
Stored in memory, lowest byte first (little-endian)
AD
42
4B
1E
Decode
↓ undo the rotation
Rotate right 7
5A
3C
96
85
↓ undo the XOR
XOR with the same key: back to 100
00
00
00
64
XOR gives 0x5A3C9685. Rotating left by 7 moves the high 7 bits (0x2D) to the low end, giving 0x1E4B42AD, stored as AD 42 4B 1E.
Take value = 100 and key = 0x5A3C96E1. The whole round trip, before each
arrow is worked out below:
In hexadecimal, 100 is 0x64, because 6 × 16 + 4 = 96 + 4 = 100; as a 32-bit
number it is 0x00000064. Each hexadecimal digit stands for exactly four bits:
6 is 4 + 2 = 0110, 4 is 0100, A (10) is 8 + 2 = 1010, and E (14)
is 8 + 4 + 2 = 1110. That lets both numbers be written out bit by bit.
Step 1, XOR with the key. XOR outputs 1 in each bit position where its two
inputs differ and 0 where they match:
value 0x00000064 0000 0000 0000 0000 0000 0000 0110 0100
The top six hexadecimal digits of value are zero, and XOR with 0 changes
nothing, so the key’s own digits 5A3C96 pass straight through. Only the last
byte mixes: 0110 0100 against 1110 0001 differ in the positions that give
1000 0101, which is 0x85.
Step 2, rotate left by 7. Every bit moves seven places toward the high end,
and the seven bits pushed off the top re-enter at the bottom. Here are the same
32 bits split after the seventh, before and after the move:
value ^ key 0101101 0001111001001011010000101
(top 7) (remaining 25)
rotated 0001111001001011010000101 0101101
(remaining 25) (top 7, now at the bottom)
regrouped 0001 1110 0100 1011 0100 0010 1010 1101
hex digits 1 E 4 B 4 2 A D = 0x1E4B42AD
x86 stores a 32-bit number lowest byte first, which is called little-endian, so
memory holds the four bytes in the reverse of the order they are written in
hexadecimal:
+064
+100
+200
+300
The plain value 100, 0x00000064, as it would sit in memory: lowest byte first.
+0AD
+142
+24B
+31E
What memory holds instead: encode(100, 0x5A3C96E1) = 0x1E4B42AD. No byte is 64, so a memory scan for the value 100 never matches this field.
Decoding runs the same steps backward. Rotating 0x1E4B42AD right by 7 carries
its bottom seven bits, 0101101, back to the top and restores 0x5A3C9685.
XOR with the key then cancels it: the top six digits are identical in both
numbers, and anything XORed with itself is 0, while the last byte gives
1000 0101 against 1110 0001 = 0110 0100 = 0x64 = 100.
Encoded words still become ordinary bytes. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
value = selected_value
mixed = value XOR key
stored = rotate_left(mixed, rotation)
read the same stored bits
unmixed = rotate_right(stored, rotation)
decoded = unmixed XOR key
Result: decoded 100, matching the original 100. Reversibility does not establish integrity.
Final values: value = 100; rotation = 7; key = 0x5A3C96E1; mixed = 0x5A3C9685; stored = 0x1E4B42AD; bytes = AD 42 4B 1E; unmixed = 0x5A3C9685; decoded = 100.
Read every step of the lesson’s example
Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.
value = 100; rotation = 7.
Step 2, line 2. XOR flips exactly the bit positions selected by the key.
Watch it happenThe same 32 bits: XOR, rotate, store in memory, and back
The value 100 as 32 bits is XORed with the key 0x5A3C96E1, giving 0x5A3C9685, then rotated left by seven to 0x1E4B42AD. In memory the four bytes appear lowest first as AD, 42, 4B, 1E. Decoding rotates right by seven and XORs with the key again, returning 100.
Read the steps
The plain value 100 is 0x00000064 as 32 bits. The key 0x5A3C96E1 is below it. The width, 32 bits, and the rotation, 7, stay the same all the way through.
XOR with the key flips every bit that sits above a 1 in the key. 0x00000064 XOR 0x5A3C96E1 is 0x5A3C9685.
Rotate left by seven: the top seven bits wrap round to the bottom. The result is 0x1E4B42AD. No bit is lost; every bit just changes position.
x86 stores the lowest byte first. Reading the 32 bits in bytes from the right, the memory holds AD, 42, 4B, 1E, in that order. A scan for the byte 64 never matches this field.
To decode, read the four bytes back as one little-endian number, 0x1E4B42AD, and undo the rotation first: rotate right by seven. The low seven bits return to the top, restoring 0x5A3C9685.
Then XOR with the same key: the key bits cancel, leaving 0x00000064, which is 100. Decoding reversed both the order of the steps and the direction of the rotation.
It shows a reversible representation; the separate integrity relation must still be checked before a decoded value is used.
Compare encodings of values that differ in one known way
Encoding a few chosen values with the same key exposes the transform’s parts
one at a time. With the key from the example:
Plain value
Encoded value
Stored bytes
100
0x1E4B42AD
AD 42 4B 1E
101
0x1E4B422D
2D 42 4B 1E
0
0x1E4B70AD
AD 70 4B 1E
The comparisons work because the key cancels. Rotation only moves bits, and XOR
works on each bit position separately, so rotating two numbers and then XORing
them gives the same result as XORing them first and rotating afterward. For any
two plain values a and b, the key drops out of encode(a) ^ encode(b):
100 against 101 exposes the rotation. 100 is 0110 0100 and 101 is
0110 0101: adding 1 to an even number flips only bit 0, so 100 ^ 101 = 1.
The two encodings must then differ by rotl(1, 7) = 2⁷ = 128 = 0x80, a
single bit at position 7. They do. 0xAD is 1010 1101 and 0x2D is
0010 1101, which differ only in their top bit, bit 7 of the lowest byte. A bit
that went in at position 0 came out at position 7, and that gives the rotation
count without knowing the key.
+080
+100
+200
+300
encode(100) ^ encode(101), byte by byte: AD ^ 2D = 80, and every other byte meets an identical byte and gives 00. The key has cancelled, leaving rotl(1, 7) = 0x00000080.
0 exposes the key.encode(0) is rotl(0 ^ key, 7) = rotl(key, 7), so
rotating that stored value right by 7 returns the key itself. The third row
also follows from the first without the key. encode(100) ^ encode(0) is
rotl(100, 7), and 100 is only seven bits wide, so no bit reaches the top and
the rotation is a plain multiplication: 100 × 128 = 12,800 = 0x3200 (check:
3 × 4,096 + 2 × 256 = 12,288 + 512 = 12,800). XORing 0x1E4B42AD with
0x00003200 changes only the byte 42: 0100 0010 against 0011 0010 gives
0111 0000 = 0x70, so the result is 0x1E4B70AD.
One pair on its own cannot pin a transform down, because many formulas turn 100
into 0x1E4B42AD. The pairs above narrow it because each one isolates a single
part. If the same plain value encodes differently after a restart, the key is
not a constant: it is generated per session or per object, and has to be
recovered for each one.
Useful clues in a read path include:
XOR, rotate, byte-swap, add, subtract, and multiply by odd constants;
a nearby key or generation field;
duplicate calculations before a comparison;
a branch that rejects the value;
conversion to float or clamping immediately after decoding.
Machine arithmetic is performed at a width. u32::wrapping_add is not the same
as arbitrary-precision arithmetic, and an 8-bit rotate is not a 32-bit rotate.
A decoder has to reproduce operand widths, truncations, sign extensions, and
byte order exactly. The worked example above comes out right only because the
rotation is a 32-bit rotation and the four bytes are read lowest first.
For multiplication modulo 2^32, only an odd multiplier has a multiplicative
inverse. That mathematical fact lets a reversible transform undo the multiply;
an even multiplier loses information.
A tag verifies a defined relation, not every field
A small record can store a toy tag beside the encoded value and verify it before
decoding. That can detect accidental changes in the example, but the relation
is still reproducible by anyone who knows the algorithm and key. Name the
claim accurately: it is a toy integrity relation, not cryptographic
authentication.
over edge values such as 0, 1, u32::MAX, values around a carry boundary,
and multiple keys. Mutation tests should flip each encoded bit and confirm that
the toy tag check refuses changed samples.
An encoded value is understood only when its read and write paths agree. The
inverse must undo operations in reverse order at the same machine widths, and
a changed plain input must produce the encoded bytes the model predicts. A
value that merely looks plausible is weaker evidence than that round trip.