Skip to content
Lesson 5.10Executable Maps, Instruction References, and Scans

Event Tracing for Windows

Understand how ETW records time-stamped events around a running game and how to read a short performance trace.

Lesson 5.10
Read or listenPrint chapter or book
About 28 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 5.10Key ideas, reference and questions

A one-page summary of “Event Tracing for Windows” you can keep beside your notes. Plain Markdown.

Lesson 5.10 — Event Tracing for Windows: cheatsheet

  • Remember: Absence from a trace is meaningful only after checking what was recorded and whether any events were lost.
  • Also useful: Think of a recording chain: providers produce observations, a controller starts the session, buffers carry events, and a consumer interprets the saved result.

Key terms

  • run_wpr: run_wpr is the command wrapper from the complete helper above.

Formulas and patterns

  • providers → ETW session buffers → gha-game.etl → Windows Performance Analyzer

Review

  • Q: What makes ETW useful for investigating a short game-performance problem? A: Timestamped events from system and application providers can be lined up on one timeline
  • Q: Why might you choose ETW over a breakpoint debugger for a stutter? A: Stopping a thread changes timing, while ETW records events with much less disturbance
Download cheatsheet (.md)

A debugger stops or single-steps a thread. That is perfect when you need the exact instruction and registers, but stopping changes timing. A game may stutter, a network timeout may expire, or another thread may continue while the selected thread is paused.

Event Tracing for Windows, shortened to ETW, records structured events with much less disturbance. Windows and applications can report process, thread, image-load, CPU-sampling, disk, networking, and other activity. The exact events depend on which providers and keywords a trace enables.

ETW is for questions such as:

  • Which thread used most of the CPU during a slow frame?
  • Which DLLs loaded while the game started?
  • Did a long pause line up with disk activity?
  • Was work happening on one thread or many?

ETW does not tell you the meaning of an arbitrary gold field. Use the tool that matches the question.

ETW is a producer–buffer–consumer system. Providers describe events, a session controls which streams are active, and consumers turn the ordered records into a model:

Event providers
Typed timestamped events
Trace session controller
Kernel buffers
ETL event stream
Trace consumer
Timeline and aggregates

Buffers separate fast event production from slower analysis, but they are finite. A trace can lose records when production outruns collection, so “missing from this capture” does not automatically mean “never happened.”

A full session buffer cannot take the fourth recordThree slots, four arrivals. The fourth is stopped at the buffer and is not recorded.
  1. 1 Records 1, 2 and 3 fill it

    Session buffer

    Slots
    record 1
    record 2
    record 3

    The session buffer has three slots, and each event fills one.

  2. 2 Event 4 arrives: no free slot

    Arrival

    Fourth event
    event 4dropped

    Session buffer

    Slots
    record 1
    record 2
    record 3

    The buffer is full, so the new record is dropped before it is stored.

  3. 3 Draining later leaves a gap

    Session buffer

    Slots
    free
    free
    free

    What the log can say

    Log
    records 1 to 3 exist
    event 4: unknownmay have happened

    Reading frees the slots, but event 4 is gone. The log shows a gap, not proof that the event never happened.

Three slots, four arrivals. The fourth is stopped at the buffer and is not recorded.

The names sound formal, but the jobs are simple:

  1. A provider creates events. Windows, a driver, or an application can be a provider.
  2. A session receives selected events into buffers and possibly an .etl file.
  3. A controller starts and stops the session and selects providers.
  4. A consumer reads the events live or from the saved file.

Windows Performance Recorder, or WPR, is a controller. Windows Performance Analyzer, or WPA, is a graphical consumer.

providers → ETW session buffers → gha-game.etl → Windows Performance Analyzer

An event normally includes a timestamp, provider identity, event identity, process/thread context, and provider-defined fields. A keyword is a bitmask used to select groups of related events from a provider.

A general performance profile can include activity from the whole machine, not just the game. That may reveal filenames, process names, or timing from unrelated applications.

Use a clean disposable Windows VM, close unrelated programs, record only long enough to reproduce the game action, and do not publish the .etl file without reviewing it. The course helper refuses to overwrite an existing trace.

The helper launches wpr.exe directly with std::process::Command. It does not build a command string for PowerShell or cmd.exe, so the output path is passed as one argument rather than interpreted as shell code.

yes
no
yes
no
run_wpr(-start, GeneralProfile, -filemode)
print prompt,
read_line waits for Enter
read_line failed?
run_wpr(-cancel)
return Err
run_wpr(-stop, output)
stop failed?
run_wpr(-cancel)
return the stop error
print Saved output
Complete lab source: etw_capture.rs

etw_capture.rs

windows-labs/src/bin/ · Rust, 53 lines

