Brightness, reading palette, text size, comments and reading sound.Adds page background, hover cards, button corners, code colours, width and layout.Everything, including diagram styling, motion, inline code, depth and fine details.
Reading sound Off
Sound starts off. Play when you want it.
Original sounds, released into the public domain under CC0.
Margin comments
Hide author and reader comments without deleting them.
Page brightness
Lesson text size
Reading palette
More options
Reader finish
Modern adds semantic colours and refined controls. Soft depth adds frost and shadows.
Page background
Hover cards
Definitions, examples, tips, and lesson summaries that open over words and links. Off also removes the underlines.
Chat button
Which corner the button for asking questions about the book sits in. Top corners stop short of the lesson list and contents; at the bottom right it sits under the Notes button. It takes the colour of the chapter you are reading.
Notes button
Where the 📝 Notes button sits on lessons. Hidden leaves a thin tab on the edge; Alt + N brings it back.
Settings panel
Popover opens under the button. Side sheet slides in from the right with a dimmed page behind it.
Sidebar and header contrast
Original keeps the dark lesson list and header. Unified tints them to match the page and palette.
Reading width
How wide the lesson text column is. Standard is about 70 characters a line.
Table of contents
Light or dark for the “On this page” panel on the right, whatever the page is.
Lesson spacing
Code brightness
Syntax colours
Moving effects
System respects reduced motion while still advancing steps. On Play enables moving effects when you start a diagram. Off keeps manual steps.
Fine-tuning
Depth
Flat is the original. Soft adds faint shadows to diagram boxes, cards and buttons and lifts them slightly on hover.
Inline code
Classic keeps the badge look. Soft quiets the background and border for paragraphs full of keywords.
Floating buttons
Docked shows the Notes and chat buttons normally. Minimal makes them small icons that fade until you point at them.
Gradients
Use flat colours for headings, cards and diagram backgrounds.
Diagram background
Changes diagram surfaces; labels and role colours remain readable.
Diagram boxes
Heading rectangles
Diagram label backgrounds
Diagram frames
Diagram size
Page grid
Time per step
Semantic colours
Ligatures
What the colours mean
Terms, inputs, and data
Code and processing
Results and tips
Cautions
These colours follow your theme; their meanings stay the same. Data tables and diagram explanations use the information colour; code panels use processing. The lesson header and navigation identify its chapter. Correct answers are green; errors are red.
Alt + T palette · Alt + D page · Alt + C code · Alt + S syntax · Alt + H semantic · Alt + L ligatures · Alt + N lesson list · Alt + O on this page
6. Windows Processes and Callable Interfaces — print chapter
Game Hacking Academy
Chapter 6: Windows Processes and Callable Interfaces
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.
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.
1On disk: the file wesnoth.exe
Disk
Files
wesnoth.exe
The file holds code, data and headers, and does nothing by itself.
2Mapped into a running process
Disk
Files
wesnoth.exe
Running process
↓ maps
Main image
image mapped from wesnoth.exe
Windows maps the file into a new process as its main image.
3Runtime 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.
All 3 steps shown
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:
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:
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.
moveax, dword ptr fs:[0x30]
; 64-bit Windows: obtain the current process's PEB pointer.
movrax, 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.
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
CreateToolhelp32Snapshot copies the target’s module list at this moment. This successful tour follows the first entry, whose name is the requested name.
OwnedHandle takes responsibility for closing the snapshot handle. Keeping the wrapper alive keeps the snapshot available during enumeration.
The correctly sized MODULEENTRY32W is filled by Module32FirstW. The copied entry contains szModule, modBaseAddr, and modBaseSize.
The wrapper compares szModule with wanted_name without regard to letter case. This name matches. A nonmatching entry would advance with Module32NextW.
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.
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.
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:
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.
#[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.");}
An executable has become a running process, but its address space does not yet
tell us what runs the game’s logic. Before reading a memory map or applying
a native-code method to an unfamiliar game, identify its engine and runtime.
Wesnoth, AssaultCube, and Urban Terror let us practice with compiled native
code. A game using a managed runtime or script host may give us names and
types that those labs had to infer, and its objects may have different
lifetimes and may move in memory.
For example, searching for a field at player base + 0x138 assumes that we
have the right kind of player object, that the field has that layout in this
build, and that the object is still alive when we read it. Discover the runtime
first; then choose what evidence can answer the health question.
The branches are not different difficulty levels. They are different questions.
In any branch, an offset describes a layout in a particular build; it does not
keep an object alive. The runtime changes what evidence is available for
recovering that layout and whether the object can move during its lifetime.
The same address arithmetic, three outcomes0x5000 + 0x138 = 0x5138 every time. What sits there depends on the object’s lifetime and the build’s layout.
1Matching build, live player
Read player base + offset
Address arithmetic
0x5000player base
+ 0x138health in this build
= 0x5138address read
At 0x5000 right now
Object, then field
Player, alive
health = 100
The layout is right and the object is alive, so 0x5138 holds health.
2The player was destroyed; its storage reused
Read player base + offset
Address arithmetic
0x5000player base
+ 0x138health in this build
= 0x5138address read
At 0x5000 right now
Object, then field
Projectile
a projectile field, not health
Same address, different object: 0x5138 now belongs to a projectile.
3A new build moved health to +0x140
Read player base + offset
Address arithmetic
0x5000player base
+ 0x138the old build’s offset
= 0x5138address read
At 0x5000 right now
Object, then field
Player, alive
armor, not health
The object is right but the layout is not: in this build +0x138 holds another field.
All 3 steps shown
0x5000 + 0x138 = 0x5138 every time. What sits there depends on the object’s lifetime and the build’s layout.
Possibly native code; inspect the loaded modules before deciding
Confirm it against the process’s loaded-module list, because a folder can lie
and a launcher can start something other than the executable you inspected.
The folder shows what is installed; the module list shows what the running
process actually mapped. Lesson 12.1 later shows a tool for collecting that
list.
For a native build, the debugger and PE techniques from Chapters 2, 3, and 5
remain useful. An object layout can have fixed field offsets within that
build. The object base can still change after allocation, collection growth,
destruction, or a restart, as Lessons 1.8 and 3.10 explained. Reconfirm the
module, object identity, and lifetime before reusing an address.
Assembly-CSharp.dll is not a native DLL. It holds .NET intermediate
language, which keeps class names, method names, and field names in its
metadata. A decompiler shows something close to the original C#, including the
field you are looking for and the method that changes it.
This changes the shape of the work completely. You are no longer inferring that
offset 0x138 behaves like health across controlled tests. You are reading a
field called health and the method called TakeDamage that writes it. The
recovery skills from Chapter 3 still matter, but they become confirmation
rather than discovery.
One useful nuance: Unity documents its managed garbage collector as
non-compacting. It does not move surviving managed objects merely to close
gaps in the heap. That removes one reason an address might change, but it does
not make a pointer chain a permanent identity: the game can destroy and
recreate an object, reuse storage, or change the layout in another build.
IL2CPP converts the same C# ahead of time into C++ and compiles it, so the
logic ships as native code inside GameAssembly.dll. There is no
Assembly-CSharp.dll to decompile, and at first glance you are back to reading
disassembly.
The runtime also needs metadata for the managed features that remain in the
build. A typical Unity IL2CPP layout has global-metadata.dat; it can reveal
useful names and type relationships. It is not a promise that every original
class, method, or field name survived: build stripping and packaging affect
what remains. Correlate the metadata you can actually inspect with the native
code and with a controlled observation in that exact build.
A .NET game running on CoreCLR is the case where the earlier assumption really
does break. CoreCLR’s garbage collector is generational and compacting: it
relocates surviving objects to remove gaps. An object’s address is therefore
valid only until the next collection that moves it.
t0object at 0x5000
t1, after GCobject at 0x5000
With Unity's non-compacting collector, this surviving object stays at the same address across a collection. Destruction and recreation can still change its address later.
t0object at 0x5000
t1, after GCobject moved to 0x5A00
CoreCLR or a moving JVM collector such as G1 can relocate a surviving object; after this example collection, its old raw address is no longer valid.
That differs from Unity’s non-compacting collector. Both are “managed” and
“garbage collected”; those words alone do not tell you whether a particular
object may move. Check the runtime and collector, as well as the object’s
lifetime.
Where objects can move, a raw address is not a durable identity. Use a
runtime-supported reference, a validated handle, or a fresh lookup when the
runtime exposes one. Lesson 3.10 built slot-and-generation handles for a
similar identity problem: storage can be reused even when nothing moves.
Many JVM collectors, including G1, also move surviving objects. The collector
and configuration matter; do not infer a permanent address from the word
“Java” alone.
A layout does not keep an object at an address. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
object begins at address 0x1000
remember address and field offset 8
apply the selected lifetime event
resolve the current object identity
compare the remembered address
read only through a still-valid identity
Result: use the newly resolved field at 0x2008. The unchanged offset never guaranteed lifetime or location.
Unreal combines native C++ code with an engine object system. Its reflected
UObject types carry class and property information, while ordinary native
types and unreflected fields can still require the earlier layout method.
That makes the engine’s object and name facilities useful orientation points,
not a guarantee that every gameplay value is listed there. Inspect the exact
game build before choosing a search target.
A .pck file may suggest Godot, but a game can add native extensions or use a
different scripting arrangement. A launcher can also start a different
executable from the one beside the package you inspected. Treat the folder
clues as a starting hypothesis and verify it with the loaded-module inventory
and one observed behavior.
A runtime classification goes stale the same way an offset does. Unity lets a
studio switch a project’s scripting backend between Mono and IL2CPP, so an
early-access release and the finished game can land on different branches of
the flowchart above. The classification holds for the exact build you
inspected; a new build means reading the folder and the loaded
modules again.
Apply the method to a second folder. Suppose you see GameAssembly.dll and
il2cpp_data/Metadata/global-metadata.dat, but no
Assembly-CSharp.dll. Unity IL2CPP is a useful first hypothesis, so inspect
the loaded modules and available metadata. It would be a mistake to conclude
that every old field name survived, or that a pointer found today will still
identify the same object after a scene reload. If the next build instead
loads coreclr.dll, revisit the collector and reference model before reusing
any raw object address. Verify runtime, build,
and object lifetime separately before reusing an address.
A build hash can tell us which executable we intend to inspect, and a PID can
tell us which running instance to ask about. Neither lets a program read its
memory. Windows makes access a separate decision when it issues a process
handle.
A process ID identifies a running process. It does not grant access to that process. To ask Windows for a usable process handle, a program calls OpenProcess with an access mask: a group of bits describing the operations it wants.
Windows checks whether the caller may obtain those rights to the target process. The call either returns a handle carrying the granted rights or fails with an error:
The handle is not the process itself. It is a Windows-managed reference plus a specific set of permissions.
Think of the PID as the number on a catalogue entry and the handle as a claim
ticket issued for a particular visit. Knowing the number lets you ask;
Windows checks the caller and the target before issuing a ticket marked with
the allowed operations. The analogy ends there: a handle is an opaque value
meaningful to Windows in this process, and an API checks its recorded rights,
not a handwritten label. This is why finding the right PID does not rescue a
handle that lacks PROCESS_VM_READ.
The call that issues the handle takes three arguments:
OpenProcess, argument by argument
dwDesiredAccessaccess maskrequired
The rights to ask for, as bits ORed together. Ask for the smallest set the job
needs; the table further down lists the rights this course uses.
bInheritHandlebooldefault false
Whether a process this one starts later inherits the handle. The course passes
false, so nothing a tool launches quietly receives access.
dwProcessIdu32required
The PID of the target. It names the process and grants nothing by itself.
return valuehandle, or an error
A handle carrying the granted rights, or an error such as “access denied” when
Windows refuses the requested access. Close a handle you no longer need.
Optional: practise the three-argument call below. For a supplied PID, this
helper asks only for basic process-query access and disables inheritance. A
successful call gives it an owned HANDLE, which it immediately closes; an
error returns through ?. Real inspectors keep an ownership guard when more
fallible work happens between opening and closing. These are the course’s
windows 0.62 projected signatures,
with the paired handle-release contract.
A capability combines a reference to an object with authority to perform particular operations. A process handle opened for limited queries can identify the process and ask for its image path, but it cannot become a memory-writing handle because a later function wishes it could.
That is useful architecture, not just security etiquette. Give read-only observation code a type that owns only a query/read handle. Give patching code a separately created write-capable handle after the user selects an explicit lab action. Then ordinary scanner functions cannot accidentally grow into writers without changing their inputs and call sites.
Rights belong to the handle, not permanently to the PID. Opening the same PID twice with different access masks can produce two handles with different capabilities, and closing one does not close the other.
When you sign in, Windows creates security information describing your account and groups. A process receives an access token representing its security context. That token includes identity information, privileges, restrictions, and an integrity level.
An integrity level is a boundary used by Windows to limit which processes may influence others. A normal desktop program commonly runs at medium integrity. A program started through an administrator approval may run at high integrity. A lower-integrity caller generally cannot gain powerful access to a higher-integrity target merely because both processes are visible in Task Manager.
An integrity level is not a score for whether software is good or bad. It is one input to an access decision.
A process object also has a security descriptor. Its DACL—Discretionary Access Control List—describes which trustees may receive which rights. When OpenProcess runs, Windows checks the requested rights against this information and the restrictions on protected processes.
This explains a common beginner mistake: “I found the PID, so why did reading memory fail?” Finding and opening are separate steps with different permissions.
The access check happens when Windows creates or duplicates the handle. Afterward, APIs compare their required rights with the rights recorded on that handle. Asking for extra rights up front can make OpenProcess fail even when the smaller operation you actually needed would have been allowed.
Ask for basic information such as the executable path.
PROCESS_QUERY_INFORMATION
Query information needed by several memory APIs.
PROCESS_VM_READ
Copy readable bytes from the target with ReadProcessMemory.
PROCESS_VM_OPERATION
Change or allocate virtual-memory regions.
PROCESS_VM_WRITE
Copy bytes into the target with WriteProcessMemory.
PROCESS_ALL_ACCESS is not a convenient “make it work” flag. It requests many unrelated powers and makes failures harder to diagnose.
The original helper asks Windows for every process right just to query an executable path. OwnedHandle owns the returned handle, and query_full_process_image_name is the path-query helper below.
The small request documents the tool’s purpose. If Windows grants it, you know the program did not quietly obtain memory-write, thread-creation, handle-duplication, or termination rights.
An issued handle carries specific rights. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
granted = selected_handle_mask
needed = selected_operation_right
intersection = granted & needed
allowed = intersection == needed
perform only the allowed operation
report the issued authority
Result: read permitted by this mask. A PID or correct build name does not supply a missing right.
This program finds a named process through the shared ToolHelp code, opens it with only PROCESS_QUERY_LIMITED_INFORMATION, and asks Windows for the image path:
#[cfg(windows)]fn main() -> anyhow::Result<()> { use anyhow::Context; use gha_windows_labs::Process; use windows::{ Win32::System::Threading::{ PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, QueryFullProcessImageNameW, }, core::PWSTR, }; let process_name = std::env::args() .nth(1) .unwrap_or_else(|| "wesnoth.exe".to_owned()); let entry = Process::find(&process_name)?; let process = Process::open_with_access(entry, PROCESS_QUERY_LIMITED_INFORMATION) .context("Windows denied even limited process information")?; let mut path = vec![0_u16; 32_768]; let mut length = u32::try_from(path.len()).context("path buffer is too large")?; // SAFETY: `path` owns `length` writable UTF-16 units, and `length` remains // valid while Windows updates it with the number of units actually used. unsafe { QueryFullProcessImageNameW( process.raw_handle(), PROCESS_NAME_WIN32, PWSTR(path.as_mut_ptr()), &mut length, )?; } path.truncate(usize::try_from(length)?); println!("Process: {} (PID {})", process.name(), process.id()); println!("Image: {}", String::from_utf16_lossy(&path)); println!("Rights: PROCESS_QUERY_LIMITED_INFORMATION"); println!("No memory-read, memory-write, thread, or debug right was requested."); Ok(())}#[cfg(not(windows))]fn main() { eprintln!("This access-rights lab must run on Windows.");}
#[cfg(windows)]
fnmain() -> anyhow::Result<()> {
useanyhow::Context;
usegha_windows_labs::Process;
usewindows::{
Win32::System::Threading::{
PROCESS_NAME_WIN32,
PROCESS_QUERY_LIMITED_INFORMATION,
QueryFullProcessImageNameW,
},
core::PWSTR,
};
letprocess_name = std::env::args()
.nth(1)
.unwrap_or_else(|| "wesnoth.exe".to_owned());
letentry = Process::find(&process_name)?;
letprocess = Process::open_with_access(
entry,
PROCESS_QUERY_LIMITED_INFORMATION,
)
.context("Windows denied even limited process information")?;
QueryFullProcessImageNameW writes UTF-16 text into caller-owned memory. The compiler cannot prove what the operating system will do, so the call is unsafe.
The comment states the exact proof:
path owns the buffer;
length begins as the buffer capacity;
both remain alive during the call;
Windows reports the number of UTF-16 units it wrote;
The wrapper truncates the vector before converting the used portion to a string.
unsafe does not mean “skip safety.” It means the programmer must supply the proof the compiler cannot check.
Start a local match, then use a normal, non-administrator terminal:
Terminal window
cd windows-labs
cargo run --release --target i686-pc-windows-msvc `
--bin access_probe -- wesnoth.exe
Repeat with:
Terminal window
cargo run --release --target i686-pc-windows-msvc `
--bin access_probe -- ac_client.exe
cargo run --release --target i686-pc-windows-msvc `
--bin access_probe -- Quake3-UrT.exe
The result should show the exact executable path. That helps prevent a tool from acting on a different file that happens to share a familiar process name.
the target runs under another account or at a higher integrity level;
the DACL does not grant the requested rights;
Windows restricts access because the target is a protected process;
the process exited between enumeration and OpenProcess;
the PID was reused after the old process ended;
the caller requested more rights than the job required.
The first troubleshooting step is to print the exact error and requested mask. Do not automatically enable debug privileges or elevate the whole tool. For the offline open-source course games, matching the user and integrity level is normally enough.
That boolean is a visible decision at the call site. A scanner can open read-only; a verified patcher must deliberately opt into writing.
The “group of bits” from the top of this lesson is not a metaphor. Microsoft documents PROCESS_QUERY_INFORMATION as 0x0400 and PROCESS_VM_READ as 0x0010. ORing them sets bit 10 and bit 4 of one 32-bit value and leaves every other bit 0, producing 0x0410. Allowing writes ORs in PROCESS_VM_OPERATION (0x0008) and PROCESS_VM_WRITE (0x0020), setting two more bits for a final mask of 0x0438. OpenProcess compares that exact bit pattern against what the target’s DACL permits; there is no separate “and also let me read memory” step hiding behind the named constant.
Predict first
A scanner found the game's correct PID and opened it with only PROCESS_QUERY_LIMITED_INFORMATION, mask 0x1000.
It now calls ReadProcessMemory with that handle. What happens?
Answer: ReadProcessMemory fails with access denied. 0x1000 has only bit 12 set. VM_READ is bit 4, 0x0010, and 0x1000 AND 0x0010 is 0, so the handle lacks the one right a read needs.
Which calls does this handle allow?The PID only chose the process. Every later call checks the rights stored with the handle.
The handle’s mask, then the call’s check
Rights stored with the handle
Bits 12, 10, 5, 4 and 3
1QUERY_LIMITED
0QUERY_INFO
0VM_WRITE
0VM_READ
0VM_OPERATION
↓ combine
OR of the set bits
0x1000 = 0x1000
ReadProcessMemory checks the handle
Needs
VM_READ
Result
fails: access denied
0x1000 does not include VM_READ. The PID is the right one, but ReadProcessMemory checks the rights stored with the handle, so it fails with access denied.
Transfer the idea to a file: CreateFileW can return a handle opened for
reading only. Knowing the path does not turn that existing handle into a
writing one. The file API may succeed at a read and refuse a write for the
same reason this chapter’s query-only process handle cannot read game memory.
The previous lesson opened a process with a specific set of rights. Now ask
who is responsible for releasing that permission-bearing reference. A tool
that handles denial correctly can still leak resources or close a handle twice
if ownership is vague.
Windows keeps kernel-managed objects for processes, threads, files, events, snapshots, and many other resources. A user-mode program normally refers to one of those objects through a handle.
The numeric handle value is an index-like token meaningful to Windows in the current process: Windows uses it to find a row in that process’s handle table, and the row points to the event, file, process, thread, or snapshot object. It is not the object’s memory address, and reading memory at that number does not reveal the object.
A handle entry also carries granted access rights. Two handles referring to the same process can permit different operations.
Two handles to one process, with different rightsWindows looks a handle value up in the calling process’s table. The row holds the rights and points to the kernel object.
1ReadProcessMemory with handle 0x12C
This process’s handle table (kept by the kernel)
Handle 0x12C
query, readrights
the game’s process objectpoints to
Handle 0x134
query onlyrights
the same process objectpoints to
Result
ReadProcessMemory
allowed
Row 0x12C includes read, so the call reaches the game’s process object.
2ReadProcessMemory with handle 0x134
This process’s handle table (kept by the kernel)
Handle 0x12C
query, readrights
the game’s process objectpoints to
Handle 0x134
query onlyrights
the same process objectpoints to
Result
ReadProcessMemory
access denied
Same process object, but row 0x134 holds query rights only, so the read is refused.
3Reading your own memory at address 0x12C
This process’s handle table (kept by the kernel)
Handle 0x12C
query, readrights
the game’s process objectpoints to
Handle 0x134
query onlyrights
the same process objectpoints to
Your process’s memory
Address 0x12C
nothing mapped: access violation
A handle value is not an address. The low 64 KiB of a process are never mapped, so this read faults instead of showing the object.
All 3 steps shown
Windows looks a handle value up in the calling process’s table. The row holds the rights and points to the kernel object.
Many APIs return a new handle that the caller owns. When the program is finished with it, the owner calls CloseHandle. Forgetting causes a handle leak. Repeated leaks can exhaust resources or keep files and processes alive longer than expected.
Closing the same handle twice is also wrong. Windows may have already reused that number for a different object, turning a cleanup mistake into a difficult intermittent bug.
That sequence is worth spelling out, because it explains why this class of bug
surfaces so far away from its cause:
1. you open a process and receive handle 0x12C
2. you close 0x12C -> correct
3. elsewhere, the program opens a log file
Windows reuses the free slot and returns 0x12C again
4. your cleanup code closes 0x12C a second time
5. the log file is now closed, and nothing says why
As a timeline of calls between your code and Windows, the same five steps look like this:
Step 4 does not report an error. It closes something perfectly real — just not
the object you meant. The failure appears later, in unrelated code, at a moment
that depends on the exact order in which the program happened to allocate
handles. That timing dependence is precisely why double-close bugs are so hard
to reproduce and so easy to blame on the wrong component.
A handle is closed exactly once by its owner. Forgetting leaks it; closing twice can close whatever Windows reused the number for, such as a log file. The interactive version needs JavaScript; the lesson text around it explains the same idea.
The ownership rule should be simple:
One owner closes one owned handle exactly once. Borrowers may use it temporarily but never close it.
The word owned matters because not every handle-shaped value is released with CloseHandle. Some APIs return pseudo-handles or borrowed handles that must not be passed to CloseHandle; other APIs use specialized cleanup functions. The wrapper constructor should document which API created the value and which function releases it.
Model those categories with different types instead of one universal HANDLE wrapper. A type that always closes is safe only for resources whose contract says the caller owns a closable handle.
RAII means Resource Acquisition Is Initialization. A value owns the resource, and its Drop implementation releases the resource when the value leaves scope.
The ownership path is deliberately one-way:
Borrowing permits temporary use; only the owner reaches the cleanup path.
// SAFETY: this value owns the handle and never closes it elsewhere.
let_ = unsafe { CloseHandle(self.0) };
}
}
OwnedHandle does not implement Copy or Clone. Moving it transfers ownership, so the old variable cannot be used afterward. Borrowing &OwnedHandle lets another function call Windows without taking responsibility for cleanup.
RAII connects resource lifetime to lexical scope, including early returns and ? errors. It does not decide whether the original ownership contract was correct. Wrapping a borrowed handle as owned merely turns the eventual double-close into an automatic bug.
Drop::drop cannot return Result. Cleanup often happens while another error is already unwinding the stack. The course therefore performs a best-effort CloseHandle and keeps the original error.
A close failure normally means the ownership invariant was already broken, such as an invalid or previously closed handle. The fix is to correct ownership, not to retry closing an unknown numeric value.
The lab records the current process’s handle count, creates 128 unnamed event objects, wraps every returned handle, then drops the vector:
The lab counts the process’s handles before, while 128 event handles are owned, and after they are dropped. Wrapped in OwnedHandle the count falls back; raw handles that are never closed leave the count raised. The interactive version needs JavaScript; the lesson text around it explains the same idea.
#[cfg(windows)]fn main() -> anyhow::Result<()> { use gha_windows_labs::OwnedHandle; use windows::Win32::System::Threading::{ CreateEventW, GetCurrentProcess, GetProcessHandleCount, }; fn handle_count() -> windows::core::Result<u32> { let mut count = 0_u32; // SAFETY: GetCurrentProcess returns this process's pseudo-handle and // `count` remains a valid output pointer for the duration of the call. unsafe { GetProcessHandleCount(GetCurrentProcess(), &mut count)? }; Ok(count) } let before = handle_count()?; let events: Vec<OwnedHandle> = (0..128) .map(|_| { // SAFETY: no security structure or name pointer is supplied. Windows // creates a new unnamed, auto-reset, initially nonsignaled event. let raw = unsafe { CreateEventW(None, false, false, None) }?; OwnedHandle::from_raw(raw) }) .collect::<windows::core::Result<_>>()?; let while_owned = handle_count()?; drop(events); let after_drop = handle_count()?; println!("Before: {before} handles"); println!("While owned: {while_owned} handles"); println!("After drop: {after_drop} handles"); anyhow::ensure!( while_owned >= before.saturating_add(128), "the event handles were not visible in the process count" ); anyhow::ensure!( after_drop < while_owned, "dropping OwnedHandle did not reduce the handle count" ); println!("Rust dropped the vector, and each OwnedHandle called CloseHandle once."); Ok(())}#[cfg(not(windows))]fn main() { eprintln!("This RAII handle lab must run on Windows.");}
#[cfg(windows)]
fnmain() -> anyhow::Result<()> {
usegha_windows_labs::OwnedHandle;
usewindows::Win32::System::Threading::{
CreateEventW,
GetCurrentProcess,
GetProcessHandleCount,
};
fnhandle_count() -> windows::core::Result<u32> {
letmutcount = 0_u32;
// SAFETY: GetCurrentProcess returns this process's pseudo-handle and
// count remains a valid output pointer during the call.
"the event handles were not visible in the process count",
);
anyhow::ensure!(
after_drop < while_owned,
"dropping OwnedHandle did not reduce the handle count",
);
println!("Each OwnedHandle called CloseHandle once.");
Ok(())
}
#[cfg(not(windows))]
fnmain() {
eprintln!("This RAII handle lab must run on Windows.");
}
The iterator returns Result<OwnedHandle> for every event. collect::<Result<Vec<_>, _>>() stops on the first failure. Any handles already collected live in the partially built vector, so they are dropped while the error returns.
That is the important improvement over manual cleanup:
The original helper creates events and stores raw HANDLE values. If a later creation fails, ? returns before the earlier handles are closed; dropping a vector of raw handles does not call CloseHandle. Even success makes cleanup the caller’s job.
raw_handles.push(raw); // A later error leaks the earlier events.
}
Ok(raw_handles)
}
RAII gives each successful creation an owner immediately. Collecting the results drops those owners if a later item fails; success returns a vector whose owners close the events when dropped.
Run the self-contained handle lab in the Windows VM. It prints a count before
creating events, a higher count while they are owned, and another count after
their owners leave scope. The change across those three moments is the proof.
Terminal window
cd windows-labs
cargo run --release --target i686-pc-windows-msvc --bin handle_raii
The middle count should rise by at least 128. The final count should fall again. Small background differences are possible because process instrumentation may briefly create its own handles, so the program checks the meaningful direction instead of demanding identical before/after numbers.
Some APIs return a pseudo-handle, such as GetCurrentProcess. It is a special constant interpreted as the current process and should not be wrapped as a newly owned handle.
Other APIs return borrowed handles whose documentation says another component owns them. Ownership comes from the API contract, not from the fact that a program can store the number.
A correctly owned process handle with read rights answers who may ask for
memory. It does not say whether any particular address can be read. The target
process has a changing map of pages, and each page has its own state and
protection. We need both checks before interpreting a scanner failure.
A process sees memory as a huge range of numbered virtual addresses. Windows does not manage that range one byte at a time. It groups adjacent addresses into fixed-size pages, commonly 4 KiB on x86 and x86-64 systems.
Pages that share the same state and protection are reported together as a region. A 200 KiB read-only region is therefore a run of many pages with matching state and protection, not one enormous hardware page.
This matters to every scanner and patcher in the course. An address can be a perfectly valid integer while the page at that address is absent, guarded, read-only, or non-executable.
The address printed by a debugger is a virtual address. It names a location
inside one process’s address space; it does not directly name a RAM chip cell.
Windows and the CPU use page tables to translate the virtual page into a
physical frame when the page is present.
For any page size, an address can be split into two pieces:
Optional: practise separating the page number from the byte offset without dividing by zero.
With a 4 KiB page, address 0x1234_5ABC has offset 0xABC inside its page.
The remaining upper part identifies the virtual page. The translation changes
the page part; the offset stays the same because it selects one byte inside that
page.
1
2
3
4
5
A
B
C
virtual page number: 0x12345
offset inside the page: 0xABC
Splitting the hex digits of 0x1234_5ABC at a 4 KiB (3 hex digit) boundary. Translation changes the page-number part; the offset is untouched.
Two games can both use virtual address 0x0040_0000 while those addresses map
to different physical memory. One process can also have no mapping there at
all. ReadProcessMemory takes a process handle precisely so Windows knows
which process map should interpret the address.
Committed also does not mean “currently sitting in physical RAM.” Windows may
bring a committed page back from its backing storage when accessed. That event
is a page fault handled by the operating system; it is not automatically a bug
in the game or scanner.
State, type, and protection answer different questions
Windows describes a region using three groups of facts — and they are independent axes, not a hierarchy:
State says whether storage is ready:
MEM_FREE: no address range is reserved there;
MEM_RESERVE: the address range is set aside, but physical/page-file storage is not committed;
MEM_COMMIT: Windows has committed storage and the process may use it according to its protection.
Type says where committed pages came from:
MEM_IMAGE: a mapped PE image such as an EXE or DLL;
MEM_MAPPED: a mapped file or shared section;
MEM_PRIVATE: private process memory such as many heap allocations.
Protection says which operations are allowed. The course prints the three permissions as R, W, and X:
Display
Meaning
R--
Data can be read but not written or executed.
RW-
Data can be read and written but not executed.
R-X
Instructions can be read and executed but not normally edited.
RWX
The same page can be read, written, and executed.
---
The wrapper considers the region inaccessible.
PAGE_GUARD is an extra flag. The first access raises an exception and clears the guard flag. Windows uses guard pages for mechanisms such as growing thread stacks. A scanner should skip them instead of treating the exception as ordinary data.
Normal compiled code is usually readable and executable. Writable data is usually non-executable. Keeping those roles separate follows the write xor execute idea: memory should normally be writable or executable, not both at once.
Some legitimate runtimes generate code dynamically, and old software may use broad permissions. An RWX region is therefore a question, not a conviction:
Which component created it?
Is it inside the expected image or private memory?
Does it exist in a clean run of the same build?
Does its lifetime match a known feature?
This lab reports RWX regions but never writes to them or starts a thread there.
The shared Process::regions wrapper begins at an address, calls VirtualQueryEx, records the returned MEMORY_BASIC_INFORMATION, and advances by RegionSize:
Optional: practise the named operations and their inputs with a few whole-token blanks.
letreturned = unsafe {
// 🔍 Ask about the run containing `current`; this describes pages but does
// not copy bytes from them or grant permission to read/write them.
VirtualQueryEx(
process_handle,
Some(currentas *constc_void),
&mutinformation,
size_of::<MEMORY_BASIC_INFORMATION>(),
)
};
ifreturned == 0 {
// ✅ Zero ends the walk. No stale MEMORY_BASIC_INFORMATION is consumed.
break;
}
// 📏 Move from the returned base—not merely from `current`—to the first address
// after this entire run of pages with identical attributes.
letnext = (information.BaseAddressasusize)
.checked_add(information.RegionSize)
.context("memory map overflowed")?;
anyhow::ensure!(next > current, "memory map did not advance");
current = next;
The checked addition prevents an invalid region from wrapping to a low address. The progress check prevents an endless loop if the reported range does not move forward.
The fields of MEMORY_BASIC_INFORMATION that the walk and the scanner rely on:
What VirtualQueryEx reports about one run
BaseAddresspointer
Where the run starts. Pages are 4 KiB, so this is the queried address rounded down to a page boundary. The walk advances from here, not from the address it asked about.
RegionSizebytes
How long the run is. Every page from BaseAddress for this many bytes shares one state, one type, and one protection, and a new run begins wherever any of the three changes.
StateMEM_COMMIT, MEM_RESERVE, or MEM_FREE
Whether storage is ready. Only committed pages can hold bytes a scanner may read.
TypeMEM_IMAGE, MEM_MAPPED, or MEM_PRIVATE
Where committed pages came from: a PE image, a mapped file or shared section, or private process memory.
ProtectPAGE_* flags
Which operations the pages allow. The course prints the read, write, and execute bits as R, W, and X, and treats PAGE_GUARD as a reason to skip the run.
AllocationBasepointer
Where the whole allocation begins. Several runs with different protections can belong to one allocation, so they share this value.
#[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 process = Process::open_by_name(&process_name, false)?; let (module_base, module_size) = process.module(&module_name)?; let module_end = module_base .checked_add(module_size) .context("module range overflowed")?; let regions = process.regions(module_base, module_end)?; println!("{} (PID {})", process.name(), process.id()); println!("Module: {module_name} {module_base:#010x}..{module_end:#010x}"); println!("Range Size Access"); let mut rwx_count = 0_usize; for region in regions { if region.base >= module_end { break; } let region_end = region .base .checked_add(region.size) .context("memory region overflowed")? .min(module_end); let shown_size = region_end.saturating_sub(region.base); let access = format!( "{}{}{}", if region.readable { 'R' } else { '-' }, if region.writable { 'W' } else { '-' }, if region.executable { 'X' } else { '-' }, ); if region.readable && region.writable && region.executable { rwx_count += 1; } println!( "{:#010x}..{:#010x} {:#010x} {access}", region.base, region_end, shown_size ); } println!("RWX regions in this module: {rwx_count}"); println!("The tool queried page metadata and did not write or change protection."); Ok(())}#[cfg(not(windows))]fn main() { eprintln!("This virtual-memory map uses VirtualQueryEx and must run on Windows.");}
println!("RWX regions in this module: {rwx_count}");
println!("The tool queried page metadata and changed nothing.");
Ok(())
}
#[cfg(not(windows))]
fnmain() {
eprintln!("This virtual-memory map must run on Windows.");
}
false in open_by_name(&process_name, false) is important. It means the wrapper requests query and read rights without PROCESS_VM_OPERATION or PROCESS_VM_WRITE.
The original helper opens a writable process handle even though it only lists module regions. In this course wrapper, true requests write-related rights; print_regions only formats the returned metadata.
Start each game before running its command. The first argument names the
process to open; the second names the module whose live pages to report. The
output groups regions by state, type, and protection so you can compare a
mapped image with its section flags on disk.
Compare the regions with the PE section flags printed by pe_inspector. The on-disk section table describes intended mapping characteristics; VirtualQueryEx describes the live pages in this run.
Keep these address ideas separate:
Name
Meaning in this course
file offset
byte position inside the EXE or DLL file
RVA
position relative to the image base
virtual address
live address inside one process
physical address
hardware-memory location after page translation
A normal user-mode pattern scanner needs the live virtual address and lets
Windows perform translation. It should not guess physical addresses. The
offline DMA chapter introduces page-table translation separately because a raw
capture file has no running Windows process to translate addresses for it.
VirtualQueryEx reports a region as MEM_COMMIT with protection PAGE_READWRITE | PAGE_GUARD. The scanner's handle includes PROCESS_VM_READ.
What does the course's scanner do with that region?
Answer: Skips it. The scanner reads only committed, readable, unguarded regions. PAGE_GUARD on top of RW- fails the third test, so ReadProcessMemory is never called for this region.
Does the scanner read this region?VirtualQueryEx describes the region first. The scanner reads only committed, readable, unguarded regions, and the read still needs the handle’s right.
Region filter first, handle right second
What VirtualQueryEx reports
State
MEM_COMMITstorage is committed
Protection
PAGE_READWRITE | PAGE_GUARD (RW- guarded)
What the scanner does
Handle has VM_READ?
not reached
Result
skips it: touching a guard page would raise an exception
The scanner never calls ReadProcessMemory here, so the handle’s rights are not even used.
The memory scanner visits only committed, readable, non-guarded regions. That avoids predictable failures and reduces wasted work. A later ReadProcessMemory can still fail because the process continues running and page state can change between the query and read.
This is another time-of-check/time-of-use race. A robust scanner treats a failed region read as a normal event, reports useful context, and continues when safe.
A readable query can become a failed read. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
query the page region
require committed, readable, non-guarded
process may change the mapping
attempt a bounded read if eligible
accept copied bytes only on success
report skipped, failed, or copied
Result: four bytes copied. The scanner must handle a mapping race even after checking page metadata.
Final values: page = readable; change = stable; copied_bytes = 4; status = copied; eligible = true; live_readable = true.
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.
page = readable; change = stable.
Step 2, line 1. The metadata describes the query moment.
The memory map says which pages existed when the tool asked. It cannot tell
whether the game is changing an object at the same moment. To understand that
time gap, we need to know who executes the game’s instructions and which state
each execution path shares.
A process supplies the address space, handles, modules, and security boundary. A thread is a path of execution inside that process.
Every process begins with at least one thread. Games commonly add threads for rendering, audio, networking, asset loading, input, and background work. Those names describe roles chosen by the program; Windows fundamentally schedules thread IDs, priorities, and CPU state.
Threads in the same process share code and most data, but each thread has its own:
thread ID, or TID;
instruction pointer and general CPU registers;
stack and stack pointer;
thread-local storage;
scheduling state and priority information;
TEB, introduced in the PEB/TEB lesson.
Two threads can access the same object at nearly the same time. That is why games use mutexes, events, atomics, queues, and other synchronization tools.
The process supplies shared resources, while each execution path keeps its own paused CPU state and call history:
This shared-versus-private split explains both communication between threads and the races that communication can create.
Step through one game from program file to process to two threads taking turns on a core, with the numbers worked out:
From a program file to a process to threadsOne game from disk to running threads, ten steps. The numbers continue the sample file from lesson 5.1. Step through them, or read the complete picture below.
All 10 steps shown
Complete picture: two threads in one process, one CPU core switching between them.
The complete pictureEverything at once: the program file, the process Windows built from it, two threads that share the process's memory but keep their own stacks and registers, and one core that took turns between them for nine ticks. Thread A ran 6 instructions, B ran 3, and each kept its own count in EAX.
1 Program: a file on disk
game.exe14,336 bytes (0x3800)
Headers0x0400
.text0x1C00
.rdata0x0C00
.data0x0400
.rsrc0x0800
Instructions and data, written down. It sits still: no CPU time, no memory.
Starting it asks Windows to build a process.
2 Process: its own space in memory
No process yet: the file is still only a file.
Address spaceThe image sits at 0x00400000 to 0x00406FFF.
Handle tableReferences to the files, events and sockets this process opens.
Shared by every threadGlobal score at 0x00404000 holds 10.
3 Threads: paths of execution inside the process
Thread ARunning on the CPU
Own stack (1 MiB)
0x00200000 to 0x002FFFFF
Local variable
0x002FFFFC
In the CPU now
EIP 0x004012D2, ESP 0x00300000, EAX 6
Thread BReady: waiting for a turn
Own stack (1 MiB)
0x00300000 to 0x003FFFFF
Local variable
0x003FFFFC
Saved in its thread context
EIP 0x00401509, ESP 0x00400000, EAX 3
The scheduler gives a core to one thread at a time.
4 One CPU core: who runs now
A1
A2
A3
B4
B5
B6
A7
A8
A9
Each turn lasts 3 ticks, and a tick here is one instruction.
The core is running thread A
EIP 0x004012D2, ESP 0x00300000, EAX 6
Context switch: the registers of one thread were just saved to its context and the other's loaded.
The steps
Step 1 A program is a file
game.exe sits on disk: 14,336 bytes (0x00003800) of headers, instructions and data. Nothing in it is running. It uses no CPU time and no memory, only disk space.
Step 2 Starting it creates a process
Windows creates a process: a private address space, a handle table for the files and events it will open, and an owner. The loader maps the file's sections into that space at the image base. The image is 0x00007000 bytes, so it covers 0x00400000 to 0x00406FFF. No instruction has run yet: a process only holds resources.
Step 3 The first thread starts
Windows creates thread A inside the process. It gets its own stack, 1 MiB of address space (0x00200000 to 0x002FFFFF), and its own registers. EIP starts at the entry point: base 0x00400000 + RVA 0x000012C0 = 0x004012C0. ESP is the first address past the empty stack, 0x00300000.
Step 4 A second thread joins
The game starts thread B. It lives in the same process, so it gets no new address space, only its own stack (0x00300000 to 0x003FFFFF) and its own registers: EIP 0x00401500, ESP 0x00400000. One process can hold many threads.
Step 5 Memory is shared, stacks are not
Both threads see the same address 0x00404000, the start of .data (base 0x00400000 + RVA 0x00004000). If A stores 10 in the global score, B reads 10. A local variable is different: it lives on the thread's own stack, 0x00300000 − 4 = 0x002FFFFC for A and 0x00400000 − 4 = 0x003FFFFC for B, so the two never collide.
Step 6 One core runs one thread at a time
On a single core the scheduler gives each thread a turn of 3 ticks. Thread A goes first. Each tick runs add eax, 1, which is 3 bytes, so EAX goes 1, 2, 3 and EIP goes 0x004012C0 + 3 × 3 = 0x004012C9. (A tick is one instruction here. A real time slice lasts a few milliseconds, millions of instructions.)
Step 7 The turn ends: save one thread, load the other
A context switch. The scheduler saves A's registers into A's thread context in memory: EAX 3, EIP 0x004012C9. Then it loads B's saved registers into the CPU: EAX 0, EIP 0x00401500. EIP now points into B's code, so B is what runs next.
Step 8 Thread B runs its turn
Ticks 4 to 6 belong to B: EAX goes 1, 2, 3 and EIP goes 0x00401500 + 3 × 3 = 0x00401509. Thread A waits, ready to run, with its registers parked in its context.
Step 9 Switch back: restore A
The scheduler saves B (EAX 3, EIP 0x00401509) and restores A's saved registers: EAX 3, EIP 0x004012C9. A cannot tell it was ever paused: the next instruction it runs is exactly the one it was about to run.
Step 10 Thread A carries on
Ticks 7 to 9 belong to A again. EAX goes from 3 to 6 and EIP goes 0x004012C9 + 3 × 3 = 0x004012D2. In nine ticks A ran six instructions and B three, and neither lost its place.
The addresses are illustrative; a real run is placed by Windows and changes with ASLR. A real time slice lasts milliseconds, not three instructions, and the scheduler picks threads by priority and readiness. The shape is what matters: every thread keeps its own registers and stack, and the process memory is shared.
Windows schedules runnable threads by giving them turns on CPU cores. On one
core, threads can interleave: one runs, pauses, and another runs. On several cores,
some threads can also run truly in parallel. The program must be correct under both
possibilities.
Suppose the update thread changes a player record in two steps while an overlay
thread reads between them:
The overlay may observe a combination that was never a complete game state. This
is an interleaving problem. “It worked ten times” does not prove it is safe,
because the scheduler may choose a different order on the next run.
A bug whose result depends on which thread happens to run first is a race
condition. The torn read above is one. The most common kind loses a write.
“Add 500 gold” is one line of source code, but the CPU does it in three steps:
read the value into a register, add, and write the result back. Put two threads
on the same gold field, one adding a 500-gold quest reward and one taking 300
for a purchase, and let the scheduler interleave them:
Step
Reward thread
Purchase thread
gold in memory
1
reads 1,000
1,000
2
reads 1,000
1,000
3
1,000 + 500 = 1,500
1,000 − 300 = 700
1,000
4
writes 1,500
1,500
5
writes 700
700
Watch it happenTwo local results race to overwrite one gold field
Two threads each copy the shared gold value 1,000. One register becomes 1,500 after a 500 reward; the other becomes 700 after a 300 purchase. Writing 1,500 then the stale 700 loses the reward. The intended combined total is 1,200.
Read the steps
The shared field holds 1,000 gold. The reward thread reads it into its private register, while memory remains 1,000.
Before the reward is written, the purchase thread also reads 1,000. Its private copy does not change when the reward thread later writes memory.
The private calculations produce 1,000 + 500 = 1,500 and 1,000 − 300 = 700. Both are correct. The shared field still holds 1,000.
The reward thread copies 1,500 back into gold. The purchase register remains 700: the completed reward does not repair that earlier calculation.
The purchase writes its stale 700 last and overwrites the reward. The intended total is 1,000 + 500 − 300 = 1,200. The missing 500 is exactly the lost reward.
A mutex makes the purchase wait and read the completed 1,500 before subtracting 300. It protects the whole read–modify–write sequence.
This simulation needs JavaScript. The lesson text around it works through the same numbers.
The player should have 1,000 + 500 − 300 = 1,200 and has 700. The reward
vanished, because the purchase thread wrote a result computed from a value that
was already out of date. Most runs interleave harmlessly, with one thread
finishing before the other starts, which is why a race can pass every test and
still fail for a player.
Two threads update one gold valueBoth threads start from 1,000. Writing 1,500 and then 700 loses the reward; the intended combined result is 1,200.
Reward threadGold in memoryPurchase thread
1
RequestRead gold
Step 1: Reward thread to Gold in memory
The reward thread loads the value it will add 500 to.
2
Reply1,000
Step 2: Gold in memory to Reward thread
The reward thread now holds a copy of 1,000.
3
RequestRead gold
Step 3: Purchase thread to Gold in memory
The purchase thread reads before the reward has written anything.
4
Reply1,000
Step 4: Gold in memory to Purchase thread
Both threads hold the same copy of 1,000. Neither can see the other’s plan.
5
DataWrite 1,500
Step 5: Reward thread to Gold in memory
1,000 + 500 is stored.
6
DataWrite 700
Step 6: Purchase thread to Gold in memory
The purchase computed 1,000 − 300 from its old copy and overwrites the 1,500.
7
AloneFinal gold: 700 (should be 1,200)
Step 7: Gold in memory (alone)
Each thread was correct on its own. The result is wrong because the read and the write were not one step.
All 7 steps shown
Both threads start from 1,000. Writing 1,500 and then 700 loses the reward; the intended combined result is 1,200.
A mutex fixes it by making read, add, and write one unit: the purchase thread
waits at step 2 until the reward thread has written 1,500, then reads 1,500 and
writes 1,200. For a single number, an atomic add does the same job more cheaply;
on x86, lock xadd performs the whole read, add, and write as one indivisible
instruction.
The lock has its own failure, deadlock. Moving gold from player A to player B
means locking both. If one thread locks A and then waits for B, while another
thread locks B and then waits for A, each holds what the other needs and neither
ever continues. Taking locks in one agreed order, say always the lower player ID
first, makes that cycle impossible, as
Lesson 12.2 shows for
the Windows loader lock.
The same lost update appears whenever two writers share one value: a tool that
writes a value the game is also updating every frame is racing the game, and one
of the two writes will be lost.
Synchronization creates an agreement about ordering or exclusive access. A mutex
can protect a group of fields as one critical section. An atomic operation can make
one small value indivisible. A queue can transfer ownership of work from one thread
to another. These tools solve different problems; adding an atomic flag does not
automatically make the object around it coherent.
Two common non-deadlock failures deserve names:
An atomicity violation happens when several operations must behave as one
unit, but another thread can observe or change state in the middle. “Check pointer,
then use pointer” is unsafe if another thread can free it between those steps.
An order violation happens when one action assumes another action already
occurred, but no synchronization enforces that order. A render worker reading a
snapshot before initialization finishes is one example.
Here is the pointer version of the first problem. A true check does not
reserve the object for the next line:
The fix must enforce the required relationship, not merely make the timing less
likely. A lock can protect one compound state change. An event, condition variable,
or channel can establish that initialization completed before consumption begins.
Safety: can something bad happen, such as a torn state, double update, or
use-after-free?
Progress: can the intended work eventually happen, or can threads wait
forever?
A mutex may improve safety but create a deadlock if two threads acquire locks
in opposite orders. A thread may be safe yet suffer starvation if other work
continually wins access. A spin loop may make progress in a test and waste an
entire core in the real game.
For an observer tool, prefer immutable snapshots and message passing when
possible. One component reads a bounded snapshot, validates it, and hands owned
data to rendering or logging. That reduces the amount of shared mutable state whose
interleavings you must reason about.
Ready, running, and waiting explain thread activity
A thread that is not executing is not necessarily broken. In a simplified state
model, a thread can be:
running: currently executing on a core;
ready: able to run, waiting for the scheduler to choose it;
waiting: blocked until I/O, a timer, lock, event, or another condition changes.
Windows transitions threads between these states. A network thread may spend most
of its life waiting and still be perfectly healthy. A CPU profiler mainly samples
running work; wait analysis explains why a thread is not running. Use the tool that
matches the question.
A thread context is a structure containing register state for a particular architecture. On 32-bit x86, useful fields include:
Eip: the next instruction address;
Esp: the top of the current stack;
Ebp: a frame pointer when the compiler uses one;
Eax, Ebx, Ecx, Edx, Esi, and Edi: general-purpose registers;
EFlags: condition and control bits, including the trap flag used for single-stepping.
On x86-64, those names become Rip, Rsp, Rbp, Rax, and so on, with additional registers.
The context is meaningful only at a controlled instant. Reading or writing a running thread’s registers while it continues executing would race with the CPU. A debugger receives an event while Windows has stopped the relevant threads, or a tool explicitly suspends a thread and must later resume it.
This course’s debugger uses the debugger event model. It does not randomly freeze a game thread and hope cleanup succeeds.
When function A calls function B, the program must remember where B should return. Arguments, saved registers, local variables, and return information may be stored in a stack frame.
newest framespend_gold()
end_turn()
process_player_action()
oldest showngame_loop()
A stack trace lists frames newest first: spend_gold was called from inside end_turn, called from inside process_player_action, called from game_loop.
A stack trace walks from the current frame toward older callers. Optimized code can inline functions, omit frame pointers, move values into registers, or reuse stack slots, so the neat classroom picture is a guide rather than a promise.
That complexity is why Microsoft recommends StackWalk64 instead of inventing a stack walker from raw pointers. Matching symbols and unwind information make the result much more reliable.
Worker threads and tasks: keeping slow work off the frame
Lesson 4.1 showed that a frame has about 16.67 ms, and that loading a file or planning a
path can take longer. Games solve this with worker threads. The main thread runs
the frame loop, and the other threads do jobs for it.
Starting a new thread for every job would be costly. Each thread reserves a stack, which
is 1 MiB of address space by default for a Windows program, so 1,000 threads reserve about
1,000 MiB, and the scheduler has to juggle all of them. Engines instead start a fixed
thread pool: a handful of workers that wait for jobs in a queue. A common size is one
fewer than the number of cores, leaving a core for the main thread, so 7 workers on an
8-core processor. A job is a small function called a task. Whenever a worker is idle it
takes the next task from the queue. A hundred tasks of 4 ms each take 100 ÷ 7 = 14.3, so 15,
rounds of 4 ms = 60 ms on the 7 workers, against 100 × 4 = 400 ms on one thread.
A task finishes on a worker thread, but the main thread must not find its objects
changing in the middle of a frame. So the worker does not touch game objects. It sends its
result through a channel, a queue that one thread writes and another reads, and the
main thread picks results up at one known point, such as the start of a frame, when
nothing else is using the data. Lesson 6.11 builds such a channel for a recorder.
A result can arrive late. Suppose a task planned a path for entity 42 and, by the time
the result is ready, that entity was despawned and its slot reused for a new entity. The
task remembered the entity’s generation number (Lesson 3.10), so the main thread compares it
with the slot’s current one and drops the result if they differ. Some engines write tasks
as async functions that pause at a wait point and resume on the pool later, which is the
same idea in different syntax.
For an observer, a pool shows up as several threads that start at the same address and
spend most of their time waiting. A tool that pauses threads, as the next lab does, may
catch workers that are doing nothing.
Before opening a thread or asking for its context, identify which threads belong to the target process. TH32CS_SNAPTHREAD creates a system-wide snapshot, even when a PID is supplied. The program must filter th32OwnerProcessID itself:
#[cfg(windows)]fn main() -> anyhow::Result<()> { use std::mem::size_of; use gha_windows_labs::{OwnedHandle, Process}; use windows::Win32::{ Foundation::ERROR_NO_MORE_FILES, System::Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, TH32CS_SNAPTHREAD, THREADENTRY32, Thread32First, Thread32Next, }, }; fn no_more_files(error: &windows::core::Error) -> bool { error.code() == ERROR_NO_MORE_FILES.to_hresult() } let process_name = std::env::args() .nth(1) .unwrap_or_else(|| "wesnoth.exe".to_owned()); let process = Process::find(&process_name)?; // TH32CS_SNAPTHREAD is system-wide; the PID is used only for filtering below. // SAFETY: no pointers are supplied and OwnedHandle owns the returned snapshot. let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0) }?; let snapshot = OwnedHandle::from_raw(snapshot)?; let mut entry = THREADENTRY32 { dwSize: size_of::<THREADENTRY32>() as u32, ..Default::default() }; // SAFETY: entry has the required size and remains writable during the call. unsafe { Thread32First(snapshot.raw(), &mut entry) }?; let mut threads = Vec::new(); loop { if entry.th32OwnerProcessID == process.id { threads.push((entry.th32ThreadID, entry.tpBasePri)); } // SAFETY: entry remains a correctly sized writable output structure. match unsafe { Thread32Next(snapshot.raw(), &mut entry) } { Ok(()) => {} Err(error) if no_more_files(&error) => break, Err(error) => return Err(error.into()), } } threads.sort_unstable_by_key(|(thread_id, _)| *thread_id); println!( "{} (PID {}) has {} thread(s)", process.name, process.id, threads.len() ); for (thread_id, base_priority) in threads { println!(" TID {thread_id:<8} base priority {base_priority}"); } println!("No thread was opened, suspended, queued, or given a new context."); Ok(())}#[cfg(not(windows))]fn main() { eprintln!("This read-only thread inventory must run on Windows.");}
println!(" TID {thread_id:<8} base priority {base_priority}");
}
println!("No thread was opened, suspended, queued, or changed.");
Ok(())
}
#[cfg(not(windows))]
fnmain() {
eprintln!("This read-only thread inventory must run on Windows.");
}
dwSize tells Windows how large a THREADENTRY32 the caller prepared. The final ERROR_NO_MORE_FILES is not a failure; it is the documented end of enumeration.
Run the inventory once for each game process. Each row gives a thread ID and
base priority from that moment’s snapshot. Look for threads that appear and
disappear between runs before trying to assign any one ID a permanent role.
The count may change between runs because games create and retire worker threads. A TID is temporary and can eventually be reused. Do not store it as a version-specific constant.
Base priority is scheduling input, not “importance.” The currently busy rendering thread might have the same base priority as an idle worker. ETW CPU samples and debugger stacks show which threads are actually doing work.
Likewise, a TID does not identify a permanent role. A thread’s role shows up in
several places: its start address or module, its stack sampled over time, its CPU
and wait activity, its thread description when the game sets one, and which game
events line up with that activity. One paused stack shows only what the thread was
doing at that instant. In an engine with a job system, the same worker thread can
be mixing audio in one sample and decompressing a texture in the next.
Changing another thread’s instruction pointer, queuing executable work to it, or suspending service threads is not required to understand game execution. Those operations can deadlock a process, strand locks, corrupt state, or stop the diagnostics that would explain the failure.
The course inventories threads read-only and changes a context only inside the explicit breakpoint lifecycle of the version-matched Wesnoth debugger, where every event is continued exactly once.
The executable-analysis chapter showed how a DLL export name leads to a
function address. Finding that address does not tell us how to call the
function. Before our code becomes a library inside another process, we
need the agreement between caller and callee: what values cross the
boundary, where they arrive, and who owns any memory they refer to.
The earlier text lesson supplied one part of this agreement: a string
pointer had to refer to the right encoding and remain valid for the call.
A DLL export requires the same care even when its name is visible.
A DLL export table may reveal a name such as gha_start, GetPlayer, or FormatMessage. That name only tells you that other code can locate the function. It does not fully explain:
which arguments the caller passes;
where those arguments arrive;
what the return value means;
which side owns pointed-to memory;
whether the call changes global state;
whether it is safe to call more than once.
Recovering that call contract is interface reversing. We will practice on this book’s own Windows DLL, where the source is available afterward for checking our conclusions.
Treat a machine-level function boundary as four connected contracts:
Representation: How many bits does each value use, and is it an integer, float, pointer, flag set, or structure?
Transport: Which registers or stack locations carry arguments and results?
Ownership: Who allocates, may mutate, and eventually releases pointed-to data?
Lifetime: For how long do those addresses and returned views remain valid?
Calling conventions mainly answer transport. They do not answer ownership or lifetime. A function can correctly receive a pointer in rcx and still crash later because the caller freed the buffer too early. Likewise, a return value in rax might be a borrowed pointer, an owned allocation, a handle, or merely a status code.
Each part of the contract follows from something the machine code does. A callee that reads four bytes through the pointer and never stores it needs only a short read-only borrow. A callee that saves the pointer in a global needs the data to outlive the call.
A saved pointer can outlive the data it points toThe address in the saved pointer never changes. What is stored at that address does.
1During the call: the pointer is valid
Saved pointer → its 4 bytes → read
0x0A401000saved pointer
64
00
00
00
100correct
The caller’s buffer (blue) holds 100 as 64 00 00 00.
2After the call: freed and reused
Saved pointer → its 4 bytes → read
0x0A401000saved pointer
2C
01
00
00
not read
Other code (gray) now owns those bytes and stores 300 as 2C 01 00 00.
3Later: the same pointer reads 300
Saved pointer → its 4 bytes → read
0x0A401000saved pointer
2C
01
00
00
300wrong data
The address is still mapped, so the read succeeds with the wrong value.
All 3 steps shown
The address in the saved pointer never changes. What is stored at that address does.
On 64-bit Windows, the first four integer or pointer arguments normally arrive in rcx, rdx, r8, and r9. A return value normally leaves in rax. On 32-bit Windows, several calling conventions exist, so also inspect stack cleanup and decorated export names.
Suppose a call site looks like this:
These instructions are the machine-level version of that path. Read them in
order: clear ecx, call the export through its import slot, test the returned
eax, and branch on a nonzero result.
xorecx, ecx
callqword ptr [gha_start]
testeax, eax
jne start_failed
Read in plain English, before any types are guessed, the call site says:
The caller puts zero in the first argument register.
It makes an indirect call through an imported function slot.
It tests the low 32 bits of the result.
Zero takes the success path; nonzero takes the failure path.
Reading a call site: what the caller does with the result. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
xor ecx, ecx
call qword ptr [gha_start]
test eax, eax
jne start_failed
; zero: the success path continues here
Execution falls through to the success path. From this one call site you can write the tentative contract: zero in the first argument, zero back for success.
Step 1, line 1. xor ecx, ecx exclusive-ors a register with itself, which always gives zero. The caller is putting zero in the first argument register.
ecx = 0 (first argument).
Step 2, line 2. call goes through the import slot to gha_start and runs it. The function leaves its return value in eax; here it is 0.
ecx = 0 (first argument); eax = 0.
Step 3, line 3. test eax, eax combines eax with itself and records the outcome in the flags without changing eax. Zero gives ZF = 1.
ecx = 0 (first argument); eax = 0; ZF = 1.
Step 4, line 4. jne jumps if not equal, which reads ZF = 0. ZF is 1, so the jump is not taken.
ecx = 0 (first argument); eax = 0; ZF = 1.
Step 5, line 5. Execution falls through to the success path. From this one call site you can write the tentative contract: zero in the first argument, zero back for success.
extern "system" selects the platform’s Windows ABI—application binary interface, the complete machine-level agreement between compiled pieces: which registers or stack slots carry arguments, who cleans up the stack, and where the return value lands. It is the compiled counterpart to a source-level API. unsafe is honest: the compiler cannot prove that a raw address really points to a function with this exact contract.
GetProcAddress returns an untyped address. Keep the dangerous conversion in one function:
The resolver below takes the loaded module handle, asks for the exact
gha_start export, and converts the returned address to one typed function
pointer. A missing export must return an error; a found address is still safe
to call only if the contract inferred above is correct for this DLL build.
Why not scatter transmute through the program? Because every conversion is a promise about ABI and types. One narrow boundary gives reviewers one place to check that promise.
A source-level API can say that a function accepts one address and
returns a number. The ABI answers the machine-level question of
where that address arrives. On a 32-bit build, one convention might
put it in a register while another expects it on the stack. The
functions can look identical at the source level, yet the callee
reads the wrong location if caller and callee disagree.
A wrong ABI can appear to work in one small test and still corrupt registers or the stack. The call itself may return without crashing; the damage shows up later, when an unrelated function reads garbage from a register it expected the callee to preserve, or when a mismatched stack-cleanup convention leaves esp off by a few bytes and a later ret lands somewhere nonsensical. Check:
Which architecture is the caller and DLL?
Who removes stack arguments on 32-bit builds?
Does the callee return with a plain ret or ret N?
Does a decorated name encode argument bytes?
Do several calls return with the stack pointer unchanged?
For x64 Windows there is one main user-mode convention, but argument type and ownership can still be wrong.
Test against the DLL from windows-labs rather than an unknown third-party export. Each test has an expected result:
Test
Expected result
First call with null
Returns Started
Second call
Returns AlreadyRunning
Unknown export name
Resolver returns an error; no call occurs
Wrong architecture
Windows refuses to load the DLL
Stop after start
Export returns and worker observes the stop flag
The source in windows-labs/src/windows_impl/dll.rs then shows the real contract, and any difference from the recovered one points at the exact part of the machine code that was misread.
Never guess a function pointer type and “try it” in an important process.
Use the disposable course fixture; a contract recovered from it applies to that exact build.
Treat every pointer’s size, mutability, lifetime, and ownership as a separate question.
Preserve an Unknown result path.
Keep loading, resolving, converting, calling, and unloading as visible stages.
This same method works for undocumented game plug-ins, supported mod SDKs, and old middleware. The goal is not merely to make one call succeed. The goal is to explain the interface well enough that a small wrapper can enforce it.
The contract gives a function address meaning: it tells a caller which
values are allowed and which obligations survive the call. In-Process
DLLs and Loader Boundaries builds the small DLL used here.
Its explicit start and stop functions turn the recovered agreement into
an interface whose loading, work, and cleanup can be followed in order.
Lesson 6.8
How an Engine Orders and Shares Its Work
Lesson 4.1 split a game into entities, components, and systems, and followed one
frame from input to the screen. A real frame runs dozens or hundreds of systems.
Four questions decide what you will see when you look at such a game from outside:
How do systems pass data to one another?
Which system runs first, and which can run at the same time?
Which systems run on a given frame at all?
How does the engine avoid redoing work for things that did not change?
The answers explain symptoms that look like bugs: a value that updates one frame
late, a copy that does not follow your edit, a number that freezes in a menu. They
hold in every engine of this kind. Where a detail belongs to one engine, the text
names it; the examples use the Breakout game from Lesson 4.1, with 1 paddle, 1
ball, and 40 bricks.
Systems never call one another. Each is a separate function the engine runs, and
they cooperate only through data the engine owns. That data comes in three shapes:
Where shared data lives
Shape
How many exist
Who can read it
In Breakout
Component
one per entity that has it
any system that asks for entities with it
Position on the paddle, the ball, and 40 bricks: 42 values
Resource
exactly one for each kind
any system
Score, the clock, the settings: one value each
Event
a short queue of messages
every system that reads that kind
BrickHit for brick 7
In memory the three look different. The 42 positions are one run of 42 records,
which a scan finds together. A resource is one record at one address, so a scan
for the score finds a single candidate instead of forty. An event is an entry in
a queue that the engine empties again within a frame or two.
A resource is a value that exists once for the whole world, whatever entities
there are: the clock, the score, the settings read at startup, the state of the
keyboard. Because there is exactly one of each kind, a system asks for it by kind
and needs no entity.
The choice between a resource and an entity is about how the data is used. If
every system should treat the value like any other character or object, an entity
is the better home. A single-player game’s player can be either. Keeping the
player as an entity lets the same health-and-damage system serve the player and
the enemies, and a resource would not. A score, a clock, or the current level
belongs to the game as a whole, so a resource fits.
An event says what changed rather than what is true now, the distinction
Lesson 4.9
draws for an outside observer. Inside an engine it carries one fact from the
systems that notice it to the systems that care. The collision system does not know that a score or a sound exists; it sends
BrickHit and moves on. Any number of systems can read it, and a new system, such
as a particle burst, can be added later without touching the collision code.
Mechanically an event queue is a list. A sender appends to it. Each reader keeps
its own count of how many entries it has read, so two readers each see every
event once. The engine empties old entries after a short time. Bevy keeps each
event for at least two frames, and other engines choose differently, so a system
that does not run during that window never sees the event. A paused game is the
usual case: the systems that read events stop, the events expire, and the
reaction is lost for good.
Order matters as well, because a reader only sees what has already been sent when
it runs:
ExampleOne brick hit, two orders
The game runs at 60 frames a second, so a frame is 1000 ÷ 60 = 16.67 ms and frame
10 starts at 10 × 16.67 = 166.7 ms. During frame 10 the collision system sends
BrickHit. The score system reads it, but when depends on which runs first:
Order in the frame
Score system reads the event in
Score changes at
Late by
collision, then score
frame 10
166.7 ms
none
score, then collision
frame 11
183.3 ms
one frame, 16.67 ms
One frame is short. A frame of delay between a bat swing and its sound or a key
press and its effect is something a player can feel, which is why engines let you
state the order.
One event, two system schedules. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
start frame 10 with score = 0
run the first selected system
collision publishes BrickHit(7)
score reads only published events
start frame 11 and read pending events
report the frame of the score change
Result: one score increment in frame 10. Order changed the latency, not the event’s meaning.
Final values: order = collision-first; frame = 10; pending = 0; score = 1; changed_frame = 10.
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.
A system does not loop over every entity and ask what it is. It describes the
components it needs, and the engine hands it exactly the entities that have them.
That description is a query. A query can say:
What a query can say
data wantedcomponents
Which components to read or write, such as Position and Velocity. An entity
is included only if it has all of them.
with / withoutfilters
Components an entity must have, or must not have, without reading their values:
every brick, or everything except the ball.
optionalcomponent
A component that may be missing. The system gets the value when it is there and
None when it is not.
changed / addedfilters
Only entities whose component changed, or first appeared, since this system last
ran. The next section covers these.
In Breakout, with 42 entities, the queries below match:
Query
Entities it matches
Count
Position and Velocity
the ball
1
Position and Size
the paddle, the ball, and the bricks
42
Position and Size, without Velocity
the paddle and the bricks
41
Brick
the bricks
40
PlayerControlled
the paddle
1
A query that should match exactly one entity, such as the player, can ask for
exactly one and fail loudly when there are two or none. A query can also hand out
every pair of entities, and the cost of that grows quickly. With n entities
there are n × (n − 1) ÷ 2 pairs:
pairs=2n(n−1)
For the 42 Breakout entities that is 42 × 41 ÷ 2 = 861 pairs. For 1,000 entities it
is 1,000 × 999 ÷ 2 = 499,500 pairs, and at 60 steps a second that is
499,500 × 60 = 29,970,000 contact tests every second. This is why engines test
only nearby pairs instead of all of them.
Some work only needs doing when a value changed. A health bar must be redrawn when
health changes, and not on every frame for every character. An outside tool finds
such changes by comparing two snapshots;
an engine’s change detection records them as they are written, so a system can
ask for exactly those entities.
The engine keeps a counter that rises with every frame. When a system writes a
component, the engine stamps that value with the current counter. Each system
remembers the counter from the last time it ran, and a Changed query returns
the entities whose stamp is newer than that. Suppose the health-bar system last
ran at counter 41:
Entity
Health last written at
Newer than 41?
Redrawn?
A
40
no
no
B
43
yes
yes
C
44
yes
yes
The saving can be large. With 10,000 characters and a redraw that costs 0.002 ms
each, redrawing all of them costs 10,000 × 0.002 = 20 ms, more than a whole 16.67
ms frame. If only 12 changed this frame, the cost is 12 × 0.002 = 0.024 ms, about
833 times less.
Three consequences follow, and the third one matters when you edit memory:
A system that was paused and runs again still sees every change since it last
ran, because it compares against its own counter. Events behave differently,
as above.
A removed component is a special case: the data is gone, so there is no stamp to
read. Engines keep a short list of the entities whose component was removed
this frame instead.
Writing a value counts as a change even when the new value equals the old
one. A tool that rewrites a value every frame therefore makes the engine
believe it changes every frame, and everything triggered by that change runs
every frame too.
Every system belongs to a schedule, a named phase of the frame, and the engine
runs the phases in a fixed sequence. The names below are Bevy’s; other engines use
other names for the same idea.
Two things about this list explain a great deal of what you will measure.
First, the fixed steps from Lesson 4.1 are a loop inside the frame. A fixed step
is a slice of game time, not of real time, so the number of steps a frame runs
depends on how long the frame took. With a 60 Hz step, a step is 1000 ÷ 60 = 16.67
ms. At 30 frames a second every frame runs 2 steps, at 60 exactly 1, and at 240 one
frame in 4 runs a step.
Predict first
The game runs 60 fixed steps a second, so each step covers 16.67 ms of game time. The screen draws 144 frames a second, so each frame lasts 1000 ÷ 144 = 6.94 ms.
How many fixed steps does one frame run?
Answer: None on most frames, one on some: 5 of every 12. 12 frames last 12 × 6.94 = 83.33 ms, which holds 83.33 ÷ 16.67 = 5 whole steps. The leftover time carries over, so 5 of those 12 frames run one step and the other 7 run none.
Fixed steps inside drawn framesEach frame adds its length to the time the game owes; a fixed step runs each time a whole step’s worth has built up. Change the frame rate and follow 12 frames.
Step length, frame length, steps per frame
Two clocks
Step and frame length
1000 ÷ 60 = 16.67 msone fixed step
1000 ÷ 144 = 6.94 msone drawn frame
Fixed steps run in each of 12 frames
Steps in frames 1 to 6
0
0
1
0
1
0
Steps in frames 7 to 12
0
1
0
1
0
1
Over the 12 frames
5 steps12 × 6.94 = 83.33 ms of drawing
A step runs only when 16.67 ms have built up, so 7 frames run none and 5 frames run 1 step. The leftover time carries into the next frame.
The step rate (Example: Bevy's default fixed step is 64 steps a second, which is 1000 ÷ 64 = 15.625 ms. Many games choose 60.) is
a setting of the game, so a value your tool watches can change once per step in
one game and once per frame in another.
Second, the engine’s own work is made of ordinary systems that live in these same
phases. Updating the world position of every object from its parents, and deciding
which objects are visible, run in the bookkeeping phase near the end of the frame.
Anything that reads those results earlier in the frame sees yesterday’s answer,
which Lesson 7.1 shows for positions.
A system sees the version it was givenIn frame 7 the ball moves from x = 10 to x = 11. The engine copies positions into the world position only in PostUpdate, near the end of the frame.
1Update: movement writes x = 11
Position x = 11just written
World position x = 10copied in frame 6
The movement system writes the new position. The world position still holds the copy made in frame 6.
2Update: a later system reads the world position
Position x = 11
World position x = 10read now: one frame old
It gets 10, the value from one frame ago, although the position already says 11.
3PostUpdate copies 11; rendering draws 11
Position x = 11
World position x = 11drawn at x = 11
The bookkeeping phase updates the world position, so the systems after it and the renderer see 11.
All 3 steps shown
In frame 7 the ball moves from x = 10 to x = 11. The engine copies positions into the world position only in PostUpdate, near the end of the frame.
Within a phase the engine decides which system runs next from what each one
declares it reads and writes. A system is ready to run when all three conditions
hold:
no system that is running right now writes any data this one touches, and none
reads data this one writes;
every system that was ordered before it has finished or been skipped;
its run conditions, covered next, are true.
The first condition is what prevents the lost update of
Lesson 6.6: two systems never write,
or read and write, the same data at the same time. A ready system starts on any
free thread of the engine’s worker pool, so systems that touch different data
run at the same time. When the order of two systems is not stated, the engine may
run them either way round, and the order can even change from one frame to the
next.
Take five systems with these costs: read_input writes the player’s intent
(1 ms), enemy_ai (3 ms), move_player writes Position and runs after
read_input (1 ms), collide reads Position and sends events (2 ms), and
play_sounds reads the events (1 ms). One thread would take 1 + 3 + 1 + 2 + 1 =
8 ms. On two threads the result depends on what enemy_ai touches:
The AI reads Position
enemy_ai reads Position, so move_player, which writes it, must wait for the
AI to finish.
Time
Thread A
Thread B
0 to 1 ms
read_input
enemy_ai
1 to 3 ms
idle: move_player cannot start
enemy_ai
3 to 4 ms
move_player
4 to 6 ms
collide
6 to 7 ms
play_sounds
The frame’s systems take 7 ms.
The AI reads its own copy
enemy_ai reads a copy of last frame’s positions, so nothing it touches conflicts.
Time
Thread A
Thread B
0 to 1 ms
read_input
enemy_ai
1 to 2 ms
move_player
enemy_ai
2 to 3 ms
collide
enemy_ai
3 to 4 ms
collide
4 to 5 ms
play_sounds
The longest chain, 1 + 1 + 2 + 1 = 5 ms, sets the frame’s systems at 5 ms. The
price is that the AI sees positions one frame old.
An unstated order costs correctness as well as time. If move_player happens to
run before read_input, a key pressed during frame N is not applied until frame
N + 1. At 60 frames a second that adds 16.67 ms of delay, and at 30 frames a
second it adds 33.33 ms. The same mistake in the other direction, a reader running
before the writer it depends on, is the one-frame lag of the events example.
A run condition is a yes-or-no check the engine makes before a system runs, on
every frame: is the game in the playing state, has a timer finished, does an enemy
exist? When the answer is no, the system is skipped and anything it would have
changed stays as it was. A condition can be attached to one system or to a whole
set of them.
States are the usual source of conditions. A game is in exactly one state of a
kind, such as menu, loading, playing, paused, or game over, and most gameplay
systems run only in the playing state. Changing state is queued rather than
immediate, and the engine carries it out at one fixed point in the next frame:
A system asks for the new state. Nothing changes yet.
At the start of the next frame the engine runs the exit work of the old state,
such as despawning the match.
It runs the work attached to the move itself.
It runs the enter work of the new state, such as spawning the game-over screen.
This is why a value stops changing the moment you open a menu: its system has a
condition that is now false, and nothing is wrong with the value. It also ties back
to events. A system skipped for 120 frames reads nothing from an event queue that
expired in two.
Creating or removing an entity while systems are running would move data that other
systems are reading. So a system does not do it directly. It records a deferred
command, such as “spawn a bullet here” or “despawn brick 7”, and the engine applies the
queue at a safe point, normally the end of the phase, or between two systems whose
order was stated.
The consequence is a delay you can measure. A bullet spawned by a system in frame
N appears in the queries of later phases, but systems that already ran earlier in
frame N cannot see it, and it first reaches them in frame N + 1, 16.67 ms later
at 60 frames a second. A despawned entity is gone from the next query in the same
way, and its memory is free to be reused, which is the reuse Lesson 3.10 warned
about.
Lesson 4.1 said that each kind of component sits in its own run of memory. There are
two common ways to arrange that, and an engine can offer both: table storage,
where entities with the same set of components share columns, and sparse-set
storage, where one kind of component lives in its own pool. Tables make reading
fast and adding a component slow; sparse sets make adding cheap and reading slower.
Optional reference: table and sparse-set storage compared
Table storage
Entities with the same set of components share a table, and each component has a
column of equal-sized records. Take Position (8 bytes), Velocity (8 bytes), and
Health (4 bytes): one row is 8 + 8 + 4 = 20 bytes, and 10,000 entities make
200,000 bytes.
Position column10,000 × 8 bytes = 80,000 bytes, one run
Velocity column10,000 × 8 bytes = 80,000 bytes, one run
Health column10,000 × 4 bytes = 40,000 bytes, one run
One table: each component is one unbroken run, so a system that needs positions reads 80,000 bytes straight through.
Reading is fast, because a system walks one column from start to end. Adding a
component to one entity is the slow case: its row no longer fits this table, so all
20 bytes move to a table that has the new column, and 4 more are written for a
4-byte Burning component.
Sparse-set storage
A component of this kind lives in its own pool, with an index from entity to
position in the pool. Adding Burning to one entity appends one 4-byte record to
that pool and an entry to the index. Nothing else moves.
Adding and removing is cheap, but reading costs an extra step for every entity,
because the engine looks in the index before it reaches the value, and the values
are not in entity order. That suits a marker that comes and goes every frame, such
as “this enemy has noticed the player”, and does not suit data that every system
reads.
Tables have a limit of their own. Each distinct set of components needs its own
table. If 10,000 bullets all carry Position, Velocity, and Damage, they share
one table. If each bullet may also carry any of 8 optional markers, up to 2⁸ =
256 different sets can appear, so the same 10,000 bullets are spread over as many
as 256 tables, about 10,000 ÷ 256 = 39 to a table. Each table is a separate run in
memory, and a scan for one component finds it in many pieces.
Everything above holds in any engine of this kind. If you would like to see the names,
Bevy writes the same ideas in a few lines of Rust: a component is a plain struct, a
system is an ordinary function, and a query is the function’s parameter list.
Optional: Breakout's systems in Bevy
A query for every entity that has both Position and Velocity, and the clock as a
resource:
A command to remove the bricks that were hit, and the order and the run condition,
which are written where the systems are added. Hit is a marker component that the
collision system adds to a brick, and GameState is an enum the game defines. The
other systems are written the same way:
.run_if(in_state(GameState::Playing)), // skipped in every other state
)
.run();
}
Each piece matches a section above: Res<Time> is a resource, the two Query
parameters are queries, Changed<Position> is change detection, Commands holds
deferred commands, .chain() states the order, and .run_if(in_state(...)) is a run
condition. These lines were compiled against Bevy 0.19. Bevy’s names change between
versions, so check the one you use.
Three questions that come up once the pieces are in place, each worth a minute and none
needed for the next lesson:
Could more CPU threads make the frame's systems finish faster than 5 ms?
No. The chain read_input, move_player, collide, play_sounds runs one system
after another, because each needs the one before it: 1 + 1 + 2 + 1 = 5 ms. Extra threads
can run enemy_ai beside the chain, but nothing can start a system before the one it
waits for has finished. The longest chain of waiting systems sets a floor under the
frame, whatever the number of threads. In the first tab above, enemy_ai joined that
chain because it read Position, and the floor rose to 3 + 1 + 2 + 1 = 7 ms.
What happens when two systems write the same value and no order is stated?
The result depends on which one runs last. Say one system sets a character’s speed to 100
and another sets it to 50. If the first runs first, the speed ends the frame at 50, and if
the second runs first, it ends at 100. An unstated order can change from one frame to the
next, so the value can flip between 50 and 100 with no input from you. A number that
alternates like that is a sign of two systems writing it.
Why are events dropped after a short time instead of kept until someone reads them?
A queue that was never emptied would grow for as long as the game ran. An event sent on
every frame that nobody reads would add 60 × 60 = 3,600 events a minute at 60 frames a
second. Dropping each event after a short, fixed time keeps the queue small, at the cost
of the missed-event problem described above.
Our DLL now has a controlled lifecycle, and the scanner can do work off the game thread. A local tool also needs a deliberate way to start or stop a feature. Pressing a menu hotkey, sending a synthetic key to an offline game, and calling a window procedure solve different problems, even though all three sound like “input.” This lesson is about the ways Windows delivers input and the ways a tool can read or send it. Lesson 8.5 follows the input inside a game once it has arrived, from device events to per-frame state and actions.
Windows has several input paths because programs receive interaction in
different ways:
Path
What it really does
Good course use
Common surprise
GetAsyncKeyState
Samples the current physical key state
Toggle a local tool menu
It does not wait for an event
SendInput
Inserts keyboard or mouse events into the system input stream
Drive an offline game while its window is active
It does not target one HWND
SendMessageW / PostMessageW
Calls or queues a message for a window procedure
Control a window you created or a documented local test window
Many games read Raw Input or device state instead
Each path lands on a different destination, which is the real reason to pick carefully:
There is no general Win32 function named SendKey. .NET’s SendKeys is a
separate convenience layer, while the old keybd_event function has been
superseded by SendInput. Naming the path correctly prevents a lot of confused
debugging. ⌨️
State, events, and messages are not interchangeable
GetAsyncKeyState answers a state question: “is this key down at the instant I sample it?” SendInput submits input events to the desktop input pipeline. SendMessageW invokes a window-procedure message contract. Similar names do not put them on the same path.
A game may combine several paths: Raw Input for aiming, ordinary window messages for menus, and polled key state for a developer console. Observe how the target consumes input before choosing an API. Sending WM_KEYDOWN to a window that reads device state is not a failed keyboard event; it is the wrong abstraction.
Always pair a down transition with an up transition, including cancellation and error paths. A state machine should own which synthetic inputs are currently held so shutdown can release exactly those inputs.
A held button and a new press are different. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
previous = last_frame_down
current = this_frame_down
pressed = current and not previous
released = previous and not current
store previous = current for the next sample
report level, press edge, and release edge
Result: level=true, pressed=true, released=false. A held button does not create another press.
Final values: previous = up; current = down; was_down = false; is_down = true; pressed = true; released = false; next_previous = true.
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.
previous = up; current = down.
Step 2, line 2. Read the current level independently of the remembered level.
The message path makes more sense if we first follow an ordinary
window. A key press or resize can place a message in the thread’s
queue. A message loop retrieves it and DispatchMessage calls
the window’s window procedure. That procedure is a callback: a
function the application provides so Windows can call it when a
message is dispatched. For example, it might respond to WM_SIZE
by updating the window’s layout. Microsoft’s
window-message walkthrough
shows this path.
message arrives for a window
loop retrieves it
DispatchMessage calls that window's procedure
procedure handles it and returns
loop retrieves the next message
GetMessage waits when no queued message is available. That suits a
window that has nothing to do until the next event. A game often must
update its world even when the player touches nothing. Its loop can
use PeekMessage to check for waiting messages without blocking,
then continue to update and draw. This is a conceptual comparison,
not complete Win32 source:
ordinary event loop:
wait for a message with GetMessage
translate and dispatch it
game-style loop:
while PeekMessage finds a waiting message:
translate and dispatch it
update the world
draw the next frame
The waiting path and the continuously updating path look different:
TranslateMessage can post character messages derived from key
messages. See Microsoft’s
GetMessageW
and PeekMessageW
references for their return values and queue behavior.
The callback must also return promptly. While a window procedure is
busy reading a large file or waiting on a network request, other
messages for windows on that thread cannot be handled. The window
may stop repainting or responding to input. Microsoft’s
window-procedure guidance
explains this bottleneck. The same concern appears when a hook adds
work to a game’s time-sensitive path: move slow work elsewhere and
make overload visible, as Lesson 6.11 does for a recorder.
being handledWM_KEYDOWN, reading a large file
waitingWM_MOUSEMOVE
waitingWM_PAINT
waitingWM_CLOSE
these messages wait until the first handler returns
One thread's message queue. A slow first handler delays movement, repainting, and close handling.
Polling is the other side of this distinction. If no one sends
your tool a message when the game’s gold changes, the tool can check
the value on its own schedule. It does work even when nothing changed.
Lesson 4.9 shows how to compare successive snapshots
so many polls produce one meaningful event instead of many repeated
actions.
The window that receives those messages is also what the game draws into, and a few
of its properties explain problems you will meet in later lessons.
Window rectangle and client area. The window rectangle includes the title bar and
the borders. The client area is the part the game draws into. On one typical
Windows setup, a window whose client area must be 1280 × 720 needs an outer rectangle of
about 1280 + 16 = 1296 wide and 720 + 39 = 759 tall, because the borders and title bar
take 16 pixels across and 39 down. The exact sizes depend on the theme and the scale, so
the system works them out (AdjustWindowRect). A point in the client area becomes a
screen point by adding where that area starts on the desktop (ClientToScreen), which is
why Lesson 7.9 matches an overlay to the client area.
Resizing. Dragging the edge sends WM_SIZE with the new client width and height. A
game then resizes its buffers and recomputes the projection’s aspect ratio (Lesson 7.1).
With a client area of 1280 × 720 the aspect is 1280 ÷ 720 = 1.7778; a game that keeps the
old value after the window becomes 1000 × 720 draws a squashed picture.
Scale factor. A display set to 125% reports a window 1,000 logical pixels wide as
1,000 × 1.25 = 1,250 physical pixels. A game that is not aware of this has its picture
stretched by the system, and one that is aware sizes its menus by the factor (Lesson 8.7). Moving the window to a display with a different scale sends WM_DPICHANGED.
More than one window. An application can own several windows, such as the game’s
window and a separate settings or tool window. Each has its own handle (HWND), client
area, and scale factor, and windows made by one thread share that thread’s message
queue. A screen point belongs to the window that contains it. Say the game’s client
area starts at the screen point (100, 100) and is 1280 wide, and a tool window’s
client area starts at (1400, 100). The screen point (1500, 200) is
(1500 − 100, 200 − 100) = (1400, 100) in the game’s client coordinates, which is past
its 1280-pixel width, and (1500 − 1400, 200 − 100) = (100, 100) in the tool window’s.
Subtracting the wrong window’s origin is a common reason an overlay is off by a
constant amount.
Fullscreen.Exclusive fullscreen takes over the display and may change its
resolution. Borderless windowed is an ordinary window with no border, sized to the
desktop, which is quick to switch away from and can be drawn over by other windows.
Presenting. The game draws into a hidden back buffer and then presents it. With
vertical sync on, the present waits for the display’s next refresh, so a 60 Hz display
caps the game at 60 frames a second (Lesson 4.1). A game with little moving, such as a
menu, may redraw only when something changes, to save power. A screenshot is a copy of
the finished back buffer saved as an image file.
Focus and the cursor. A window that loses focus may pause its game, but
GetAsyncKeyState still reports keys, so a tool must check focus itself (below). A game
can hide the cursor, confine it to the window, replace it with its own image, or read
relative mouse movement instead of a position (Lesson 8.5 shows the arithmetic of a
look-around loop).
The current windows crate exposes
GetAsyncKeyState
as an unsafe function taking a virtual-key number and returning an i16.
Windows puts “down right now” in the most significant bit. Because that is the
sign bit of an i16, a negative result means down:
bit 15 (sign)1 if down now
bits 14-1unused here
bit 0stale “pressed since last call” flag
GetAsyncKeyState returns an i16. A negative result (bit 15 set) means the key is down right now. Bit 0 is kept for old compatibility, but another process's call can consume it first, which is why the edge detector below is built from bit 15 instead.
usewindows::Win32::UI::Input::KeyboardAndMouse::{
GetAsyncKeyState, VIRTUAL_KEY, VK_INSERT,
};
fnkey_is_down(key: VIRTUAL_KEY) -> bool {
// 🛡️ SAFETY: every VIRTUAL_KEY value is a valid integer query. This call
// only samples desktop input state and does not retain any borrowed pointer.
Do not use result & 1 != 0 as a reliable “pressed once” event. Microsoft
keeps that low bit for old compatibility, but another process can observe it
first. Build your own edge detector from the trustworthy high bit:
letrising_edge = down && !self.was_down; // ✅ one event per press
self.was_down = down;
rising_edge
}
}
Predict first
KeyEdge starts with was_down = false, and the tool calls pressed_now once per poll.
The player holds Insert down across 5 polls in a row. How many times does pressed_now return true?
Answer: 1. Only the first down poll still has was_down = false, so true && !false = true there. The other four have was_down = true, so the hold reports exactly one press.
KeyEdge over eight pollspressed_now is down && !was_down, and was_down starts false. Click a poll to press or release the key there. 1 = down, ✓ = true.
Key at polls 1 to 8 (1 = down)
Eight polls
Each poll compares now with the poll before
key_is_down
poll 1poll 8
0
1
1
1
1
1
0
0
was_down (before)
0
0
1
1
1
1
1
0
pressed_now
–
✓
–
–
–
–
–
–
Result
Presses reported
1 press, at poll 2
The key is down in 5 polls but changes from up to down once. A press is “down now and not down before”, so a held key reports one press, not one per poll.
Poll at a modest rate such as every 8–16 milliseconds. A tight loop wastes a
CPU core. A zero result can also mean the desktop is inactive or Windows
integrity-level restrictions prevent the query; “zero” does not prove a keyboard is broken.
Sampling can miss a very short press between polls. That is a property of polling, not a reason to trust the ambiguous low bit. For a tool hotkey, choose a comfortable key and polling interval, then test focus changes, key repeat, and shutdown while held.
anyhow::ensure!(sent == events.len() asu32, "sent {sent} of 2 events");
Ok(())
}
A key event is an INPUT whose type says “keyboard” and whose union holds a
KEYBDINPUT. The code above sets two of its fields and leaves the rest at zero:
wVk, the same virtual-key number GetAsyncKeyState takes, and dwFlags, zero
for a key going down and KEYEVENTF_KEYUP for its release.
The input API reference lists every field and
flag, including the scan-code form that some games read instead.
Always release a key or mouse button during shutdown, even if a feature fails.
SendInput is subject to User Interface Privilege Isolation (UIPI): Windows
blocks input from a lower-integrity process into a window owned by a
higher-integrity one, such as a game running as administrator while your tool
does not. Windows does not tell you why: neither SendInput’s return value
nor GetLastError identifies UIPI as the cause, and the target window simply
never reacts, which looks identical to a wrong virtual-key code or an unfocused
window.
If synthetic input silently does nothing, check
whether the target is elevated before suspecting your own code. Some games also
intentionally use Raw Input or device APIs that do not behave like a normal text
box. Do not respond by repeatedly flooding input.
Every Win32 window has a window procedure that receives numbered messages.
SendMessageW invokes that procedure synchronously and waits for it to finish.
PostMessageW puts a message on the owning thread’s queue and returns. For a
window outside your process, a bounded wait is usually safer than an unlimited
one:
// 🛡️ This custom message belongs to our own lab window. It carries only
// integer values—never a pointer into this process.
letdelivered = unsafe {
SendMessageTimeoutW(
window,
WM_LAB_REFRESH,
WPARAM(0),
LPARAM(0),
SMTO_ABORTIFHUNG,
100,
Some(&mutmessage_result),
)
};
anyhow::ensure!(delivered.0 != 0, "lab window did not answer in 100 ms");
Ok(message_result)
}
Use PostMessageW when the receiver’s documented contract says queuing is
correct and no immediate result is required. Never pass a local pointer in
LPARAM to an unrelated process: Windows automatically marshals only certain
system messages below WM_USER. A custom WM_APP message is appropriate when
both sides share a pointer-free contract.
Most 3D games do not treat WM_KEYDOWN as authoritative gameplay input. They
may poll device state, consume Raw Input, or ignore messages while unfocused.
Sending a window message is therefore not a stronger version of SendInput;
it is a different communication path.
The code above uses only the fields and calls already explained; these
references list the remaining variants.
Optional reference: every KEYBDINPUT field and flag
KEYBDINPUT: the fields that matter
wVkvirtual-key coderequired
Which key. This is the number from VK_INSERT and its relatives, the same ones
GetAsyncKeyState takes.
dwFlagsflagsdefault 0
Zero means the key goes down. KEYEVENTF_KEYUP means it is released, which is why
the code sends one of each. KEYEVENTF_SCANCODE switches the event to the
hardware scan code in wScan instead.
The four flags and how they combine
Flag
Value
Meaning
KEYEVENTF_EXTENDEDKEY
0x0001
the scan code carries the 0xE0 prefix that extended keys use, such as the arrow keys in their own cluster and the right-hand Ctrl and Alt
KEYEVENTF_KEYUP
0x0002
the key is released
KEYEVENTF_UNICODE
0x0004
wScan holds a Unicode character instead of a key, and wVk must be zero
KEYEVENTF_SCANCODE
0x0008
wScan identifies the key, and wVk is ignored
Flags are bits, so they combine with OR. Releasing a key by scan code sets two of them:
0x0008 | 0x0002 = 0x000A, which is 8 + 2 = 10.
wScanscan codedefault 0
The key’s position on the keyboard, used only with KEYEVENTF_SCANCODE. Some games
read scan codes, so a virtual-key event does not reach them.
timemillisecondsdefault 0
Zero lets Windows stamp the event itself.
dwExtraInfointegerdefault 0
An extra value the receiving window can read back. Almost nothing uses it.
Optional reference: other Windows APIs worth knowing
Choose by question instead of memorizing a giant list:
An HWND is also called a handle, but it is owned by the window manager and is
not closed with CloseHandle. This is why “wrap every H... value in the same
RAII type” is wrong. Learn the creation, borrowing, and destruction contract
for each API family.
The scanner in Lesson 5.8 found where a value was stored; the disassembler in Lesson 5.9 named instructions near an address. To learn which instruction actually changes Wesnoth’s gold during one turn, we need to pause when that instruction runs and inspect the live registers. A debugger provides that pause.
Windows reports events such as process creation, DLL loading, exceptions, and process exit. A breakpoint is one such event that this tool deliberately causes and then handles.
If the debugger forgets to continue an event, the target remains frozen.
A software breakpoint replaces one instruction byte with 0xCC. To let the
original instruction run later, the debugger must remember both its address
and that displaced byte. Keying records by address also lets several
breakpoints coexist without a single global “current breakpoint” slot:
One byte swapped, one byte backThe instruction sub dword ptr [esi+30h], eax is 29 46 30 at 0x00A31000. The debugger owns a breakpoint on its first byte.
1Armed: CC over the first byte
Code at 0x00A31000
Before
29…1000
46…1001
30…1002
Armed
CC…1000
46…1001
30…1002
Breakpoint map
0x00A31000
original byte 0x29
phase: Armed
The debugger read 0x29, saved it in its map, then wrote 0xCC. The other two bytes do not change.
2The CPU executes CC
Code at 0x00A31000
Bytes
CC…1000
46…1001
30…1002
ip = 0x00A31001
ip…1001
Breakpoint map
0x00A31000
original byte 0x29
phase: Armed
int3 is a whole one-byte instruction, so once it has run, ip already points one byte past it, and Windows reports EXCEPTION_BREAKPOINT.
3Restore 0x29, move ip back one
Code at 0x00A31000
Bytes
29…1000
46…1001
30…1002
ip = 0x00A31000
ip…1000
Breakpoint map
0x00A31000
original byte 0x29
phase: SteppingOriginal
0x00A31001 − 1 = 0x00A31000, so ip points at the whole real instruction again, ready to run once.
All 3 steps shown
The instruction sub dword ptr [esi+30h], eax is 29 46 30 at 0x00A31000. The debugger owns a breakpoint on its first byte.
A basic debugger view of the target while it is paused.
When a software breakpoint installed by this debugger fires:
suspend the relevant flow through the debug event;
restore the original byte;
move the instruction pointer back by one byte;
enable the CPU trap flag for one instruction;
continue.
The next single-step exception lets the debugger reinsert 0xCC and clear the trap flag.
This two-step cycle is why a breakpoint has state; it is not just one changed byte.
enumBreakpointPhase {
// 🔴 The target currently contains 0xCC at the owned address.
Armed,
// 👣 The original byte is temporarily restored for exactly this thread.
SteppingOriginal { thread_id: u32 },
// ⚪ No debugger-owned byte is installed at this address.
Disabled,
}
The two-step life of a software breakpoint. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
orig = read_byte(addr) # set the breakpoint
write_byte(addr, 0xCC) # int3 replaces the first byte
# the game reaches addr; the CPU raises int3
if exception_addr not in map: pass it on
write_byte(addr, orig) # put the real byte back
ip = ip - 1 # int3 was 1 byte long
trap_flag = 1 # stop after ONE instruction
# the instruction runs; single-step exception
write_byte(addr, 0xCC) # re-arm
trap_flag = 0 # continue the game
Clear the trap flag and let the game run freely. The cycle ends where it began: armed, with the original byte safely in the map.
Final values: addr = 0x00A31000; orig = 0x29; phase = Armed; exception_addr = 0x00A31000; ip = 0x00A31003; trap_flag = 0.
Read every step of the lesson’s example
Step 1, line 1. To set a breakpoint at 0x00A31000 the debugger first reads the byte there, 0x29, and remembers it in its map. Without that saved byte it could never run the real instruction.
addr = 0x00A31000; orig = 0x29; phase = Disabled.
Memory: …1000 = 29; …1001 = 46; …1002 = 30.
Step 2, line 2. It overwrites that one byte with 0xCC, the one-byte int3 instruction. The rest of the instruction is untouched. The breakpoint is now armed.
addr = 0x00A31000; orig = 0x29; phase = Armed.
Memory: …1000 = CC; …1001 = 46; …1002 = 30.
Step 3, line 3. The game reaches the breakpoint. The CPU executes the 0xCC byte, which raises int3, and the debugger gets a debug event. The CPU has already moved ip past that one byte.
Step 6, line 6. Move ip back one byte. It had stepped past the int3, but the instruction that should run is the real one, which starts at the breakpoint address.
Step 7, line 7. Set the CPU's trap flag, so the CPU raises a single-step exception after exactly one instruction. Then the debugger continues the game.
Step 8, line 8. The real instruction runs once. Because of the trap flag, the CPU immediately raises a single-step exception and the debugger regains control.
A debugger is a measuring instrument, and every instrument affects its subject
somehow. A software breakpoint temporarily replaces one instruction byte.
Pausing one thread can delay rendering, audio, input, or network work. Single
stepping makes a short path take far longer than normal, and Windows can expose
whether a process is being debugged.
That creates an important distinction:
Result
What it shows
The behavior happens both attached and detached
It is probably part of the target rather than a pause artifact
Only elapsed time changes while attached
The debugger changed scheduling or timing
A flag or exception path changes while attached
The target can observe the debugging environment
A byte differs at your breakpoint address
Your debugger deliberately changed code to gain control
On the course target, run the same input once without a debugger and once
attached, logging inputs, chosen branches, elapsed time, and output both times.
A difference that appears only while attached comes from the measurement, not
from the game’s normal logic. That effect is called instrumentation bias:
the tool used to measure a program changes what the program does.
Debugger-aware behavior usually comes from a small set of sources: flags,
handles, exceptions, timing, instructions, memory, and interaction with the
desktop. A change that appears only while attached traces back to one of them.
This lesson intentionally does not turn those sources into concealment or bypass
instructions.
Once the breakpoint cycle works in the tiny target, point the debugger at the 32-bit wesnoth.exe 1.14.9 process. Add one software breakpoint at the income instruction from lesson 2.10:
0x009B4D00 add dword ptr [eax+4], edx
Start a local match with income and end the turn. When the event fires, copy the thread context and log:
instruction = 0x009B4D00
side record = EAX
gold address = EAX + 0x4
income delta = EDX
gold before = read_u32(EAX + 0x4)
Then perform the restore/single-step/re-arm sequence and continue Wesnoth. The turn must finish normally, and the breakpoint should fire again next turn. This turns the debugger project into a real game-hacking tool rather than leaving it attached only to a sample program.
End a turn to hit the real income instruction. The tool prints EAX, signed and hexadecimal EDX, the calculated EAX + 4 address, and gold before the add. Press End between events to restore 0x01, detach, and close all handles.
The complete state machine is wesnoth_debugger.rs. It implements attach, exact opcode verification, WaitForDebugEvent, event-handle cleanup, x86 thread contexts, EIP rewind, trap-flag stepping, re-arming, error-path continuation, byte restoration, and detach using the windows crate.
#[cfg(all(windows, target_arch = "x86"))]mod app { use anyhow::{Context as _, Result}; use gha_windows_labs::{OwnedHandle, Process}; 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 INCOME_INSTRUCTION: usize = 0x009B_4D00; const TRAP_FLAG: u32 = 1 << 8; #[derive(Clone, Copy, Debug)] enum BreakpointPhase { Armed, SteppingOriginal { thread_id: u32 }, } struct AttachGuard { process_id: u32, active: bool, } impl AttachGuard { fn detach(&mut self) -> Result<()> { if self.active { // SAFETY: this debugger attached to this PID and has no event // waiting for continuation when this method is called. unsafe { DebugActiveProcessStop(self.process_id) }?; self.active = false; } Ok(()) } } impl Drop for AttachGuard { fn drop(&mut self) { if self.active { // SAFETY: best-effort cleanup for the matching attach call. let _ = unsafe { DebugActiveProcessStop(self.process_id) }; } } } fn close_event_handle(handle: HANDLE) { if !handle.is_invalid() { // SAFETY: debug events transfer these handles to the debugger. This // tool does not retain them because it opens its own process/thread // handles when needed. let _ = unsafe { CloseHandle(handle) }; } } fn open_event_thread(thread_id: u32) -> Result<OwnedHandle> { let access = THREAD_GET_CONTEXT | THREAD_SET_CONTEXT | THREAD_QUERY_INFORMATION; // SAFETY: the thread id comes from a live debug event; inheritance is off. let handle = unsafe { OpenThread(access, false, thread_id) }?; Ok(OwnedHandle::from_raw(handle)?) } fn get_context(thread: &OwnedHandle) -> Result<CONTEXT> { let mut context = CONTEXT { ContextFlags: CONTEXT_FULL_X86, ..Default::default() }; // SAFETY: Windows suspended the event thread and `context` is writable. unsafe { GetThreadContext(thread.raw(), &mut context) }?; Ok(context) } fn set_context(thread: &OwnedHandle, context: &CONTEXT) -> Result<()> { // SAFETY: the context belongs to this suspended event thread. unsafe { SetThreadContext(thread.raw(), context) }?; Ok(()) } fn end_pressed() -> bool { // SAFETY: VK_END is a valid virtual-key value. unsafe { GetAsyncKeyState(VK_END.0.into()) & 1 != 0 } } fn dispose_event_handles(event: &DEBUG_EVENT) { // SAFETY: the active union field is selected by dwDebugEventCode. 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 handle_exception( process: &Process, event: &DEBUG_EVENT, original_byte: u8, phase: &mut BreakpointPhase, saw_initial_breakpoint: &mut bool, ) -> Result<NTSTATUS> { // SAFETY: the event code was checked before this function was called. let exception = unsafe { event.u.Exception }; let record = exception.ExceptionRecord; let code = record.ExceptionCode; let address = record.ExceptionAddress as usize; if code == EXCEPTION_BREAKPOINT && address == INCOME_INSTRUCTION { anyhow::ensure!( matches!(phase, BreakpointPhase::Armed), "income breakpoint fired while it was not armed" ); process.write_code(INCOME_INSTRUCTION, &[original_byte])?; let thread = open_event_thread(event.dwThreadId)?; let mut context = get_context(&thread)?; // INT3 is one byte, and EIP points immediately after it. Put EIP // back on the original ADD instruction and ask the CPU to trap once. context.Eip = u32::try_from(INCOME_INSTRUCTION)?; context.EFlags |= TRAP_FLAG; let gold_address = (context.Eax as usize) .checked_add(4) .context("EAX + gold offset overflowed")?; let gold_before = process.read_u32(gold_address)?; println!( "income: EAX={:#010x}, EDX={} ({:#010x}), gold @ {gold_address:#010x} = {gold_before}", context.Eax, context.Edx as i32, context.Edx, ); set_context(&thread, &context)?; *phase = BreakpointPhase::SteppingOriginal { thread_id: event.dwThreadId, }; return Ok(DBG_CONTINUE); } if code == EXCEPTION_SINGLE_STEP { let BreakpointPhase::SteppingOriginal { thread_id } = *phase else { return Ok(DBG_EXCEPTION_NOT_HANDLED); }; if thread_id != event.dwThreadId { return Ok(DBG_EXCEPTION_NOT_HANDLED); } let thread = open_event_thread(event.dwThreadId)?; let mut context = get_context(&thread)?; context.EFlags &= !TRAP_FLAG; set_context(&thread, &context)?; process.write_code(INCOME_INSTRUCTION, &[0xCC])?; *phase = BreakpointPhase::Armed; return Ok(DBG_CONTINUE); } // Windows generates one breakpoint to finish an attach. It is not the // game's income breakpoint, but the debugger must consume it once. if code == EXCEPTION_BREAKPOINT && !*saw_initial_breakpoint { *saw_initial_breakpoint = true; 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()?, "this debugger profile needs 32-bit Wesnoth" ); let original_byte = process.read_bytes(INCOME_INSTRUCTION, 1)?[0]; anyhow::ensure!( original_byte == 0x01, "expected ADD opcode 0x01 at {INCOME_INSTRUCTION:#010x}, found {original_byte:#04x}" ); // SAFETY: the PID came from ToolHelp and identifies the exact target. unsafe { DebugActiveProcess(entry.id) }?; let mut attach = AttachGuard { process_id: entry.id, active: true, }; process.write_code(INCOME_INSTRUCTION, &[0xCC])?; let mut phase = BreakpointPhase::Armed; let mut saw_initial_breakpoint = false; println!( "Attached to Wesnoth PID {}. End a turn to hit {INCOME_INSTRUCTION:#010x}; press End to detach.", entry.id ); loop { if matches!(phase, BreakpointPhase::Armed) && end_pressed() { process.write_code(INCOME_INSTRUCTION, &[original_byte])?; attach.detach()?; println!("Restored the breakpoint byte and detached."); break; } let mut event = DEBUG_EVENT::default(); // A short timeout makes the End key usable without another thread. // SAFETY: `event` is a correctly initialized writable structure. 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 event_result = if event.dwDebugEventCode == EXCEPTION_DEBUG_EVENT { handle_exception( &process, &event, original_byte, &mut phase, &mut saw_initial_breakpoint, ) } else { Ok(DBG_CONTINUE) }; dispose_event_handles(&event); let continue_status = match event_result { Ok(status) => status, Err(error) => { // Never leave Wesnoth frozen because a log/read failed. // SAFETY: this exactly matches the event returned above. let _ = unsafe { ContinueDebugEvent( event.dwProcessId, event.dwThreadId, DBG_EXCEPTION_NOT_HANDLED, ) }; return Err(error); } }; // SAFETY: every event is continued exactly once. unsafe { ContinueDebugEvent(event.dwProcessId, event.dwThreadId, continue_status)?; } if event.dwDebugEventCode == EXIT_PROCESS_DEBUG_EVENT { attach.active = false; // the process no longer exists to detach from println!("Wesnoth exited; debugger finished."); 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 debugger for i686-pc-windows-msvc.");}
Lesson 6.11
Sampling Game State Under Load
Once repeated polls have become meaningful events, we may want a record of
them. A log can answer when gold changed or whether an action timed out, but
recording must not hold up the game or disguise samples it failed to capture.
When samples arrive faster than they can be stored, the writer creates
backpressure. A bounded queue makes us choose explicitly what happens
when it fills.
A call logger, frame observer, or value recorder has two jobs with different timing needs:
capture a small fact while it is fresh;
format and store that fact somewhere durable.
Writing a file can pause for filesystem, antivirus, indexing, or device activity. If the same thread is also handling a debugger event or time-sensitive sample, one slow write can disturb the program being measured.
A telemetry pipeline separates those jobs:
The producer never waits for disk. The consumer owns the file.
Record overload and failure instead of hiding them
A fault is one thing going wrong: the disk pauses, a queue fills, the target
closes, or the writer thread exits. A failure is the recorder breaking its
promise to the learner—for example, inventing continuous data after samples
were lost or labeling an incomplete file as clean.
The design cannot prevent every fault. It can keep each one from becoming a
misleading result:
Fault during a run
Honest recorder response
queue reaches 128 events
apply the declared drop policy and increment dropped
writer disconnects
stop recording and return an error
target memory becomes unreadable
mark the run incomplete; never repeat the last value as if it were new
an older reader sees a new schema
reject the unknown version or use a reader written for it
shutdown begins
close producers, drain accepted events, flush, join the writer, and report counts
This is why the current recorder counts dropped samples and versions its file.
A durable clean-shutdown marker is the next extension described below. These
facts let a later graph distinguish “gold stayed at 100” from “the tool
stopped observing for two seconds.” 📉
A bounded sampler reports overload. This step-by-step trace needs JavaScript. Here is the code and where the run ends.
queue = []; capacity = 4
for each arriving event:
if room: queue.push(event)
else: dropped += 1
writer drains up to its chosen count
report written, queued, and dropped
Result: 2 written, 2 pending, 1 dropped. Those counts account for every arrival.
Final values: arrivals = 5; drain = 2; queued = 2; dropped = 1; written = 2.
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.
arrivals = 5; drain = 2.
Step 2, line 2. Capture does not wait for the writer.
let (sender, receiver) = mpsc::sync_channel::<GoldEvent>(128);
A normal blocking send would wait when all 128 slots are occupied. The producer uses try_send instead:
The match below implements those outcomes: accepted events keep sampling,
a full queue increments a visible loss count, and a dead writer stops the
recorder. With a full queue, try_send returns immediately instead of
waiting while the observed game runs.
matchsender.try_send(event) {
Ok(()) => Ok(()),
Err(TrySendError::Full(_)) => {
self.dropped += 1;
Ok(())
}
Err(TrySendError::Disconnected(_)) => {
anyhow::bail!("telemetry writer stopped early")
}
}
Dropping an event sounds bad, but silently pausing every target thread can be worse. Report sample loss explicitly. The final report prints both written and dropped counts.
Predict first
The sampler sends one GoldEvent every 50 ms into the empty 128-slot queue. Then the disk pauses, and the writer removes nothing for 10 seconds.
How many of the samples sent during those 10 seconds does the sampler drop?
Answer: 72. 10,000 ms ÷ 50 ms = 200 samples arrive. The first 128 fill the empty queue; the other 200 − 128 = 72 get Full, and each adds 1 to dropped.
A stalled writer and a bounded queueThe sampler keeps sending while the writer removes nothing. try_send fills the free slots, then reports Full and the sampler counts a drop instead of waiting.
Arrivals, free slots, drops
While the writer is stalled
Events that arrive and slots that are free
10000 ms ÷ 50 ms = 200events sent
128 − 0 = 128free slots
What try_send does with them
Queue: 128 slots
0queued before
+128Ok: accepted
0still free
↓ Full adds 1
The drop counter
dropped = 7272 × Full: the log has a gap
200 events arrive but only 128 slots are free, so 200 − 128 = 72 get Full. Each one adds 1 to dropped; none makes the sampler wait.
When the writer resumes and removes events, later samples can enter again.
The queue absorbed a short delay, but it did not make the disk faster or keep
every sample. In the finished log, a nonzero dropped count warns that the
measured timeline has a gap even if the surrounding gold values look flat.
Different tools can choose different policies:
Tool
Sensible full-queue policy
Five-second call logger
Drop and count; stop if loss exceeds a threshold
Turn-by-turn strategy log
Block briefly or stop because every event matters
High-rate overlay metrics
Keep only the newest sample
Crash report
Flush synchronously because the process is already failing
Backpressure is not one library call. It is the decision a system makes when producers outrun consumers.
The sink owns an optional sender and writer thread. Finishing removes the final sender:
senderSome(SyncSender)
writerSome(JoinHandle)
Before finish: the sink still owns both handles.
senderNone
writerNone
After finish: taking the sender disconnects the channel, so the writer's recv() loop ends on its own; then the writer handle is joined.
self.sender.take();
letwritten = writer.join()??;
The receiver’s loop ends naturally when the channel is disconnected and empty. No special “stop event” can become stuck behind normal events in a full queue.
The writer then flushes BufWriter, returns the count, and joins. This ordering proves that a successful finish means all accepted events reached the operating-system file interface.
That promise is deliberately narrow. BufWriter::flush sends buffered bytes to
the file object, but it is not a guarantee that every storage device could
survive an immediate power loss. A short course telemetry run needs honest
completion and visible loss; a crash-resilient journal would need a stronger,
explicit durability design.
Drop repeats the close-and-join path as a fallback. Explicit finish is still better because it returns errors and counts to main.
The version explains the schema. The target line records run-specific context.
For a Wesnoth telemetry file you want to compare across several launches, also
record:
executable SHA-256;
game and tool versions;
module base;
sampling period;
field profile name;
wall-clock start in UTC;
written and dropped counts in a footer or sidecar.
Do not silently reinterpret old columns after a format change. Create v2.
The linked gold_telemetry.rs writes a v1 header and detail rows, then prints
written and dropped counts to the console. It does not yet write a trailer.
If the file itself must prove how a run ended, extend it to v2 with three parts:
the header says how to interpret the run;
the detail rows contain repeated observations;
the trailer says how the run ended.
For example:
headertwo comment lines: format version, then target and build info
detail rowselapsed_ms,gold, then one row per sample
trailerone comment line: written, dropped, and clean_shutdown
Three parts, one file. The trailer's written count should agree with the number of detail rows above it.
# gha-gold-telemetry v2
# target=wesnoth.exe build=1.14.9 sample_ms=50
elapsed_ms,gold
0,100
50,75
# end written=2 dropped=0 clean_shutdown=true
This v2 example is a design exercise, not the output of the v1 code below. A
v2 writer would write the trailer after draining accepted events, and its
reader would check that the number of rows matches written. A missing trailer,
clean_shutdown=false, or nonzero dropped count would label the run incomplete
instead of quietly treating a gap as normal game behavior.
The loop sleeps for 50 ms, which aims for about 20 samples per second. Desktop scheduling is not a metronome. The code records Instant::elapsed() for each sample instead of assuming sample 20 happened at exactly one second.
The tool opens Wesnoth without write rights, resolves the verified pointer path once, and records for 1–300 seconds. If the match ends and the address becomes unreadable, the run returns an error instead of fabricating a value.
For a longer recorder, re-resolve and validate the object identity periodically rather than trusting one pointer forever.
Spend or gain gold during the window. Inspect the file:
Terminal window
Get-Content .\wesnoth-gold.csv
Or let PowerShell parse only the data rows:
Terminal window
Get-Content .\wesnoth-gold.csv |
Where-Object { -not $_.StartsWith("#") } |
ConvertFrom-Csv |
Select-Object -First 20
The tool should report roughly 300 accepted samples for 15 seconds (15 seconds × 20 samples per second = 300) and zero drops on a normal disk. A nonzero drop count is not hidden; it means the writer or chosen rate needs attention.
The call logger already knows the time-sensitive facts while Windows holds a debug event. Its advanced path should:
copy registers and module-relative addresses into a compact CallEvent;
restore/re-arm the breakpoint;
call try_send;
continue the debug event;
let the writer format symbols and text later.
Never send a borrowed DEBUG_EVENT union member to the writer. Its validity belongs to the current event-handling scope. Copy only the fields whose meaning has already been checked.
Each event owns a copy of the facts it needs; it cannot keep a borrowed
debug-event buffer or process handle alive by accident. A full bounded queue
means producers outran the writer, so the written and dropped counts both
belong in the result. Dropping the last sender lets the writer drain accepted
events and end cleanly. Each event carries a measured timestamp because a
sample number alone cannot reveal delays or gaps in observation.