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
3. Typed Memory, Ownership, and Object Layouts — print chapter
Game Hacking Academy
Chapter 3: Typed Memory, Ownership, and Object Layouts
Chapter 2 found game state and the code that changes it. Now we need to carry
that knowledge into a tool without mistaking a remote address for our own
memory. This chapter begins with safe type and ownership boundaries, then
derives how bytes represent numbers and pointers. An external reader turns
those representations into local values; objects, collections, encoded
fields, and text show what those values can mean inside a game. DLL call
contracts return later when our code enters the process itself.
Game tooling needs low-level control: exact integer sizes, raw bytes, operating-system APIs, and sometimes pointers. Most of the program can still use compile-time checked ownership and types.
Keep the two responsibilities separate:
Write safe code for the algorithm. Put unavoidable raw-pointer work in one small wrapper, document its assumptions, and return an ordinary owned type.
Local memory and game memory are different trust zones
The compiler can prove many facts about memory owned by the current program. A
borrowed slice has a known length and owner. A Vec<u8> keeps its allocation
alive. A reference cannot normally outlive the value it borrows.
It cannot prove equivalent facts about an address copied from another
process. The target can free an object, change page permissions, exit, or race
with our read. That is why cross-process code should not manufacture a normal
reference such as &Player from a remote address.
Instead, use a clear boundary:
Watch it happenCarrying remote bytes across a checked boundary
A tool asks Windows to read four bytes at 0x2000 in the game. Windows checks the range, copies the bytes 00 50 00 00 across into a local buffer owned by the tool, leaving the game’s memory unchanged, and the tool parses the local copy as 0x5000.
Read the steps
Our tool names a range in the game process: address 0x2000, length 4. That address means something only inside the game, so the tool cannot follow it like a pointer of its own.
The tool asks Windows to copy the bytes. The read is checked, and it may fail or return fewer bytes than asked for.
Windows reads the four bytes from the game’s memory and copies them across the boundary. The game’s own bytes are left where they are.
The copy now belongs to the tool. From here on, ordinary safe code can work on a bounded local buffer, with no remote address in sight.
Parsing and searching use only that local copy. 00 50 00 00 read as a little-endian number is 0x5000.
A remote address is never treated as a local pointer; Windows copies bytes before safe parsing begins.
After the copy succeeds, ordinary ownership rules protect the local buffer. Before
the copy, the address is untrusted input that needs checked arithmetic, region
validation, and a useful error.
Use usize for address calculations, not for game values just because it is convenient.
The target process does not store the words u32 or f32 beside each value.
For example, 00 00 20 41 reads as 1,092,616,192 with u32 and 10.0 with
f32; the type tells the reader which interpretation to use. We need the widths now
to read memory safely. Lesson 3.6 derives the bit patterns
behind those interpretations before we build the external reader.
usize is large enough to index memory in the current build. It is 32 bits in
an i686-pc-windows-msvc tool and 64 bits in an x86-64 tool. A game’s u32
health value stays four bytes on both. Address width and value width answer
different questions.
// damage(Health(50), PlayerId(3)); // rejected: the arguments are swapped
These are newtypes. The wrapper tells the compiler that equal byte
representations do not mean interchangeable values, so a remote address, a
byte offset, and a game value cannot be swapped just because all three are
integers.
Lesson 3.4 gives offsets and addresses their own newtypes
and derives the traits they need.
An owned Rust value can be lent for reading with &T or for editing with
&mut T. The Rust Primer introduced those forms and
Option for a value that may be absent. Here a search returns
Option<usize>: a found byte offset (Some(offset)) or no match (None).
&[u8] is a borrowed slice: a view into bytes owned somewhere else. The function can read them but cannot keep or change them.
A slice is two pieces of information used together: where the first element is
and how many elements are available. Indexing and windows use the length to
avoid walking beyond the allocation. A raw pointer by itself carries no such
length.
Assigning an owned String to a new owner makes the old name unusable;
the Primer’s interactive view
shows that transfer. The remote reader here borrows a slice instead of
moving its whole byte buffer into every search function.
Borrowing is different from moving. A borrow gives code temporary permission
to use a value without taking responsibility for freeing it. Shared borrows
(&T) allow reading. A mutable borrow (&mut T) allows changing, but it must be
the only usable borrow of that value for its lifetime.
This interactive ownership model needs JavaScript. The surrounding lesson still explains every step in text.
This is the borrow checker doing memory-safety bookkeeping before the program
runs. Vec::push may need to move the vector’s heap allocation to a larger
space. Keeping view alive across that operation could leave a dangling
reference, so Rust rejects the overlap.
The shared borrow can end before mutation when its final use comes first:
The ? means “if this step fails, return None now.” No out-of-bounds read and no panic are needed.
from_le_bytes names the byte order explicitly. transmute or an unchecked
pointer cast would silently depend on layout, alignment, size, and host byte
order. Low-level code is easier to review when every conversion states its
assumption.
A three-byte buffer stops at the first ? and gives None, which is
different from a decoded Some(0).
Lesson 3.3 follows each step of this checked decode. The next
boundary needs an actual error reason, so it uses Result.
For a one-purpose tool such as memory_scanner.exe, the anyhow crate is useful
because each failed Windows call can say which process, address, or operation
failed. Code reused by several lessons—such as a packet, PE (Portable Executable, the
header-and-sections layout Windows uses for .exe and .dll files), or byte-pattern
parser—benefits from a typed error enum. The caller can then handle truncated
bytes differently from an unsupported size or invalid text. Those errors should
also explain themselves when printed. Debug shows the exact failure variant
while you debug a parser. Display gives the learner a useful sentence such as
“the input ended too soon.”
Lesson 3.5 implements both traits by hand for a parser error, the code that
thiserror would otherwise derive.
An error value should report what failed; the outermost tool should decide what
to do about it. A memory reader should not silently retry forever, and a parser
should not terminate the whole application.
low-level causeReadProcessMemory returned access denied
operation contextwhile reading the local-player pointer
The scanner and the game-specific logic should not each repeat Windows
handle checks, byte counts, and error conversion. An abstraction gives
that work one named interface. This sketch describes the operation the
rest of a tool needs, without claiming that the method alone makes an
address safe:
trait states a behavior another type can implement. &self lets
the method use an existing reader, address is the location to request,
and Result carries either the decoded u32 or an error. The
implementation still must verify the target process, readable region,
requested length, and actual bytes returned. The scanner can then ask
for a value through read_u32 while one implementation owns the
operating-system boundary. Programming Fundamentals called this an
abstraction: a smaller interface hides the details of a larger operation.
The reader trait now gives that idea a concrete shape.
Predict first
Windows maps memory in 4096-byte pages, so offsets inside a page run from 0 to 4095. A read_u32 call has a handle with the read right, and its address is offset 4094 of a readable page. The next page is not committed: nothing backs it yet.
What should read_u32 return?
Answer: An error: only 2 of the 4 bytes can be copied. The page ends after offset 4095, so 4096 − 4094 = 2 bytes lie inside it. The other 2 would come from the uncommitted page, so the copy is short and there is no complete u32 to decode.
What a 4-byte remote read has to passread_u32 asks Windows to copy 4 bytes out of the game. In this example the page after the one being read is not committed.
4 bytes at 0x0A3C0100
Requested bytes
page offset 256
+0this page
+1this page
+2this page
+3this page
Rights, page, bytes copied
1. VM_READ: yes
2. readable
3. 4 of 4
read_u32 returns
Ok(u32) from 4 copied bytes
All 4 bytes, 0x0A3C0100 to 0x0A3C0103, lie in the readable page, so Windows copies all of them.
Avoid pub use windows_impl::* in the shared lab crate. A wildcard could expose
an unfinished hook or internal address helper merely because somebody marked it
pub inside that module. It can also make the same type appear under several
paths. Export only the tools the lessons are meant to use:
modwindows_impl;
// ✅ Course tools get a short, reviewable list of supported imports.
The Windows lab keeps its implementation module private, so a memory scanner
imports gha_windows_labs::Process and a reversible hook imports
gha_windows_labs::PatchPlan. #[doc(inline)] puts the ownership and error
notes at those exact paths. A learner should not have to search several modules
to discover which process wrapper is the supported one.
A raw pointer is written *const T for reading or *mut T for writing. Rust does not automatically know whether it is:
non-null;
aligned;
inside readable memory;
pointing to a real T;
still valid.
Dereferencing one therefore requires unsafe:
/// # Safety
/// `pointer` must be non-null, aligned, readable, and point to an initialized `u32`.
unsafefnread_raw_u32(pointer: *constu32) -> u32 {
// 🛡️ SAFETY: the caller promises all requirements listed above.
unsafe { pointer.read() }
}
This function reads a pointer in the tool’s own address space only when its
caller can uphold every safety condition in the comment. A numeric address
from the game process is different: the external tool must ask Windows to
copy those bytes rather than casting the remote address to *const u32 and
dereferencing it locally.
Raw pointers have an offset
method, but its number is an element count, not a byte count. If pointer
has type *const u32, one element is four bytes:
+0x0010
+0x0420
+0x0830
+0x0C40
first, at offset 0
first.offset(2): two elements along, 8 bytes → 30
Each box is one u32, 4 bytes. .offset() counts in elements of the pointer's type, so offset(2) moves two boxes, not two bytes, landing on 30.
letvalues = [10_u32, 20, 30, 40];
letfirst: *constu32 = values.as_ptr();
// 🧭 `offset(2)` means "move by two u32 values," so this advances 8 bytes.
letthird = unsafe { first.offset(2) };
// 🛡️ SAFETY: `third` is aligned, initialized, and still inside `values`.
assert_eq!(unsafe { third.read() }, 30);
The signed isize argument is what makes .offset() different from .add().
first.add(2) can move forward by two elements; third.offset(-1) can move
backward by one element. When the number you recovered is a byte offset,
use a byte pointer or .byte_offset() so the unit is obvious:
Calling .offset() is already unsafe, even before dereferencing the result.
Rust requires the start pointer, the result, and the range between them to stay
inside the same allocation (or exactly one element past its end). The byte
calculation must also fit in an isize. An address that merely looks plausible
does not satisfy those promises.
This distinction matters in a game-memory tool:
This original example tries to load health by casting a remote address to a local Rust pointer. Do not execute it: the address belongs to another process, so the required local allocation and pointer-validity guarantees cannot be established.
Before: dereference an address from another process
Keep remote addresses as integers, use checked addition for the field offset, then let the process-reading wrapper copy the bytes into a local buffer. It returns a normal error if the remote read fails.
// SAFETY: cannot be established for a remote address; this is the bug.
let health = unsafe { *((remote_base as *const u8).offset(0x30).cast::<u32>()) };
let health_address = remote_base.checked_add(0x30)
.ok_or(ReadError::AddressOverflow)?;
let health = process.read_u32(health_address)?;
Ok(health)
}
Takeaway: remote memory requires a process-aware copy; a cast does not move bytes across address spaces.
❌ The first version pretends memory owned by another process is part of this
tool process’s allocation. It is not. ✅ The second version treats the remote
location as a checked integer address and asks Windows to copy the bytes into a
local buffer. Use pointer .offset() only when you can prove the allocation
and bounds; use checked address arithmetic for remote memory.
Notice that Rust 2024 still requires an inner unsafe block inside an unsafe fn. The dangerous operation remains easy to find in review.
Those pointer checks and ownership guarantees become easier to inspect in a small executable. Create a
separate Cargo project for the external-memory examples; cargo new makes
its source file and manifest, and cd selects that project for later builds.
Terminal window
cargo new memory_lab
cd memory_lab
Add the libraries:
[dependencies]
anyhow = "1"
[dependencies.windows]
version = "0.62"
features = [
"Win32_Foundation",
"Win32_System_Diagnostics_Debug",
"Win32_System_Threading",
]
The windows crate generates typed bindings from Microsoft’s Windows metadata. We enable only the API groups we use.
Customize the editor enough that reading and navigating feel comfortable, then
practice the few actions that shorten almost every debugging session:
go to a definition;
find every use of a name;
rename a symbol safely;
format the current file;
run the nearest test;
open the integrated terminal and debugger.
Gamers already learn key bindings and tune an environment for feedback. Apply
the same skill here. A readable font, visible errors, and a reliable test key
matter more than endlessly changing decorations. ⌨️
Run this small quality loop from PowerShell:
Terminal window
# ✅ Format first so later output focuses on behavior, not spacing.
cargo fmt --check
# 🧭 Ask the compiler for type, ownership, and name errors quickly.
cargo check
# 🧪 Run behavior checks after the program type-checks.
cargo test
# 💡 Ask Clippy for common mistakes and clearer alternatives.
# 📚 Build documentation for this project and its dependencies.
cargo doc --open
cargo test runs
the project’s test targets. Clippy’s official usage guide
explains its lint levels. A lint is guidance, not an oracle: understand a
suggestion before accepting it, especially around raw pointers and Windows
FFI.
Fix a warning when you can. If a Windows boundary genuinely needs an exception,
keep it as narrow as possible and record the reason with #[expect(..., reason = "...")]. Prefer expect to a broad allow: the compiler can then tell you
when the expected warning no longer occurs and the exception has become stale.
Never disable a lint for an entire crate just to hide one unexplained cast.
Learn syntax by typing tiny examples, but focus on the tools that matter most
here: ownership, Result, enums, exact integer sizes, and small
documented unsafe boundaries. If you forget a spelling, look it up. Useful
programming is not a closed-book memory contest.
Crates can save months of work. Before adding one, check:
Does its API match the actual problem?
Does it support the toolchain and Windows versions you use?
Is the license compatible with the project?
Are its unsafe boundaries and maintenance history understandable?
Can you cover your use of it with a small test?
Autocomplete and AI can also save typing. For every non-trivial suggestion you
accept, be able to explain the types, ownership, side effects, failure path,
and test. “It compiled” proves syntax and types; it does not prove the call
targets the right process, preserves the right bytes, or cleans up a handle.
u32 is always four bytes; usize follows the tool’s pointer width. A
borrowed slice lends access to bytes without transferring ownership.
Option represents an absent value, while Result carries success or a
failure reason. Raw pointers cannot prove their own validity, so every
dereference needs a safety contract. Before building the external reader,
How Bytes Become Numbers and Pointers works through the
representations we will read. The reader then keeps the necessary unsafe
Windows calls at one boundary and hands ordinary owned, typed values to
the rest of the program.
Lesson 3.2
Rust: Missing Values, Failures, and States
The Rust Primer introduced Some, None, Ok, and Err.
Here we follow those shapes through several operations. An inventory slot
may be missing, a purchase may fail, and a dash may be cooling down. Each
case deserves a value that tells the caller what happened.
The examples use ordinary arrays and numbers in an invented game. The
traces simulate those examples in the page; changing a control never reads
or writes another program’s memory. Every trace starts with a worked run.
Typing and recall practice are optional.
checked_sub gives Some(8) for (12, 4), because the subtraction
fits in u32. It gives None for (2, 4), because an unsigned value
cannot hold the negative answer. For (4, 4), the answer is
Some(0), not None; zero is a valid remaining amount. This differs
from dash, which returned false and left the runner’s stamina unchanged
on a failed dash. The return type tells the caller which story
it will receive. In Rust, the last expression without a semicolon is
the returned value; here that expression is the checked_sub result.
Some zero is different from no answer. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
let remaining = stamina.checked_sub(cost);
match remaining {
Some(left) => println!("Left: {left}"),
None => println!("Not enough stamina"),
}
The result is Some(0); the caller can use the stored number.
Final values: stamina = 4; cost = 4; remaining = Some(0); left = 0; output = Left: 0.
Read every step of the lesson’s example
Step 1, line 1. Ask for 4 minus 4 without allowing an unsigned underflow.
stamina = 4; cost = 4.
Step 2, line 1. The answer fits, so checked_sub returns Some(0). Zero is a valid number.
stamina = 4; cost = 4; remaining = Some(0).
Step 3, line 2. match selects the arm for the Some shape.
stamina = 4; cost = 4; remaining = Some(0).
Step 4, line 3. The Some arm names the number left and prints 0.
The default run returns Some(0). Lower stamina below the cost and the result becomes None; it never manufactures a negative unsigned value.
Another common Rust type, Result, represents either a success (Ok)
or an error (Err) when the reason for failure matters. The
official Option documentation
has more examples when you need them.
An Option<u32> contains a number or contains no number. map applies a
function to the number inside Some, then puts the answer back in Some.
For None, it returns None without calling the function.
letprice = Some(8_u32);
letdoubled = price.map(|n| n * 2); // Some(16)
|n| n * 2 is a closure: a small function written where it is needed.
The bars name its input; the expression computes its output. A closure may
also borrow values from the surrounding scope, such as budget below.
Closures are functions with capture; they do not run merely because you
write them down.
The next calculation can itself have no answer: an unsigned subtraction
cannot represent a negative amount. and_then calls a function returning
another Option, and keeps a single layer of Some or None.
With slot 1 and budget 20, the sequence is Some(8) → Some(16) →
Some(4) → Ok(4). With slot 4, get returns None; both closures are
skipped. ok_or turns a missing answer into an error with a reason. This
example deliberately gives the same reason for a missing item and a high
price. Use distinct error variants when callers need to distinguish them.
Three slots through quoted_balanceBudget 20 and costs = [4, 8, 12]. Each step works on the result of the step before; once a slot’s value is None, the later closures are skipped.
1costs.get(slot).copied()
Some(8)slot 1
Some(12)slot 2
Noneslot 4: no item
Slot 4 is past the end of the three prices, so get returns None.
2map(|n| n * 2)
Some(16)slot 1
Some(24)slot 2
Noneslot 4: skipped
The closure runs only for Some: 8 × 2 = 16 and 12 × 2 = 24.
3and_then(|n| budget.checked_sub(n))
Some(4)slot 1
Noneslot 2: 20 − 24 fails
Noneslot 4: skipped
20 − 16 = 4 fits; 20 − 24 would be −4, so checked_sub gives None.
4ok_or("not affordable")
Ok(4)slot 1
Errslot 2: not affordable
Errslot 4: not affordable
None becomes Err with the one chosen reason, whatever caused it.
All 4 steps shown
Budget 20 and costs = [4, 8, 12]. Each step works on the result of the step before; once a slot’s value is None, the later closures are skipped.
Transform a value, then ask a fallible question. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
let costs = [4_u32, 8, 12];
let price = costs.get(slot).copied();
let doubled = price.map(|n| n * 2);
let left = doubled.and_then(|n| budget.checked_sub(n));
let result = left.ok_or("not affordable");
ok_or keeps the successful amount, including zero.
Final values: slot = 1; budget = 20; costs = [4, 8, 12]; price = Some(8); doubled = Some(16); left = Some(4); result = Ok(4).
Read every step of the lesson’s example
Step 1, line 1. The array owns three prices. A slot beyond its length has no value.
slot = 1; budget = 20; costs = [4, 8, 12].
Step 2, line 2. get lends the price; copied copies the u32 into Some(8).
Option answers whether a value exists; Result can explain a failure:
Question
Rust shape
Example
Might there be no value?
Option<T>: Some or None
No item at index 5.
Might an operation fail with a reason?
Result<T, E>: Ok or Err
Text is not a whole number.
T and E name the success-value and error types. Lesson 3.1
applies this distinction to a memory reader and explains where to handle errors.
Result::map changes an Ok value and leaves Err alone. map_err changes
an error and leaves Ok alone. and_then chains another operation that can
return an error. The success path keeps flowing; the first error passes
through without running later success closures.
.and_then(|n| u8::try_from(n).map_err(|_| "score too large"))
}
Widening to u16 before multiplication gives room for double any u8.
try_from checks whether that answer fits back into u8. Converting an
error with map_err is an explicit choice: here the exact numeric
conversion error is replaced with a short domain description.
Result map changes success; and_then can fail again. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
let reading: Result<u8, &str> = input;
let score = reading.map(|n| u16::from(n) * 2);
let small = score.and_then(|n| u8::try_from(n)
.map_err(|_| "score too large"));
The next operation succeeds, so and_then keeps one Result layer.
Final values: reading = Ok(100); score = Ok(200); small = Ok(200).
Read every step of the lesson’s example
Step 1, line 1. A successful reading carries a u8.
reading = Ok(100).
Step 2, line 2. map transforms 100 into 200 inside Ok. Widening first prevents u8 multiplication overflow.
reading = Ok(100); score = Ok(200).
Step 3, line 3. The next operation succeeds, so and_then keeps one Result layer.
reading = Ok(100); score = Ok(200); small = Ok(200).
? takes the value out of Ok so the next line can use it. On Err, it
returns from the enclosing function immediately, converting the error to
the function’s error type through From when necessary. It does not
retry, recover, or turn an error into a default value.
The default trace buys a 4-gold item with 12 gold. Invalid text stops before
subtraction; an excessive cost stops before Ok(left). Returning zero gold
after an exact-price purchase is still success.
A reason travels through Result and question mark. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
fn buy(text: &str, gold: u32) -> Result<u32, &'static str> {
let cost = text.parse::<u32>().map_err(|_| "bad cost")?;
let left = gold.checked_sub(cost).ok_or("not enough gold")?;
Ok(left)
}
The caller receives success and can choose what to do with the remaining amount.
Final values: text = 4; gold = 12; cost = 4; left = 8; result = Ok(8).
Read every step of the lesson’s example
Step 1, line 1. The function promises either remaining gold or an error reason.
text = 4; gold = 12.
Step 2, line 2. Parsing gives Ok(4). map_err skips its closure; ? supplies 4 to cost.
text = 4; gold = 12; cost = 4.
Step 3, line 3. Subtraction fits. ok_or makes Ok and ? takes out its value.
text = 4; gold = 12; cost = 4; left = 8.
Step 4, line 4. The caller receives success and can choose what to do with the remaining amount.
text = 4; gold = 12; cost = 4; left = 8; result = Ok(8).
The previous purchase used short domain reasons. This parser instead
propagates the standard library’s parse error unchanged, then shows both
inputs through a complete caller:
For "4", parse produces Ok(4), ? gives 4 to cost, and the
function returns Ok(4). For "tired", parse produces Err, so
the function stops before Ok(cost) and passes that error to its caller.
The caller’s match still decides what to do with it. Read ? as
“continue with the value, or return the error now.”
The angle brackets in Result<u32, ParseIntError> supply concrete types
to a reusable type: success holds a u32, error holds a parse error.
Option<u32>, Vec<u32>, and HashMap<&str, u32> use the same
generic pattern with different values inside.
Separate ready: bool and ticks: u32 fields permit contradictory
combinations such as “ready with five cooldown ticks.” An enum with
data puts the alternatives into one value:
Cooling carries a count. Ready carries none. tick takes ownership of
the old state and returns the next one. The guard if ticks > 1 adds a
condition to the first matching pattern; the following arm handles the
remaining cooldown counts. .. means those fields are not needed here.
Only the current state carries its data. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
enum Dash { Ready, Cooling { ticks: u32 } }
let state = Dash::Cooling { ticks };
let next = match state {
Dash::Cooling { ticks } if ticks > 1 => Dash::Cooling { ticks: ticks - 1 },
Dash::Cooling { .. } => Dash::Ready,
Dash::Ready => Dash::Ready,
};
Exactly one arm produced the next state; every variant has an arm.
Final values: state = Cooling { ticks: 3 }; ticks = 3; next = Cooling { ticks: 2 }.
Read every step of the lesson’s example
Step 1, line 2. Cooling contains the count needed by that state. There is no separate ready flag to contradict it.
state = Cooling { ticks: 3 }.
Step 2, line 3. match checks the variant, then its optional guard.
state = Cooling { ticks: 3 }; ticks = 3.
Step 3, line 4. The guard is true: one tick elapses and the next state carries the smaller count.
state = Cooling { ticks: 3 }; ticks = 3; next = Cooling { ticks: 2 }.
Step 4, line 7. Exactly one arm produced the next state; every variant has an arm.
state = Cooling { ticks: 3 }; ticks = 3; next = Cooling { ticks: 2 }.
let else names values from the expected shape and exits otherwise.
Its else branch must diverge: for example, return from the function.
matches! asks whether a value matches a pattern and produces a bool.
A match guard checks an additional condition after a pattern matches.
1..=100 includes both endpoints. A player at zero health does not take
the healing arm, because alive is false. The final _ catches other
numeric values. For an enum representing distinct application states,
prefer named arms so adding a new variant requires a deliberate decision.
Name the successful shape, then test its meaning. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
let Some(health) = player_health else { return "no player"; };
let alive = matches!(health, 1..=100);
match health {
n if n < 25 && alive => "heal",
0 => "respawn",
_ => "wait",
}
The selected arm returns heal.
Final values: player_health = Some(20); result = heal; health = 20; alive = true.
Read every step of the lesson’s example
Step 1, line 1. let else names the health inside Some. Its else branch is skipped.
player_health = Some(20); result = not chosen.
Step 2, line 2. matches! produces a bool; it does not bind a new variable outside the macro.
player_health = Some(20); result = not chosen; health = 20; alive = true.
Step 3, line 3. Arms are tried in order, and a guard may reject an otherwise matching pattern.
player_health = Some(20); result = not chosen; health = 20; alive = true.
Step 4, line 4. The selected arm returns heal.
player_health = Some(20); result = heal; health = 20; alive = true.
Use the Option and
Result references
to check exact signatures. Next, collections and bytes
connect these outcome shapes to bounded buffers and repeated work.
Lesson 3.3
Rust: Collections, Bounds, and Bytes
A memory reader and a packet parser both receive sequences of bytes.
Before using those bytes, they need to check the available length, decide
how to group elements, and choose the numeric interpretation. Ordinary
Rust collections let us learn those decisions in a small local example.
The previous lesson explains the Option and Result
values returned when a check fails.
The guarded purchase in Lesson 1.4 used two Player records,
Ada and Bo. A fixed array was enough when we knew there were exactly two. What if players can join, we need one player’s score by name,
or we must handle pending actions in arrival order? Those are different
questions, and different collections make each one natural:
Collection
Picture it as
Good first use
Important limit
Array, [T; N]
numbered slots with a fixed count
two known players, three prices
its count does not grow
Vector, Vec<T>
numbered slots that can grow
players joining or a history of health samples
removing from the middle shifts later items
Hash map, HashMap<K, V>
a key pointing to its value
find Ada’s gold by her name or ID
a key may be missing; order is not the promise
Queue, VecDeque<T>
a line of waiting items
handle actions in arrival order
it is for the next item, not fast arbitrary lookup
Grid
rows and columns
map tiles at (row, column)
boundaries must be checked
Graph
places and their connections
waypoints and reachable paths
you must store and inspect the connections
T stands for the kind of item stored. A vector of u32 values, for
example, is written Vec<u32>. K and V stand for a map’s key and
value types. A queue can be implemented with Rust’s VecDeque, which
lets us add at the back and take from the front.
The array we used has a fixed number of slots. A vector keeps numbered
slots but can grow, which fits a history of gold samples:
letmutrecent_gold = vec![100, 75];
recent_gold.push(65);
A hash map answers “what value belongs to this key?” A queue answers
“what item arrived next?” The following Rust fragments use all three,
including a lookup or queue removal with no answer.
A grid can be a vector of rows, so tiles[row][column] reaches a tile.
A graph stores which waypoints connect; Lesson 4.8 uses
one to search for a route. These are arrangements of data, not magic
algorithms. Choose by the operation you need: next by position, look up
by key, take the oldest, or visit neighbors. Lesson 3.10
shows how their layouts can be recognized in a running game.
Different questions call for different collections. A vector keeps a
growable sequence of samples. A hash map finds a value by a key. A queue
keeps actions in arrival order. These fragments reuse the Primer’s Mira
stamina values, separately from its complete two-runner program. The use
line makes the two standard
library collection types available by their short names:
usestd::collections::{HashMap, VecDeque};
letmutrecent_stamina = vec![12, 8];
recent_stamina.push(6);
letsecond_sample = recent_stamina[1];
println!("Second sample: {second_sample}");
letmutstamina_by_name = HashMap::new();
stamina_by_name.insert("Mira", 8);
ifletSome(amount) = stamina_by_name.get("Mira") {
println!("Mira has {amount} stamina");
}
letmutpending = VecDeque::new();
pending.push_back("dash");
pending.push_back("rest");
ifletSome(action) = pending.pop_front() {
println!("Next action: {action}");
}
vec! starts a growable vector with two samples, push adds a third,
and [1] selects the second sample because positions start at zero.
The hash map associates the key "Mira" with 8, so get("Mira") can
find the amount without scanning a list of names. The queue adds actions
at the back and takes the oldest from the front; after the first pop,
"rest" is still waiting.
Both the map lookup and the queue removal may fail to produce an item:
the name might be absent or the queue empty. Their results therefore
use Some(value) or None. Lesson 3.2 develops that
possible-value shape with checked subtraction and fallible operations.
The grid/graph distinction above connects these choices to map tiles and paths.
An iterator yields a sequence of items on demand. filter keeps items
that satisfy a predicate, map transforms each kept item, and fold
combines items into one running answer. The closures describe those
operations; a consuming operation such as fold drives the sequence.
fndoubled_total(budget: u32) -> u32 {
letprices = [3_u32, 8, 12];
prices.into_iter()
.filter(|cost| *cost <= budget)
.map(|cost| cost * 2)
.fold(0, |sum, cost| sum + cost)
}
For a budget of 8, filter keeps 3 and 8, map produces 6 and 16, and
fold produces 22. This is a query, not a purchase: each price is checked
against the same unchanged budget. It does not ask whether buying all the
items together would be affordable.
into_iter consumes this array and yields its u32 values. filter
passes a reference to its predicate so an item can still be yielded after
the check; *cost reads the borrowed number. map then receives each
kept number by value. If you need to borrow the collection instead, start
with .iter(); for copyable numbers, .iter().copied() yields values
while keeping the original collection.
Keep affordable costs, transform them, then accumulate. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
let prices = [3_u32, 8, 12];
let total = prices.into_iter()
.filter(|cost| *cost <= budget)
.map(|cost| cost * 2)
.fold(0, |sum, cost| sum + cost);
No items remain. The total is the fold accumulator, including zero when no price passed.
Final values: budget = 8; sum = 22; prices = [3, 8, 12]; cost = 12; kept = false; doubled = 16; total = 22.
Read every step of the lesson’s example
Step 1, line 1. The owned array contains three prices. This query does not make purchases or reduce the budget.
budget = 8; sum = 0; prices = [3, 8, 12].
Step 2, line 2. into_iter yields each u32 by value. The adaptors are lazy until fold asks for values.
budget = 8; sum = 0; prices = [3, 8, 12].
Step 3, line 3. filter borrows 3 to test it: keep.
A second query counts runners with at least 4 stamina. Unlike the doubled
total above, its result is how many items pass the predicate:
letstamina_samples = [8_u32, 3, 6];
letready = stamina_samples
.iter()
.copied()
.filter(|stamina| *stamina >= 4)
.count();
println!("{ready} runners can dash"); // 2
.iter().copied() keeps the original array while yielding cheap u32
copies. The filter predicate still borrows each candidate, so *stamina
reads its number. count consumes the kept values. The equivalent loop is:
letstamina_samples = [8_u32, 3, 6];
letmutready = 0;
forstaminainstamina_samples {
ifstamina >= 4 {
ready += 1;
}
}
// ready is 2
Both forms produce 2. Choose the form that makes the question and each
value transformation clearest.
Suppose three position samples are 2, 6, and 10. We want each change
between adjacent samples. windows(2) yields borrowed slices [2, 6]
and [6, 10]. They overlap because the middle sample belongs to both
neighbor pairs.
Every yielded window has exactly two elements, so its two indexes are
valid. The example’s small coordinates also keep the subtraction in
i32 range; arbitrary external coordinates need a checked subtraction
or a wider type. collect consumes the iterator into a new Vec<i32>.
A slice shorter than the window size yields no windows. A zero window
size would panic, so validate a reader-supplied size before calling it.
Overlapping windows keep neighboring samples together. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
let positions = [2_i32, middle, 10];
let deltas: Vec<i32> = positions.windows(2)
.map(|pair| pair[1] - pair[0])
.collect();
collect consumes the iterator into a new owned Vec. The original positions remain unchanged.
chunks_exact(2) divides a buffer into separate two-byte groups.
Unlike windows, the groups do not overlap. A final single byte does not
form a complete record. Rust exposes it through remainder() so the
parser can decide whether to reject it or buffer it for later.
The buffer [4, 0, 9, 0] decodes as two little-endian numbers, 4 and 9.
Appending 255 makes this complete-packet parser return an error. For a
stream being received in pieces, keeping that byte for the next read may
be appropriate; Lesson 9.1 explains that distinction.
This function fixes the group size at two. A zero size would panic.
Fixed records leave a remainder that must be handled. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
let records = bytes.chunks_exact(2);
let tail = records.remainder();
if !tail.is_empty() { return Err("partial record"); }
let totals: Vec<u16> = records
.map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
.collect();
The complete packet yields two values.
Final values: length = 4; record_size = 2; tail = []; record = 9; totals = [4, 9].
Read every step of the lesson’s example
Step 1, line 1. Non-overlapping chunks contain exactly two bytes each. A zero chunk size would panic; this example fixes it at two.
length = 4; record_size = 2.
Memory: +0 = 04; +1 = 00; +2 = 09; +3 = 00.
Step 2, line 2. remainder borrows the short tail; the record iterator has not consumed anything yet.
length = 4; record_size = 2; tail = [].
Memory: +0 = 04; +1 = 00; +2 = 09; +3 = 00.
Step 3, line 4. The tail is empty, so the complete record iterator can be consumed.
length = 4; record_size = 2; tail = [].
Memory: +0 = 04; +1 = 00; +2 = 09; +3 = 00.
Step 4, line 5. Each two-byte pair decodes using an explicit little-endian order.
A slice borrows a contiguous sequence and includes its length.
bytes[3] assumes index 3 exists; it panics if that assumption is false.
bytes.get(3) instead returns Some(&byte) or None. The same checked
operation accepts a range: get(..4) asks for the first four bytes.
The range check establishes the length. try_into produces an exact
[u8; 4] array from that slice; .ok() turns its Result into an
Option, and ? exits if it is missing. from_le_bytes interprets
[0x2C, 0x01, 0, 0] as 300. 300_u32.to_le_bytes() reverses that
conversion and produces the same four bytes. Use from_be_bytes and
to_be_bytes when the format says the high byte comes first.
Predict first
Decimal 100 is 0x64, so 100_u32.to_le_bytes() gives the four bytes 64 00 00 00, lowest byte first.
A reader passes those same four bytes to u32::from_be_bytes. What number does it get?
Answer: 0x64000000, which is 1,677,721,600. from_be_bytes treats the byte at +0 as the highest byte, so 0x64 lands in the top byte, which is worth 256 × 256 × 256 = 16,777,216: 100 × 16,777,216 = 1,677,721,600. The bytes did not change; only the reading order did.
Write a u32 low byte first, then read it backto_le_bytes puts the lowest byte at offset +0. Read the same four bytes with from_be_bytes and the number changes.
Number, bytes in memory, number again
100.to_le_bytes()
100 = 0x00000064, high byte to low byte
00
00
00
64
↓ lowest byte goes to +0
In memory, offset +0 first
64+0
00+1
00+2
00+3
u32::from_le_bytes
+0 is the lowest byte
0x00000064 = 100the same number
100 is 0x00000064. to_le_bytes stores 64 00 00 00, and from_le_bytes reads them back as 100.
Reading the first four bytes leaves any later bytes unused. A format
that requires exactly four bytes must also check the total length.
Byte conversion interprets a number; it does not decide whether that
number is a valid health amount, length, or address.
A bounded slice becomes a number, then bytes again. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
fn read_gold(bytes: &[u8]) -> Option<u32> {
let raw: [u8; 4] = bytes.get(..4)?.try_into().ok()?;
Some(u32::from_le_bytes(raw))
}
let encoded = gold.to_le_bytes();
to_le_bytes chooses the same explicit order for writing. It does not depend on the host CPU.
An unsigned eight-bit value, u8, holds 0 through 255. The mathematical
sum 250 + 10 is 260 and does not fit. Rust supplies explicit arithmetic
policies so the programmer chooses the intended behavior:
letcount = 250_u8;
letchecked = count.checked_add(10); // None
letwrapping = count.wrapping_add(10); // 4
letsaturating = count.saturating_add(10); // 255
checked_* reports that an answer cannot be represented. wrapping_*
keeps the low bits, making this sum 4 after passing 255. saturating_*
stops at the type’s limit. Use checked arithmetic for a size or balance
that must be rejected if it overflows; wrapping for an intentionally
cyclic counter; saturating for a bounded meter.
Plain integer overflow may panic when overflow checks are enabled or
wrap when disabled. Explicit methods make the behavior independent of
that build setting. Saturation is to the type limit: a game with maximum
health 100 still needs a separate clamp to 100, even if health is a u8.
Choose what an overflowing counter means. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
let count: u8 = start;
let checked = count.checked_add(gain);
let wrapping = count.wrapping_add(gain);
let saturating = count.saturating_add(gain);
saturating_add clamps at the largest value. Use this for a meter that must stop at its limit.
Final values: count = 250; gain = 10; mathematical = 260; checked = None; wrapping = 4; saturating = 255.
Read every step of the lesson’s example
Step 1, line 1. The mathematical sum may be wider than the destination type.
count = 250; gain = 10; mathematical = 260.
Step 2, line 2. The sum does not fit; checked_add gives None and lets the caller reject it.
Rust’s ordinary struct representation does not promise a C field layout.
#[repr(C)] requests the target platform’s C ordering and alignment:
#[repr(C)]
structRecord {
tag: u8,
gold: u32,
zone: u16,
}
On the Windows x64 target used in this book, tag is at offset 0,
gold at offset 4, and zone at offset 8. Three padding bytes align
gold; two tail padding bytes make the full size 12. The same fields
written consecutively into a file would occupy only 7 bytes. Padding
is part of the type’s memory layout, not an implied serialized field.
These operations measure the compiled type on the current target.
repr(C) does not select byte order, prove that remote bytes contain
valid field values, or make every C++ class compatible with this struct.
Decode external file or packet fields explicitly rather than copying
the raw bytes of a Rust struct, including its padding.
C layout includes alignment padding. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
#[repr(C)]
struct Record { tag: u8, gold: u32, zone: u16 }
let record = Record { tag: 1, gold, zone: 2 };
let offset = std::mem::offset_of!(Record, gold);
let size = std::mem::size_of::<Record>();
Two tail padding bytes round the total up to a multiple of the struct alignment.
Final values: target = Windows x64; gold = 300; alignment = 4; tag = 1; zone = 2; gold_offset = 4; size = 12.
Read every step of the lesson’s example
Step 1, line 1. repr(C) chooses C field ordering and alignment for the target platform. It is not a disk-file format.
target = Windows x64; gold = 300.
Step 2, line 2. tag begins at zero; gold needs four-byte alignment, so three bytes of padding precede it.
The previous lesson used checked slices and iterator
queries. Those functions also made promises about access: some borrowed
an existing collection, while others returned a newly owned result.
Here we name those relationships and give functions interfaces that say
which kinds of values they can accept.
'a is a lifetime parameter. The repeated name says the returned
borrow is tied to the input borrow. It does not keep the buffer alive
longer. The compiler rejects using the view after the buffer is gone or
changing the borrowed elements while that shared view is still in use.
Here the view ends after its print, and the caller retains the buffer.
prefix(&buffer, count) lends a viewThe caller owns four bytes. A prefix that fits comes back as the address of buffer[0] plus a length; a count past the end gives None.
The caller’s buffer and what prefix returns
buffer: [u8; 4], owned by the caller
bytes 0 to 1 are in the view
10index 0
20index 1
30index 2
40index 3
prefix(&buffer, 2) returns
Result
Some(&[10, 20])
↓ no bytes copied
What the view holds
address of buffer[0]
length 2
get(..2) fits inside the 4 bytes, so prefix returns a view of 2 bytes. The view stores where they start and how many there are; no byte is copied.
With one borrowed input, Rust can infer this relationship. The shorter
signature fn prefix(bytes: &[u8], count: usize) -> Option<&[u8]>
expresses the same borrowing relationship. Explicit names become useful
when several borrowed inputs make the relationship ambiguous, or when a
struct keeps a borrow. Lifetimes describe validity, not a number of seconds.
A returned view stays tied to its owner. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
The Primer’s Runner record supplies the receiver’s fields:
structRunner {
name: String,
stamina: u32,
health: u32,
max_health: u32,
}
This is record context for the method below, not a second record definition
to paste into the complete Mira/Sol program.
Replace the Primer’s brief forwarding method with this direct-check
alternative; keep one try_dash definition.
An impl block attaches behavior to Runner. This alternative method
performs the same guarded subtraction as the Primer’s dash function:
implRunner {
fntry_dash(&mutself, cost: u32) -> bool {
ifself.stamina < cost {
returnfalse;
}
self.stamina = self.stamina - cost;
returntrue;
}
}
self means the runner receiving the method call. &mut self gives
the method temporary editable access to that runner, like the earlier
&mut Runner parameter. Place this alternative impl block below the
two-runner program, replacing the forwarding method, and use
runner.try_dash(4). The result stays Mira 8 and Sol 3. The method
organizes the code differently; it keeps the same stamina check and subtraction. In Rust, a struct holds fields and an impl gives it methods;
class inheritance is not required.
Give numbers different types when they mean different things
An RVA is an offset within a loaded module. A virtual address identifies
a location in a process. Both can be represented with integers, but
interchanging them produces a different question. A newtype wraps an
existing value in a distinct type:
Rva(512) and VirtualAddress(512) have different types even though their
numbers agree. resolve cannot receive them in the opposite order. .0
reads a tuple struct’s first field. The check rejects overflow; a real
reader must still validate the address against the intended process and
memory region.
Handles benefit from the same distinction. A ProcessHandle and a
ModuleHandle should not become interchangeable just because both are
machine-sized values. An owned process handle must also close exactly
once, so it must not derive Copy: a numeric copy would duplicate the
cleanup duty. Handle ownership develops that wrapper and
its Drop implementation. A borrowed handle view has a different duty
from an owned handle.
Let derive generate ordinary trait implementations
#[derive(...)] asks the compiler to generate the listed capabilities
from the fields. Debug supports diagnostic {:?} output; PartialEq
supports equality comparisons; Eq marks equality without exceptional
cases such as floating-point NaN. Clone provides explicit .clone();
Copy permits an implicit value copy and requires Clone.
For Rva, copying its plain integer is harmless. For String, an
explicit clone allocates a separate owned string, so String does not
implement Copy. Derived capabilities are not permission checks or
address validation. If a field cannot implement a requested trait, the
derive fails to compile.
The destination annotation chooses what .into() constructs. This
conversion simply names a number’s role; it does not check a module’s
size. Use TryFrom and a Result when a conversion may fail.
Display controls user-facing {} formatting and .to_string().
Debug answers a different question: what should a diagnostic view of
this value look like? You implement Display yourself because the best
label cannot be inferred from a struct’s fields. write! writes into
the supplied formatter and reports a formatting error if its destination
fails; it does not print directly to the terminal.
Give one number a type and a readable spelling. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
Step 1, line 2. Rva is a distinct type, so a function taking Rva cannot accidentally receive a different newtype.
raw = 512; offset = not constructed.
Step 2, line 1. derive generates the named trait implementations from the field. Copy fits this plain numeric wrapper; it would be wrong for an owned handle.
raw = 512; offset = not constructed; traits = Debug, Clone, Copy, PartialEq, Eq.
Step 3, line 9. Implementing From<u32> also supplies Into<Rva>. The destination annotation chooses the conversion.
A trait names behavior that several types can provide. A fixture can
supply invented gold values; another implementation can obtain a checked
snapshot. A caller that only needs the gold does not need to know which
storage system provided it.
The &self method borrows its implementation for reading. In this
argument position, impl GoldReader means “one concrete type that
implements this trait.” Each call can use a different concrete type;
the function’s body uses the promised method. A longer spelling uses a
generic parameter: fn affordable<R: GoldReader>(reader: &R, price: u32).
Both express the same capability requirement here.
For Fixture(20) and price 12, the answer is true. For Missing,
is_some_and returns false without calling its closure. A trait makes
the interface reusable; it does not ensure that every implementation
returns accurate data or has permission to read a process.
One capability lets a function use different readers. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
A builder stores construction choices, then checks them before
producing the finished object. It is useful when a tool has several
optional settings. A simple object with two obvious required fields
usually needs only a constructor.
Each setting method takes ownership of the draft and returns the updated
draft. That enables the chain below. impl Into<String> accepts a string
literal or an owned String and stores an owned label. build validates
the limit once; the object is not ready before it succeeds.
The builder itself does not start a worker or read memory. It only
constructs a validated configuration. A zero limit returns an error that
the caller can handle.
A builder accumulates choices before validating. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
let draft = SamplerBuilder::default();
let draft = draft.limit(limit).label("gold");
let sampler = draft.build();
build checks the limit once and returns a ready sampler.
Final values: limit = 3; label = gold; validated = true; result = Ok(Sampler { limit: 3, label: gold }).
Read every step of the lesson’s example
Step 1, line 1. Default creates an ordinary draft with a limit of one and a label of sample.
limit = 1; label = sample; validated = false.
Step 2, line 2. Each consuming method returns the updated draft so the next method can use it.
limit = 3; label = gold; validated = false.
Step 3, line 3. build checks the limit once and returns a ready sampler.
The previous lesson gave values clear owners and useful
interfaces. Ownership also answers a practical question: who releases a
resource when a function finishes early? Error handling answers another:
what can the caller learn from that failure? We connect both here using
local data. The examples do not open another process or change executable code.
Suppose a function temporarily changes a byte in its own buffer. Before it
returns, it must put back the old value. Writing the restore statement at
every return is easy to get wrong when another failure path is added.
Rust’s RAII pattern puts cleanup in the owner of a resource. The name
comes from “Resource Acquisition Is Initialization”: creating an owner
establishes its responsibility; leaving its scope ends that responsibility.
Owned files and lock guards already use this pattern. A custom guard can use
Drop to perform a small cleanup action when the guard leaves scope.
main sets byte to 75 and calls inspect(&mut byte, true). The guard stores 90, then inspect takes the early return Err("stopped early"). No line in inspect writes 75.
What does main print as the restored byte?
Answer: 75. Returning leaves the scope of inspect, and leaving the scope drops guard. Its drop method writes the saved 75 back through the borrow before main reads the byte.
inspect(&mut byte, true): the early returnmain’s byte starts at 75. Follow the byte and the guard’s saved copy through the early return.
1ByteGuard::replace(byte, 90)
90main’s byte, was 75
75guard.saved
The guard copies 75 into saved, then writes 90 through its exclusive borrow of main’s byte.
2return Err("stopped early")
90main’s byte
75guard.saved
leaving scopeearly return
inspect returns before its last line, and no line in inspect writes 75. The byte still holds 90 as the guard goes out of scope.
3Scope ends: Drop runs
75main’s byte, restored
droppedguard
Rust calls drop on the guard, which runs *self.byte = self.saved. main then reads 75. The Ok(()) return leaves the scope the same way.
All 3 steps shown
main’s byte starts at 75. Follow the byte and the guard’s saved copy through the early return.
The Ok(()) return leaves the scope the same way, so it also ends with 75,
and main can borrow the byte again after either return. This example’s
cleanup cannot fail: it writes through a valid exclusive reference to a local
byte.
A scope guard restores the local byte on either return. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
Drop is not a universal recovery guarantee. Destructors normally run on
ordinary returns and panic unwinding; an abort, process termination, or
deliberately forgotten owner can skip them. A remote-memory patch also has
thread, memory-protection, and write-failure conditions that this local guard
does not address. The handle owner in Lesson 3.1 and
hook removal in Lesson 8.10 show those separate responsibilities.
The trait method has the signature fn drop(&mut self). It cannot return a
Result to the caller. If releasing a resource can fail in a way the caller
must act on, provide an explicit close, finish, or restore operation
that returns Result; keep Drop as the final fallback appropriate for that
resource. Avoid panicking in a destructor, especially while another panic is
already unwinding.
For a buffered file, for example, an explicit flush() or sync_all() can
report an I/O failure. Merely letting the file owner leave scope does not
prove that a save reached durable storage. The required completion step
depends on what your caller is trying to guarantee.
A library caller may need to handle invalid text differently from an
out-of-range number. Returning a structured error enum preserves that
distinction. The thiserror crate derives the standard error implementation
and formats the message you supply. It does not change how Result or ?
works.
usestd::num::ParseIntError;
usethiserror::Error;
#[derive(Debug,Error)]
pubenumAmountError {
#[error("amount is not an unsigned integer")]
Parse(#[from] ParseIntError),
#[error("amount {value} exceeds the maximum {maximum}")]
returnErr(AmountError::TooLarge { value, maximum });
}
Ok(value)
}
For "oops", parsing produces ParseIntError; #[from] supplies the
conversion that ? needs to return AmountError::Parse. For "80" with a
maximum of 60, parsing succeeds but the range check returns TooLarge.
For "40", the caller receives Ok(40). u16 first limits representable
values; maximum then limits the amounts this application accepts.
Keep a typed reason, then add application context. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
fn parse_limit(text: &str) -> Result<u32, LimitError> {
let n: u32 = text.parse()?;
if n > 60 { return Err(LimitError::TooLarge(n)); }
Ok(n)
}
let limit = parse_limit(text).context("load sampler limit")?;
The application receives success. Context does not change the number.
Final values: text = 12; n = 12; library = Ok(12); application = limit = 12.
Read every step of the lesson’s example
Step 1, line 1. The library returns a named error enum that another caller can match.
text = 12.
Step 2, line 2. Parsing succeeded; ? yields the number.
text = 12; n = 12.
Step 3, line 4. The library returns its validated amount.
text = 12; n = 12; library = Ok(12).
Step 4, line 6. The application receives success. Context does not change the number.
text = 12; n = 12; library = Ok(12); application = limit = 12.
thiserror is an external dependency. Add it to the small example project’s
manifest with cargo add thiserror; a library exposing this error type keeps
the enum in its public interface. The derive requires Debug, and
#[from] also identifies the underlying error as a source.
The derive writes code you can also write yourself. A parser shared by
several tools, such as a packet, PE (Portable Executable, the
header-and-sections layout Windows uses for .exe and .dll files), or
byte-pattern parser, can implement the two traits directly:
Self::Truncated => output.write_str("the input ended too soon"),
Self::TooLarge(length) => {
write!(output, "the declared length ({length} bytes) is too large")
}
Self::InvalidUtf8 => output.write_str("the text is not valid UTF-8"),
}
}
}
implErrorforParseError {}
Debug shows the exact failure variant while you debug a parser. Display
gives the learner a useful sentence such as “the input ended too soon.”
Implementing Error lets the scanner, proxy, or PE inspector keep that
parser failure underneath its own context instead of flattening it into a
string. The portable rust-labs implementation
follows this pattern and tests the message produced for an oversized field.
A command-line program usually cares about presenting a useful chain of
causes. It can use anyhow at that boundary while the library keeps its
structured errors. Context adds the operation the program was attempting.
useanyhow::{Context, Result};
fnmain() -> Result<()> {
lettext = std::env::args()
.nth(1)
.context("usage: amount-viewer <amount>")?;
letvalue = parse_amount(&text, 60)
.context("could not read the requested amount")?;
println!("accepted amount: {value}");
Ok(())
}
This main uses parse_amount from the preceding listing. Add anyhow
with cargo add anyhow. When parse_amount fails, the context describes
the attempted operation and preserves the original error underneath it.
The Option returned by .nth(1) also accepts .context(...): an absent
argument becomes a useful failure instead of an unchecked unwrap().
Context should add information. “Could not read the requested amount” tells
the user which operation failed; repeating “error happened” at every layer
does not. Keep thiserror enums when callers need to match cases. Use
anyhow or a similar context wrapper at an application boundary when a
reportable cause chain is the main need.
The safe boundary keeps an address separate from an owned byte buffer.
We still need to know how the copied bytes become a number or another
address. The introductory memory lesson made two claims without proving
them. The four bytes
00 00 20 41 are the whole number 1,092,616,192 when a u32 reads them, and
10.0 when an f32 reads them. And a pointer’s type decides what is read at the
far end, while the pointer’s own size stays fixed.
This lesson proves both, one bit at a time, until you can work out every number
yourself. It also explains how a whole number stores a minus sign.
The memory introduction read a byte by adding up place values: 128, 64, 32, 16, 8, 4, 2, and
Each one is two times the one to its right, so each can be written as a
number of twos multiplied together.
Number the positions from the right, starting at 0. Position n is then worth
2 multiplied by itself n times, which is written 2ⁿ:
Position
Worth
Written
0
1
2⁰ (no twos multiplied at all)
1
2
2¹
2
2 × 2 = 4
2²
3
2 × 2 × 2 = 8
2³
7
2 × 2 × 2 × 2 × 2 × 2 × 2 = 128
2⁷
Counting from 0 is what makes that line up: the rightmost position is 2⁰, and
2⁰ is 1, because no twos have been multiplied in yet.
Wider values keep going. A 32-bit value has positions 0 to 31, and its leftmost
position is worth 2³¹ = 2,147,483,648.
The same notation counts patterns. Every extra bit doubles the number of
patterns, so n bits make 2ⁿ of them: 8 bits make 2⁸ = 256, and 32 bits make
2³² = 4,294,967,296. Those are the ranges in the introductory type table.
A u32 spends all 32 bits on size, so it has no way to say “minus”. An i32
needs one, and it gets it with a simple change: the leftmost position is worth
a negative amount. Every other position keeps its ordinary value.
It is easiest to see in a single byte, the i8 type. There, the leftmost
position is worth −128 instead of +128:
−1281
641
321
161
81
41
21
11
−128
64 + 32 + 16 + 8 + 4 + 2 + 1 = 127
−128 + 127 = −1
The byte 0xFF read as an i8. The highlighted position is worth −128, so all eight bits set means −1.
Read as a u8, the same 0xFF is 255. Read as an i8, it is −1. Nothing about
the byte changed; only the value given to its leftmost position did.
An i32 works the same way across four bytes, with its leftmost position worth
−2³¹ = −2,147,483,648. So the bytes FF FF FF FF are 4,294,967,295 as a u32
and −1 as an i32. This scheme is called two’s complement. Its big
advantage is that the CPU can use one adding circuit for both types: add 1 to
0xFF and the result carries out of the byte and leaves 0x00. Read as an
i8, that is the correct answer to −1 + 1. Read as a u8, it is 255 + 1
wrapping around to 0, because 256 does not fit in a byte.
The i32 range falls straight out of this. The most negative value has only
the leftmost bit set: −2,147,483,648. The most positive value has every bit set
except the leftmost. Those 31 bits add up to one less than 2³¹, for the same
reason 64 + 32 + 16 + 8 + 4 + 2 + 1 made 127, one less than 128. So the top of
the range is 2,147,483,647.
A whole number cannot hold 3.5 or 0.25. For that, an f32 uses its 32 bits in
a completely different way. It follows a standard called IEEE 754, which is
the rulebook nearly every CPU uses for fractional numbers.
To see the idea, start with how scientists write very large or very small
numbers:
1,500 = 1.5 × 10³
0.0025 = 2.5 × 10⁻³
Slide the decimal point until exactly one non-zero digit sits in front of it,
then count how far you slid it as a power of ten. A negative power means the
point slid the other way: 10⁻³ means “divide by 10 three times”.
An f32 does the same thing in binary. Take 10. As a binary whole number it is
1010, because the positions holding a 1 are worth 8 and 2, and 8 + 2 = 10.
Slide the binary point three places to the left and count those three places as
a power of two:
10 = 1010 in binary = 1.010 in binary × 2³
That number now has three parts: a sign (positive), a power of two (3), and the
digits after the point (010). The 1 in front of the point is missing from that
list on purpose; step 5 below explains why it never needs storing. An f32
stores exactly those three things, in three fields that together use all 32
bits:
Field
Bits
What it stores
sign
1
0 for positive, 1 for negative
exponent
8
the power of two, shifted by 127
fraction
23
the digits after the binary point
1 + 8 + 23 = 32. The name floating point comes from that middle field: the
exponent decides where the binary point floats to.
Here is 10.0 as it sits in memory: the bytes 00 00 20 41. Little endian put
the least-significant byte first, so written most-significant first, the way you
write a number, they are 41 20 00 00. In binary, 0x41 is 0100 0001 and
0x20 is 0010 0000.
The fields do not follow the byte boundaries. Keep the same 32 bits in the same
order and regroup them: the first bit is the sign, the next eight are the
exponent, and the last 23 are the fraction.
The next eight bits, 1000 0010, are an ordinary unsigned whole number, read
with the same place values as any byte:
1281
640
320
160
80
40
21
10
2⁷
2¹
128 + 2 = 130
The exponent field. Only two positions hold a 1: position 7, worth 2⁷ = 128, and position 1, worth 2¹ = 2.
That is where the 128 and the 2 come from. The leftmost bit of an eight-bit
field sits in position 7 and is worth 2⁷ = 128. The second bit from the right
sits in position 1 and is worth 2¹ = 2. No other bit is set, so the field holds
128 + 2 = 130.
The real power of two is not 130. It is the stored number minus 127:
130 − 127 = 3, so the number is multiplied by 2³ = 8
That 127 is called the bias, and it exists to solve a real problem. An
eight-bit field can only store 0 to 255, with no minus sign. But exponents need
to be negative too, because numbers smaller than 1 are written with a negative
power of two, the way 0.0025 needed 10⁻³:
0.5 = 1.0 in binary × 2⁻¹
0.25 = 1.0 in binary × 2⁻²
One fix would be to spend one of the eight bits on a minus sign. IEEE 754 does
something neater: it shifts the whole range. Every stored exponent is the real
exponent plus 127, so the reader subtracts 127 to get it back.
Stored field
Real exponent
Multiplies by
1
1 − 127 = −126
2⁻¹²⁶, a tiny fraction
126
−1
½
127
0
1
130
3
8
254
127
2¹²⁷, an enormous number
127 is used because it sits in the middle of 0 to 255, so there are about as
many negative exponents as positive ones. The stored values 0 and 255 are kept
aside for special cases, covered later in this lesson, which is why the table
stops at 1 and 254.
The shift has a bonus. Because a bigger stored exponent always means a bigger
number, two positive f32 values compare in the same order as their raw bit
patterns do, which lets hardware compare floats cheaply.
The last 23 bits are the digits after the binary point. In decimal, the digits
after the point are worth a tenth, a hundredth, a thousandth — each position a
tenth of the one before. In binary each position is worth half the one
before:
½0
¼1
⅛0
1/160
1/320
1/640
the other 17all 0
0.25
The fraction field's place values. The last of the 23 is worth 1/8,388,608. For 10.0 only the ¼ position holds a 1.
So the fraction 010 0000 … means 0 × ½ + 1 × ¼ + nothing else = 0.25. That
matches the .010 in 1.010 × 2³ from the start of this section.
Scientific notation always leaves one non-zero digit in front of the point. In
decimal that digit can be anything from 1 to 9. In binary the only non-zero
digit is 1, so in front of the point there is always a 1.
A bit that is always 1 tells you nothing, so an f32 does not store it. It is
called the hidden bit, and the reader adds it back:
1 + 0.25 = 1.25
The full number with its hidden 1 put back, 1.25 here, is called the
significand. Many tools and older books call it the mantissa. Leaving
the 1 out gives an f32 24 bits of precision from 23 stored bits.
In the formula below, ^ means “to the power of”, so 2^3 is the same as 2³.
The sign counts as +1 for positive and −1 for negative.
value = sign × significand × 2^(stored exponent − 127)
= +1 × 1.25 × 2^(130 − 127)
= 1.25 × 8
= 10.0
Predict first
Memory holds 00 00 20 41, which is 10.0. A tool changes only the third byte, from 20 to A0, so memory now holds 00 00 A0 41.
What value does the f32 hold now?
Answer: 20.0. Most significant first the bits are 0x41A00000. The top bit of A0 (1010 0000) is the lowest bit of the exponent field, so the exponent goes from 130 to 131. The significand stays 1.25, and 1.25 × 2⁴ = 1.25 × 16 = 20.0.
Regroup four bytes into an f32Type the 32 bits written most significant first. The lab shows the bytes in memory order, the same bits regrouped into sign, exponent and fraction, and the value they make.
Bytes, fields, value
In memory, lowest byte first
00+0
00+1
20+2
41+3
↓ regroup 0x41200000
Sign, exponent, fraction
0+
1000 0010130 − 127 = 3
010 0000 0000 0000 0000 0000fraction 0.25
Value
+1.25 × 2³ = 10.0
Most significant first the bits are 0x41200000: sign 0, exponent 130 − 127 = 3, significand 1 + 0.25 = 1.25.
A u32 reads the same four bytes with none of these boundaries. It treats all
32 bits as one whole number, and gets something unrecognisable:
00
00
20
41
read as u32: 1,092,616,192
read as f32: 10.0
One four-byte pattern, read two ways. Both readings are correct; they answer different questions.
An f64 follows the same plan with more room: 1 sign bit, 11 exponent bits
with a bias of 1,023, and 52 fraction bits.
10.0 is exactly 1.25 × 8, so its other 22 fraction bits are zeros it does not
need. Most numbers are not that tidy. 10.1 is 1.2625 × 8, and 0.2625 has binary
digits that repeat forever, so the 23 bits keep a rounded fraction and the
f32 holds 10.100000381…, the closest value it can store. This is why float
values found in games are rarely round, and why memory scanners let you search
for a float within a small range instead of only an exact value. The optional
rounding reference derives those
bits and the spacing between neighbouring f32 values.
The stored exponents 0 and 255 do not follow the formula:
Stored exponent
Fraction
Meaning
0
all zero
zero; with the sign bit set, −0
0
not zero
a number too small for the normal formula: the hidden digit becomes 0 and the power is fixed at 2⁻¹²⁶
255
all zero
infinity, for example the result of 1.0 ÷ 0.0
255
not zero
NaN, “not a number”, for example the result of 0.0 ÷ 0.0
Zero needs its own pattern because it has no leading 1 to hide.
The special exponent-zero decoding also explains what the bytes for an integer 100 mean as a
float. Read 64 00 00 00 as an f32 and, most significant first, it is
0x00000064: sign 0, exponent field 0, fraction 100 out of 8,388,608. With the
hidden digit off and the power fixed at 2⁻¹²⁶, the value is 100 ÷ 8,388,608 ×
2⁻¹²⁶, about 1.4 × 10⁻⁴³: a decimal point, then 42 zeros, then 14. A u32 read
of the same bytes gives 100.
Now apply the same decoding to a 3D position stored as three f32 values in a
row:
+0x0000
00
80
3F
+0x0400
00
00
40
+0x0800
00
60
C0
x = 1.0
y = 2.0
z = −3.5
A position stored as three f32 values. The labels are offsets from the start of the position.
Each value decodes with the same six steps. Take z. Its bytes 00 00 60 C0,
written most significant first, are 0xC0600000. 0xC0 is 1100 0000 and
0x60 is 0110 0000, so regrouping the bits into fields gives:
The exponent field 1000 0000 has only position 7 set, so it holds 128, and
128 − 127 = 1. The number is multiplied by 2¹ = 2.
The fraction starts 11, so it holds ½ + ¼ = 0.75. With the hidden 1 put
back, the significand is 1.75.
So z = −1 × 1.75 × 2 = −3.5.
Decode x and y the same way
x is 00 00 80 3F, which is 0x3F800000. In bits that is
0011 1111 1000 0000 followed by sixteen zeros. The sign bit is 0. The next
eight bits are 0111 1111, which is 64 + 32 + 16 + 8 + 4 + 2 + 1 = 127, and
127 − 127 = 0, so the multiplier is 2⁰ = 1. Every fraction bit is 0, so the
significand is just the hidden 1. x = +1 × 1 × 1 = 1.0.
y is 00 00 00 40, which is 0x40000000: 0100 0000 followed by 24 zeros.
The sign bit is 0. The exponent field is 1000 0000, which is 128, and
128 − 127 = 1, so the multiplier is 2¹ = 2. The fraction is all zeros, so the
significand is 1. y = +1 × 1 × 2 = 2.0.
The interpreter below reads any four bytes as an unsigned, a signed, and a float
value at once, so you can check a decode by hand. Try 00 00 60 C0 for −3.5, or
9A 99 21 41 to see how close 10.1 really gets.
This interactive lab needs JavaScript. The lesson text around it still explains the same idea.
Suppose a pointer holds 0x5000, where the bytes are 00 00 20 41:
0x500000
0x500100
0x500220
0x500341
one location, four unchanged bytes
The type of the pointer decides how many of these destination bytes a read consumes and what they mean.
Following that address through a *const u32 reads all four bytes as
1,092,616,192. Through a *const f32, the same four bytes mean 10.0. Through a
*const u8, it reads only the first byte, 0. The pointee type decides how many
bytes to read at the destination and how to interpret them. Two more facts
complete the picture: why the pointer’s own size never changes, and how the
pointee type controls a pointer that moves.
A pointer has to be able to hold any address the process might use. A 32-bit
process numbers its memory with 32-bit addresses. That gives it 2³² =
4,294,967,296 possible addresses, from 0 to 4,294,967,295, and a pointer needs
all 32 bits — 4 bytes — to name any one of them. What it points to plays no
part: a pointer to a single byte and a pointer to a 2,000-byte player object are
both 4 bytes. A 64-bit process uses 64-bit addresses, so every pointer in it is
8 bytes.
0x200000
0x200150
0x200200
0x200300
the address 0x5000 as a pointer in a 32-bit process: 4 bytes
0x200000
0x200150
0x200200
0x200300
0x200400
0x200500
0x200600
0x200700
the same address as a pointer in a 64-bit process: 8 bytes
The pointer's size depends only on the process. The pointee type changes nothing here.
Rust’s usize, the pointer-sized whole number from Lesson 3.1,
has the same width: 4 bytes in a 32-bit program, 8 in a 64-bit one. So a
64-bit tool reading a 32-bit game’s pointers must read 4 bytes for each one,
even though its own usize is 8.
The pointee type also decides how far a pointer moves. Adding 1 to a pointer
means “the next value of this type”, not “the next byte”, as
Lesson 3.1’s .offset() example showed. Rust writes
this p.add(1) inside unsafe, because the compiler cannot check that the new
address still lands inside the list:
0x50000A
00
00
00
0x500414
00
00
00
0x50081E
00
00
00
p → 10
p.add(1) → 20
p.add(2) → 30
A *const u32 named p pointing at three u32 values in a row. Each step moves 4 bytes, one whole u32. A *const u8 would move 1 byte per step.
0x0A is 10, 0x14 is 16 + 4 = 20, and 0x1E is 16 + 14 = 30. A pointer to a
64-byte structure would step 64 bytes at a time.
The pointee type lives in the source code and in the compiler, which uses it to
choose the right instructions: a four-byte whole-number read, a float load, or a
one-byte read. The compiled game keeps only those instructions. When you study a
game, you work out what a pointer points to the same way you work out any other
type: from what the code does at the destination.
The same bytes can describe a whole number, a fraction, or an address,
depending on the width and interpretation selected by the code. Knowing
that representation still does not prove which object the bytes belong
to or whether its address remains valid. How External Tools Read Game
Memory combines these widths, byte orders, and encodings with the safe boundary:
copy a checked byte range first, then interpret the local result.
These derivations show where the rounded value of 10.1 comes from. The
decoding steps above do not depend on them. Lesson 5.6 relies
on the same binary32 rounding when floating-point instructions add, scale, or
convert values.
Optional reference: what the rest of the fraction bits are for
For 10.0, only one fraction bit is set and the other 22 are 0. They are not
wasted. They hold finer and finer halves — ⅛, 1/16, 1/32, all the way down to
1/8,388,608 — and 10.0 simply does not need any of them, because it is exactly
1.25 × 8. That is like writing 1.2500000 in decimal: the extra zeros are real
digits that happen to be zero.
Most numbers are not that tidy. Take 10.1. It is 1.2625 × 8, so the fraction has
to be 0.2625. In binary, 0.2625 never ends: its digits settle into 0011
repeating forever, the same way 1 ÷ 3 is 0.333… forever in decimal. The 23 bits
store as much of it as fits, rounded at the last bit:
sign0
exponent1000 0010
fraction010 0001 1001 1001 1001 1010
130 − 127 = 3
about 0.26250005
10.1 as an f32, the bytes 9A 99 21 41 in memory. Nearly every fraction bit is now doing work, and the stored value is still only close to 10.1.
The stored fraction is 0.2625000476…, so the value is 1.2625000476… × 8 =
10.100000381…, the closest an f32 can get. A debugger that prints enough
digits shows 10.10000038.
The last fraction bit sets how fine that precision is. It is worth 1/8,388,608,
and for numbers near 10 it is multiplied by 2³ = 8, so neighbouring f32 values
near 10 are about 0.00000095 apart. That works out to roughly seven
significant decimal digits.
Where the binary digits of 0.2625 come from
To turn a fraction into binary digits, double it again and again. Each time the
result reaches 1 or more, the next binary digit is 1 and you keep only the part
after the point. Otherwise the digit is 0.
0.2625 × 2 = 0.525 → digit 0
0.525 × 2 = 1.05 → digit 1, keep 0.05
0.05 × 2 = 0.1 → digit 0
0.1 × 2 = 0.2 → digit 0
0.2 × 2 = 0.4 → digit 0
0.4 × 2 = 0.8 → digit 0
0.8 × 2 = 1.6 → digit 1, keep 0.6
0.6 × 2 = 1.2 → digit 1, keep 0.2
After the eighth line the leftover is 0.2 again, just as it was after the
fourth line. So lines five to eight repeat forever, producing 0011 over and
over: 0.2625 is 0.0100 0011 0011 0011 … in binary. The fraction field holds
the first 23 of those digits, rounded at the last one.
Doubling works because each doubling moves the binary point one place to the
right, pulling the next digit in front of it.
Our tool runs beside the game, not inside it. Windows must grant a process handle with specific rights before the tool can read or write memory.
An address makes sense only in a process’s address space. The number 0x5000
can exist in both processes without naming the same bytes. The process matters
as much as the numeric address:
Windows maps virtual addresses separately for each process. Reading a local
pointer with the same number would inspect the tool’s own memory. A process
handle names the target and carries the rights granted for this opening;
the Windows read uses that handle with the target address and a byte count.
Lesson 6.4 later examines handles in more depth.
Reading four bytes from the gameThe game has 64 00 00 00 at its address 0x5000, which encodes u32 100. Windows copies the bytes into the tool; the tool decodes its own copy.
ToolWindowsGame process
1
RequestRead 4 bytes at game address 0x5000
Step 1: Tool to Windows
The tool names the game by handle and gives a number that only means something inside the game’s address map.
2
RequestCheck the handle, look up 0x5000
Step 2: Windows to Game process
Windows checks that the handle allows reading, then translates 0x5000 with the game’s page tables, not the tool’s.
3
Data64 00 00 00
Step 3: Game process to Windows
These four bytes are what the game has stored there.
4
ReplyCopy into the tool’s own buffer
Step 4: Windows to Tool
The tool now holds a copy. It does not share the game’s memory.
5
AloneDecode the copy: u32 100
Step 5: Tool (alone)
Little-endian 64 00 00 00 is 0x00000064, which is 100. A local pointer numbered 0x5000 would use the tool’s own address map instead.
All 5 steps shown
The game has 64 00 00 00 at its address 0x5000, which encodes u32 100. Windows copies the bytes into the tool; the tool decodes its own copy.
That read is the Windows function ReadProcessMemory. Its arguments split cleanly
into what belongs to the game and what belongs to the tool:
ReadProcessMemory, argument by argument
hProcesshandlerequired
The process handle, opened with the right to read (PROCESS_VM_READ). It says
which process’s address map to use.
lpBaseAddressaddress in the gamerequired
Where to start reading. To the tool this is only a number, so it is never
dereferenced locally.
lpBufferaddress in the toolrequired
Where Windows stores the copied bytes: memory the tool owns, with room for at
least nSize bytes.
nSizebytesrequired
How many bytes to copy.
lpNumberOfBytesReadbytes, optional
Where Windows reports how many bytes were actually copied. A tool that ignores it
can decode stale bytes left in its buffer.
return valuenonzero on success
Zero means the read failed. GetLastError then says why, such as
ERROR_PARTIAL_COPY (299) when only part of the range could be read.
For this lab, the program discovers wesnoth.exe with the Windows ToolHelp API,
opens it, resolves the verified pointer chain, and performs the guarded write.
There is no PID or raw-address placeholder for the reader to fill in.
An external tool cannot dereference a target-process pointer directly. Windows
mediates the boundary, and the handle represents the particular access the
observer was granted:
The returned bytes belong to the observer, but their interpretation is valid
only for the target build and moment that justified the layout. A write is a
separate, stronger capability and needs its own expected-value check.
Even two successful reads can describe different moments. Suppose the tool
follows a player pointer, the game removes that player and reuses the same
allocation, and the tool then reads health. Both requests can succeed while
the combined result describes no real player:
Watch that race. Each read can succeed while the pair of observations
becomes inconsistent:
Watch it happenTwo successful reads, two different objects
A tool and a game exchange messages. The tool reads a pointer and gets 0x5000, which holds enemy A. The game then reuses 0x5000 for enemy B. The tool reads health at 0x5030 and gets 87, which is enemy B’s health, and wrongly reports enemy A with health 87.
Read the steps
The tool wants enemy A’s health. First it asks Windows to read the pointer stored at 0x2000 in the game. That request names a location, not a permanent object.
The game answers 0x5000, which names enemy A at this moment. The tool keeps that address for its next read.
Before the tool’s next request, the game removes enemy A and reuses the allocation at 0x5000 for enemy B. The number 0x5000 has not changed, but who lives there has.
The tool adds the health offset 0x30 to the 0x5000 it kept and reads at 0x5030. That location now belongs to enemy B.
Windows reads it successfully and returns 87. Those bytes are enemy B’s health.
Combining the two results reports enemy A with health taken from enemy B. Each copy succeeded, yet the pair describes no real player.
This is an illustrated failure, not a valid snapshot. Copy checks and object-path revalidation help detect uncertainty; they do not make separate reads atomic.
This simulation needs JavaScript. The lesson text around it works through the same numbers.
Check the returned byte count and revalidate the object path after a multi-step
read. That reduces stale observations; it does not turn several Windows calls
into one atomic snapshot. Lesson 4.3 builds a bounded
double-capture check for that remaining uncertainty.
For the exact 32-bit Windows build used by this book, the verified gold path is:
This is the practical result from Lesson 2.8. Confirm it in Cheat Engine before running the tool: add a pointer manually, use 0x017EED18 as the base, then add offsets 0xA90 and 0x4. Start three fresh local matches and verify that it resolves to the gold shown in the game each time.
A Windows handle is an opaque ticket stored in one process’s handle table.
It is not the process, file, event, or section itself, and it is not an address
you should dereference. Windows uses the ticket to find the kernel-managed
object and to remember which access rights this particular opening received.
Two handles can therefore name the same object while granting different
permissions.
That gives a handle four requirements:
Scope: a handle value is normally meaningful only in the process whose
handle table contains it. DuplicateHandle can deliberately create another.
Rights:OpenProcess(PROCESS_VM_READ, ...) asks for a capability. The
returned handle carries only the rights Windows granted for that opening.
Lifetime: the handle stays valid until its owner closes it. Closing the
last handle to many kernel objects lets Windows destroy that object.
Correct closer: many kernel-object handles use CloseHandle, but not
every Windows value does. SOCKET, HKEY, HMODULE, and HWND follow
their own APIs and ownership rules. Never call CloseHandle merely because
a type’s name begins with H.
The HANDLE
type is intentionally opaque. The Drop trait gives an owned handle an
automatic cleanup path:
// 👀 Borrow the ticket for one API call; ownership stays in `self`.
self.0
}
}
implDropforOwnedHandle {
fndrop(&mutself) {
// 🧹 SAFETY: this instance owns the live handle and closes it exactly once.
let_ = unsafe { CloseHandle(self.0) };
}
}
The wrapper itself is not magic. Its constructor must accept only a valid,
owned handle; it must not wrap pseudo-handles such as GetCurrentProcess() as
though they need closing; and its raw value should be borrowed briefly instead
of copied into long-lived state. In larger tools, std::os::windows::io types
such as OwnedHandle and BorrowedHandle can express the same ownership split
for compatible handle families.
The ownership improvement is small in code and large in behavior:
The original helper opens a raw process handle, reads four gold bytes, then closes the handle. If the read fails or copies fewer than four bytes, ? or ensure! returns before the closing line and leaks the handle. Both versions receive a discovered ProcessEntry, the requested rights, and the verified gold address; they use the linked lab library.
Before: close the handle only on success
usestd::ffi::c_void;
usegha_windows_labs::ProcessEntry;
usewindows::Win32::{
Foundation::CloseHandle,
System::{
Diagnostics::Debug::ReadProcessMemory,
Threading::{OpenProcess, PROCESS_ACCESS_RIGHTS},
},
};
fnload_gold(
entry: ProcessEntry,
rights: PROCESS_ACCESS_RIGHTS,
gold_address: usize,
) -> anyhow::Result<u32> {
// SAFETY: a numeric PID is passed; Windows validates it and the requested rights.
anyhow::ensure!(bytes_read == bytes.len(), "short read"); // Also skips cleanup.
// SAFETY: this helper owns the live handle and has not closed it yet.
unsafe { CloseHandle(handle) }?;
Ok(u32::from_le_bytes(bytes))
}
RAII moves handle ownership into Process. Its open_with_access method requests the same rights, and read_u32 requires a complete four-byte copy. Leaving the function drops the owner on success and on every early return, while the original error still reaches the caller.
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) }}
The Process type stores this OwnedHandle. The rest of the program cannot
forget to close it. Its complete ToolHelp implementation is in
process.rs; the important
discovery step is:
.with_context(|| format!("process {name:?} is not running"))
}
Process::list creates a TH32CS_SNAPPROCESS snapshot, initializes
PROCESSENTRY32W.dwSize, and repeatedly calls Process32FirstW and
Process32NextW. That code is shown in full in the linked source rather than
being replaced by “enumerate processes here.”
The wrapper now returns ordinary values, so the caller can follow the
previously verified pointer path without touching raw pointers. In this
example, wesnoth_gold_address resolves the current run’s gold field;
read_u32 copies its four bytes into the tool. A successful run prints the
address and the current gold value. A missing process, invalid path, or failed
read returns an error through ? instead of printing a made-up result.
Writing should be opt-in and should verify the expected old value first:
The method below turns the diagram into a checked write. Given an address,
the value we expect there, and a replacement, it returns Ok(false) without
writing if the value has changed. A matching value permits one four-byte
write, and the result is true only when Windows reports all four bytes
written. Another game thread can still race this check, so the caller must
treat the result as evidence for this attempt, not a permanent guarantee.
anyhow::ensure!(changed, "gold changed before the guarded write");
println!("gold is now 999");
Return to Wesnoth and recruit a unit or end the turn to refresh the display. The gold should show 999 and behave as real spendable gold. Close and reopen the game to prove that the program resolves the dynamic address again.
You can build and run the finished program directly:
#[cfg(windows)]fn main() -> anyhow::Result<()> { use anyhow::Context; use gha_windows_labs::Process; // These three values belong to Wesnoth 1.14.9, 32-bit Windows. const PLAYER_ROOT: usize = 0x017E_ED18; const GAME_OFFSET: usize = 0x0A90; const GOLD_OFFSET: usize = 0x0004; let amount = std::env::args() .nth(1) .map(|text| text.parse::<u32>()) .transpose() .context("usage: wesnoth_gold [new_amount]")? .unwrap_or(999); let process = Process::open_by_name("wesnoth.exe", true)?; anyhow::ensure!(process.is_32_bit()?, "this profile requires 32-bit Wesnoth"); // First bracket: read the player pointer stored at the fixed root. let player = process.read_u32(PLAYER_ROOT)? as usize; anyhow::ensure!(player != 0, "start a local match before running the tool"); // Second bracket: add 0xA90, then read the current side pointer. let side_pointer_address = player .checked_add(GAME_OFFSET) .context("player + game offset overflowed")?; let side = process.read_u32(side_pointer_address)? as usize; anyhow::ensure!(side != 0, "the current side pointer is null"); // The gold number lives four bytes into the side record. let gold_address = side .checked_add(GOLD_OFFSET) .context("side + gold offset overflowed")?; let old_gold = process.read_u32(gold_address)?; // Guard the write: stop if the value changed between our read and write. let check = process.read_u32(gold_address)?; anyhow::ensure!( check == old_gold, "gold changed before the write; try again" ); process.write_u32(gold_address, amount)?; println!("Wesnoth gold: {old_gold} -> {amount} at {gold_address:#010x}"); println!("Recruit a unit or end the turn to refresh the on-screen number."); Ok(())}#[cfg(not(windows))]fn main() { eprintln!("Build this lab on Windows with the i686-pc-windows-msvc target.");}
Imagine that a game originally contained this C++ class:
classPlayer {
public:
virtualvoidupdate();
Vec3position; // three coordinate numbers; Chapter 7 develops the math
floatyaw;
floatpitch;
charname[32];
booldead;
};
The compiler does not normally copy that neat declaration into the finished .exe. Names such as Player, position, and update may disappear. The running program still needs a repeatable arrangement of bytes, though. It must know where each player’s position begins, where the name lives, and which function to call.
Reversing a class means rebuilding a description of that arrangement from what the running program does. It does not restore the original source; it produces a layout that matches the machine code and the program’s behavior. 🧠
This lesson uses AssaultCube 1.2.0.2 32-bit Windows in a local bot match. Offsets are version-specific. A different build may have a different layout.
Recovering a class works upward through several levels, and each level is shown by a different technique:
Level
Example
What shows it
Bytes
Four bytes at this address changed.
Reading the memory before and after.
Instruction
movss read those four bytes.
Decoded machine code.
Data flow
The value reaches the view-angle calculation.
Register and memory tracing.
Field
Offset +0x40 behaves like yaw.
Controlled turns across many instances.
Object
Several fields share one stable base address.
Repeated base + offset accesses.
Class behavior
Objects sharing one table dispatch related methods.
Checked indirect calls and object lifetimes.
Game meaning
This is the active player entity.
Behavior, call context, and lifecycle tests.
Each level rests on the ones below it. If +0x40 turns out not to be yaw, the
object and class levels built on that field have to be checked again, which is
why a jump from “bytes changed” straight to “this is a Player class” is so
easy to get wrong.
A source-code recipe that combines data with functions.
Object or instance
One real player, weapon, or entity created from that recipe.
Field or member
One piece of data inside an object, such as health or yaw.
Method
A function that operates on an object.
Structure layout
The byte distance, size, type, and alignment of each field.
Suppose one player object begins at address 0x12340000, and its yaw is at offset 0x40. The yaw address is:
object base + field offset = field address
0x12340000 + 0x40 = 0x12340040
An offset is a distance, not an address by itself. Many player objects can use the same yaw offset while living at different base addresses.
The yaw field is the same distance from every player’s baseTwo separate allocations: one at 0x12340000, the other at a different base. Each has yaw 0x40 bytes after its start.
1First player: yaw is 0x40 bytes after its base
Player 1
base0x12340000
other fields+ 0x40 bytes
yaw0x12340040
The first allocation starts at 0x12340000, so its yaw is at 0x12340000 + 0x40 = 0x12340040.
2Second player, base B: yaw is at B + 0x40
Player 1
base0x12340000
other fields+ 0x40 bytes
yaw0x12340040
Player 2
baseB
other fields+ 0x40 bytes
yawB + 0x40
Another allocation starts at some other base B. The offset belongs to the layout, not to one object, so its yaw is found the same way.
All 2 steps shown
Two separate allocations: one at 0x12340000, the other at a different base. Each has yaw 0x40 bytes after its start.
For the course build of AssaultCube, our recovered player model contains these useful landmarks:
Offset from player base
Candidate field
How to test it
+0x04
X position (f32)
Move east/west and watch a smooth finite value change.
+0x08
Y position (f32)
Move north/south while X stays mostly steady.
+0x0C
Z position (f32)
Jump or change elevation.
+0x40
Yaw (f32)
Turn left/right; expect an angle-like value.
+0x44
Pitch (f32)
Look up/down.
+0x225
Name bytes
Compare several bots with different names.
+0x338
Dead flag (u32)
Observe zero while alive and a changed flag after death.
+0x04x
+0x08y
+0x0Cz
unknown
+0x40yaw
+0x44pitch
unknown
+0x225name
unknown
+0x338dead
position
view angles
The confirmed landmarks, drawn in order but not to scale. Each unknown box stands for bytes nobody has tested yet.
Notice the gaps. We have not claimed that every byte between 0x0C and 0x40 is useless. Those bytes may contain velocity, dimensions, pointers, flags, or padding. Unknown means unproven, not empty.
Padding is especially easy to miss. CPUs and compilers often place a field at an
aligned address — usually a multiple of that field’s own size — leaving unused
bytes in between. Three fields declared in this order produce a layout with a
hole in it:
+0x0000
??
??
??
+0x0464
00
00
00
+0x0800
00
B4
42
dead
padding
health = 100
yaw = 90.0
assume no padding and “health” at +0x01 reads three padding bytes and one byte of the real health
A dead flag, a u32, and an f32, with the padding the compiler inserts after the one-byte flag.
The three bytes at +0x01, +0x02, and +0x03 hold nothing meaningful. They
exist only so that health begins at a multiple of four. If you assume fields
sit back to back and put health at +0x01, then every offset after it is
wrong by three bytes, and each read returns a value stitched together from
pieces of two neighboring fields. ❌
Find the local player’s yaw with a float scan while turning left and right.
Put a hardware access breakpoint on that address.
Turn again and inspect the instruction that uses it.
Look for an address calculation shaped like [register + constant].
You may find an instruction shaped like this:
movssxmm0, dword ptr [ecx+40h]
Read it as: “copy the 32-bit float located 0x40 bytes after the object in ecx into xmm0.” If the stopped yaw address is 0x12340040, then ecx should be 0x12340000.
Verify the arithmetic in the debugger:
stopped field address - instruction offset = candidate object base
0x12340040 - 0x40 = 0x12340000
0x12340000object base
· · ·
0x12340040yaw
the instruction's offset, 40h
The breakpoint stopped on movss xmm0, dword ptr [ecx+40h] at yaw's address, so ecx should hold 0x12340000.
One matching subtraction is a clue, not proof. Repeat it while turning, respawning, and starting a new match.
A C++ call such as player.update() looks as if it passes no player argument. A non-static member function still needs to know which player it should update. The compiler supplies a hidden argument called this.
Shared method code still needs a particular receiver
A field offset describes a position within one layout; it is not an
absolute field address. In the invented layout below, both objects store
health at base + 8. Ada’s base is 0x1000, so her health is at 0x1008.
Bo’s base is 0x2000, so his health is at 0x2008.
Choose the receiver and follow the same method body. The example uses the
x86 Microsoft __thiscall convention’s ECX receiver and limits damage so
this small subtraction cannot make health negative. It illustrates address
selection; it does not define a universal C++ layout or model the whole ABI.
Microsoft documents this receiver convention in its
__thiscall reference.
The receiver selects which object the method changes. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
mov ecx, selected_base
call damage
jmp done
damage: mov eax, [ecx+8]
sub eax, amount
mov [ecx+8], eax
ret
done: nop
Ada has 40 health; Bo still has 50. Sharing a method and field offset did not share the field's storage.
Identical code and identical field offsets can modify different objects.
To explain one write, recover both the layout and the receiver used on that
call. A pointer to the code is not a pointer to the instance’s fields.
Receiver cheatsheet: shared method code is not shared object storage; the receiver supplies the instance address; base plus field offset selects that instance’s field.
That argument belongs to one call’s working state. Each thread has a stack
region for return addresses and temporary call data. On 32-bit x86, esp
marks its current top. Calling another function claims a new stack frame;
returning releases that frame. The object that this points to can live much
longer on the heap. The pointer in a call frame and the object it names have
different lifetimes:
0x0019FF80main: return address and locals
0x0019FF50update_world: return address and locals
0x0019FF30apply_damage: return address and locals
esp →scratch: the next call writes here
in use, released newest first
free
One thread's stack on 32-bit x86. Each call claims a frame below the previous one. When apply_damage returns, esp moves back up; a later call can reuse those bytes. The player object reached through this need not disappear with this frame.
This explains why a debugger’s call stack can lead you to the caller but a
saved address of one of its temporary values cannot be assumed valid after
the call returns. Lesson 3.10 will trace the longer lifetimes of heap objects.
For the 32-bit Microsoft C++ convention commonly called __thiscall, this usually arrives in ecx:
movecx, esi; esi is the player-object base
call update_player
Inside the method, reads often use that same base register:
moveax, [ecx+338h] ; read one member from this player
testeax, eax
jne player_is_dead
On 64-bit Windows, the first integer or pointer argument normally arrives in rcx, so this is commonly in rcx. This is a calling-convention rule, not a promise that the compiler keeps the value in that register for the whole method. It may copy the pointer into rbx, rsi, or a stack slot.
The practical question is: which value stays constant while several member offsets are read from it? That value is a strong object-base candidate.
A class can declare a method as virtual, allowing different object types to choose different implementations. Many C++ compilers implement this with:
a vtable: a table containing function addresses;
a vptr: a hidden pointer in each object that points to its vtable.
The purpose is to let a single call site reach different code depending on what
the object actually is. The compiler cannot know at build time whether a given
entity will be a human player or a bot, so rather than writing a fixed
destination it writes “call whatever address is in slot 3 of this object’s
table.” Each class gets its own table, and every object carries a pointer to
the right one.
A 32-bit virtual call often has this general shape:
moveax, [ecx] ; read the object's possible vptr
calldword ptr [eax+0Ch] ; call virtual slot 3
That is two lookups before any game code runs. Followed with real numbers:
ecx = 0x1234_0000 the object
read [ecx] -> 0x00A1_B200 the vtable this object uses
0x00A1_B200 + 0x0C = 0x00A1_B20C the slot being called
read [0x00A1_B20C] -> 0x004C_7710 the function that finally runs
0x00A1B200slot 0
0x00A1B204slot 1
0x00A1B208slot 2
0x00A1B20C0x004C7710
slot 3
The vtable the object's vptr points at: a row of function addresses, four bytes each.
The offset 0x0C is 12, and a pointer in a 32-bit process is four bytes, so
12 / 4 = 3: this is the fourth entry counting from zero, which is why it is
called slot 3. Two objects of different classes executing this identical
instruction end up in different functions, because the second step read a
different table.
Do not label every first pointer a vptr. Test it:
The object field should point into a readable table, usually in a module’s read-only data.
Several table entries should look like valid pointers.
Those entries should lead into executable sections.
Objects believed to share a class should usually share the same table pointer.
Breaking on one table entry should show a sensible method receiving the object as this.
This pattern is common, but the C++ language does not require one universal object layout. Link-time optimization, devirtualization, multiple inheritance, and custom engines can all change it.
With single inheritance, the base-class part is often placed at the beginning of the derived object. A BotPlayer may therefore begin with all fields needed by Player, followed by bot-specific state.
Multiple inheritance can place several base parts, each with its own vptr,
inside one allocation. A pointer typed as the second base then points a few
bytes into the object, and a thunk, a tiny compiler-generated stub, may
adjust this before jumping to the real method. So when two candidate bases
always differ by the same small constant, they are usually two views of one
object. RTTI (run-time type information), when a release build keeps it,
can expose a class name near a virtual table; the name says nothing about where
any field sits.
One object can therefore answer three questions differently: which block was
allocated, which base view a particular pointer addresses, and its dynamic
type, the most-derived implementation virtual dispatch chooses.
Lesson 3.9 separates those identities
with a multiple-inheritance layout and the thunk that adjusts this.
A constructor and a destructor bracket that object’s life. A constructor runs
right after an allocation, calls base constructors first, and switches the vptr
from the base table to the derived table; an ordinary reset function may fill
the same defaults but does none of those three things. A destructor marks the
point after which every cached address to the object is stale, which matters
more to a memory tool than the original class name.
Lesson 3.10 follows that timeline from allocation to free.
One memory window is not enough to recover a structure. A repeatable sequence is:
Anchor one field. Find a value with an obvious controlled behavior, such as yaw, health, or ammunition.
Find code that uses it. Break on access. The instruction’s base register and displacement give the field’s offset: [ecx+40h] means the field sits 0x40 bytes (4 × 16 = 64) past the address in ecx.
Propose an object base. Subtract the displacement from the known field address: a yaw at 0x12340040 read through [ecx+40h] gives a base of 0x12340040 − 0x40 = 0x12340000.
Collect nearby candidates. Watch adjacent aligned values as position, view, health, and state change.
Compare instances. Inspect the local player and several bots at the same offsets.
Assign types carefully. Check byte width, signedness, endianness, value range, and how instructions consume the field.
Trace methods. Look for repeated this + offset accesses and virtual calls.
Test lifetimes. Join, die, respawn, leave, and start a new match. The moments when pointers become null or objects are replaced mark where cached addresses stop being valid.
A field nobody has managed to change on purpose stays unnamed, as field_48 or plain padding, until a controlled change or a traced read shows what it holds.
A reference promises that valid, correctly aligned local memory exists for a lifetime. A number copied from another process cannot keep those promises.
Copy each field through ReadProcessMemory instead:
// ✅ The process boundary and the fallible read remain visible.
letyaw = process.read_f32(player_base + 0x40)?;
Reading fields separately is also friendlier to an incomplete layout. You do not need to invent hundreds of padding bytes just to inspect three confirmed values.
The optional probe reference
applies this rule to the confirmed AssaultCube fields with checked offsets and
plausibility checks.
An object base is the start of one instance, and each field offset is a byte
distance from that base. The hidden this argument selects the instance for
a member call; a vptr commonly leads to its virtual-method table. Padding
and inheritance can add gaps or make one object reachable through several
base views. Those observations form a layout model for the tested game build,
not a promise for every build. A remote object address remains an address in
the game process, never a valid local Rust reference.
The same technique appears in debuggers, crash analyzers, compatibility tools,
profilers, and digital-forensics programs. Each one recovers structure from raw
memory without the original source code.
The read-only probe below applies this lesson’s layout to a live AssaultCube
match. The reconstruction above does not depend on building or running it.
Optional build reference: a read-only object-layout probe
Windows VM
The lab implementation is windows-labs/src/bin/object_layout_probe.rs. It opens ac_client.exe with read access only, follows the verified local-player and entity-list pointers, then copies the confirmed fields into local snapshots.
Here is the path a pointer takes through the probe before anything is printed:
The most important types are deliberately small:
usestd::num::NonZeroUsize;
useanyhow::Context;
#[derive(Clone,Copy,Debug)]
structObjectAddress(NonZeroUsize);
#[derive(Clone,Copy,Debug)]
structFieldOffset(usize);
implObjectAddress {
fnnew(value: usize) -> Option<Self> {
// ✅ Once constructed, an ObjectAddress can never contain null.
ObjectAddress and FieldOffset are both numbers underneath, but they mean
different things. The wrappers make it harder to accidentally treat a distance
as a complete address. NonZeroUsize also moves the null check into
construction. After ObjectAddress::new succeeds, every later function can
rely on that invariant instead of asking “is this zero?” again. This is what a
strong type does: it stores both a value and a rule about what that value is
allowed to mean.
The field reader performs bounded copies and plausibility checks:
anyhow::ensure!(dead_raw <= 1, "unexpected dead flag {dead_raw}");
Ok(PlayerSnapshot {
address: player,
name: read_name(process, player)?,
position,
yaw,
pitch,
dead: dead_raw != 0,
})
}
Why validate values after a successful Windows read? Because “four bytes were copied” does not mean “our class model is correct.” A stale pointer can still lead to readable memory. Rejecting impossible coordinates and flags catches some wrong-object guesses early.
Optional build reference: run the probe
Use 32-bit AssaultCube 1.2.0.2 on Windows and start a private local match with bots. From the repository root:
Terminal window
cargo run --manifest-path windows-labs/Cargo.toml `
--target i686-pc-windows-msvc `
--bin object_layout_probe
The output is a set of local snapshots, not live references:
local 0x16f7a3c0 name="player" pos=(112.50, 86.25, 5.00) yaw=271.20 pitch=2.10 dead=false
A C++ programmer may write a class named EnemyFactory, a State interface, and an Observer list. The processor never sees those friendly names. After compilation it sees addresses, loads, stores, comparisons, and calls.
That sounds discouraging, but design patterns still leave shapes in memory and code. A vtable is a row of function pointers. A factory allocates an object and initializes it. An observer loop walks a collection and calls similar functions. One shape alone is common in unrelated code, but several that fit together usually point to one pattern. 🧩
When debug symbols exist for a build, they give the real names and layouts, so they can check a layout recovered from the version-pinned executable.
A design pattern names recurring roles and expected changes, not just
dependency removal. Similar machine-code shapes can have different intent.
For example, both State and Strategy may delegate through a function table.
State usually represents behavior that changes as an object moves through a
lifecycle. Strategy usually selects one replaceable calculation or action, such as how a weapon chooses its aim point. Observed transitions
and call sites matter more than the table shape.
In your own tool, use a pattern after repeated change exposes a real
problem: duplicated lifecycle code, tangled ownership, or features that cannot
be tested separately. Do not add a factory, observer, or trait hierarchy only
because the name sounds professional. A smaller function or enum is often the
clearer design.
A pattern name is a likely role, not a recovered source name or proof of intent.
Programming paradigms: ways to organize a purchase
A programming paradigm is a style for organizing instructions and
responsibilities. It does not change what a purchase means. Ada still
starts with 100 gold, pays 25 if she can, and ends with 75. The styles
answer a different question: where should the affordability check and subtraction live, and how should
other code use it?
The examples below revisit the guarded purchase from Lesson 1.4.
For each isolated fragment, the caller starts with mutable gold of 100 and
cost of 25. A minimal player record and the shared calculation are:
The direct-update version is procedural. It says how to update the
variable, in order:
ifgold >= cost {
gold = gold - cost;
}
That is often the clearest way to begin. It becomes awkward only if every
screen and bot copies the same purchase steps, because a later change to the affordability check
would have to be repeated everywhere. Our named function solves
that duplication.
A Player record already keeps related data together. A method can put
the purchase behavior beside that record, so ada.buy(25) reads as an
action on Ada. The Rust Primer
shows the same organization with impl Runner and explains why a method
receives editable access to its runner. Rust methods do not require class inheritance;
Lesson 3.8 later examines how a compiled C++ game’s
classes and methods appear in memory.
Our spend_if_possible function illustrates a functional style. Given
100 and 25, it returns 75. It does not secretly reach out and change
Ada. The caller decides whether to store the answer:
letnext_gold = spend_if_possible(100, 25); // 75
That makes the calculation easy to check with different inputs. A larger
program can mix this style with state-changing methods; the two are
useful for different jobs.
A real game stays open instead of running main once and closing. A
Buy click can produce an event, and the interface or game engine can
call a registered handler in response:
fnon_buy_click(player: &mutPlayer) {
player.gold = spend_if_possible(player.gold, 25);
}
This shows the handler’s job, not a complete window setup. A game also
updates and draws while no one clicks. Lesson 6.9
explains Windows messages and game loops; Lesson 4.9
compares events with checking for changes on a schedule. For now, notice
that the event-driven style decides when the purchase calculation
runs.
One game can use all four styles: an event calls a handler, which asks a
player method to update state using a small calculation. A paradigm is a
way to organize work, not a team you must join forever.
Suppose three heap objects all begin with the same pointer. That pointer leads into a read-only section containing executable addresses. Several call sites do this:
movrcx, rbx; rcx is probably the current object (`this`)
movrax, [rcx] ; read the first field, possibly a vtable pointer
callqword ptr [rax+20h] ; call virtual slot 4
What this code shows is small:
Objects at these addresses share a function table, and slot four receives the object address.
Slot four is at +0x20 because each slot holds one 8-byte function address on 64-bit Windows, and 0x20 is 2 × 16 = 32 bytes, which is 32 ÷ 8 = 4 slots past slot zero.
Predict first
A 32-bit build of the same game makes the call as call dword ptr [eax+20h]. A 32-bit process stores each function address in 4 bytes.
Which slot of the table does this call use?
Answer: Slot 8. 0x20 is still 32 bytes, but each slot is now 4 bytes wide, so 32 ÷ 4 = 8 slots past slot zero.
Which table slot does the call use?call [table + displacement] reads one function address from the table. Each slot holds one pointer, so the slot number is the displacement divided by the pointer size.
0x20 selects slot 4
Table, 8 bytes per slot
slot 0+0x00
slot 1+0x08
slot 2+0x10
slot 3+0x18
slot 4+0x20
slot 5+0x28
Displacement ÷ slot size
0x20 = 32
÷ 8
slot 4
0x20 is 32 bytes, and 32 ÷ 8 = 4, so the call reads slot 4. Slot 0 is the first entry, at +0x00.
The code does not show the source class name, the inheritance tree, or the method name. Two facts can be checked directly: offset 0x00 points into read-only module memory, and the entries of that table point to executable module pages. Together they match how C++ compilers implement a vptr, so offset 0x00 is very likely one. A name such as Actor is a label chosen from the object’s behavior; nothing in the binary says what the original class was called.
A singleton is one shared object. Look for many functions loading the same global pointer before accessing fields:
movrax, [rip+player_manager]
testrax, rax
je not_ready
movecx, [rax+30h]
One global pointer alone does not make a singleton; it might be a cache or the current scene. A singleton is allocated once during initialization, reused by many unrelated call sites, and released at shutdown.
A factory chooses which concrete object to create. A common compiled shape is:
inspect a type ID;
choose an allocation size;
call an allocator;
call one of several constructors;
return a common base pointer.
A switch followed by several constructor calls is more useful than the word “factory.” Follow each constructor and compare its first vptr write and field initialization.
State and strategy patterns both replace one behavior with another. Memory may contain a pointer to a small behavior object or a function table. The owner delegates work to it each update.
Each arrow is a moment when the owner’s behavior pointer changes. If it changes when an enemy moves from idle to chase to attack, “state” is a useful name. If it changes when a weapon changes its aim-point calculation, “strategy” may fit better. The bytes do not care which textbook word you choose; behavior over time is the deciding clue.
An observer system keeps a collection of listeners and calls them when an event occurs. Look for one loop that:
walks a vector, list, or handle table;
passes the same event record to each element;
makes a direct or virtual call;
tolerates an empty collection.
Do not confuse this with an ordinary entity update loop. An observer loop usually starts from an event or notification function and passes similar event data to many unlike objects.
Composition stores behavior in separate components rather than one deep inheritance tree. Useful clues include:
an entity ID used to index several pools;
type IDs mapped to separate arrays;
bit masks describing which components an entity owns;
tightly packed arrays of positions or health values;
systems that loop over one component type at a time.
pos 0
pos 1
pos 2
pos 3
pos 4
one component type, packed together
A movement system walks this array and touches nothing else.
If position records are contiguous while unrelated entity fields live elsewhere, do not force them into one giant Player structure. The game may be data-oriented.
A table slot dispatches to one object. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
receiver = selected_object
table = receiver.vptr
slot_address = table + 4 * 8
function = read_pointer(slot_address)
call function(receiver)
record which object received the call
Result: object A received the call. The table gives a dispatch mechanism, not the original source class name.
movqword ptr [rcx+10h], 0; likely pointer or 64-bit field
+0x00vptr
+0x04vptr
+0x08100
+0x0C?
+0x100
+0x140
mov [rcx], rax: 8 bytes
dword 100
not written
qword 0
Each box is four bytes. The three stores reach +0x18; the bytes at +0x0C are never written, so they stay unknown.
The last store starts at +0x10 and writes a qword—eight bytes—so it touches
offsets +0x10 through +0x17; +0x18 is the first byte after the store.
That gives a minimum object size of at least 0x18 bytes, but alignment may
make the allocation larger. Compare the allocator’s requested size, constructor
stores, destructor reads, and ordinary methods. No single function tells the
whole story.
Lesson 3.8 placed a
single-inheritance base part at the start of the derived object. Multiple
inheritance makes the map less tidy:
+0x00vptr for base A
A's fields
+0x10vptr for base B
B's fields
derived fields
one allocation, one complete object
what a pointer typed as B points at: 0x10 bytes in
Multiple inheritance, with an example offset. A method of B receives the object as a pointer to the B part, not to the start.
Multiple inheritance can add more than one base subobject and more than one vptr. A method may even adjust this before using it.
When inspecting object-oriented code, separate three questions:
Allocation identity: which complete block of memory was created and must
eventually be released?
Subobject view: which base-class-shaped part does this particular pointer
address?
Dynamic type: which most-derived implementation will virtual dispatch
choose at this moment?
These answers can differ. A pointer passed to a base-class method may point a
few bytes into the complete allocation. A thunk — a tiny compiler-generated
stub that fixes something up and then jumps to the real function — may adjust
this before jumping to the real method. Subtracting every observed field from
that adjusted pointer can produce negative offsets and a broken layout even
though the pointer is valid for its particular base view.
When two candidate bases always differ by the same small constant, they are
usually two views of one object. The thunk or call site that adds the constant
shows where the adjustment happens, and the constructor, which writes both
vptrs, shows how the two views sit inside one allocation.
RTTI stands for run-time type information: extra data the compiler emits so
that code can ask what class an object really is while the program is running.
It is what makes dynamic_cast possible.
When it is present it can expose type-descriptor clues near virtual tables,
sometimes including a readable class name. That name tells you what the class
was called and nothing more: release builds can strip RTTI entirely, and a
name does not tell you where any of the class’s fields sit.
Imagine that offset 0x40 points to a weapon. Knowing that does not answer the dangerous questions:
Does the player own the weapon?
Can several players share it?
Can it disappear while another system still remembers it?
Is the value a pointer, an index, or a generation-checked handle?
These are lifetime questions. A reliable memory tool needs them because an address that was correct one frame ago may now belong to a different allocation.
The heap is memory handed out by an allocator for objects whose lifetime
does not simply end when the creating function returns. A spawned enemy, a
resized list, or a text buffer can live across many calls; the game later
releases or reuses its allocation. Unlike a call’s stack frame, cleanup is
controlled by an owner or a game event. An individually allocated object
commonly stays at one address while that allocation lives, but containers can
move their elements when they grow and an allocator can reuse a freed address.
Follow one address through two different owners:
0x5000enemy A · health 100
0x5080free
0x5100chat text
Before: enemy A lives at 0x5000.
0x5000enemy B · health 87
0x5080free
0x5100chat text
After A is freed, the allocator reuses 0x5000 for B. The address is readable but no longer identifies A.
A pointer being non-null and readable therefore does not prove object identity.
Check its owner, generation or other identity fields, and the event that ends
its lifetime. This is the deeper version of the brief address warning in
Lesson 1.8.
Compiler libraries have implementation details that can change. Instead of memorizing one screenshot of std::vector, recognize its behavior.
A vector-like container commonly needs three logical facts:
Fact
Meaning
begin
address of the first element
end
one element past the last live element
capacity end
one element past the allocated storage
The element count is (end - begin) / element_size. Before using that formula, verify all of these:
begin <= end <= capacity_end
(end - begin) is divisible by element_size
the addresses belong to readable memory
the resulting count fits a sensible limit
Worked through with real numbers, a vector of 32-byte entities might look like
this:
begin = 0x0453_1000
end = 0x0453_1140
capacity_end = 0x0453_1400
end - begin = 0x140 = 1×256 + 4×16 = 320 bytes
capacity_end - begin = 0x400 = 4×256 = 1024 bytes
320 / 32 = 10 live elements
1024 / 32 = 32 elements of allocated room
0x045310000
1
2
· · ·
9
0x0453114010
· · ·
31
live: 0x140 ÷ 32 = 10 elements
allocated, never constructed: 22
The element storage behind begin, end, and capacity_end. Boxes are elements, not bytes.
The gap between end and capacity_end is the container’s spare room. That is
exactly why the count has to come from end rather than from the size of the
allocation — using the allocation would report 32 entities, 22 of which were
never constructed.
Spare room runs out. When end reaches capacity_end, the next append cannot
fit in the same block.
Predict first
A vector of 32-byte entities is full: begin = 0x0453_1000 and end = capacity_end = 0x0453_1400, so it holds 0x400 ÷ 32 = 32 elements. Your tool cached element 3's address, 0x0453_1000 + 3 × 32 = 0x0453_1060.
The game appends a 33rd entity. What is at 0x0453_1060 afterwards?
Answer: Freed memory that can be handed to anything. With no room left, the vector allocates a bigger block, copies the 32 elements into it, adds the new one and frees the old block. Element 3 now lives at the new begin + 0x60.
Growing a full vector moves every element32-byte entities, begin = 0x04531000. A tool cached element 3 at 0x04531000 + 3 × 32 = 0x04531060.
1Full: 32 of 32 slots used
Old block at 0x04531000
beginend = capacity_end
0
…
30x04531060
…
31
32 × 32 bytes = 0x400, so end = 0x04531400 = capacity_end: no room for one more.
2Append a 33rd: allocate, copy, add
↓ copy 32 elements
New block at 0x06120000
0
…
30x06120060
…
31
32new
A bigger block (here 64 slots) receives copies of all 32 elements, then the new one.
3The old block is freed
Old block, now freed
0
…
30x04531060: stale
…
31
New block at 0x06120000
0
…
30x06120060
…
31
32
Element 3 is now at 0x06120000 + 0x60. The cached 0x04531060 points into freed memory.
All 3 steps shown
32-byte entities, begin = 0x04531000. A tool cached element 3 at 0x04531000 + 3 × 32 = 0x04531060.
Notice how much rests on element_size being correct. Guess 16 bytes instead
of 32 and the same three pointers report 320 / 16 = 20 elements, half of them
the second halves of real entities. Every address is readable and the count
looks entirely reasonable. The divisibility check rejects many wrong guesses,
but not one that happens to divide evenly like this, so the element size has to
come from somewhere else: the stride the code actually uses when it walks from
one element to the next.
Those checks describe meaning. They remain useful even if a compiler changes field order.
Live elements versus allocated room. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
begin = 0x04531000; capacity_bytes = 1024
used_bytes = end - begin
check 0 <= used_bytes <= capacity_bytes
check used_bytes % stride == 0
count = used_bytes / stride
report checked count and remaining uncertainty
Result: 10. Confirm the stride from observed accesses; readable, divisible bytes alone do not prove it.
Final values: used = 320; stride = 32; end = 0x4531140; capacity_bytes = 1024; bounded = true; divisible = true; count = 10.
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.
used = 320; stride = 32.
Step 2, line 2. End is 0x4531140; capacity end is 0x04531400.
used = 320; stride = 32; end = 0x4531140; capacity_bytes = 1024.
Step 3, line 3. Compare the live range with the allocation.
used = 320; stride = 32; end = 0x4531140; capacity_bytes = 1024; bounded = true.
Step 4, line 4. The remainder after dividing by stride is 0.
used = 320; stride = 32; end = 0x4531140; capacity_bytes = 1024; bounded = true; divisible = true.
Step 5, line 5. The structural checks allow this count.
Lesson 1.3 grouped one player’s ID, health, position, and team into a
record. With several players, one implementation can put complete records
side by side. Player 1 is highlighted here:
id 0
hp 0
pos 0
team 0
id 1
hp 1
pos 1
team 1
player 0
player 1
Array of structures: fields for one player sit together.
Another implementation can keep a separate collection for each kind of
field. Player 1’s information is now spread among the collections:
id 0
id 1
hp 0
hp 1
pos 0
pos 1
team 0
team 1
IDs
health
positions
teams
Structure of arrays: one field type sits together, while one player's fields are spread out.
The diagrams show logical order, not exact byte sizes. Both layouts
represent the same players. The first can be convenient when code handles
one complete player at a time. The second can reduce unrelated data read
into the CPU cache when code sweeps across only positions or only health.
Neither is automatically faster for every workload. Watch the addresses a
repeating instruction visits: a fixed stride through records suggests the
first arrangement; separate dense runs of one field suggest the second.
An entity-component system can take the separation further by putting
components in distinct pools, as shown later in this lesson.
Fixed offsets and scaled offsets answer different questions
Machine code often exposes an address formula. Read the formula before naming
the container:
base + fixed_offset → one possible field
base + index × stride → one possible array element
base + index × stride + offset → one field inside an array element
+0x00record 0
+0x08field
+0x30record 1
+0x38field
+0x60record 2
+0x68field
0x30 bytes
0x30 bytes
0x30 bytes
base + index × 0x30 + 0x08 lands on the highlighted field of each record. Not to scale.
For example, base + index * 0x30 + 0x08 suggests records that are 0x30
bytes apart and a candidate field eight bytes into each record. Test several
indexes and compare the same behavior at each calculated address.
The formula shows how the code computes an address, not the declared type. A
compiler can unroll a small loop into three fixed-offset stores, making an array
look like a structure. Conversely, a table-driven system can compute what is
logically a field offset, making a structure look indexed. Access patterns over
time settle it:
repeated neighboring strides support an array-of-records model;
many unrelated functions reusing the same constants support named fields;
allocation size gives an upper bound, not a complete layout;
instruction width constrains a field’s size, not necessarily its full type;
one readable value never distinguishes a pointer, handle, counter, or bit set.
A vector lets code reach element n with arithmetic on one block. A short C++
string may instead keep its text inside the object and switch to a heap
pointer when the text grows; a linked list, search tree, or hash table reaches
its elements by following pointers or hashing a key. The optional container
reference shows each shape’s clue in
the code that walks it. Before walking any linked shape, a scanner needs a
maximum-node limit and a visited-address set, because corrupted data can form a
cycle that would otherwise trap it forever.
Choose the model from what the game does with the data, as
Lesson 3.3’s collection choices
do, and then ask whether items can be inserted or removed while you read them. A
readable address alone cannot identify a container.
A field that leads to another object may own it alone, share it through
reference counts, or name it indirectly. The optional ownership
reference shows the clues that unique and shared owners
leave. A handle is the indirect form.
The index chooses a slot. The generation proves that the slot still represents the same logical object. If a deleted entity’s slot is reused, its generation changes and old handles stop resolving. ✅
For example, a saved handle for slot 12 with generation 3 resolves only
while slot 12 still reports generation 3. If that enemy dies and a new
enemy occupies slot 12 with generation 4, the old handle is rejected.
The slot may hold a real living object at a valid address; the
generation mismatch is what reveals that it is the wrong object.
A memory snapshot shows one state. Construction and destruction show how the
state came to exist and which parts belong together.
A constructor candidate often:
receives a newly allocated or caller-provided object address;
writes repeated default values at fixed offsets;
calls base constructors before initializing derived fields;
writes one or more vptr candidates, sometimes replacing an earlier base vptr;
returns the same object or passes it into a registration system.
None of those clues identifies a constructor alone, because an ordinary reset
function can also fill fields with defaults. What a reset function does not do
is run right after an allocation, call base constructors first, and switch the
vptr from the base table to the derived table.
Destructors show ownership and the point after which a cached address is stale.
For a memory tool, that lifetime boundary matters more than recovering the
original C++ class name. Ask:
Which child objects are destroyed?
Which pointers are merely cleared?
Which reference counts are released?
Which collections remove the object?
Is memory freed immediately or returned to a pool?
Set a breakpoint on an owned toy object’s destructor and delete it through the normal game action. The call chain and the fields the destructor touches answer those five questions. Repeat the experiment, because one run may include unrelated cleanup.
Component pools store fields in separate collections
In an entity-component system, an entity handle may resolve into several pools:
A sparse set often combines a sparse index array with a dense component array. Deletion may swap the final dense element into the removed slot, so a component’s address can move even while the entity remains valid.
slot 0A
slot 1B
slot 2C
slot 3D
A dense component array. Entity B's component is about to be removed.
slot 0A
slot 1D
slot 2C
Swap-remove: the last element, D, moves into the hole. D's entity did not change, but its component now lives at a different address.
That is why a stable entity ID is often more meaningful than a cached component pointer.
Do not immediately use #[repr(C)] struct Player for a layout that is only partly known. A staged representation shows which parts are still unknown:
#[derive(Debug,Clone,Copy)]
structSuspectedPlayerLayout {
object_size: usize,
health_offset: Option<usize>,
weapon_link_offset: Option<usize>,
weapon_link_kind: LinkKind,
}
#[derive(Debug,Clone,Copy)]
enumLinkKind {
Unknown,
RawPointer,
SharedOwner,
GenerationalHandle,
}
An Option says the field has not been confirmed. Unknown prevents a
guess from silently becoming a fact. Once source, symbols, and repeated
behavior agree, the model can become more precise.
The derive line generates formatting and copying for this small
description of a layout, as Lesson 3.4
explains; it does not make an unconfirmed game-memory layout safe to read.
object size agrees with allocation and construction;
every field offset is seen at multiple call sites;
container count and capacity pass arithmetic checks;
traversal has bounds and cycle detection;
ownership claims agree with destruction behavior;
cached addresses are invalidated when the game changes scenes;
the layout is stored with the build it belongs to;
disagreements remain visible instead of being “fixed” by guessing.
Those checks assume we know what each field’s stored bytes represent.
If the game’s visible health is 125 but no candidate field contains 125,
the next question is whether the game stores a transformed value.
Lesson 14.9 follows that question without throwing away
the object and lifetime checks we just built.
These layouts come from particular libraries and containers. The vector
header, lifetime, and handle checks above do not depend on them.
Optional reference: strings can store short text inside themselves
Many C++ string implementations use a small-string optimization. Short names may live directly inside the string object, while longer names use a heap buffer. This explains a confusing sight: one player name looks like text at offset 0x20, but a longer name makes the same bytes look like a pointer.
Here is one real implementation’s layout, the 32-bit Microsoft C++ library’s
std::string, holding the three-character example name Ivy and then a
longer one. The name is only sample text; its length is what matters:
+0x00Ivy, then a zero byte
+0x10size 3
+0x14capacity 15
16 bytes: the text itself
Short: up to 15 characters live inside the object.
+0x00pointer to heap text
+0x10size 40
+0x14capacity ≥ 40
the same 16 bytes now start with a pointer
Long: the first four bytes point to a heap buffer, and the capacity tells you which mode you are in.
Do not read an assumed pointer until you identify the mode flag or length behavior. Compare the same object with names on both sides of the suspected short-string limit. 🔬
A vector keeps its elements side by side, so code finds element n with
arithmetic. Three other common containers give up that arithmetic in exchange
for something else, and each exchange leaves a recognisable pattern in the code
that walks it.
Optional reference: a linked list, every element points to the next
A linked list stores each element in its own heap block, called a node,
together with the address of the next node, and often the previous one too. It
exists to make inserting and removing cheap. Taking the first of 10,000 elements
out of a vector means sliding the other 9,999 down one place; taking it out of a
doubly linked list means rewriting two pointers in its neighbours.
The price is that a list has no arithmetic shortcut. Reaching the 1,000th node
takes 1,000 reads, each one loading the next pointer out of the node just
reached. That repeated load from a fixed offset inside the current node, such as
mov eax, [eax+4] in a loop, is the list’s clue. The nodes are wherever the
allocator put them, not in one block.
Optional reference: a search tree, each comparison discards half
A binary search tree node holds a key and two child pointers: smaller keys
go to the left, larger ones to the right. Finding a key means comparing and
stepping left or right. In a balanced tree, each step discards roughly half
the remaining possibilities, so a tree of 1,000,000 entries needs about 20
steps: 2²⁰ = 1,048,576. std::map typically maintains such a balanced tree.
Its clue is a loop that compares a key and then loads one of two pointers
depending on the result. Its failure is lopsidedness: keys inserted in sorted
order all go the same way and turn the tree into a slow list, which is why real
implementations rebalance themselves.
Optional reference: a hash table, arithmetic picks the bucket
A hash table turns the key into a number, its hash, and uses that number
to pick one slot, or bucket, in an array of buckets. Only the entries in that
bucket are checked. With a suitable hash and enough buckets for the entries,
lookup is usually close to constant time as the table grows; collisions or a
full table can make it slower. With 16 buckets, a hash of 0x5A3 selects bucket
0x5A3 & 0xF = 3, because the mask 0xF keeps only the last hex digit. The
clue is arithmetic on the key (multiplies, shifts, XORs), then that mask or a
remainder, then a short walk through one bucket. Two keys can land in the same
bucket, a collision, which is why each bucket may hold a short list.
Lesson 11.3 shows the same idea inside
Lua’s tables.
Optional reference: invariants for walking linked structures
Validate structural invariants:
a doubly linked node’s next.prev should return to the node;
tree children should eventually end at a sentinel or null;
bucket indices must stay below the bucket count;
traversal needs a maximum-node limit and a visited-address set.
The visited set is important. Corrupted data could form a cycle and otherwise trap a scanner forever.
Optional reference: unique ownership
If one owner’s destructor directly destroys the pointed object, and moves clear the old pointer, unique ownership is likely. That resembles C++ unique_ptr, but use a neutral label until symbols confirm it.
Optional reference: shared ownership
Shared objects often have a separate control block with strong and weak reference counts. Copies increment a count; releases decrement it; a transition to zero calls a destructor. The object pointer and control-block pointer may travel together.
Never edit a suspected count. The calls around it show whether its updates are atomic, as a thread-safe reference count’s must be; a plain integer that happens to rise and fall is not enough to call something a reference count.
The screen may show “Ford,” but memory stores bytes. An encoding explains how bytes represent characters.
Common forms include:
UTF-8—used by String and str;
UTF-16—many Windows “wide” APIs (Reference: Microsoft's guide to strings in Windows programs explains wide characters, the A and W versions of an API, and Unicode.);
older single-byte encodings;
null-terminated C strings.
Keep four layers separate:
a character is the human idea, such as é;
a code point is the Unicode number assigned to that character idea;
a code unit is one storage unit used by an encoding, such as u8 for UTF-8 or u16 for UTF-16;
the bytes are the actual memory representation, with byte order relevant for multi-byte code units.
One visible character can use several code units. That is why byte count,
UTF-16 unit count, Unicode scalar count, and what a reader sees on screen are
not interchangeable lengths. Four short examples show every column coming
apart:
text UTF-8 bytes UTF-16 units scalars visible
"A" 1 1 1 1
"é" (U+00E9) 2 1 1 1
"🎮" (U+1F3AE) 4 2 1 1
"e" + U+0301 3 2 2 1
The last row is e followed by a combining acute accent, which displays as a
single é but is two separate scalars. The practical consequence: a buffer
sized from “the name is at most 16 characters” can be far too small, and code
that truncates a string at a fixed byte count can cut a character in half and
produce bytes that no longer decode. Always say which of these four lengths a
number refers to.
That final row is one grapheme cluster,
the unit approximating a character a reader perceives.
Pack a code point into UTF-8 bytescafé is 63 61 66 C3 A9: c, a and f fit in one byte each, é needs two. Change the code point to see how its bits are split and marked.
U+00E9: 2 bytes
U+00E9 as 11 bits, split 5 + 6
00011
101001
↓ add the markers
Marker bits, then the payload
110 00011110: 2-byte start
10 10100110: continues
↓ write as hex
UTF-8 bytes in memory
C3
A9
U+00E9 needs 8 bits. One byte carries only 7, so UTF-8 uses 2 bytes, which carry 11: C3 A9.
This interactive lab needs JavaScript. The lesson text around it still explains the same idea.
Compare ASCII, Unicode, and Base64 with the same text
ASCII defines a 7-bit repertoire: 128 values, numbered 0 through 127,
covering basic Latin letters, digits, punctuation, and control codes. An ASCII value
fits in an 8-bit byte with its top bit zero, as RFC 20
describes. Values above 127 belong to some other encoding, not standard ASCII.
Unicode preserves those ASCII assignments within a much larger repertoire.
Its code points identify characters; UTF-8 and UTF-16 choose their stored code
units and bytes. The Unicode UTF FAQ
explains that relationship. Compare the same two-letter text, HI, in each
representation. No terminator or byte-order mark is included:
View of HI
Representation
What to count
Unicode code points
U+0048 U+0049
Two code points: H and I.
ASCII / UTF-8 bytes, in hex
48 49
Two bytes, with decimal values 72 and 73.
Those bytes, in binary
01001000 01001001
Sixteen bits, displayed as two 8-bit bytes.
UTF-16LE bytes, in hex
48 00 49 00
Four bytes for two 16-bit code units.
Base64 of the ASCII / UTF-8 bytes
SEk=
Four output symbols representing the original two bytes.
Base64 encodes bytes as ASCII text. It is reversible without a secret key,
so encoding is not encryption. Standard Base64 uses the 64-symbol alphabet
A–Z, a–z, 0–9, +, /; = is padding. RFC 4648
defines these rules.
For 48 49, join the 16 bits and append two zero pad bits to complete a
six-bit group: 010010 000100 100100. The group values are 18, 4, and 36,
which select S, E, and k. Two input bytes leave one = padding symbol,
so the result is SEk=.
Below, HI is worked out bit by bit; type your own text to see how each step changes:
From characters to bytes to Base64Every character is a number, every number is one byte, and Base64 re-cuts those bytes into groups of six bits. Type up to 6 plain ASCII characters, or start from the worked example.
Worked example for HI
Base64SEk=
1 Each character is one byte
ASCII gives each character a number from 32 to 126, and that number fits in one byte of 8 bits. Here it is as decimal, hexadecimal and binary.
Character
Decimal
Hex
Binary
H
72
48
01001000
I
73
49
01001001
H is code 72. In hex, 72 = 4 × 16 + 8, so 48. In binary, 72 = 64 + 8, so 01001000.
I is code 73. In hex, 73 = 4 × 16 + 9, so 49. In binary, 73 = 64 + 8 + 1, so 01001001.
2 Join the bits, then cut them into groups of six
2 bytes × 8 = 16 bits. 16 ÷ 6 = 2.67, so 3 groups of six bits (the last one completed with 2 zero bits). 3 symbols need 1 “=” to reach 4, a multiple of four.
H · 4801001000I · 490100100101001018 → S0001004 → E10010036 → k
Top: each byte with its character and hex value. Bottom: the same bits, in the same columns, cut into groups of six, each with its value and symbol. Hatched zeros were added to finish the last group; they are not part of the data.
3 Each group is a number, and each number is a symbol
The Base64 alphabet runs A to Z (0 to 25), a to z (26 to 51), 0 to 9 (52 to 61), then + (62) and / (63).
Group
Bits
Value
Symbol
1
010010
18
S
2
000100
4
E
3
100100
36
k
010010 = 16 + 2 = 18; 18 is in 0 to 25, the capital letters (A is 0), so the symbol is S.
000100 = 4; 4 is in 0 to 25, the capital letters (A is 0), so the symbol is E.
100100 (1001 plus 2 added zero bits) = 32 + 4 = 36; 36 is in 26 to 51, the small letters (a is 26): 36 − 26 = 10, so the symbol is k.
4 Pad to a multiple of four symbols
Base64 output comes in blocks of four symbols. 3 symbols is not a multiple of four, so 1 “=” sign is added to make 4. They carry no data: they only tell the decoder how many bytes were missing.
The same text in five forms
Form
This text
How to tell it
Text (ASCII)
HI
Readable characters.
Binary
01001000 01001001
Only 0 and 1, in groups of 8.
Decimal
72 73
Numbers; for printable ASCII always from 32 to 126.
Hexadecimal
48 49
Digits 0 to 9 and letters A to F, two per byte.
Base64
SEk=
Letters, digits, + and /, with = at the end when the groups run short.
The base64url variant in RFC 4648
replaces + with - and / with _. Some protocols allow omitted =
padding; follow the receiving protocol’s rule. The example needs neither
replacement character, so its padded base64url form is also SEk=.
A terrain description: the text this lesson searches for in memory.
In the offline Wesnoth target, open a terrain description and search memory for one distinctive phrase using the appropriate text encoding (Tip: In Cheat Engine, choose the scan type Search for text. Tick UTF-16 to find text stored as two-byte units, and leave it unticked to find one-byte text. Try both if one finds nothing.).
Cheat Engine's Search for text. Tick UTF-16 to find two-byte text.
Change the selected tile and repeat. A static label may stay at one address, while a formatted output buffer changes.
Set a read breakpoint on the first byte of a promising string, trigger the description, and inspect the code that consumes its address.
A read breakpoint on the first byte of the string.
For copied bytes 41 00 00 00, the caller first finds the two-byte zero
terminator and passes the preceding 41 00 slice. The function rebuilds
0x0041 as one UTF-16 unit and returns Ok("A"). Passing an odd number of
bytes returns an error instead of silently dropping the last byte.
Many C-style functions expect a zero byte at the end:
usestd::ffi::CString;
letlabel = CString::new("Rust lab")
.expect("the label has no interior zero byte");
letpointer = label.as_ptr();
CString adds the terminator and rejects an interior \0, which would make a C function think the string ended early.
Keep the CString alive for as long as the foreign function may use pointer. Returning as_ptr() from a function after the CString is dropped creates a dangling pointer.
Also determine whether the foreign function copies the text during the call or stores the pointer for later. “The call returned” ends the borrow only in the first case. Ownership and lifetime are part of the string contract just as much as encoding and termination.
On the Den of Onis map in offline Wesnoth 1.14.9, right-click a Ford tile
and open Terrain Description. Search for a distinctive part of its text,
then set a read breakpoint on a confirmed byte and open the description again.
In the book’s run, the live string began at 0x10CE996B. Stepping out reached
the print path at 0x005ED114: edx led to the text object and the call at
0x005ED129 printed it.
At this point, answer the text questions: which bytes were read, where does
the string end, who owns its buffer, and does the print call keep the pointer?
The breakpoint and the changing description provide the evidence. They do
not require an injected DLL.
Once you have copied a bounded byte range into your own tool, a read-only
check can separate the terminator from the text. This example assumes you
already verified UTF-8; another encoding needs its own decoder:
The zero ends this C-style string; bytes after it do not belong to the
displayed text. In a live process, cap the copy length before searching for
the zero so a missing terminator cannot make the tool read indefinitely.
Lesson 8.3
returns to this exact call after teaching patch ownership and DLL lifecycle.
There, the debugger cave changes one letter, and the complete F2 hook displays
the second player’s gold. The original call, verified five-byte patch,
near_call/near_jump code, naked cave, and restoration are preserved
with that worked implementation.
Calling an undocumented internal print routine can be fragile. For new tool UI, a separate overlay or console is often easier to version, test, and remove.
Use an internal function only when the lesson is specifically about understanding that function and the target profile is exact.
The bytes of a string need an encoding before they become text. UTF-16LE
often has a zero high byte for an ASCII-range letter; that byte alone does
not end the string. A C-style terminator marks the end of the encoded
sequence, and CString owns a terminated buffer while rejecting interior
zeros. The pointer passed to a print function is useful only while that
buffer remains alive, so tracing a text call means checking both its bytes
and its lifetime. That is one part of a larger function contract: the caller
and callee must also agree on argument positions, return values, and ownership.
What a DLL Function Promises Its Caller reconstructs those
promises later, before the in-process DLL labs.
We can now interpret a game’s primitive values, objects, collections, and
text without confusing their representation with their meaning. The next
chapter asks how those pieces form a changing world. Game Engines: Input, Simulation, and Rendering starts by separating the engine’s reusable
machinery from one game’s movement, damage, timers, and content.