Memory Regions and Value Scanning
Understand region permissions, copied snapshots, candidate filtering, and guarded writes in a value scanner.
Cheatsheet for Lesson 5.8
A one-page summary of “Memory Regions and Value Scanning” you can keep beside your notes. Plain Markdown.
Lesson 5.8 — Memory Regions and Value Scanning: cheatsheet
- Remember: Each scan keeps only candidates that survived all earlier ones. The list can only shrink, which is why a few controlled changes narrow millions of bytes to one.
- Also useful: Think of a phone book: reading every page costs more as the book grows, but jumping straight to the right letter barely changes. Ask which kind of work you add.
Key terms
VirtualQueryEx:VirtualQueryExdescribes these facts; it does not copy the target bytes.- O(1): That is O(1) on average: hashing one address chooses a bucket instead of reading the entire candidate list.
size:baseandsizeare integers because a normal local reference cannot borrow memory owned by another process.- enum with data: An enum with data represents only supported filters.
Review
- Q: Why compare scan candidates after a controlled value change? A: Only addresses that follow the change can hold the tracked value; the others matched by coincidence
- Q: A first scan finds every address containing 100. You spend points and the visible value becomes 75. What should the next scan keep? A: Only the old candidates that now contain 75
The scanner pipeline
Section titled “The scanner pipeline”The pattern scanner in Lesson 5.7 searched executable bytes for a known instruction shape. The first gold scan from Chapter 1 asked a different question: “Which readable data locations currently hold the visible value 100?” A memory scanner answers that question by collecting candidates, then checking which of those same addresses follow the gold as it changes to 85 and 92.
That difference determines the pipeline. We describe readable memory, copy bounded chunks, interpret four bytes as a value, and retain only candidates that follow the controlled change:
The Windows calls are only the outer shell. Candidate filtering uses checked local slices.
This simulation needs JavaScript. The lesson text around it works through the same numbers.
What the scanner is searching
Section titled “What the scanner is searching”A process does not own one giant, equally readable byte array. Windows divides its virtual address space into pages and reports neighboring pages with similar state as regions.
| Region fact | Question it answers |
|---|---|
| State | Is this address range committed, reserved, or free? |
| Protection | Can the target read, write, or execute it? Is it guarded? |
| Base address | Where does the region begin? |
| Region size | Where must the region walk advance next? |
VirtualQueryEx describes these facts; it does not copy the target bytes. ReadProcessMemory performs the copy later. Keeping “describe” and “read” as separate steps makes failures easier to explain.
A number such as 100 is also not self-identifying. The bytes 64 00 00 00 represent 100 only when interpreted as a little-endian u32. The same four bytes can be part of a pointer, an instruction, a larger number, or unrelated data. That is why the first scan returns candidates rather than an answer.
Repeated scans are set intersection
Section titled “Repeated scans are set intersection”Imagine the first scan finds 40,000 addresses containing 100. You spend 15 gold and scan for 85. Most unrelated addresses do not make that exact transition, so perhaps 18 survive. Earn 7 gold and scan for 92; perhaps one remains.
The scanner never rescans the whole process after the first pass. It re-reads only the addresses that survived every earlier pass, so the candidate set only shrinks. This makes later passes faster and preserves the history needed for filters such as increased, decreased, changed, and unchanged.
How the work grows
Section titled “How the work grows”How much faster is worth working out, because the same reasoning decides whether any tool feature is usable. Suppose the game has 400 MiB of readable memory and each candidate is a 4-byte value:
| Pass | What it reads | Bytes read |
|---|---|---|
| first scan | every readable byte: 400 × 1,048,576 | 419,430,400 |
| second scan | 40,000 candidates × 4 bytes | 160,000 |
| third scan | 18 candidates × 4 bytes | 72 |
The second pass reads about 2,600 times less than the first (419,430,400 ÷ 160,000 ≈ 2,621), and it no longer depends on how big the game is at all, only on how many candidates survived.
The first scan’s cost grows in step with its input: give the game twice the memory and the scan reads twice the bytes. Programmers write that as O(n), read “order n”, where n is the size of the input. Some costs do not grow at all: finding one candidate’s previous value in a hash table keyed by address takes about the same time with 40 candidates or 40,000, as Lesson 3.10 explains. That is O(1) on average: hashing one address chooses a bucket instead of reading the entire candidate list. Collisions and a badly overloaded table can make individual lookups slower, so this is not a promise of identical timing. And a loop inside a loop multiplies. Comparing every one of the 40,000 candidates with every other takes 40,000 × 40,000 = 1,600,000,000 comparisons, 40,000 times the work of reading the list once, and doubling the candidates makes it four times worse. That is O(n²). Before adding a scan feature, ask what happens to the work when the memory or the candidate list doubles.
Describe readable regions
Section titled “Describe readable regions”VirtualQueryEx describes ranges of a target process, but the scanner needs
only a few facts from each Windows structure. This local record holds the
range and the permission decisions without pretending that its bytes are
already available to read:
#[derive(Clone, Copy, Debug)]struct MemoryRegion { base: usize, size: usize, readable: bool, writable: bool, executable: bool,}This struct is a local description, not a view into the remote process. base and size are integers because a normal local reference cannot borrow memory owned by another process. The Boolean fields are decisions already derived from Windows protection flags, so the scanning loop does not need to repeat bit-mask logic.
Use VirtualQueryEx to walk the target’s virtual address space. Keep only committed regions with readable protection, and skip guard or no-access pages.
Advance with checked math:
let next = region.base.checked_add(region.size) .context("memory-region walk overflowed")?;anyhow::ensure!(next > current, "memory-region walk made no progress");current = next;The progress check prevents an infinite loop if Windows or a wrapper ever reports a zero-sized region. checked_add treats address overflow as an error instead of wrapping from the top of the address space back to zero.
Scan copied chunks
Section titled “Scan copied chunks”Suppose the visible gold value is 1,000. The number becomes four little-endian
bytes, E8 03 00 00, and the scan compares that byte pattern with each
four-byte window in a copied chunk. Its base argument is the target address
where that copied chunk began, so a match at local offset 12 becomes target
address base + 12:
fn find_u32_in_chunk(base: usize, bytes: &[u8], wanted: u32) -> Vec<usize> { // 🧭 Convert the human number to the byte order used by x86 Windows memory. let needle = wanted.to_le_bytes();
// 🔍 Overlapping windows allow unaligned values; enumerate supplies the // local offset that is translated back to a remote virtual address. bytes.windows(needle.len()) .enumerate() .filter_map(|(offset, window)| { (window == needle) .then(|| base.checked_add(offset)) .flatten() }) .collect()}wanted.to_le_bytes() creates the exact four-byte representation used by the 32-bit Windows course games. .windows(4) yields overlapping four-byte slices, as in Lesson 3.3, so a value may start at any byte; an aligned-only scan would step by four instead. When a match appears, base + offset translates the local slice position back into the target’s virtual address.
The complete program reads 1 MiB chunks. Adjacent chunks overlap by three bytes because a four-byte value can begin in the final three positions of the earlier chunk. Without that overlap, a value split across the boundary would never exist in either local buffer as a complete four-byte window.
u32 value 1,000 (bytes E8 03 00 00), split across the boundaryu32 window still exists.Keep candidate values
Section titled “Keep candidate values”After the first pass, the important fact is no longer only where a value was found. The next pass must compare a new read with the previous read at that same address. Store both together, then make the possible comparisons named choices:
#[derive(Clone, Copy, Debug)]struct Candidate { address: usize, previous: u32,}
enum Filter { Equal(u32), Increased, Decreased, Changed, Unchanged,}Filter by re-reading each candidate:
fn keep(filter: &Filter, previous: u32, current: u32) -> bool { // ✅ An exhaustive match means adding a new filter cannot silently fall // through to a default behavior; the compiler demands a decision here. match filter { Filter::Equal(value) => current == *value, Filter::Increased => current > previous, Filter::Decreased => current < previous, Filter::Changed => current != previous, Filter::Unchanged => current == previous, }}The enum replaces a stringly typed filter:
The original helper recognizes two strings and returns false for every other spelling. A typo silently removes candidates instead of identifying an unsupported scan comparison.
fn keep(kind: &str, previous: u32, current: u32) -> bool { if kind == "changed" { return current != previous; } if kind == "same" { return current == previous; } false // Misspellings silently look like a filter that kept nothing.}An enum with data represents only supported filters. Its exhaustive match requires a decision for every variant; Equal(u32) carries the number to compare with.
Improved code
enum Filter { Equal(u32), Increased, Decreased, Changed, Unchanged,}
fn keep(filter: &Filter, previous: u32, current: u32) -> bool { match filter { Filter::Equal(value) => current == *value, Filter::Increased => current > previous, Filter::Decreased => current < previous, Filter::Changed => current != previous, Filter::Unchanged => current == previous, }}Show the diff
fn keep(kind: &str, previous: u32, current: u32) -> bool { if kind == "changed" { return current != previous; } if kind == "same" { return current == previous; } false // Misspellings silently look like a filter that kept nothing.}enum Filter { Equal(u32), Increased, Decreased, Changed, Unchanged,}
fn keep(filter: &Filter, previous: u32, current: u32) -> bool { match filter { Filter::Equal(value) => current == *value, Filter::Increased => current > previous, Filter::Decreased => current < previous, Filter::Changed => current != previous, Filter::Unchanged => current == previous, }}Takeaway: use a finite type for a finite set of scan comparisons, and let the compiler check every case.
Update previous for candidates that survive. It belongs beside the address because “changed” always compares two reads of the same candidate.
Expect regions to disappear
Section titled “Expect regions to disappear”Between the region query and the read, the game can free an allocation, change its protection, or exit. This is a normal race, not necessarily a bug: treat an unreadable candidate as removed, not as a reason to crash the scan. The scanner’s snapshot is always slightly behind the running process. A robust tool reports skipped work and continues with the candidates it can still read.
Return a scan report:
struct ScanReport { regions_seen: usize, regions_read: usize, bytes_read: u64, candidates: Vec<Candidate>, skipped_regions: usize,}Bound the work
Section titled “Bound the work”A scan for a common value such as zero can produce millions of matches. Refuse or require a narrower range rather than exhausting memory. Each limit prevents a specific failure:
- a chunk limit, such as 1 MiB, bounds each allocation and Windows copy;
- a maximum region size per read avoids one suspicious mapping monopolizing the scan;
- a candidate limit bounds local memory and later re-read time;
- an output limit keeps the terminal usable;
- a time or cancellation limit gives control back to the learner;
- a fixed list of supported value types keeps each comparison well defined.
Without these limits, perfectly memory-safe code can still make an unusable program.
Guard writes
Section titled “Guard writes”Finding one candidate is still an observation, not permission to replace it. The game may have changed the value since the last scan. This final write checks the exact address and previous value immediately before changing four bytes; a mismatch returns an error and leaves memory alone:
fn write_candidate( process: &Process, candidate: Candidate, replacement: u32,) -> anyhow::Result<()> { // ⚠️ The game continued after the scan. Confirm the candidate still holds // the value the last scan saw before changing anything. let current = process.read_u32(candidate.address)?; anyhow::ensure!( current == candidate.previous, "value changed since the last scan" ); // 🎯 Only this verified address is written. The candidate list remains // observational data. process.write_u32(candidate.address, replacement)?; Ok(())}The UI should show the old and new value and require an explicit action.
The second read closes a time-of-check/time-of-use gap. If the current value no longer equals candidate.previous, the address may be stale or the game may have updated that field; the write must stop rather than overwrite newer state.

Separate types
Section titled “Separate types”Use a trait or enum for u8, u16, u32, i32, and f32 rather than casting everything to one type. Float searches need special handling for NaN and approximate comparisons.
Start with one type and make it correct. A small scanner with clear errors is a stronger project than a giant clone with undefined behavior.
For floating-point scans, exact byte equality is sometimes useful and sometimes misleading. NaN does not equal itself, -0.0 and 0.0 compare equal despite different bit patterns, and visible decimal text may round the stored value. A future ValueKind should state whether it compares bits, exact numeric values, or values within a tolerance.
Run the complete scanner
Section titled “Run the complete scanner”Start one supported course game with a value you can change visibly—for example, gold in Wesnoth—and run:
.\target\i686-pc-windows-msvc\release\memory_scanner.exe wesnoth.exe 100Change the value in the game, press Enter, and type the new value. The scanner re-reads only surviving addresses. If one remains, it offers one guarded replacement and first confirms the value has not changed again.
The full program is memory_scanner.rs. It uses VirtualQueryEx, skips unreadable or oversized regions, reads at most 1 MiB per chunk, adds a three-byte overlap so a u32 cannot hide across a chunk boundary, caps candidates at 250,000, handles disappearing regions, and prints at most 25 results.
Complete lab source: memory_scanner.rs
memory_scanner.rs
Show the codeHide the code
#[cfg(windows)]
fn main() -> anyhow::Result<()> {
use std::io;
use anyhow::Context;
use gha_windows_labs::Process;
const CHUNK_SIZE: usize = 1024 * 1024;
const MAX_REGION_SIZE: usize = 64 * 1024 * 1024;
const MAX_CANDIDATES: usize = 250_000;
let mut arguments = std::env::args().skip(1);
let process_name = arguments
.next()
.context("usage: memory_scanner <process.exe> <initial_u32>")?;
let initial = arguments
.next()
.context("missing initial value")?
.parse::<u32>()
.context("initial value must be an unsigned number")?;
let process = Process::open_by_name(&process_name, true)?;
println!(
"Scanning {} (PID {}) for {initial}...",
process.name(),
process.id()
);
let mut candidates = Vec::new();
for region in process.regions(0x1_0000, usize::MAX)? {
if !region.readable || region.size == 0 || region.size > MAX_REGION_SIZE {
continue;
}
let mut offset = 0_usize;
while offset < region.size {
let count = CHUNK_SIZE.min(region.size - offset);
// Read three extra bytes when possible. A four-byte number can begin in
// the last three bytes of a chunk, so this small overlap prevents a
// match from falling through the crack between two reads.
let read_count = count.saturating_add(3).min(region.size - offset);
let address = region.base + offset;
let Ok(bytes) = process.read_bytes(address, read_count) else {
break; // the game changed this region while we were scanning
};
for (inside_chunk, window) in bytes.windows(4).enumerate() {
if inside_chunk >= count {
break; // the next chunk owns starts beyond this boundary
}
if window == initial.to_le_bytes() {
candidates.push(address + inside_chunk);
anyhow::ensure!(
candidates.len() <= MAX_CANDIDATES,
"too many matches; choose a less common value"
);
}
}
offset += count;
}
}
println!("First scan: {} candidates", candidates.len());
println!("Change the value in the game, then press Enter.");
let mut line = String::new();
io::stdin().read_line(&mut line)?;
line.clear();
println!("What is the new value?");
io::stdin().read_line(&mut line)?;
let new_value = line.trim().parse::<u32>().context("not a u32 value")?;
candidates.retain(|address| {
process
.read_u32(*address)
.is_ok_and(|value| value == new_value)
});
println!("Next scan: {} candidates", candidates.len());
for address in candidates.iter().take(25) {
println!(" {address:#010x}");
}
if candidates.len() == 1 {
line.clear();
println!("Type a replacement number, or press Enter without typing to stop:");
io::stdin().read_line(&mut line)?;
if !line.trim().is_empty() {
let replacement = line.trim().parse::<u32>().context("not a u32 value")?;
let address = candidates[0];
anyhow::ensure!(
process.read_u32(address)? == new_value,
"the value changed before the guarded write"
);
process.write_u32(address, replacement)?;
println!("Changed {new_value} -> {replacement} at {address:#010x}");
}
} else {
println!("Repeat the experiment with another changed value to narrow the list.");
}
Ok(())
}
#[cfg(not(windows))]
fn main() {
eprintln!("This memory scanner uses Windows APIs and must run on Windows.");
}#[cfg(windows)]fn main() -> anyhow::Result<()> { use std::io;
use anyhow::Context; use gha_windows_labs::Process;
const CHUNK_SIZE: usize = 1024 * 1024; const MAX_REGION_SIZE: usize = 64 * 1024 * 1024; const MAX_CANDIDATES: usize = 250_000;
let mut arguments = std::env::args().skip(1); let process_name = arguments .next() .context("usage: memory_scanner <process.exe> <initial_u32>")?; let initial = arguments .next() .context("missing initial value")? .parse::<u32>() .context("initial value must be an unsigned number")?;
let process = Process::open_by_name(&process_name, true)?; println!( "Scanning {} (PID {}) for {initial}...", process.name(), process.id() );
let mut candidates = Vec::new(); for region in process.regions(0x1_0000, usize::MAX)? { if !region.readable || region.size == 0 || region.size > MAX_REGION_SIZE { continue; }
let mut offset = 0_usize; while offset < region.size { let count = CHUNK_SIZE.min(region.size - offset); // 🔍 A u32 can begin in the final three bytes of a chunk. This overlap // keeps a value from falling through the crack between two reads. let read_count = count.saturating_add(3).min(region.size - offset); let address = region.base + offset; let Ok(bytes) = process.read_bytes(address, read_count) else { break; // the game changed this region while we were scanning };
for (inside_chunk, window) in bytes.windows(4).enumerate() { if inside_chunk >= count { break; // the next chunk owns starts beyond this boundary } if window == initial.to_le_bytes() { candidates.push(address + inside_chunk); anyhow::ensure!( candidates.len() <= MAX_CANDIDATES, "too many matches; choose a less common value" ); } } offset += count; } }
println!("First scan: {} candidates", candidates.len()); println!("Change the value in the game, then press Enter."); let mut line = String::new(); io::stdin().read_line(&mut line)?; line.clear(); println!("What is the new value?"); io::stdin().read_line(&mut line)?; let new_value = line.trim().parse::<u32>().context("not a u32 value")?;
candidates.retain(|address| { process .read_u32(*address) .is_ok_and(|value| value == new_value) }); println!("Next scan: {} candidates", candidates.len()); for address in candidates.iter().take(25) { println!(" {address:#010x}"); }
if candidates.len() == 1 { line.clear(); println!("Type a replacement number, or press Enter without typing to stop:"); io::stdin().read_line(&mut line)?; if !line.trim().is_empty() { let replacement = line.trim().parse::<u32>().context("not a u32 value")?; let address = candidates[0]; anyhow::ensure!( process.read_u32(address)? == new_value, "the value changed before the guarded write" ); process.write_u32(address, replacement)?; println!("Changed {new_value} -> {replacement} at {address:#010x}"); } } else { println!("Repeat the experiment with another changed value to narrow the list."); } Ok(())}
#[cfg(not(windows))]fn main() { eprintln!("This memory scanner uses Windows APIs and must run on Windows.");}Read the source as two different jobs. The first half discovers possible addresses in bounded chunks. The second half re-reads only those addresses after you change the game value. Keeping those jobs separate is what turns millions of raw bytes into a short, testable list.
Walk through one run
Section titled “Walk through one run”Suppose Wesnoth displays 100 gold:
- The program parses
wesnoth.exeand100before opening anything. Process::open_by_name(..., true)requests the additional write right only because this lab may offer a final guarded write.- The region loop skips unreadable, empty, and oversized ranges.
- Each chunk is copied. Failed reads abandon only that changing region.
- Matching local windows become target addresses until the candidate cap is reached.
- You change gold normally and enter the new visible value.
retainremoves every address that cannot be read or no longer matches.- Only one survivor unlocks the optional replacement prompt.
- The program re-reads that survivor immediately before writing.
Diagnose by phase
Section titled “Diagnose by phase”Troubleshooting by phase
| Symptom | Phase to inspect |
|---|---|
| Zero first-pass candidates | Value type, endianness, range permissions, or target build |
| Candidate cap reached | Initial value is too common; start from a less common value |
| Good first pass, zero second pass | Entered value, timing, or a value stored in another representation |
| Read failures grow rapidly | Target exited or memory layout changed during the scan |
| One address survives but write is refused | The value changed after filtering; scan again |
The refusal cases are successful safety behavior. They stop the program from writing to an address whose last read is out of date.
This interactive lab needs JavaScript. The lesson text around it still explains the same idea.
Narrow the candidates
Why compare scan candidates after a controlled value change?
Each filter pass re-reads only the existing candidates and keeps those that now hold the new value, so unrelated addresses that happened to match once drop out.
Finished Lesson 5.8?
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.