Skip to content
Lesson 6.1Windows Processes and Callable Interfaces

The Running Game as a Process

An executable becomes a process with threads, modules, handles, and Windows-managed state; the PEB and TEB reveal useful structure without being a safe API.

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

A one-page summary of β€œThe Running Game as a Process” you can keep beside your notes. Plain Markdown.

Lesson 6.1 β€” The Running Game as a Process: cheatsheet

  • Remember: The executable supplies starting code and data; a running match also depends on live memory, threads, handles, and other process state.
  • Also useful: Picture one process-wide information hub with a separate thread desk for every execution path: each TEB leads back to the shared PEB.

Key terms

  • program file: wesnoth.exe on disk is a program file.
  • PEB: The PEB is a user-mode structure Windows uses to keep process-wide information.
  • dwSize: dwSize is important: it tells Windows which version and size of the output structure the caller prepared.

Formulas and patterns

  • program on disk process in memory; -------------------------- --------------------------------------; wesnoth.exe bytes β†’ image mapped at a live module base; section RVAs β†’ virtual addresses after base + RVA; entry-point RVA β†’ first user-code address Windows calls

Review

  • Q: Which item exists only after Windows creates a running process? A: A virtual address space with threads, handles, stacks, and mapped modules
  • Q: Why is the first code run at a program's entry point usually not the developer's own main function? A: Startup code prepares the runtime first, then it calls main
Download cheatsheet (.md)

The previous chapters followed executable files, saved data, mods, and scripts. All of them meet in a running Windows process. The executable’s bytes help create that process, but the running game contains more than that file.

wesnoth.exe on disk is a program file. When Windows starts it, Windows creates a process: an isolated running instance with its own address space, handles, threads, security information, and loaded modules.

That distinction matters. The PE chapter reads the file on disk. A debugger or memory tool studies the running process after Windows has mapped that file and its DLLs into virtual memory.

The questions now come in a useful order: what runtime owns the game’s objects, which exact build is running, what access Windows grants, and how pages and threads change while the game runs. A dump at the end of the chapter will preserve a slice of that state for later inspection.

program on disk process in memory
-------------------------- --------------------------------------
wesnoth.exe bytes β†’ image mapped at a live module base
import names β†’ live function pointers in the IAT
section RVAs β†’ virtual addresses after base + RVA
entry-point RVA β†’ first user-code address Windows calls

The process is not simply a copy of the file. Windows may place sections at different virtual addresses, fill zero-initialized data, apply relocations, resolve imports, and create private memory that never existed in the EXE.

The file on disk and the process that runs itwesnoth.exe on disk is only the starting point. A running process adds resources that no file contains.
  1. 1 On disk: the file wesnoth.exe

    Disk

    Files
    wesnoth.exe

    The file holds code, data and headers, and does nothing by itself.

  2. 2 Mapped into a running process

    Disk

    Files
    wesnoth.exe

    Running process

    Main image
    image mapped from wesnoth.exe

    Windows maps the file into a new process as its main image.

  3. 3 Runtime resources that no file holds

    Running process

    From the file
    image mapped from wesnoth.exe
    Loaded
    DLLs
    private heap
    Per thread
    thread stack
    thread context
    Held by the process
    handles
    security state

    Everything else exists only while the process runs.

wesnoth.exe on disk is only the starting point. A running process adds resources that no file contains.

A process groups process-wide resources and a set of independently scheduled threads. Each thread adds its own execution state inside that shared container:

Running process
Process-wide state
Thread set
Address space and modules
Handles and access token
One thread
Registers, stack, and TEB

Threads share the process address space and handles, but each thread needs its own registers, stack, and thread bookkeeping so Windows can pause and resume it.

The PE entry point is also not automatically the game’s main function. Windows transfers control to startup code chosen by the executable and its runtime. That code can initialize security state, global constructors, thread-local storage, and library support before it calls the developer’s main function. Reverse the startup chain as a sequence of responsibilities rather than labeling the first code address β€œmain.”

