Skip to content
Lesson 5.8Executable Maps, Instruction References, and Scans

Memory Regions and Value Scanning

Understand region permissions, copied snapshots, candidate filtering, and guarded writes in a value scanner.

Lesson 5.8
Read or listenPrint chapter or book
About 35 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 5.8Key ideas, reference and questions

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: VirtualQueryEx describes 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: base and size are 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
Download cheatsheet (.md)

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:

Enumerate regions
Keep readable data
Copy bounded chunks
Decode typed values
Store candidate addresses
Filter with the next scan

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.

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.

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.

scan: value == 100
value became 85
value became 92
all readable addresses
40,000 candidates
about 18 candidates
1 candidate

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

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.

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.

A[end−3]E8
A[end−2]03
A[end−1]00
B[0]00
the u32 value 1,000 (bytes E8 03 00 00), split across the boundary
For example: if the value 1,000 begins three bytes before the end of chunk A, three of its bytes are the last bytes read into chunk A's buffer and the fourth is the first byte of chunk B's buffer. The 3-byte overlap gives the scanner one buffer where a complete u32 window still exists.

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.

Before: choose a scan filter with text
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.

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,
}

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.

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(())
}
yes
no
read current value
at candidate.address
current == candidate.previous?
write replacement
refuse:
value changed since the last scan

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.

A simple memory-scanner interface

A simple memory-scanner interface like the one this lesson builds.

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.

Windows VM

Start one supported course game with a value you can change visibly—for example, gold in Wesnoth—and run:

Terminal window
.\target\i686-pc-windows-msvc\release\memory_scanner.exe wesnoth.exe 100

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

no
yes
match
no match
yes
no
for region in process.regions(...)
region.readable and
region.size within MAX_REGION_SIZE?
read_bytes in CHUNK_SIZE windows,
+3 byte overlap
bytes.windows(4)
== initial.to_le_bytes()?
candidates.push(address)
print candidates.len()
after the first scan
wait for the new value,
then candidates.retain(...)
candidates.len() == 1?
guarded write:
re-check, then write_u32
ask to narrow further
Complete lab source: memory_scanner.rs

memory_scanner.rs

windows-labs/src/bin/ · Rust, 105 lines

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.

Suppose Wesnoth displays 100 gold:

  1. The program parses wesnoth.exe and 100 before opening anything.
  2. Process::open_by_name(..., true) requests the additional write right only because this lab may offer a final guarded write.
  3. The region loop skips unreadable, empty, and oversized ranges.
  4. Each chunk is copied. Failed reads abandon only that changing region.
  5. Matching local windows become target addresses until the candidate cap is reached.
  6. You change gold normally and enter the new visible value.
  7. retain removes every address that cannot be read or no longer matches.
  8. Only one survivor unlocks the optional replacement prompt.
  9. The program re-reads that survivor immediately before writing.

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.

Knowledge check

Narrow the candidates

Why compare scan candidates after a controlled value change?

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.

Chapter 5: 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.