Debug Events and Software Breakpoints
Understand how Windows debug events, breakpoint bytes, and thread state let a debugger pause and resume a real instruction.
Cheatsheet for Lesson 6.10
A one-page summary of βDebug Events and Software Breakpointsβ you can keep beside your notes. Plain Markdown.
Lesson 6.10 β Debug Events and Software Breakpoints: cheatsheet
- Remember: A breakpoint owns a temporary code change; its record must also make restoring that exact byte possible.
- Also useful: Compare debugger runs with ordinary runs, like comparing a slowed replay with real-time play: timing differences may come from the observer.
Key terms
- instrumentation bias: That effect is called instrumentation bias: the tool used to measure a program changes what the program does.
Formulas and patterns
instruction = 0x009B4D00;side record = EAX;gold address = EAX + 0x4;income delta = EDX;gold before = read_u32(EAX + 0x4)
Review
- Q: What must a minimal Windows debugger do after processing a debug event? A: Continue it with a suitable status and close any handles that the event owns
- Q: After an
int3breakpoint fires, why restore the original byte, rewindEIP, set the trap flag, and continue for one instruction? A: To let the real instruction run once before putting0xCCback
A debugger is an event loop
Section titled βA debugger is an event loopβ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.
Model events immediately
Section titled βModel events immediatelyβConvert Windows structs into owned typed data at the FFI boundary:
#[derive(Debug)]enum DebugEvent { ProcessCreated { process_id: u32, thread_id: u32 }, ThreadCreated { thread_id: u32 }, ModuleLoaded { base: usize }, Exception { thread_id: u32, code: u32, address: usize, first_chance: bool, }, ProcessExited { code: u32 }, Other,}Close or transfer ownership of any handles contained in the raw Windows event. Do not leak them during a long session.
Own breakpoint state
Section titled βOwn breakpoint stateβ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:
#[derive(Debug)]struct SoftwareBreakpoint { address: usize, original_byte: u8, armed: bool,}
struct Debugger { process: Process, breakpoints: std::collections::HashMap<usize, SoftwareBreakpoint>,}Setting a breakpoint:
- read the original byte;
- store it;
- write
0xCC; - flush the instruction cache;
- mark it armed.
1 Armed: CC over the first byte
Code at 0x00A31000
Before29β¦100046β¦100130β¦1002ArmedCCβ¦100046β¦100130β¦1002Breakpoint map
0x00A31000original byte 0x29phase: ArmedThe debugger read 0x29, saved it in its map, then wrote 0xCC. The other two bytes do not change.
2 The CPU executes CC
Code at 0x00A31000
BytesCCβ¦100046β¦100130β¦1002ip = 0x00A31001ipβ¦1001Breakpoint map
0x00A31000original byte 0x29phase: Armedint3 is a whole one-byte instruction, so once it has run, ip already points one byte past it, and Windows reports EXCEPTION_BREAKPOINT.
3 Restore 0x29, move ip back one
Code at 0x00A31000
Bytes29β¦100046β¦100130β¦1002ip = 0x00A31000ipβ¦1000Breakpoint map
0x00A31000original byte 0x29phase: SteppingOriginal0x00A31001 β 1 = 0x00A31000, so ip points at the whole real instruction again, ready to run once.
The instruction sub dword ptr [esi+30h], eax is 29 46 30 at 0x00A31000. The debugger owns a breakpoint on its first byte.