A normal game process contains several kinds of state:

  • the virtual address space, which gives the process its own numbered memory locations;
  • one or more threads, which are the paths of CPU execution;
  • modules, including the main EXE and loaded DLLs;
  • heaps and stacks, used for dynamic objects and function calls;
  • a handle table, containing Windows-managed references to files, events, sockets, and other objects;
  • an access token, describing the process security context;
  • user-mode bookkeeping structures, including the PEB and one TEB per thread.

Windows creates and manages these resources, checks access to protected objects, and schedules threads. A process can read much of its own user-mode state, but an internal Windows field can change between versions even when its address is readable. Prefer a documented API when one provides the information you need.

The useful description is an inventory of everything required to stop this running program and later continue it:

address space + thread register contexts + scheduling state
+ open handles + security context + operating-system bookkeeping

The EXE supplies code and initial data, but the process is this larger live state. That explains why copying wesnoth.exe does not copy a running match and why a crash dump can reveal much more than the original file.

Keep mechanism separate from policy. A context switch is a mechanism for stopping one thread and restoring another. Scheduling policy decides which ready thread receives a CPU next. A handle is a mechanism for naming a Windows object; access checks are policy decisions based on the requested rights and security context. Mixing the two makes an implementation detail sound like a guarantee.

The PEB is a user-mode structure Windows uses to keep process-wide information. It includes or leads to information such as:

  • the process image base;
  • loader bookkeeping for DLLs;
  • process parameters such as the command line and environment;
  • heap-related state;
  • flags used by parts of Windows and the loader.

Model the PEB as one root in a graph of process metadata. Its fields lead to other structures such as process parameters and loader records; those records contain more links and values. Walking the graph requires the correct architecture and structure layout at every step. It is internal implementation data, so a field offset copied from an old 32-bit article is not automatically valid for a current 64-bit process.

The loader lists connected to the PEB describe modules known to the Windows loader. They are why low-level debuggers can teach you a lot about how DLL loading works. They are also live linked lists: another thread may load or unload a module while you are walking them.

Each thread has its own TEB. It contains thread-specific user-mode state, including a route to the PEB, stack boundaries, thread-local storage information, and error-related fields.

Each thread’s TEB points to the one PEB shared by the whole process, which in turn fans out into the structures described above:

route to
TEB
(one per thread)
PEB
(one per process)
loader lists
(loaded modules)
process parameters
(command line, environment)
heap-related state
loader / Windows flags

On 32-bit x86 Windows code, the fs segment register helps locate thread state. On x86-64 Windows code, gs fills that role. That is why you may see instructions such as these in low-level Windows disassembly:

; 32-bit Windows: obtain the current process's PEB pointer.
mov eax, dword ptr fs:[0x30]
; 64-bit Windows: obtain the current process's PEB pointer.
mov rax, qword ptr gs:[0x60]

This is not ordinary game data. fs: and gs: tell the CPU to use a segment-relative address associated with the current thread. 0x30 and 0x60 are offsets in different Windows layouts, not an instruction to double every structure offset when moving from 32 to 64 bits. Wider pointers and alignment help explain why layouts change; the actual offsets come from the layout being examined. Treat these internal structures as an observation in a particular architecture and Windows build, not as a public API contract.

+0x00TEB start
Β· Β· Β·
+0x30PEB pointer
32-bit x86: the PEB pointer sits 0x30 bytes into the TEB.
+0x00TEB start
Β· Β· Β·
+0x60PEB pointer
x86-64: the same fields are wider β€” 8 bytes instead of 4 β€” so the PEB pointer lands at 0x60 instead, roughly twice as far in.

Walking internal loader pointers is useful when you are learning in a debugger. For a beginner-facing tool, Windows already provides a documented, read-only alternative: a ToolHelp snapshot.

