Safe Boundaries for Game Memory
Learn the ownership, address, byte-buffer, error, and operating-system boundaries used by every memory tool.
Cheatsheet for Lesson 3.1
A one-page summary of “Safe Boundaries for Game Memory” you can keep beside your notes. Plain Markdown.
Lesson 3.1 — Safe Boundaries for Game Memory: cheatsheet
- Remember: Never treat another process's address as your own pointer. Copy the bytes into a buffer you own, then work on the copy.
- Also useful: Think of labelled jars: sugar and salt look alike, but the label stops you pouring one where the other belongs. A newtype is that label for numbers.
Key terms
usize:usizeis large enough to index memory in the current build.?: The?means “if this step fails, returnNonenow.” No out-of-bounds read and no panic are needed.from_le_bytes:from_le_bytesnames the byte order explicitly.address:&selflets the method use an existing reader,addressis the location to request, andResultcarries either the decodedu32or an error.u32:u32is always four bytes;usizefollows the tool's pointer width.Option:Optionrepresents an absent value, whileResultcarries success or a failure reason.
Review
- Q: Why should raw-pointer and Win32 FFI work live in a small Rust boundary? A: The assumptions can be documented in one place, and callers can use checked types
- Q: Why should code that inspects another process's memory avoid building a normal safe reference to an object at a remote address? A: The other process can free, change or race with that memory at any moment
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.
The Rust primer extension explains ownership, typed handles and cleanup with a small local example before those ideas are applied here. Its cleanup companion shows why ordinary early returns still drop an owner.
Keep raw-pointer work at a checked boundary
Section titled “Keep raw-pointer work at a checked boundary”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
Section titled “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:
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.
Values and types
Section titled “Values and types”Memory work depends on sizes, so we use explicit integer types:
let health: u32 = 100; // unsigned, 32 bitslet direction: i32 = -1; // signed, 32 bitslet x: f32 = 12.5; // 32-bit floating pointlet address: usize = 0x1000; // pointer-sized integerUse 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.
Give equal-sized values different meanings
Section titled “Give equal-sized values different meanings”A player ID and a health amount might both fit in a u32, yet swapping
them should be an error. Rust can wrap the same stored bits in distinct
types:
struct PlayerId(u32);struct Health(u32);
fn damage(target: PlayerId, amount: Health) { /* ... */ }// damage(Health(50), PlayerId(3)); // rejected: the arguments are swappedThese 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.
Ownership and borrowing
Section titled “Ownership and borrowing”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).
fn find_value(bytes: &[u8], needle: [u8; 4]) -> Option<usize> { bytes.windows(needle.len()) .position(|window| window == needle)}
let memory = vec![0_u8, 100, 0, 0, 0, 5];let offset = find_value(&memory, 100_u32.to_ne_bytes());assert_eq!(offset, Some(1));&[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:
fn main() { let mut bytes = vec![10, 20, 30]; let view = &bytes; println!("{}", view[0]); bytes.push(40);}Convert bytes deliberately
Section titled “Convert bytes deliberately”A debugger displays bytes; a game interprets them as types. The conversion should state that meaning explicitly:
fn read_u32(bytes: &[u8]) -> Option<u32> { let first_four: [u8; 4] = bytes.get(..4)?.try_into().ok()?; Some(u32::from_le_bytes(first_four))}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.
Errors are part of the function
Section titled “Errors are part of the function”Use Result<T, E> when callers need to know why an operation failed:
#[derive(Debug)]enum ReadError { AddressOverflow, Unreadable { address: usize }, WrongSize { expected: usize, actual: usize },}
fn add_offset(address: usize, offset: usize) -> Result<usize, ReadError> { address.checked_add(offset).ok_or(ReadError::AddressOverflow)}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.
Separate the cause from the decision
Section titled “Separate the cause from the decision”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.
outer policy, is the only one that decides what the tool actually does.Add context at each boundary without erasing the original Windows or parsing error. Then classify the final decision:
| Situation | Sensible tool behavior |
|---|---|
| Game exited | Stop normally and close owned handles. |
| Pointer changed during a scene load | Discard the snapshot and observe again. |
| Layout validation failed | Refuse the action and report the build plus field. |
| Programmer invariant failed | Stop; retrying would hide a tool bug. |
This makes failure part of the design. “Keep going” is safe only when the next iteration starts from fresh, validated state.
Put uncertain reads behind one boundary
Section titled “Put uncertain reads behind one boundary”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 MemoryReader { fn read_u32(&self, address: usize) -> anyhow::Result<u32>;}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.
4 bytes at 0x0A3C0100
All 4 bytes, 0x0A3C0100 to 0x0A3C0103, lie in the readable page, so Windows copies all of them.
Give each game helper one obvious import path
Section titled “Give each game helper one obvious import path”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:
mod windows_impl;
// ✅ Course tools get a short, reviewable list of supported imports.#[doc(inline)]pub use windows_impl::{OwnedHandle, PatchPlan, Process};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.
Raw pointers and unsafe
Section titled “Raw pointers and unsafe”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`.unsafe fn read_raw_u32(pointer: *const u32) -> 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.
What .offset() actually does
Section titled “What .offset() actually does”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:
first, at offset 0first.offset(2): two elements along, 8 bytes → 30u32, 4 bytes. .offset() counts in elements of the pointer's type, so offset(2) moves two boxes, not two bytes, landing on 30.let values = [10_u32, 20, 30, 40];let first: *const u32 = values.as_ptr();
// 🧭 `offset(2)` means "move by two u32 values," so this advances 8 bytes.let third = 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:
use std::mem::size_of;
let bytes = values.as_ptr().cast::<u8>();let one_u32_in_bytes = isize::try_from(size_of::<u32>()).unwrap();
// 📏 This count is explicitly bytes because `bytes` points to u8 values.let second = unsafe { bytes.offset(one_u32_in_bytes).cast::<u32>() };assert_eq!(unsafe { second.read() }, 20);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.
fn read_health(process: &Process, remote_base: usize) -> Result<u32, ReadError> { // SAFETY: cannot be established for a remote address; this is the bug. let health = unsafe { *((remote_base as *const u8).offset(0x30).cast::<u32>()) }; Ok(health)}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.
Improved code
fn read_health(process: &Process, remote_base: usize) -> Result<u32, ReadError> { let health_address = remote_base.checked_add(0x30) .ok_or(ReadError::AddressOverflow)?; let health = process.read_u32(health_address)?; Ok(health)}Show the diff
fn read_health(process: &Process, remote_base: usize) -> Result<u32, ReadError> { // 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.
Set up a Windows project
Section titled “Set up a Windows project”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.
cargo new memory_labcd memory_labAdd 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.
Make the tools teach you
Section titled “Make the tools teach you”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:
# ✅ 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.cargo clippy --all-targets --all-features -- -D warnings
# 📚 Build documentation for this project and its dependencies.cargo doc --opencargo 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.
Reuse code without turning off judgment
Section titled “Reuse code without turning off judgment”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.
The shape of our programs
Section titled “The shape of our programs”Keep the project in layers:
main.rs parse input and print useful errorsprocess.rs own the Windows process handlememory.rs safe read/write methodsaddress.rs checked address and pointer-chain logicThe Windows calls may be unsafe. Finding a value in a byte slice, filtering candidates, parsing numbers, and printing results do not need to be.
The boundary carried into the tool
Section titled “The boundary carried into the tool”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.
Contain the assumption
Why should raw-pointer and Win32 FFI work live in a small Rust boundary?
A narrow boundary makes validity rules reviewable and keeps most of the program in ordinary safe Rust.
Finished Lesson 3.1?
Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.
Plain Markdown, saved in this browser.
This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.