Show the codeHide the code
#[cfg(windows)]
fn main() -> anyhow::Result<()> {
    use std::{io, path::PathBuf, process::Command};

    use anyhow::{Context, ensure};

    fn run_wpr(arguments: &[&std::ffi::OsStr]) -> anyhow::Result<()> {
        let status = Command::new("wpr.exe")
            .args(arguments)
            .status()
            .context("could not start wpr.exe")?;
        ensure!(status.success(), "wpr.exe returned {status}");
        Ok(())
    }

    let output = std::env::args_os()
        .nth(1)
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("gha-game.etl"));
    ensure!(
        !output.exists(),
        "refusing to overwrite {}",
        output.display()
    );

    run_wpr(&[
        std::ffi::OsStr::new("-start"),
        std::ffi::OsStr::new("GeneralProfile"),
        std::ffi::OsStr::new("-filemode"),
    ])?;

    println!("ETW recording started.");
    println!("Launch or exercise the game now, then press Enter to stop.");
    let mut answer = String::new();
    if let Err(error) = io::stdin().read_line(&mut answer) {
        let _ = run_wpr(&[std::ffi::OsStr::new("-cancel")]);
        return Err(error).context("could not read Enter; the trace was cancelled");
    }

    let stop_result = run_wpr(&[std::ffi::OsStr::new("-stop"), output.as_os_str()]);
    if stop_result.is_err() {
        let _ = run_wpr(&[std::ffi::OsStr::new("-cancel")]);
    }
    stop_result?;
    println!("Saved {}", output.display());
    println!("Open the ETL file in Windows Performance Analyzer.");
    Ok(())
}

#[cfg(not(windows))]
fn main() {
    eprintln!("This ETW capture helper must run on Windows.");
}
#[cfg(windows)]
fn main() -> anyhow::Result<()> {
use std::{io, path::PathBuf, process::Command};
use anyhow::{Context, ensure};
fn run_wpr(arguments: &[&std::ffi::OsStr]) -> anyhow::Result<()> {
let status = Command::new("wpr.exe")
.args(arguments)
.status()
.context("could not start wpr.exe")?;
ensure!(status.success(), "wpr.exe returned {status}");
Ok(())
}
let output = std::env::args_os()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("gha-game.etl"));
ensure!(
!output.exists(),
"refusing to overwrite {}",
output.display()
);
run_wpr(&[
std::ffi::OsStr::new("-start"),
std::ffi::OsStr::new("GeneralProfile"),
std::ffi::OsStr::new("-filemode"),
])?;
println!("ETW recording started.");
println!("Exercise the game, then press Enter to stop.");
let mut answer = String::new();
if let Err(error) = io::stdin().read_line(&mut answer) {
let _ = run_wpr(&[std::ffi::OsStr::new("-cancel")]);
return Err(error)
.context("could not read Enter; trace cancellation requested");
}
let stop_result = run_wpr(&[
std::ffi::OsStr::new("-stop"),
output.as_os_str(),
]);
if stop_result.is_err() {
let _ = run_wpr(&[std::ffi::OsStr::new("-cancel")]);
}
stop_result?;
println!("Saved {}", output.display());
println!("Open the ETL file in Windows Performance Analyzer.");
Ok(())
}
#[cfg(not(windows))]
fn main() {
eprintln!("This ETW capture helper must run on Windows.");
}

Starting a trace creates system state that outlives the first wpr.exe process. If reading from the terminal fails or stopping the session fails, the helper asks WPR to cancel.

The original helper starts WPR, waits for Enter, then stops the recording. If the terminal read fails, ? returns early and the recording session remains active. run_wpr is the command wrapper from the complete helper above.

Before: return early without cancelling a trace
fn record_trace(output: &Path) -> anyhow::Result<()> {
run_wpr(&[OsStr::new("-start"), OsStr::new("GeneralProfile")])?;
let mut answer = String::new();
io::stdin().read_line(&mut answer)?; // An error skips the stop command.
run_wpr(&[OsStr::new("-stop"), output.as_os_str()])?;
Ok(())
}

Handle that early-return boundary explicitly and request best-effort cleanup before returning the original input error. The same cleanup request is made if stopping fails. Cancellation itself can fail, so the returned error says that it was requested rather than claiming it succeeded.

Improved code

fn record_trace(output: &Path) -> anyhow::Result<()> {
run_wpr(&[OsStr::new("-start"), OsStr::new("GeneralProfile")])?;
let mut answer = String::new();
if let Err(error) = io::stdin().read_line(&mut answer) {
let _ = run_wpr(&[OsStr::new("-cancel")]);
return Err(error).context("could not read Enter; trace cancellation requested");
}
let stop_result = run_wpr(&[OsStr::new("-stop"), output.as_os_str()]);
if stop_result.is_err() {
let _ = run_wpr(&[OsStr::new("-cancel")]);
}
stop_result?;
Ok(())
}

Show the diff

