How External Tools Read Game Memory
Open the target, read a verified address, and write through a small checked process-memory wrapper.
Cheatsheet for Lesson 3.7
A one-page summary of βHow External Tools Read Game Memoryβ you can keep beside your notes. Plain Markdown.
Lesson 3.7 β How External Tools Read Game Memory: cheatsheet
- Remember: An external tool asks Windows to copy bytes out of the game, then reads its own copy. A write is a stronger power and needs its own check.
- Also useful: Think of a self-closing door: however you leave the function, normally or through an early error, Drop closes the handle for you.
Key terms
- handle: A process handle names the target and carries the rights granted for this opening; the Windows read uses that handle with the target address and a byte count.
- opaque ticket: A Windows handle is an opaque ticket stored in one process's handle table.
true: A matching value permits one four-byte write, and the result istrueonly when Windows reports all four bytes written.
Review
- Q: What should an external Rust observer do with a target-process field? A: Copy bounded bytes through the operating-system API into a local typed value
- Q: Why read the current value and compare it with the expected value immediately before writing? A: It catches a stale scan result or a value that changed since you inspected it
External means βa separate processβ
Section titled βExternal means βa separate processββOur tool runs beside the game, not inside it. Windows must grant a process handle with specific rights before the tool can read or write memory.
An address makes sense only in a processβs address space. The number 0x5000
can exist in both processes without naming the same bytes. The process matters
as much as the numeric address:
Windows maps virtual addresses separately for each process. Reading a local pointer with the same number would inspect the toolβs own memory. A process handle names the target and carries the rights granted for this opening; the Windows read uses that handle with the target address and a byte count. Lesson 6.4 later examines handles in more depth.
Request Read 4 bytes at game address 0x5000
Step 1: Tool to Windows
The tool names the game by handle and gives a number that only means something inside the gameβs address map.
Request Check the handle, look up 0x5000
Step 2: Windows to Game process
Windows checks that the handle allows reading, then translates 0x5000 with the gameβs page tables, not the toolβs.
Data 64 00 00 00
Step 3: Game process to Windows
These four bytes are what the game has stored there.
Reply Copy into the toolβs own buffer
Step 4: Windows to Tool
The tool now holds a copy. It does not share the gameβs memory.
Alone Decode the copy: u32 100
Step 5: Tool (alone)
Little-endian 64 00 00 00 is 0x00000064, which is 100. A local pointer numbered 0x5000 would use the toolβs own address map instead.
The game has 64 00 00 00 at its address 0x5000, which encodes u32 100. Windows copies the bytes into the tool; the tool decodes its own copy.
That read is the Windows function ReadProcessMemory. Its arguments split cleanly
into what belongs to the game and what belongs to the tool:
ReadProcessMemory, argument by argument
hProcesshandlerequired
The process handle, opened with the right to read (PROCESS_VM_READ). It says
which processβs address map to use.
lpBaseAddressaddress in the gamerequired
Where to start reading. To the tool this is only a number, so it is never dereferenced locally.
lpBufferaddress in the toolrequired
Where Windows stores the copied bytes: memory the tool owns, with room for at
least nSize bytes.
nSizebytesrequired
How many bytes to copy.
lpNumberOfBytesReadbytes, optional
Where Windows reports how many bytes were actually copied. A tool that ignores it can decode stale bytes left in its buffer.
return valuenonzero on success
Zero means the read failed. GetLastError then says why, such as
ERROR_PARTIAL_COPY (299) when only part of the range could be read.
For this lab, the program discovers wesnoth.exe with the Windows ToolHelp API,
opens it, resolves the verified pointer chain, and performs the guarded write.
There is no PID or raw-address placeholder for the reader to fill in.
An external tool cannot dereference a target-process pointer directly. Windows mediates the boundary, and the handle represents the particular access the observer was granted:
The returned bytes belong to the observer, but their interpretation is valid only for the target build and moment that justified the layout. A write is a separate, stronger capability and needs its own expected-value check.
Even two successful reads can describe different moments. Suppose the tool follows a player pointer, the game removes that player and reuses the same allocation, and the tool then reads health. Both requests can succeed while the combined result describes no real player:
Watch that race. Each read can succeed while the pair of observations becomes inconsistent:
A tool and a game exchange messages. The tool reads a pointer and gets 0x5000, which holds enemy A. The game then reuses 0x5000 for enemy B. The tool reads health at 0x5030 and gets 87, which is enemy Bβs health, and wrongly reports enemy A with health 87.
Read the steps
- The tool wants enemy Aβs health. First it asks Windows to read the pointer stored at 0x2000 in the game. That request names a location, not a permanent object.
- The game answers 0x5000, which names enemy A at this moment. The tool keeps that address for its next read.
- Before the toolβs next request, the game removes enemy A and reuses the allocation at 0x5000 for enemy B. The number 0x5000 has not changed, but who lives there has.
- The tool adds the health offset 0x30 to the 0x5000 it kept and reads at 0x5030. That location now belongs to enemy B.
- Windows reads it successfully and returns 87. Those bytes are enemy Bβs health.
- Combining the two results reports enemy A with health taken from enemy B. Each copy succeeded, yet the pair describes no real player.
This is an illustrated failure, not a valid snapshot. Copy checks and object-path revalidation help detect uncertainty; they do not make separate reads atomic.
This simulation needs JavaScript. The lesson text around it works through the same numbers.
Check the returned byte count and revalidate the object path after a multi-step read. That reduces stale observations; it does not turn several Windows calls into one atomic snapshot. Lesson 4.3 builds a bounded double-capture check for that remaining uncertainty.
Hands-on target: Wesnoth 1.14.9
Section titled βHands-on target: Wesnoth 1.14.9βFor the exact 32-bit Windows build used by this book, the verified gold path is:
This is the practical result from Lesson 2.8. Confirm it in Cheat Engine before running the tool: add a pointer manually, use 0x017EED18 as the base, then add offsets 0xA90 and 0x4. Start three fresh local matches and verify that it resolves to the gold shown in the game each time.
Own the handle with a type
Section titled βOwn the handle with a typeβA Windows handle is an opaque ticket stored in one processβs handle table. It is not the process, file, event, or section itself, and it is not an address you should dereference. Windows uses the ticket to find the kernel-managed object and to remember which access rights this particular opening received. Two handles can therefore name the same object while granting different permissions.
That gives a handle four requirements:
- Scope: a handle value is normally meaningful only in the process whose
handle table contains it.
DuplicateHandlecan deliberately create another. - Rights:
OpenProcess(PROCESS_VM_READ, ...)asks for a capability. The returned handle carries only the rights Windows granted for that opening. - Lifetime: the handle stays valid until its owner closes it. Closing the last handle to many kernel objects lets Windows destroy that object.
- Correct closer: many kernel-object handles use
CloseHandle, but not every Windows value does.SOCKET,HKEY,HMODULE, andHWNDfollow their own APIs and ownership rules. Never callCloseHandlemerely because a typeβs name begins withH.
The HANDLE
type is intentionally opaque. The Drop trait gives an owned handle an
automatic cleanup path:
use windows::Win32::{ Foundation::{CloseHandle, HANDLE}, System::Threading::{ OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_OPERATION, PROCESS_VM_READ, PROCESS_VM_WRITE, },};
struct OwnedHandle(HANDLE);
impl OwnedHandle { fn raw(&self) -> HANDLE { // π Borrow the ticket for one API call; ownership stays in `self`. self.0 }}
impl Drop for OwnedHandle { fn drop(&mut self) { // π§Ή SAFETY: this instance owns the live handle and closes it exactly once. let _ = unsafe { CloseHandle(self.0) }; }}The wrapper itself is not magic. Its constructor must accept only a valid,
owned handle; it must not wrap pseudo-handles such as GetCurrentProcess() as
though they need closing; and its raw value should be borrowed briefly instead
of copied into long-lived state. In larger tools, std::os::windows::io types
such as OwnedHandle and BorrowedHandle can express the same ownership split
for compatible handle families.
The ownership improvement is small in code and large in behavior:
The original helper opens a raw process handle, reads four gold bytes, then closes the handle. If the read fails or copies fewer than four bytes, ? or ensure! returns before the closing line and leaks the handle. Both versions receive a discovered ProcessEntry, the requested rights, and the verified gold address; they use the linked lab library.
use std::ffi::c_void;use gha_windows_labs::ProcessEntry;use windows::Win32::{ Foundation::CloseHandle, System::{ Diagnostics::Debug::ReadProcessMemory, Threading::{OpenProcess, PROCESS_ACCESS_RIGHTS}, },};
fn load_gold( entry: ProcessEntry, rights: PROCESS_ACCESS_RIGHTS, gold_address: usize,) -> anyhow::Result<u32> { // SAFETY: a numeric PID is passed; Windows validates it and the requested rights. let handle = unsafe { OpenProcess(rights, false, entry.id) }?; let mut bytes = [0_u8; 4]; let mut bytes_read = 0; // SAFETY: the handle is live and the local output buffer has four writable bytes. // Windows validates the remote range; no local pointer to that range is dereferenced. unsafe { ReadProcessMemory( handle, gold_address as *const c_void, bytes.as_mut_ptr().cast(), bytes.len(), Some(&mut bytes_read), )?; // An error returns before CloseHandle. } anyhow::ensure!(bytes_read == bytes.len(), "short read"); // Also skips cleanup. // SAFETY: this helper owns the live handle and has not closed it yet. unsafe { CloseHandle(handle) }?; Ok(u32::from_le_bytes(bytes))}RAII moves handle ownership into Process. Its open_with_access method requests the same rights, and read_u32 requires a complete four-byte copy. Leaving the function drops the owner on success and on every early return, while the original error still reaches the caller.
Improved code
use gha_windows_labs::{Process, ProcessEntry};use windows::Win32::System::Threading::PROCESS_ACCESS_RIGHTS;
fn load_gold( entry: ProcessEntry, rights: PROCESS_ACCESS_RIGHTS, gold_address: usize,) -> anyhow::Result<u32> { let process = Process::open_with_access(entry, rights)?; let gold = process.read_u32(gold_address)?; Ok(gold) // OwnedHandle::drop closes the handle on every return path.}Show the diff
use std::ffi::c_void;use gha_windows_labs::ProcessEntry;use gha_windows_labs::{Process, ProcessEntry};use windows::Win32::System::Threading::PROCESS_ACCESS_RIGHTS;use windows::Win32::{ Foundation::CloseHandle, System::{ Diagnostics::Debug::ReadProcessMemory, Threading::{OpenProcess, PROCESS_ACCESS_RIGHTS}, },};
fn load_gold( entry: ProcessEntry, rights: PROCESS_ACCESS_RIGHTS, gold_address: usize,) -> anyhow::Result<u32> { let process = Process::open_with_access(entry, rights)?; let gold = process.read_u32(gold_address)?; Ok(gold) // OwnedHandle::drop closes the handle on every return path. // SAFETY: a numeric PID is passed; Windows validates it and the requested rights. let handle = unsafe { OpenProcess(rights, false, entry.id) }?; let mut bytes = [0_u8; 4]; let mut bytes_read = 0; // SAFETY: the handle is live and the local output buffer has four writable bytes. // Windows validates the remote range; no local pointer to that range is dereferenced. unsafe { ReadProcessMemory( handle, gold_address as *const c_void, bytes.as_mut_ptr().cast(), bytes.len(), Some(&mut bytes_read), )?; // An error returns before CloseHandle. } anyhow::ensure!(bytes_read == bytes.len(), "short read"); // Also skips cleanup. // SAFETY: this helper owns the live handle and has not closed it yet. unsafe { CloseHandle(handle) }?; Ok(u32::from_le_bytes(bytes))}Takeaway: cleanup belongs to the resource owner, so a new error path cannot skip it.
process.rs
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)
}
}The Process type stores this OwnedHandle. The rest of the program cannot
forget to close it. Its complete ToolHelp implementation is in
process.rs; the important
discovery step is:
pub fn find(name: &str) -> anyhow::Result<ProcessEntry> { Process::list()? .into_iter() .find(|entry| entry.name.eq_ignore_ascii_case(name)) .with_context(|| format!("process {name:?} is not running"))}Process::list creates a TH32CS_SNAPPROCESS snapshot, initializes
PROCESSENTRY32W.dwSize, and repeatedly calls Process32FirstW and
Process32NextW. That code is shown in full in the linked source rather than
being replaced by βenumerate processes here.β
Read into a byte buffer
Section titled βRead into a byte bufferβPut ReadProcessMemory behind a method:
use std::ffi::c_void;use windows::Win32::System::Diagnostics::Debug::ReadProcessMemory;
impl Process { fn read_exact(&self, address: usize, buffer: &mut [u8]) -> windows::core::Result<()> { let mut bytes_read = 0_usize;
// π‘οΈ SAFETY: // - `buffer` is valid for exactly `buffer.len()` writable bytes. // - Windows validates the remote address against the target process. // - `self.handle` remains open for this call. unsafe { ReadProcessMemory( self.handle, address as *const c_void, buffer.as_mut_ptr().cast(), buffer.len(), Some(&mut bytes_read), )?; }
// β
A successful API call may still report a short copy. Callers asked // for an exact value, so returning partly old buffer data would be a bug. if bytes_read != buffer.len() { return Err(windows::core::Error::from_win32()); } Ok(()) }
fn read_u32(&self, address: usize) -> windows::core::Result<u32> { let mut bytes = [0_u8; 4]; self.read_exact(address, &mut bytes)?; // π§ These Windows/x86 course targets store integers least-significant // byte first; the conversion documents that byte-order agreement. Ok(u32::from_le_bytes(bytes)) }}The public method returns a normal u32. Raw pointers never escape the wrapper.
Read the verified gold
Section titled βRead the verified goldβThe wrapper now returns ordinary values, so the caller can follow the
previously verified pointer path without touching raw pointers. In this
example, wesnoth_gold_address resolves the current runβs gold field;
read_u32 copies its four bytes into the tool. A successful run prints the
address and the current gold value. A missing process, invalid path, or failed
read returns an error through ? instead of printing a made-up result.
use anyhow::{Context, Result};
fn main() -> Result<()> { let process = Process::open_by_name("wesnoth.exe", false)?; let gold_address = wesnoth_gold_address(&process)?; let gold = process.read_u32(gold_address)?; println!("Gold at {gold_address:#010x}: {gold}"); Ok(())}Run the tool from an ordinary terminal in the VM. If Windows denies access, report the error; do not automatically demand more privileges.
Add a guarded write
Section titled βAdd a guarded writeβWriting should be opt-in and should verify the expected old value first:
The method below turns the diagram into a checked write. Given an address,
the value we expect there, and a replacement, it returns Ok(false) without
writing if the value has changed. A matching value permits one four-byte
write, and the result is true only when Windows reports all four bytes
written. Another game thread can still race this check, so the caller must
treat the result as evidence for this attempt, not a permanent guarantee.
use windows::Win32::System::Diagnostics::Debug::WriteProcessMemory;
impl Process { fn write_u32_if( &self, address: usize, expected: u32, replacement: u32, ) -> windows::core::Result<bool> { // β οΈ Re-read immediately before writing. A scan result is only an old // observation, and the running game may have changed this value since. if self.read_u32(address)? != expected { return Ok(false); }
let bytes = replacement.to_le_bytes(); let mut written = 0_usize;
// π‘οΈ SAFETY: `bytes` is a live readable buffer; Windows validates the // destination; the handle was opened with write and operation rights. unsafe { WriteProcessMemory( self.handle, address as *const c_void, bytes.as_ptr().cast(), bytes.len(), Some(&mut written), )?; }
// β
Do not claim success when Windows copied only part of the value. Ok(written == bytes.len()) }}Open the handle with allow_write: true only when the user explicitly requests a lab change.
Defeat the moving-address problem
Section titled βDefeat the moving-address problemβWesnoth 1.14.9 is a 32-bit target, so a 64-bit tool must read its pointers as four-byte u32 values, not as the toolβs native usize:
read_u32 takes exactly these 4 bytesusize read would consume all 8 bytes hereusize on a 64-bit tool silently absorbs the next field's 4 bytes into the pointer value.const WESNOTH_PLAYER_ROOT: usize = 0x017E_ED18;const GAME_OFFSET: usize = 0x0A90;const GOLD_OFFSET: usize = 0x0004;
fn read_pointer32(process: &Process, address: usize) -> windows::core::Result<usize> { // π Pointer width belongs to the target process, not to this tool. Reading // a native usize here would consume eight bytes in a 64-bit build. Ok(process.read_u32(address)? as usize)}
fn wesnoth_gold_address(process: &Process) -> anyhow::Result<usize> { let player = read_pointer32(process, WESNOTH_PLAYER_ROOT)?; anyhow::ensure!(player != 0, "no local match is active");
let game_pointer_address = player.checked_add(GAME_OFFSET) .context("game pointer address overflowed")?; let game = read_pointer32(process, game_pointer_address)?; anyhow::ensure!(game != 0, "game object is unavailable");
game.checked_add(GOLD_OFFSET) .context("gold address overflowed")}Now perform the actual hack:
let process = Process::open_by_name("wesnoth.exe", true)?;let gold_address = wesnoth_gold_address(&process)?;let old_gold = process.read_u32(gold_address)?;
println!("gold at {gold_address:#010x}: {old_gold}");let changed = process.write_u32_if(gold_address, old_gold, 999)?;anyhow::ensure!(changed, "gold changed before the guarded write");println!("gold is now 999");Return to Wesnoth and recruit a unit or end the turn to refresh the display. The gold should show 999 and behave as real spendable gold. Close and reopen the game to prove that the program resolves the dynamic address again.
You can build and run the finished program directly:
cd windows-labscargo build --release --target i686-pc-windows-msvc --bin wesnoth_gold.\target\i686-pc-windows-msvc\release\wesnoth_gold.exe 999The complete entry point is wesnoth_gold.rs.
wesnoth_gold.rs
Show the codeHide the code
#[cfg(windows)]
fn main() -> anyhow::Result<()> {
use anyhow::Context;
use gha_windows_labs::Process;
// These three values belong to Wesnoth 1.14.9, 32-bit Windows.
const PLAYER_ROOT: usize = 0x017E_ED18;
const GAME_OFFSET: usize = 0x0A90;
const GOLD_OFFSET: usize = 0x0004;
let amount = std::env::args()
.nth(1)
.map(|text| text.parse::<u32>())
.transpose()
.context("usage: wesnoth_gold [new_amount]")?
.unwrap_or(999);
let process = Process::open_by_name("wesnoth.exe", true)?;
anyhow::ensure!(process.is_32_bit()?, "this profile requires 32-bit Wesnoth");
// First bracket: read the player pointer stored at the fixed root.
let player = process.read_u32(PLAYER_ROOT)? as usize;
anyhow::ensure!(player != 0, "start a local match before running the tool");
// Second bracket: add 0xA90, then read the current side pointer.
let side_pointer_address = player
.checked_add(GAME_OFFSET)
.context("player + game offset overflowed")?;
let side = process.read_u32(side_pointer_address)? as usize;
anyhow::ensure!(side != 0, "the current side pointer is null");
// The gold number lives four bytes into the side record.
let gold_address = side
.checked_add(GOLD_OFFSET)
.context("side + gold offset overflowed")?;
let old_gold = process.read_u32(gold_address)?;
// Guard the write: stop if the value changed between our read and write.
let check = process.read_u32(gold_address)?;
anyhow::ensure!(
check == old_gold,
"gold changed before the write; try again"
);
process.write_u32(gold_address, amount)?;
println!("Wesnoth gold: {old_gold} -> {amount} at {gold_address:#010x}");
println!("Recruit a unit or end the turn to refresh the on-screen number.");
Ok(())
}
#[cfg(not(windows))]
fn main() {
eprintln!("Build this lab on Windows with the i686-pc-windows-msvc target.");
}Make failure useful
Section titled βMake failure usefulβTest these cases:
- wrong PID;
- target closed;
- malformed hex address;
- unreadable address;
- expected value does not match;
- pointer in the chain is zero.
A good low-level tool is not the one that never sees errors. It is the one that sees errors clearly and stops before making a bad write.
Respect the process boundary
What should an external Rust observer do with a target-process field?
Another process owns the address. A checked copy keeps that boundary and its failure modes explicit.
Finished Lesson 3.7?
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.