Call Tracing and Bounded Logs
Connect scanning, decoding, breakpoints, and bounded logging to a trace of calls made by a running game.
Cheatsheet for Lesson 14.2
A one-page summary of “Call Tracing and Bounded Logs” you can keep beside your notes. Plain Markdown.
Lesson 14.2 — Call Tracing and Bounded Logs: cheatsheet
- Remember: A useful trace answers one bounded question; collecting everything can bury the behavior you wanted to understand.
- Also useful: Treat restore and re-arm as two separate stops: the original call needs a brief clear path before the breakpoint can watch it again.
Key terms
NearBranch32:NearBranch16andNearBranch32are the direct relative call forms used in this 32-bit target.AttachGuard: TheAttachGuardis the last line of cleanup.
Formulas and patterns
# gha-call-log v1;# target_sha256=...;# module=wesnoth.exe;time_us,thread,call_site,target,stack_pointer;15234,4108,0x1400012A0,0x140003500,0x000000BEEF...
Review
- Q: Why should a call logger use a fixed-size or capped buffer? A: A hot call site could otherwise eat memory and disturb the program being measured
- Q: Why is limiting a call logger to one code range and one moment more than a performance setting? A: It makes the log a repeatable statement, such as which calls ran there in one action
Define the question
Section titled “Define the question”A call logger should not mean “break on every call in the whole process.” That would be noisy and slow.
Choose:
- one module;
- one executable section;
- one known caller range or target function;
- one small time window;
- a maximum number of events.
This scope is not just a performance setting. It gives the log a meaning. “These calls happened while one turn ended inside this verified module range” is a result you can reproduce. “The process called thousands of things” is mostly noise.
What a call instruction really does
Section titled “What a call instruction really does”A function call has two jobs:
- remember the address of the instruction that should run afterward;
- move the instruction pointer to the called function.
On 32-bit x86, a common direct call begins with opcode E8 followed by a signed 32-bit relative displacement. The CPU adds that displacement to the address immediately after the call. A disassembler performs this calculation and exposes the final target.
Predict first
At 0x00401000 the five bytes are E8 4B 00 00 00: the opcode E8, then the displacement 0x0000004B stored lowest byte first.
Which address does this call go to?
Answer: 0x00401050. The next instruction is at 0x00401000 + 5 = 0x00401005, and 0x00401005 + 0x4B = 0x00401050. The call also pushes 0x00401005, so ret comes back there.
Bytes, then the CPU’s arithmetic
The five bytes at 0x00401000
What the CPU computes
The CPU measures from the end of the call, 0x00401005, not from 0x00401000. Adding +75 (0x4B) gives 0x00401050, and 0x00401005 is pushed so ret can come back.
An indirect call such as call eax or call [ecx+8] gets its destination from a register or memory at runtime. Static bytes alone do not reveal one final target, so the first course logger deliberately records only direct calls.
A relative call starts measuring after itself. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
call_site = 0x1000; length = 5
next_ip = call_site + length
target = next_ip + signed_displacement
push next_ip as the return address
instruction_pointer = target
ret later resumes at saved next_ipResult: call enters 0x100F and a matching return resumes at 0x1005. The saved return is independent of the signed displacement.
Final values: displacement = 10; next_ip = 0x1005; target = 0x100F; saved_return = 0x1005; instruction_pointer = 0x1005.
Read every step of the lesson’s example
Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.
displacement = 10.
Step 2, line 2. The instruction immediately after the call is at 0x1005.
displacement = 10; next_ip = 0x1005.
Step 3, line 3. Add 10 to the end address, not the call’s beginning.
displacement = 10; next_ip = 0x1005; target = 0x100F.
Step 4, line 4. Save 0x1005 so the called function knows where to return.
displacement = 10; next_ip = 0x1005; target = 0x100F; saved_return = 0x1005.
Step 5, line 5. Execution enters the computed destination.
displacement = 10; next_ip = 0x1005; target = 0x100F; saved_return = 0x1005; instruction_pointer = 0x100F.
Step 6, line 6. Result: call enters 0x100F and a matching return resumes at 0x1005. The saved return is independent of the signed displacement.
displacement = 10; next_ip = 0x1005; target = 0x100F; saved_return = 0x1005; instruction_pointer = 0x1005.
Static discovery and live observation are different jobs
Section titled “Static discovery and live observation are different jobs”The logger does not place a breakpoint on every byte equal to E8. That byte may appear inside an immediate value or data. Instead, iced-x86 walks complete instructions and reports their flow-control kind.
Find calls with the decoder
Section titled “Find calls with the decoder”Use iced-x86 on the copied .text bytes and select direct near calls:
use iced_x86::{Decoder, DecoderOptions, FlowControl};
#[derive(Clone, Copy, Debug)]struct CallSite { address: u64, target: u64,}
fn direct_calls(bytes: &[u8], bitness: u32, start_ip: u64) -> Vec<CallSite> { let mut decoder = Decoder::with_ip( bitness, bytes, start_ip, DecoderOptions::NONE, ); let mut calls = Vec::new();
while decoder.can_decode() { let instruction = decoder.decode(); if instruction.flow_control() == FlowControl::Call && matches!( instruction.op0_kind(), iced_x86::OpKind::NearBranch16 | iced_x86::OpKind::NearBranch32 ) { calls.push(CallSite { address: instruction.ip(), target: instruction.near_branch_target(), }); } } calls}NearBranch16 and NearBranch32 are the direct relative call forms used in this 32-bit target. Indirect calls need runtime register or memory values and are a separate feature.
Decoder::with_ip needs both the bytes and their live starting address. The address lets each Instruction report a correct ip() and resolve a relative target. decoder.can_decode() stops at the end of the bounded slice. flow_control() == FlowControl::Call identifies call-like control flow, while the operand-kind check narrows that set to direct near branches.
The lesson uses decode() for readability. The complete binary reuses one Instruction with decode_out, which avoids constructing a new value on every loop iteration. That optimization does not change the algorithm.
Bound the software breakpoints
Section titled “Bound the software breakpoints”Options include:
- one breakpoint on a specific call site;
- one breakpoint at a target function entry;
- a small rotating set;
- an in-process instrumentation hook for a practice program.
Start with one entry breakpoint while learning the state machine. The completed course tool expands to verified direct calls in readable executable regions, excludes known noisy sites, deduplicates addresses, and applies a hard 2,000-breakpoint cap. More instrumentation multiplies state and performance cost.
A software breakpoint changes the first byte of an instruction to 0xCC, the one-byte int3 instruction. When the CPU executes it, Windows pauses the thread and reports EXCEPTION_BREAKPOINT to the debugger. The original E8 must be remembered so the real call can still execute and can later be restored.
E8 to CC (int3). The saved original E8 is what lets the logger restore the real call before it executes.The two-event breakpoint cycle
Section titled “The two-event breakpoint cycle”One breakpoint hit is not enough. If the debugger immediately writes 0xCC back at the same address and continues, the thread hits it forever. The logger temporarily restores the call and single-steps exactly one instruction:
At the breakpoint event, Windows reports the address of int3. The logger:
- confirms the address belongs to its breakpoint map;
- restores the original
E8byte; - opens the event thread and reads its
CONTEXT; - rewinds
EIPto the call address; - sets bit 8 of
EFlags, the trap flag; - records that this thread is stepping this call;
- continues the event.
The CPU now executes the real call. Because the trap flag is set, Windows pauses after that one instruction with EXCEPTION_SINGLE_STEP. At this moment EIP contains the call destination. The logger clears the flag, reinstalls 0xCC at the call site, removes the thread from the stepping map, and prints the source and destination.
The stepping map is keyed by thread ID because several game threads can hit different breakpoints. A single global “currently stepping” variable would mix their state.
Capture a compact event
Section titled “Capture a compact event”When the single-step pause reveals a call’s destination, copy only the facts needed to answer “who called where, on which thread, and when?” The writer can format this small owned record after the target is running again:
#[derive(Debug)]struct CallEvent { timestamp_micros: u128, thread_id: u32, call_site: usize, target: usize, stack_pointer: usize,}Do not automatically dump the entire stack or arbitrary pointed-to memory. Record only what the current experiment requires.
The event is a snapshot of facts already available while the thread is paused. call_site and target explain control flow; thread_id separates timelines; stack_pointer helps compare call contexts without copying unknown stack contents.
Use a bounded channel
Section titled “Use a bounded channel”The debug loop and the log writer run at different speeds. A bounded channel holds at most 1,024 copied events; when it fills, the debugger can count a dropped event instead of leaving the game paused while disk output catches up:
use std::sync::mpsc::{self, SyncSender};
fn logger_channel() -> (SyncSender<CallEvent>, mpsc::Receiver<CallEvent>) { mpsc::sync_channel(1_024)}The debugger thread uses try_send, which fails at once when the channel is full. A bounded channel creates backpressure: it gives the producer a fixed amount of space instead of allowing logs to consume memory forever. Losing a counted event is better than holding every target thread suspended while a disk writer catches up. Lesson 14.6 compares other overload policies for the same bounded queue.
Resolve addresses after capture
Section titled “Resolve addresses after capture”Keep time-sensitive handling short:
Symbols can be slow or unavailable. A module-relative address is still useful:
wesnoth.exe + 0x1B4D00Address resolution is delayed because the debug-event loop has a deadline: every target thread remains stopped until ContinueDebugEvent. Expensive formatting, symbol lookup, and file I/O belong on a background worker after the minimal register facts have been copied.
Log a versioned format
Section titled “Log a versioned format”The paused thread supplied the addresses; the background writer now records them with a target fingerprint and explicit column names. In this example, one row says that thread 4108 reached a call site at time 15,234 microseconds and the call resolved to the listed target:
# gha-call-log v1# target_sha256=...# module=wesnoth.exetime_us,thread,call_site,target,stack_pointer15234,4108,0x1400012A0,0x140003500,0x000000BEEF...The version header says how to parse the columns, and the target hash binds the log to one binary so later analysis does not mix runs from different builds. Hexadecimal addresses keep the debugger’s notation. Lesson 14.6 extends the same versioned schema with sequence numbers and correlation IDs that join the events of one action.
Protect the target
Section titled “Protect the target”Stop logging when:
- the event cap is reached;
- the time window expires;
- the target exits;
- event drops exceed a threshold;
- the user requests stop;
- a breakpoint cannot be restored.
These conditions form the logger’s safety envelope. The event cap bounds memory and terminal output. The time cap bounds disruption. The drop threshold detects a writer that cannot keep up. A restoration failure stops the experiment because continuing would leave the target executing altered code whose ownership is uncertain.
Divide responsibility with explicit types
Section titled “Divide responsibility with explicit types”The complete executable uses two maps:
// 🔁 Address → displaced byte: everything needed to restore target code.let mut breakpoints: BTreeMap<usize, u8> = BTreeMap::new();// 👣 Thread → call site: which original instruction is between restore/re-arm.let mut stepping: HashMap<u32, usize> = HashMap::new();breakpoints maps each call address to its original byte. A BTreeMap also restores in deterministic address order, which makes diagnostics repeatable. stepping maps a thread ID to the call it is temporarily executing. A HashMap gives direct keyed lookup when the single-step event arrives.
The types prevent two different facts from being blurred together: “this address is patched” and “this thread is between restore and re-arm.”
The Windows debug-event contract
Section titled “The Windows debug-event contract”WaitForDebugEvent returns one pending event. Until the debugger calls ContinueDebugEvent, target threads remain stopped. Therefore every successful wait must lead to exactly one continue, including error paths.
Some event variants transfer handles to the debugger. The logger closes process, thread, and image-file handles after reading the active union member selected by dwDebugEventCode. It does not guess which union field is valid.
The AttachGuard is the last line of cleanup. If ordinary error propagation leaves the event loop, Drop attempts to detach. Breakpoint restoration still happens explicitly first because detaching a process whose code still contains course 0xCC bytes would be unsafe.
The capstone project
Section titled “The capstone project”This tool combines the whole section:
- the pattern scanner locates a range or anchor;
- the memory wrapper copies bytes;
- iced-x86 identifies calls;
- the debugger owns breakpoint state;
- the logger moves events off the hot path;
- RAII — resource acquisition is initialization, the habit of tying cleanup to a value’s lifetime — restores bytes and closes handles.
First prove the logger against a small test target whose source is available. Then run the real capstone against one narrow course target: in Wesnoth 1.14.9, log calls around 0x009B4D00 while ending one turn. The optional build reference lists an AssaultCube and an Urban Terror target for the same logger.
Set a limit such as 1,000 events or five seconds. The output should show real module-relative callers and targets from the selected game. Compare the log with x64dbg, then detach and verify every breakpoint byte was restored.
Run the complete Wesnoth logger
Section titled “Run the complete Wesnoth logger”The compiled course tool chooses the Wesnoth main module, asks VirtualQueryEx for readable executable regions, decodes them as x86 with iced-x86, and installs at most 2,000 verified E8 call breakpoints. It excludes two known noisy call sites from the historical lab. When a call fires, it restores E8, rewinds EIP, sets the trap flag, executes the real call once, records the destination EIP, reinserts 0xCC, and continues.
.\target\i686-pc-windows-msvc\release\call_logger.exeExercise a small Wesnoth path, then press End. The tool restores every installed call byte before detaching. The full code is call_logger.rs; shared memory and region enumeration are in process.rs. The optional build reference gives an order for reading the full source.
call_logger.rs
Show the codeHide the code
#[cfg(all(windows, target_arch = "x86"))]
mod app {
use std::collections::{BTreeMap, HashMap};
use anyhow::{Context as _, Result};
use gha_windows_labs::{OwnedHandle, Process};
use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, OpKind};
use windows::Win32::{
Foundation::{
CloseHandle, DBG_CONTINUE, DBG_EXCEPTION_NOT_HANDLED, ERROR_SEM_TIMEOUT,
EXCEPTION_BREAKPOINT, EXCEPTION_SINGLE_STEP, HANDLE, NTSTATUS,
},
System::{
Diagnostics::Debug::{
CONTEXT, CONTEXT_FULL_X86, CREATE_PROCESS_DEBUG_EVENT, CREATE_THREAD_DEBUG_EVENT,
ContinueDebugEvent, DEBUG_EVENT, DebugActiveProcess, DebugActiveProcessStop,
EXCEPTION_DEBUG_EVENT, EXIT_PROCESS_DEBUG_EVENT, GetThreadContext,
LOAD_DLL_DEBUG_EVENT, SetThreadContext, WaitForDebugEvent,
},
Threading::{
OpenThread, THREAD_GET_CONTEXT, THREAD_QUERY_INFORMATION, THREAD_SET_CONTEXT,
},
},
UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_END},
};
const MAX_BREAKPOINTS: usize = 2_000;
const TRAP_FLAG: u32 = 1 << 8;
const NOISY_CALLS: [usize; 2] = [0x0040_E3D8, 0x0040_E3EA];
struct AttachGuard {
pid: u32,
active: bool,
}
impl Drop for AttachGuard {
fn drop(&mut self) {
if self.active {
// SAFETY: best-effort pair for this tool's successful attach.
let _ = unsafe { DebugActiveProcessStop(self.pid) };
}
}
}
fn close_event_handle(handle: HANDLE) {
if !handle.is_invalid() {
// SAFETY: debug events transfer these handles to the debugger.
let _ = unsafe { CloseHandle(handle) };
}
}
fn dispose_event_handles(event: &DEBUG_EVENT) {
// SAFETY: the event code selects the active union field.
unsafe {
match event.dwDebugEventCode {
CREATE_PROCESS_DEBUG_EVENT => {
let info = event.u.CreateProcessInfo;
close_event_handle(info.hFile);
close_event_handle(info.hProcess);
close_event_handle(info.hThread);
}
CREATE_THREAD_DEBUG_EVENT => close_event_handle(event.u.CreateThread.hThread),
LOAD_DLL_DEBUG_EVENT => close_event_handle(event.u.LoadDll.hFile),
_ => {}
}
}
}
fn open_thread(thread_id: u32) -> Result<OwnedHandle> {
let access = THREAD_GET_CONTEXT | THREAD_SET_CONTEXT | THREAD_QUERY_INFORMATION;
// SAFETY: thread id came from the current debug event.
let raw = unsafe { OpenThread(access, false, thread_id) }?;
Ok(OwnedHandle::from_raw(raw)?)
}
fn context(thread: &OwnedHandle) -> Result<CONTEXT> {
let mut value = CONTEXT {
ContextFlags: CONTEXT_FULL_X86,
..Default::default()
};
// SAFETY: the event thread is suspended and this output is writable.
unsafe { GetThreadContext(thread.raw(), &mut value) }?;
Ok(value)
}
fn set_context(thread: &OwnedHandle, value: &CONTEXT) -> Result<()> {
// SAFETY: this context belongs to the suspended event thread.
unsafe { SetThreadContext(thread.raw(), value) }?;
Ok(())
}
fn direct_calls(process: &Process, base: usize, size: usize) -> Result<Vec<usize>> {
let end = base.checked_add(size).context("module range overflowed")?;
let mut calls = Vec::new();
for region in process.regions(base, end)? {
if !region.readable || !region.executable {
continue;
}
let start = region.base.max(base);
let region_end = region.base.saturating_add(region.size).min(end);
if start >= region_end {
continue;
}
let Ok(bytes) = process.read_bytes(start, region_end - start) else {
continue;
};
let mut decoder = Decoder::with_ip(32, &bytes, start as u64, DecoderOptions::NONE);
let mut instruction = Instruction::default();
while decoder.can_decode() && calls.len() < MAX_BREAKPOINTS {
decoder.decode_out(&mut instruction);
if instruction.is_invalid()
|| instruction.flow_control() != FlowControl::Call
|| !matches!(
instruction.op0_kind(),
OpKind::NearBranch16 | OpKind::NearBranch32
)
{
continue;
}
let source = instruction.ip() as usize;
let target = instruction.near_branch_target() as usize;
if (base..end).contains(&target) && !NOISY_CALLS.contains(&source) {
calls.push(source);
}
}
}
calls.sort_unstable();
calls.dedup();
calls.truncate(MAX_BREAKPOINTS);
Ok(calls)
}
fn install_breakpoints(process: &Process, addresses: &[usize]) -> Result<BTreeMap<usize, u8>> {
let mut installed = BTreeMap::new();
for &address in addresses {
let original = process.read_bytes(address, 1)?[0];
if original != 0xE8 {
continue;
}
if let Err(error) = process.write_code(address, &[0xCC]) {
for (&installed_address, &byte) in &installed {
let _ = process.write_code(installed_address, &[byte]);
}
return Err(error);
}
installed.insert(address, original);
}
Ok(installed)
}
fn restore_all(process: &Process, breakpoints: &BTreeMap<usize, u8>) {
for (&address, &original) in breakpoints {
if process
.read_bytes(address, 1)
.is_ok_and(|bytes| bytes == [0xCC])
{
let _ = process.write_code(address, &[original]);
}
}
}
fn end_pressed() -> bool {
// SAFETY: VK_END is a valid virtual-key code.
unsafe { GetAsyncKeyState(VK_END.0.into()) & 1 != 0 }
}
fn handle_exception(
process: &Process,
event: &DEBUG_EVENT,
breakpoints: &BTreeMap<usize, u8>,
stepping: &mut HashMap<u32, usize>,
initial_break_seen: &mut bool,
) -> Result<NTSTATUS> {
// SAFETY: the caller checked EXCEPTION_DEBUG_EVENT.
let exception = unsafe { event.u.Exception.ExceptionRecord };
let code = exception.ExceptionCode;
let address = exception.ExceptionAddress as usize;
if code == EXCEPTION_BREAKPOINT {
if let Some(&original) = breakpoints.get(&address) {
process.write_code(address, &[original])?;
let thread = open_thread(event.dwThreadId)?;
let mut registers = context(&thread)?;
registers.Eip = u32::try_from(address)?;
registers.EFlags |= TRAP_FLAG;
set_context(&thread, ®isters)?;
stepping.insert(event.dwThreadId, address);
return Ok(DBG_CONTINUE);
}
if !*initial_break_seen {
*initial_break_seen = true;
return Ok(DBG_CONTINUE);
}
}
if code == EXCEPTION_SINGLE_STEP {
let Some(call_address) = stepping.remove(&event.dwThreadId) else {
return Ok(DBG_EXCEPTION_NOT_HANDLED);
};
let thread = open_thread(event.dwThreadId)?;
let mut registers = context(&thread)?;
registers.EFlags &= !TRAP_FLAG;
let destination = registers.Eip;
set_context(&thread, ®isters)?;
process.write_code(call_address, &[0xCC])?;
println!("{call_address:#010x}: call {destination:#010x}");
return Ok(DBG_CONTINUE);
}
Ok(DBG_EXCEPTION_NOT_HANDLED)
}
pub fn run() -> Result<()> {
let entry = Process::find("wesnoth.exe")?;
let process = Process::open(entry.clone(), true)?;
anyhow::ensure!(process.is_32_bit()?, "call logger requires 32-bit Wesnoth");
let (base, size) = process.module("wesnoth.exe")?;
let calls = direct_calls(&process, base, size)?;
anyhow::ensure!(
!calls.is_empty(),
"no direct calls found in executable regions"
);
// SAFETY: the PID is the exact process found by ToolHelp.
unsafe { DebugActiveProcess(entry.id) }?;
let mut attach = AttachGuard {
pid: entry.id,
active: true,
};
let mut breakpoints = BTreeMap::new();
let mut stepping = HashMap::new();
let mut initial_break_seen = false;
println!("Waiting for Windows' initial attach breakpoint...");
loop {
if !breakpoints.is_empty() && stepping.is_empty() && end_pressed() {
restore_all(&process, &breakpoints);
// SAFETY: no debug event is pending between loop iterations.
unsafe { DebugActiveProcessStop(entry.id) }?;
attach.active = false;
println!("Restored {} call bytes and detached.", breakpoints.len());
break;
}
let mut event = DEBUG_EVENT::default();
// SAFETY: event is a valid writable DEBUG_EVENT.
match unsafe { WaitForDebugEvent(&mut event, 100) } {
Ok(()) => {}
Err(error) if error.code() == ERROR_SEM_TIMEOUT.to_hresult() => continue,
Err(error) => return Err(error.into()),
}
let result = if event.dwDebugEventCode == EXCEPTION_DEBUG_EVENT {
// Install while every target thread is stopped at the attach event.
if !initial_break_seen && breakpoints.is_empty() {
match install_breakpoints(&process, &calls) {
Ok(installed) => {
breakpoints = installed;
println!("Installed {} verified call breakpoints.", breakpoints.len());
}
Err(error) => {
// Continue this pending attach event before returning.
// SAFETY: this exactly matches the event above.
let _ = unsafe {
ContinueDebugEvent(
event.dwProcessId,
event.dwThreadId,
DBG_EXCEPTION_NOT_HANDLED,
)
};
return Err(error);
}
}
}
handle_exception(
&process,
&event,
&breakpoints,
&mut stepping,
&mut initial_break_seen,
)
} else {
Ok(DBG_CONTINUE)
};
dispose_event_handles(&event);
let status = match result {
Ok(status) => status,
Err(error) => {
// SAFETY: this exactly matches the pending event.
let _ = unsafe {
ContinueDebugEvent(
event.dwProcessId,
event.dwThreadId,
DBG_EXCEPTION_NOT_HANDLED,
)
};
restore_all(&process, &breakpoints);
return Err(error);
}
};
// SAFETY: every returned event is continued exactly once.
unsafe { ContinueDebugEvent(event.dwProcessId, event.dwThreadId, status) }?;
if event.dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT {
attach.active = false;
break;
}
}
Ok(())
}
}
#[cfg(all(windows, target_arch = "x86"))]
fn main() -> anyhow::Result<()> {
app::run()
}
#[cfg(not(all(windows, target_arch = "x86")))]
fn main() {
eprintln!("Build this call logger for i686-pc-windows-msvc.");
}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)
}
}This first complete version writes compact lines directly to the console. The bounded-channel design above is the next performance step when you add file output; it is not required to run or reproduce the actual call logger.
The original call-logger recording shows the running game producing trace output. Each observed call requires a breakpoint hit, execution of the saved call, a single-step event, and re-arming; bytes discovered in a static scan have not necessarily executed.
Download Executed calls appearing in a Wesnoth trace videoCompare a trace with a run that changed nothing
Section titled “Compare a trace with a run that changed nothing”One trace lists what ran while it recorded, but not which calls the action caused: most of them belong to drawing, input and timers that run anyway. To separate the two, fix the scenario first (the build, the save, the logging window and the event cap) and record it twice without changing anything. Then record it once more with a single change, such as recruiting one unit before ending the turn.
The two unchanged runs show how far the counts move on their own. A function the game calls once per frame runs as many times as frames fit in the window, and that depends on timing, so even identical runs disagree. A count in the changed run points at the change only when it falls outside the range the unchanged runs produced.
Predict first
Two runs that only end the turn called wesnoth.exe+0x0E7710 212 and 219 times. A third run that first recruits one unit called it 215 times.
Does recruiting change how often +0x0E7710 runs?
Answer: The counts cannot tell: 215 lies inside the 212 to 219 of the identical runs. A count points at the change only when it falls outside what unchanged runs produce. 215 is inside, so this function may or may not take part in recruiting, and these counts cannot separate the two.
1 Fix the scenario before any run
The same for every run
Buildwesnoth.exe, its SHA-256 in the log headerStartthe same save: turn 3, four units on the mapWindow5 seconds from the first click, at most 1,000 eventsThe action
Runs A and A′end the turnRun Brecruit one unit, then end the turnEvery run uses the same build, the same save and the same logging window. Only the action differs, and only in run B.
2 Two unchanged runs give the spread
Calls per target in runs A and A′
wesnoth.exe+0x0E7710212A219A′212 to 219spreadwesnoth.exe+0x1F0C804A4A′4spreadwesnoth.exe+0x2A1C301A1A′1spreadwesnoth.exe+0x3C5E400A0A′0spreadRuns A and A′ did the same thing, yet +0x0E7710 ran 212 and then 219 times: 219 − 212 = 7 calls of difference from timing alone, because the number of frames that fit in 5 seconds varies.
3 Read run B against the spread
Run B compared with the spread
wesnoth.exe+0x0E7710212 to 219A and A′215Binside: no evidencewesnoth.exe+0x1F0C804A and A′5Boutside: a candidatewesnoth.exe+0x2A1C301A and A′1Binside: no evidencewesnoth.exe+0x3C5E400A and A′1Boutside: a candidateOnly two targets leave the spread: +0x1F0C80 ran 5 times against 4, and +0x3C5E40 ran once against never. Those are the two places to break during the next recruit.
4 One guess to test next
+0x1F0C80
Counts4 calls with 4 units5 calls with 5 unitsGuesscalled once for each unit when a turn endsTestbreak on it in the next run: each hit should receive a different unitThe save had four units and run B had five, and +0x1F0C80 ran 4 and then 5 times. Once per unit at the end of a turn fits, but it stays a guess until a breakpoint shows each call handling a different unit.
Call counts for four logged targets in wesnoth.exe. Runs A and A′ repeat the same scenario; run B adds one recruit.
Diagnose by the last completed state
Section titled “Diagnose by the last completed state”Troubleshooting by the last completed state
| Symptom | Likely boundary |
|---|---|
| No call sites discovered | Wrong bitness, module range, region permissions, or decoder input |
| Breakpoints install but never fire | The chosen action did not execute those sites, or the scope is wrong |
| Repeated hit at one address | EIP was not rewound or the call was re-armed too early |
| Unhandled single-step event | The thread ID was absent from the stepping map |
| Target stays paused | A WaitForDebugEvent result did not reach one matching continue |
| Target crashes after detach | Original bytes were not completely restored |
This table is why the state machine is explained explicitly: the symptom tells you which transition to inspect instead of encouraging random breakpoint changes.
Optional build reference
Section titled “Optional build reference”These notes help when you reproduce the logger on another course game or read its complete source. The breakpoint cycle and bounded record above do not depend on them.
Optional build reference: other capstone targets
- AssaultCube 1.2.0.2: log entries to the internal print function at
0x00419880while names are drawn; - Urban Terror 4.3.4: log the render path around the memory-wall hook at
0x0052D2FDfor one local frame window.
Optional build reference: read the complete source in six passes
The full call_logger.rs is longer than the scanner because it owns a live state machine. Read it in this order:
- Constants and guards — caps, trap flag, known exclusions, detach cleanup.
- Thread context helpers — open only the event thread and get or set its suspended register state.
- Static discovery — copy executable regions and let iced-x86 return verified call instructions.
- Breakpoint transaction — save
E8, install0xCC, and roll back already-installed sites on partial failure. - Exception state machine — pair each breakpoint event with a later per-thread single-step event.
- Event loop and shutdown — continue every event once, close transferred handles, restore bytes, and detach.
Do not begin at main and dive into every Windows name. First locate these six responsibilities; then read the helper used by the phase you are studying.
Keep logging bounded
Why should a call logger use a fixed-size or capped buffer?
Instrumentation must control its own cost. Bounded storage, sampling, and dropped-event counters make overhead visible and predictable.
Finished Lesson 14.2?
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.