Handle the int3 pause
Section titled βHandle the int3 pauseβ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.
enum BreakpointPhase { // π΄ 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 gameClear 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.
addr = 0x00A31000; orig = 0x29; phase = Armed; exception_addr = 0x00A31000; ip = 0x00A31001.
Memory: β¦1000 = CC; β¦1001 = 46 (ip points here); β¦1002 = 30.
Step 4, line 4. 0x00A31000 is in the map, so this breakpoint is ours and the debugger handles it.
addr = 0x00A31000; orig = 0x29; phase = Armed; exception_addr = 0x00A31000; ip = 0x00A31001.
Memory: β¦1000 = CC; β¦1001 = 46 (ip points here); β¦1002 = 30.
Step 5, line 5. Restore the original byte, 0x29, so the real instruction is whole again. The breakpoint is disarmed for exactly this one run of it.
addr = 0x00A31000; orig = 0x29; phase = SteppingOriginal; exception_addr = 0x00A31000; ip = 0x00A31001.
Memory: β¦1000 = 29; β¦1001 = 46 (ip points here); β¦1002 = 30.
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.
addr = 0x00A31000; orig = 0x29; phase = SteppingOriginal; exception_addr = 0x00A31000; ip = 0x00A31000.
Memory: β¦1000 = 29 (ip points here); β¦1001 = 46; β¦1002 = 30.
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.
addr = 0x00A31000; orig = 0x29; phase = SteppingOriginal; exception_addr = 0x00A31000; ip = 0x00A31000; trap_flag = 1.
Memory: β¦1000 = 29 (ip points here); β¦1001 = 46; β¦1002 = 30.
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.
addr = 0x00A31000; orig = 0x29; phase = SteppingOriginal; exception_addr = 0x00A31000; ip = 0x00A31003; trap_flag = 1.
Memory: β¦1000 = 29; β¦1001 = 46; β¦1002 = 30.
Step 9, line 9. Put 0xCC back so the breakpoint fires next time too. This second step is why a breakpoint has state and is not just a changed byte.
addr = 0x00A31000; orig = 0x29; phase = Armed; exception_addr = 0x00A31000; ip = 0x00A31003; trap_flag = 1.
Memory: β¦1000 = CC; β¦1001 = 46; β¦1002 = 30.
Step 10, line 10. 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.
addr = 0x00A31000; orig = 0x29; phase = Armed; exception_addr = 0x00A31000; ip = 0x00A31003; trap_flag = 0.
Memory: β¦1000 = CC; β¦1001 = 46; β¦1002 = 30.
Distinguish your exceptions
Section titled βDistinguish your exceptionsβNot every breakpoint exception belongs to your debugger. Windows and the target may use breakpoints internally.
Check:
- exception code;
- address;
- whether the address is in your breakpoint map;
- first-chance status;
- current phase and thread.
Pass unhandled exceptions onward with the correct continue status.
Debugging changes what you observe
Section titled βDebugging changes what you observeβ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.
Keep FFI wrappers narrow
Section titled βKeep FFI wrappers narrowβWindows calls include:
DebugActiveProcessor process creation with debug flags;WaitForDebugEvent;ContinueDebugEvent;- thread context get/set;
- process memory read/write;
FlushInstructionCache;- handle cleanup.
Each wrapper should document handle ownership, pointer validity, buffer size, and structure initialization.
Guarantee continuation
Section titled βGuarantee continuationβUse a guard so an early parsing error does not accidentally leave an event uncontinued:
struct ContinueGuard { process_id: u32, thread_id: u32, status: NTSTATUS, active: bool,}
impl ContinueGuard { fn continue_now(mut self) -> windows::core::Result<()> { // π‘οΈ SAFETY: these IDs came from the one pending DEBUG_EVENT, and this // guard owns the responsibility to continue it exactly once. unsafe { ContinueDebugEvent(self.process_id, self.thread_id, self.status)?; } // β
Clear responsibility only after Windows accepted the continue. // If the call errors, Drop still attempts to release the pending event. self.active = false; Ok(()) }}
impl Drop for ContinueGuard { fn drop(&mut self) { if self.active { // π§Ή SAFETY: best-effort release of the still-pending event. Using // NOT_HANDLED avoids swallowing a target exception we did not own. let _ = unsafe { ContinueDebugEvent( self.process_id, self.thread_id, DBG_EXCEPTION_NOT_HANDLED, ) }; } }}Its Drop implementation can make a best-effort continue and log failures.
First milestone
Section titled βFirst milestoneβDo not start with stepping, symbols, and a GUI. First:
- launch a tiny practice target under the debugger;
- print process and thread events;
- continue every event;
- exit cleanly when the target exits.
Then add one breakpoint at a known function in the course target.
Attach it to Wesnoth
Section titled βAttach it to Wesnothβ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], edxStart a local match with income and end the turn. When the event fires, copy the thread context and log:
instruction = 0x009B4D00side record = EAXgold address = EAX + 0x4income delta = EDXgold 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.
Cleanup
Section titled βCleanupβOn detach or exit:
- restore every armed breakpoint;
- flush the instruction cache;
- continue any pending event;
- detach if the target is still running;
- close every process and thread handle.
A debugger that pauses correctly but cannot cleanly detach is not finished.
Run the completed debugger
Section titled βRun the completed debuggerβBuild the workspace for 32-bit Windows, start Wesnoth 1.14.9 in a local match, and run:
.\target\i686-pc-windows-msvc\release\wesnoth_debugger.exeEnd 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.
wesnoth_debugger.rs
Show the codeHide the code
#[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.");
}Continue every event
What must a minimal Windows debugger do after processing a debug event?
Debugging is a protocol with the operating system: every event needs correct handling, continuation, and cleanup. A target whose event is never continued stays frozen.
Finished Lesson 6.10?
Mark it done and the lesson list shows a beside it. A chapter whose lessons are all done is marked done too. Progress is saved in this browser only.
Plain Markdown, saved in this browser.
This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.