CreateToolhelp32Snapshot asks Windows for a moment-in-time copy of a process, thread, or module list. Module32FirstW fills the first MODULEENTRY32W, and Module32NextW advances through the copy.

The shared wrapper keeps that unsafe boundary in one reviewed function. Here is the order of Windows calls it makes before it ever returns a module base and size:

A snapshot is owned evidence from one moment. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

copy the current module list
own the snapshot handle
read the saved base and size
optionally unload the live module
close the owned snapshot handle
report saved evidence and live validity

Result: the module remains in this schedule; the snapshot still describes an earlier capture.

Final values: unload = no; handle = closed; saved_base = 0x400000; saved_size = 4096; record_read = true; live_module = still loaded; live_valid = true.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.

    unload = no.

  2. Step 2, line 2. The snapshot owns its own copied record.

    unload = no; handle = open; saved_base = 0x400000; saved_size = 4096.

  3. Step 3, line 3. Reading the record does not retain the live module.

    unload = no; handle = open; saved_base = 0x400000; saved_size = 4096; record_read = true.

  4. Step 4, line 4. The target can change after capture.

    unload = no; handle = open; saved_base = 0x400000; saved_size = 4096; record_read = true; live_module = still loaded.

  5. Step 5, line 5. Scope exit closes the snapshot handle.

    unload = no; handle = closed; saved_base = 0x400000; saved_size = 4096; record_read = true; live_module = still loaded.

  6. Step 6, line 6. Result: the module remains in this schedule; the snapshot still describes an earlier capture.

    unload = no; handle = closed; saved_base = 0x400000; saved_size = 4096; record_read = true; live_module = still loaded; live_valid = true.

Watch it happenCopy a module entry, match its name, return its range

A live module record is copied into a ToolHelp snapshot. OwnedHandle owns the snapshot handle, Module32FirstW fills MODULEENTRY32W, and a matching name returns its base and size. The snapshot handle closes at scope exit.

Read the steps
  1. CreateToolhelp32Snapshot copies the target’s module list at this moment. This successful tour follows the first entry, whose name is the requested name.
  2. OwnedHandle takes responsibility for closing the snapshot handle. Keeping the wrapper alive keeps the snapshot available during enumeration.
  3. The correctly sized MODULEENTRY32W is filled by Module32FirstW. The copied entry contains szModule, modBaseAddr, and modBaseSize.
  4. The wrapper compares szModule with wanted_name without regard to letter case. This name matches. A nonmatching entry would advance with Module32NextW.
  5. The successful branch returns modBaseAddr and modBaseSize. On leaving the function, OwnedHandle closes the snapshot; the returned values do not keep the module loaded.

The field names are symbolic: this example does not invent a module address. A snapshot lookup observes a range without owning the module’s lifetime.

pub fn module(&self, wanted_name: &str) -> anyhow::Result<(usize, usize)> {
let flags = TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32;
// πŸ›‘οΈ SAFETY: the PID is a value and OwnedHandle closes the returned snapshot.
let snapshot = unsafe { CreateToolhelp32Snapshot(flags, self.id) }?;
let snapshot = OwnedHandle::from_raw(snapshot)?;
let mut entry = MODULEENTRY32W {
dwSize: size_of::<MODULEENTRY32W>() as u32,
..Default::default()
};
// πŸ“ SAFETY: entry has the size Windows requires and remains writable.
unsafe { Module32FirstW(snapshot.raw(), &mut entry) }?;
loop {
if wide_text(&entry.szModule).eq_ignore_ascii_case(wanted_name) {
return Ok((entry.modBaseAddr as usize, entry.modBaseSize as usize));
}
// πŸ” Module32NextW either replaces entry or reports the end of the list.
match unsafe { Module32NextW(snapshot.raw(), &mut entry) } {
Ok(()) => {}
Err(error) if no_more_files(&error) => break,
Err(error) => return Err(error.into()),
}
}
anyhow::bail!("module {wanted_name:?} was not found")
}

