Skip to content
Lesson 6.10Windows Processes and Callable Interfaces

Debug Events and Software Breakpoints

Understand how Windows debug events, breakpoint bytes, and thread state let a debugger pause and resume a real instruction.

Lesson 6.10
Read or listenPrint chapter or book
About 24 minutes

Keyboard shortcuts

?
Open this guide
Alt + B
Show or hide the lesson list
Alt + O
Show or fold the lesson outline
Alt + N
Open or close notes
Alt + H
Toggle semantic colours
Alt + D
Toggle page brightness
Alt + C
Toggle code brightness
← β†’
Move through a focused code trace
Home End
First or final step in a focused trace
Escape
Close this guide or an open panel

Reader shortcuts pause while you type in a text field.

Cheatsheet for Lesson 6.10Key ideas, reference and questions

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 int3 breakpoint fires, why restore the original byte, rewind EIP, set the trap flag, and continue for one instruction? A: To let the real instruction run once before putting 0xCC back
Download cheatsheet (.md)

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.

WaitForDebugEvent
Decode event
Inspect or update state
ContinueDebugEvent

If the debugger forgets to continue an event, the target remains frozen.

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.

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:

  1. read the original byte;
  2. store it;
  3. write 0xCC;
  4. flush the instruction cache;
  5. mark it armed.
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.
  1. 1 Armed: 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.

  2. 2 The 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.

  3. 3 Restore 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.

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

A basic debugger view of the target while it is paused.

When a software breakpoint installed by this debugger fires:

  1. suspend the relevant flow through the debug event;
  2. restore the original byte;
  3. move the instruction pointer back by one byte;
  4. enable the CPU trap flag for one instruction;
  5. 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 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
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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.

  9. 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.

  10. 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.

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.

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.

Windows calls include:

  • DebugActiveProcess or 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.

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.

Do not start with stepping, symbols, and a GUI. First:

  1. launch a tiny practice target under the debugger;
  2. print process and thread events;
  3. continue every event;
  4. exit cleanly when the target exits.

Then add one breakpoint at a known function in the course target.

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.

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.

Windows VM

Build the workspace for 32-bit Windows, start Wesnoth 1.14.9 in a local match, and run:

Terminal window
.\target\i686-pc-windows-msvc\release\wesnoth_debugger.exe

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.

wesnoth_debugger.rs

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

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.");
}
Knowledge check

Continue every event

What must a minimal Windows debugger do after processing a debug event?

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.

Chapter 6: 11 lessons

This book grew out of the original Game Hacking Academy by attilathedud and has been rewritten as a beginner-first course. Like the original repository, its source is published under the Apache License 2.0.