fn record_trace(output: &Path) -> anyhow::Result<()> {
run_wpr(&[OsStr::new("-start"), OsStr::new("GeneralProfile")])?;
let mut answer = String::new();
io::stdin().read_line(&mut answer)?; // An error skips the stop command.
if let Err(error) = io::stdin().read_line(&mut answer) {
let _ = run_wpr(&[OsStr::new("-cancel")]);
return Err(error).context("could not read Enter; trace cancellation requested");
}
run_wpr(&[OsStr::new("-stop"), output.as_os_str()])?;
let stop_result = run_wpr(&[OsStr::new("-stop"), output.as_os_str()]);
if stop_result.is_err() {
let _ = run_wpr(&[OsStr::new("-cancel")]);
}
stop_result?;
Ok(())
}

Takeaway: every operation that starts persistent state needs a cleanup path for later failures.

An error should not leave a forgotten recorder running. Cleanup is part of correctness, just like restoring a breakpoint byte or closing a process handle.

The ignored result on -cancel is deliberate: the original error remains the most useful explanation, and cancellation is a best-effort recovery after something already went wrong.

Starting a trace, and cleaning up when something fails. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

run_wpr("-start", "GeneralProfile")  # recording begins
print("Press Enter to stop.")
if read_line() fails:
    run_wpr("-cancel")  # best effort
    return Err("trace was cancelled")
stop = run_wpr("-stop", "gha-game.etl")
if stop fails: run_wpr("-cancel")
print("Saved gha-game.etl")

The helper reports the saved file. Open it in Windows Performance Analyzer. Every path above ended with the recorder not running, and cleanup is part of correctness.

Final values: recorder = stopped; etl_file = gha-game.etl saved; read_line = ok; stop = ok.

Read every step of the lesson’s example
  1. Step 1, line 1. wpr.exe is asked to start recording with the GeneralProfile. WPR is a separate program that keeps recording after this command returns, so the recorder is now running as system state.

    recorder = running; etl_file = none yet.

  2. Step 2, line 2. The helper tells you to exercise the game and press Enter. The recording runs while you do.

    recorder = running; etl_file = none yet.

  3. Step 3, line 3. Reading Enter works. You pressed Enter, so the helper moves on to stop the recording.

    recorder = running; etl_file = none yet; read_line = ok.

  4. Step 4, line 6. wpr.exe -stop writes everything captured to gha-game.etl and ends the recording.

    recorder = stopped; etl_file = gha-game.etl saved; read_line = ok; stop = ok.

  5. Step 5, line 8. The helper reports the saved file. Open it in Windows Performance Analyzer. Every path above ended with the recorder not running, and cleanup is part of correctness.

    recorder = stopped; etl_file = gha-game.etl saved; read_line = ok; stop = ok.

Build the helper:

Terminal window
cd windows-labs
cargo build --release --target i686-pc-windows-msvc --bin etw_capture

In a clean VM, start the capture from an administrator terminal if WPR requests it:

Terminal window
.\target\i686-pc-windows-msvc\release\etw_capture.exe `
.\wesnoth-turn.etl

Then:

  1. start Wesnoth 1.14.9;
  2. load a small local scenario;
  3. end exactly one turn;
  4. return to the terminal and press Enter;
  5. open wesnoth-turn.etl in WPA.

Use the same short procedure for AssaultCube startup or one Urban Terror map load. Repeatability makes two traces comparable.

Start with broad questions before zooming into thousands of events:

  • In Process Lifetimes, confirm the expected executable and PID.
  • In CPU Usage (Sampled), filter to that process and find busy threads.
  • In Image Load, inspect DLLs loaded during startup.
  • In Disk Usage, look for long file reads that overlap a pause.
  • In the timeline, select only the few seconds around your action.
Process Lifetimes:
confirm the executable and PID
CPU Usage (Sampled):
find busy threads
Image Load:
inspect DLLs at startup
Disk Usage:
long reads overlapping a pause
Timeline:
zoom to the few seconds
around your action

A stack may show system DLLs, game functions, or unresolved addresses. Symbols translate addresses into names. Without matching symbols, the timing is still real, but the function label may remain a module plus offset.

ETW writes through buffers. If a provider produces events faster than the session can deliver them, events can be lost. A controller can report buffer and event-loss statistics.

buffer full:
events lost, not recorded
providers producing events
kernel buffers
(finite capacity)
trace consumer
gap in the capture

That means “I did not see an event” is weaker than “the event could not have happened.” Check whether the chosen profile included the relevant provider and whether the session lost data.

This chapter starts, stops, and consumes an ordinary diagnostic session. It does not disable providers, patch tracing APIs, alter another process’s telemetry, or hide module loads. Those actions would corrupt the very trace this lab is trying to read.

The complete helper is etw_capture.rs.

We now have three distinct observations: the scanner locates candidate data, the debugger pauses at exact instructions, and ETW summarizes timed system activity. The optional next lesson asks how to scan several captured regions without letting concurrency change the result. Chapter 6 then uses verified sites while building a small library inside an explicitly supported game process.

References: Microsoft: About Event Tracing, Windows Performance Recorder, and Windows Performance Analyzer.

Knowledge check

Correlate events over time

What makes ETW useful for investigating a short game-performance problem?

Finished Lesson 5.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 5: 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.