Skip to content
Lesson 3.1Typed Memory, Ownership, and Object Layouts

Safe Boundaries for Game Memory

Learn the ownership, address, byte-buffer, error, and operating-system boundaries used by every memory tool.

Lesson 3.1
Read or listenPrint chapter or book
About 28 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← →
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 3.1Key ideas, reference and questions

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: usize is large enough to index memory in the current build.
  • ?: The ? means “if this step fails, return None now.” No out-of-bounds read and no panic are needed.
  • from_le_bytes: from_le_bytes names the byte order explicitly.
  • address: &self lets the method use an existing reader, address is the location to request, and Result carries either the decoded u32 or an error.
  • u32: u32 is always four bytes; usize follows the tool's pointer width.
  • Option: Option represents an absent value, while Result carries 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
Download cheatsheet (.md)

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:

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
  1. 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.
  2. The tool asks Windows to copy the bytes. The read is checked, and it may fail or return fewer bytes than asked for.
  3. 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.
  4. 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.
  5. 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.

Memory work depends on sizes, so we use explicit integer types:

let health: u32 = 100; // unsigned, 32 bits
let direction: i32 = -1; // signed, 32 bits
let x: f32 = 12.5; // 32-bit floating point
let address: usize = 0x1000; // pointer-sized integer

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.

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

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);
}

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.

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.

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
target contextpid 4242, build a3f1c9e2, address 0x017E_ED18
outer policyretry once, skip this snapshot, or stop safely
Each layer adds context without erasing the one below it. The outermost layer, 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.

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.

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.

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.

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.

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

Before: dereference an address from another process
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.

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.
cargo clippy --all-targets --all-features -- -D warnings
# 📚 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:

  1. Does its API match the actual problem?
  2. Does it support the toolchain and Windows versions you use?
  3. Is the license compatible with the project?
  4. Are its unsafe boundaries and maintenance history understandable?
  5. 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.

Keep the project in layers:

main.rs parse input and print useful errors
process.rs own the Windows process handle
memory.rs safe read/write methods
address.rs checked address and pointer-chain logic

The Windows calls may be unsafe. Finding a value in a byte slice, filtering candidates, parsing numbers, and printing results do not need to be.

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.

Knowledge check

Contain the assumption

Why should raw-pointer and Win32 FFI work live in a small Rust boundary?

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.

Chapter 3: 11 lessons

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.