dwSize is important: it tells Windows which version and size of the output structure the caller prepared. Forgetting it is like handing someone a form without saying which form it is.

The list is stable, but the process is still running. A DLL could unload after the snapshot was created. A snapshot therefore has a time limit:

Enumeration tells you what existed during the snapshot. A later read must still be allowed to fail.

Result makes that race visible. The tool should report a normal error instead of assuming a previously seen address must remain valid.

Windows VM

Start one offline course game, then run both existing tools. 3CCD91 is the sample Wesnoth RVA examined in Lessons 5.1 and 5.2; it is a position relative to the module base, not a live address to reuse after a restart. Replace it with an RVA you verified for the exact local build if your file differs:

Terminal window
cd windows-labs
cargo build --release --target i686-pc-windows-msvc `
--bin pe_inspector --bin module_inspector
.\target\i686-pc-windows-msvc\release\pe_inspector.exe `
"C:\Games\Wesnoth 1.14.9\wesnoth.exe"
.\target\i686-pc-windows-msvc\release\module_inspector.exe `
wesnoth.exe wesnoth.exe 3CCD91

pe_inspector prints the preferred base stored in the file, and module_inspector prints the live base where the loader placed the module in this run. If the two differ, ASLR or relocation moved the whole module, and every byte inside it moved by the same amount. The RVA is measured from the module’s start, so it still names the same position, and module_inspector adds it to the live base to find where that position sits in this run.

Repeat with ac_client.exe and Quake3-UrT.exe. Do not copy one game’s module name, pointer width, or address into another game.

Do not edit PEB flags, unlink modules from loader lists, or disguise process parameters. Those changes can break loader assumptions or conceal activity from diagnostic tools. This lesson uses the structures to explain Windows, then uses documented APIs for the actual program.

The buildable implementations are module_inspector.rs and the ToolHelp wrapper in process.rs.

module_inspector.rs

windows-labs/src/bin/ Β· Rust, 36 lines

Show the codeHide the code
#[cfg(windows)]
fn main() -> anyhow::Result<()> {
    use anyhow::Context;
    use gha_windows_labs::Process;

    let mut arguments = std::env::args().skip(1);
    let process_name = arguments.next().unwrap_or_else(|| "wesnoth.exe".to_owned());
    let module_name = arguments.next().unwrap_or_else(|| process_name.clone());
    let requested_rva = arguments
        .next()
        .map(|text| usize::from_str_radix(text.trim_start_matches("0x"), 16))
        .transpose()
        .context("RVA must be hexadecimal, such as 7CCD91")?;

    let process = Process::open_by_name(&process_name, false)?;
    let (base, size) = process.module(&module_name)?;
    let end = base.checked_add(size).context("module range overflowed")?;

    println!("{} (PID {})", process.name(), process.id());
    println!("  module: {module_name}");
    println!("  live base: {base:#010x}");
    println!("  size:      {size:#010x}");
    println!("  end:       {end:#010x}");

    if let Some(rva) = requested_rva {
        anyhow::ensure!(rva < size, "RVA {rva:#x} is outside the module");
        let address = base.checked_add(rva).context("base + RVA overflowed")?;
        println!("  {base:#010x} + {rva:#010x} = {address:#010x}");
    }
    Ok(())
}

#[cfg(not(windows))]
fn main() {
    eprintln!("This live module inspector uses ToolHelp and must run on Windows.");
}

process.rs

windows-labs/src/windows_impl/ Β· Rust, 350 lines

Show the codeHide the code
use std::{ffi::c_void, mem::size_of};

use anyhow::{Context, Result};
use windows::Win32::{
    Foundation::ERROR_NO_MORE_FILES,
    System::{
        Diagnostics::{
            Debug::{FlushInstructionCache, ReadProcessMemory, WriteProcessMemory},
            ToolHelp::{
                CreateToolhelp32Snapshot, MODULEENTRY32W, Module32FirstW, Module32NextW,
                PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPMODULE,
                TH32CS_SNAPMODULE32, TH32CS_SNAPPROCESS,
            },
        },
        Memory::{
            MEM_COMMIT, MEMORY_BASIC_INFORMATION, PAGE_EXECUTE, PAGE_EXECUTE_READ,
            PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY, PAGE_GUARD, PAGE_NOACCESS,
            PAGE_PROTECTION_FLAGS, PAGE_READONLY, PAGE_READWRITE, PAGE_WRITECOPY, VirtualProtectEx,
            VirtualQueryEx,
        },
        SystemInformation::{
            IMAGE_FILE_MACHINE, IMAGE_FILE_MACHINE_I386, IMAGE_FILE_MACHINE_UNKNOWN,
        },
        Threading::{
            IsWow64Process2, OpenProcess, PROCESS_ACCESS_RIGHTS, PROCESS_QUERY_INFORMATION,
            PROCESS_VM_OPERATION, PROCESS_VM_READ, PROCESS_VM_WRITE,
        },
    },
};

use super::OwnedHandle;

#[derive(Clone, Debug)]
pub struct ProcessEntry {
    pub id: u32,
    pub name: String,
}

#[derive(Clone, Copy, Debug)]
pub struct MemoryRegion {
    pub base: usize,
    pub size: usize,
    pub readable: bool,
    pub writable: bool,
    pub executable: bool,
}

#[derive(Debug)]
pub struct Process {
    handle: OwnedHandle,
    id: u32,
    name: String,
}

fn wide_text(buffer: &[u16]) -> String {
    let end = buffer
        .iter()
        .position(|unit| *unit == 0)
        .unwrap_or(buffer.len());
    String::from_utf16_lossy(&buffer[..end])
}

fn no_more_files(error: &windows::core::Error) -> bool {
    error.code() == ERROR_NO_MORE_FILES.to_hresult()
}

impl Process {
    /// Lists every running process with ToolHelp.
    pub fn list() -> Result<Vec<ProcessEntry>> {
        // SAFETY: no borrowed pointers are passed; the returned snapshot is owned below.
        let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }?;
        let snapshot = OwnedHandle::from_raw(snapshot)?;

        let mut raw = PROCESSENTRY32W {
            dwSize: size_of::<PROCESSENTRY32W>() as u32,
            ..Default::default()
        };

        // SAFETY: `raw` is correctly sized and writable for the duration of the call.
        unsafe { Process32FirstW(snapshot.raw(), &mut raw) }?;

        let mut processes = Vec::new();
        loop {
            processes.push(ProcessEntry {
                id: raw.th32ProcessID,
                name: wide_text(&raw.szExeFile),
            });

            // SAFETY: `raw` remains a valid PROCESSENTRY32W output buffer.
            match unsafe { Process32NextW(snapshot.raw(), &mut raw) } {
                Ok(()) => {}
                Err(error) if no_more_files(&error) => break,
                Err(error) => return Err(error.into()),
            }
        }
        Ok(processes)
    }

    pub fn find(name: &str) -> Result<ProcessEntry> {
        Self::list()?
            .into_iter()
            .find(|entry| entry.name.eq_ignore_ascii_case(name))
            .with_context(|| format!("process {name:?} is not running"))
    }

    pub fn open_by_name(name: &str, allow_write: bool) -> Result<Self> {
        let entry = Self::find(name)?;
        Self::open(entry, allow_write)
    }

    pub fn open(entry: ProcessEntry, allow_write: bool) -> Result<Self> {
        let mut access: PROCESS_ACCESS_RIGHTS = PROCESS_QUERY_INFORMATION | PROCESS_VM_READ;
        if allow_write {
            access |= PROCESS_VM_OPERATION | PROCESS_VM_WRITE;
        }

        Self::open_with_access(entry, access)
    }

    pub fn open_with_access(entry: ProcessEntry, access: PROCESS_ACCESS_RIGHTS) -> Result<Self> {
        // SAFETY: the PID is a value; inheritance is disabled; `OwnedHandle` owns the result.
        let handle = unsafe { OpenProcess(access, false, entry.id) }?;
        Ok(Self {
            handle: OwnedHandle::from_raw(handle)?,
            id: entry.id,
            name: entry.name,
        })
    }

    pub fn is_32_bit(&self) -> Result<bool> {
        let mut process_machine = IMAGE_FILE_MACHINE::default();
        let mut native_machine = IMAGE_FILE_MACHINE::default();
        // SAFETY: both output pointers remain valid for the call.
        unsafe {
            IsWow64Process2(
                self.handle.raw(),
                &mut process_machine,
                Some(&mut native_machine),
            )?;
        }
        Ok(process_machine == IMAGE_FILE_MACHINE_I386
            || (process_machine == IMAGE_FILE_MACHINE_UNKNOWN
                && native_machine == IMAGE_FILE_MACHINE_I386))
    }

    pub fn id(&self) -> u32 {
        self.id
    }

    pub fn name(&self) -> &str {
        &self.name
    }

    pub fn raw_handle(&self) -> windows::Win32::Foundation::HANDLE {
        self.handle.raw()
    }

    pub fn read_exact(&self, address: usize, output: &mut [u8]) -> Result<()> {
        let mut bytes_read = 0_usize;
        // SAFETY: `output` is writable for its length. Windows validates the remote range.
        unsafe {
            ReadProcessMemory(
                self.handle.raw(),
                address as *const c_void,
                output.as_mut_ptr().cast(),
                output.len(),
                Some(&mut bytes_read),
            )?;
        }
        anyhow::ensure!(bytes_read == output.len(), "short read at {address:#x}");
        Ok(())
    }

    pub fn read_bytes(&self, address: usize, count: usize) -> Result<Vec<u8>> {
        let mut bytes = vec![0_u8; count];
        self.read_exact(address, &mut bytes)?;
        Ok(bytes)
    }

    pub fn read_u32(&self, address: usize) -> Result<u32> {
        let bytes: [u8; 4] = self
            .read_bytes(address, 4)?
            .try_into()
            .expect("the read length is exactly four");
        Ok(u32::from_le_bytes(bytes))
    }

    pub fn read_f32(&self, address: usize) -> Result<f32> {
        Ok(f32::from_bits(self.read_u32(address)?))
    }

    pub fn write_exact(&self, address: usize, bytes: &[u8]) -> Result<()> {
        let mut bytes_written = 0_usize;
        // SAFETY: `bytes` is readable for its length. Windows validates the remote range.
        unsafe {
            WriteProcessMemory(
                self.handle.raw(),
                address as *const c_void,
                bytes.as_ptr().cast(),
                bytes.len(),
                Some(&mut bytes_written),
            )?;
        }
        anyhow::ensure!(bytes_written == bytes.len(), "short write at {address:#x}");
        Ok(())
    }

    pub fn write_u32(&self, address: usize, value: u32) -> Result<()> {
        self.write_exact(address, &value.to_le_bytes())
    }

    /// Writes machine code, flushes the CPU instruction cache, and restores protection.
    pub fn write_code(&self, address: usize, bytes: &[u8]) -> Result<()> {
        let mut old = PAGE_PROTECTION_FLAGS::default();
        // SAFETY: Windows validates the target range and writes `old` before returning.
        unsafe {
            VirtualProtectEx(
                self.handle.raw(),
                address as *const c_void,
                bytes.len(),
                PAGE_EXECUTE_READWRITE,
                &mut old,
            )?;
        }

        let write_result = self.write_exact(address, bytes);
        if write_result.is_ok() {
            // SAFETY: the process handle is live and the written range is the one above.
            unsafe {
                FlushInstructionCache(
                    self.handle.raw(),
                    Some(address as *const c_void),
                    bytes.len(),
                )?;
            }
        }

        let mut ignored = PAGE_PROTECTION_FLAGS::default();
        // SAFETY: this restores the protection returned by the first call.
        let restore_result = unsafe {
            VirtualProtectEx(
                self.handle.raw(),
                address as *const c_void,
                bytes.len(),
                old,
                &mut ignored,
            )
        };

        write_result?;
        restore_result?;
        Ok(())
    }

    pub fn module(&self, wanted_name: &str) -> Result<(usize, usize)> {
        // Include SNAPMODULE32 so a 64-bit tool can list a 32-bit game module.
        // SAFETY: the PID is valid and the returned snapshot is owned below.
        let snapshot =
            unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, self.id) }?;
        let snapshot = OwnedHandle::from_raw(snapshot)?;
        let mut raw = MODULEENTRY32W {
            dwSize: size_of::<MODULEENTRY32W>() as u32,
            ..Default::default()
        };

        // SAFETY: `raw` is correctly sized and writable.
        unsafe { Module32FirstW(snapshot.raw(), &mut raw) }?;
        loop {
            if wide_text(&raw.szModule).eq_ignore_ascii_case(wanted_name) {
                return Ok((raw.modBaseAddr as usize, raw.modBaseSize as usize));
            }
            // SAFETY: `raw` remains a valid output buffer.
            match unsafe { Module32NextW(snapshot.raw(), &mut raw) } {
                Ok(()) => {}
                Err(error) if no_more_files(&error) => break,
                Err(error) => return Err(error.into()),
            }
        }
        anyhow::bail!("module {wanted_name:?} was not found in {}", self.name)
    }

    pub fn regions(&self, start: usize, end: usize) -> Result<Vec<MemoryRegion>> {
        let mut current = start;
        let mut regions = Vec::new();

        while current < end {
            let mut raw = MEMORY_BASIC_INFORMATION::default();
            // SAFETY: `raw` is a valid output structure; Windows validates the address.
            let returned = unsafe {
                VirtualQueryEx(
                    self.handle.raw(),
                    Some(current as *const c_void),
                    &mut raw,
                    size_of::<MEMORY_BASIC_INFORMATION>(),
                )
            };
            if returned == 0 {
                break;
            }

            let protection = raw.Protect;
            let blocked = protection.contains(PAGE_GUARD) || protection.contains(PAGE_NOACCESS);
            let readable = !blocked
                && raw.State == MEM_COMMIT
                && [
                    PAGE_READONLY,
                    PAGE_READWRITE,
                    PAGE_WRITECOPY,
                    PAGE_EXECUTE_READ,
                    PAGE_EXECUTE_READWRITE,
                    PAGE_EXECUTE_WRITECOPY,
                ]
                .iter()
                .any(|flag| protection.contains(*flag));
            let writable = readable
                && [
                    PAGE_READWRITE,
                    PAGE_WRITECOPY,
                    PAGE_EXECUTE_READWRITE,
                    PAGE_EXECUTE_WRITECOPY,
                ]
                .iter()
                .any(|flag| protection.contains(*flag));
            let executable = !blocked
                && [
                    PAGE_EXECUTE,
                    PAGE_EXECUTE_READ,
                    PAGE_EXECUTE_READWRITE,
                    PAGE_EXECUTE_WRITECOPY,
                ]
                .iter()
                .any(|flag| protection.contains(*flag));

            let base = raw.BaseAddress as usize;
            let size = raw.RegionSize;
            regions.push(MemoryRegion {
                base,
                size,
                readable,
                writable,
                executable,
            });

            let next = base.checked_add(size).context("memory map overflowed")?;
            anyhow::ensure!(next > current, "memory map did not advance");
            current = next;
        }
        Ok(regions)
    }
}

References: Microsoft ToolHelp snapshots, Module32FirstW, and Microsoft PE format.

Knowledge check

Separate file from execution

Which item exists only after Windows creates a running process?

Finished Lesson 6.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 6: 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.