Skip to content
Lesson 6.11Windows Processes and Callable Interfaces

Sampling Game State Under Load

Record changing game state without delaying observation, hiding dropped samples, or losing the log's meaning.

Lesson 6.11
Read or listenPrint chapter or book
About 35 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.11Key ideas, reference and questions

A one-page summary of “Sampling Game State Under Load” you can keep beside your notes. Plain Markdown.

Lesson 6.11 — Sampling Game State Under Load: cheatsheet

  • Remember: Split recording in two: a fast sampler captures facts, and a separate writer saves them, so a slow disk never stalls the game.
  • Also useful: Think of a tap over a sink: if water arrives faster than the drain empties, you slow the tap, spill some, or stop. A full queue forces that choice.

Key terms

  • fault: A fault is one thing going wrong: the disk pauses, a queue fills, the target closes, or the writer thread exits.
  • failure: 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.
  • finish: This ordering proves that a successful finish means all accepted events reached the operating-system file interface.

Review

  • Q: What should a debugger copy into an asynchronous telemetry event? A: Small owned values such as timestamps, thread IDs, and relative addresses
  • Q: A debugger call logger's file writer cannot keep up, and the bounded queue is full. Which policy best protects the paused target while keeping the loss visible? A: Send without blocking, bump a dropped counter, and stop if loss passes a limit
Download cheatsheet (.md)

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:

  1. capture a small fact while it is fresh;
  2. 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:

try_send
Read-only game sampler
Bounded queue: 128
Writer thread
Buffered, versioned CSV
Dropped counter

The producer never waits for disk. The consumer owns the file.

Record overload and failure instead of hiding them

Section titled “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
  1. Step 1, line 1. Start with the selected inputs. Follow the highlighted operation and the changing state below.

    arrivals = 5; drain = 2.

  2. Step 2, line 2. Capture does not wait for the writer.

    arrivals = 5; drain = 2; queued = 0; dropped = 0; written = 0.

  3. Step 3, line 3. Accept 4 events into the available slots.

    arrivals = 5; drain = 2; queued = 4; dropped = 0; written = 0.

  4. Step 4, line 4. Record 1 overflow event(s) as dropped.

    arrivals = 5; drain = 2; queued = 4; dropped = 1; written = 0.

  5. Step 5, line 5. The writer drains 2 accepted events.

    arrivals = 5; drain = 2; queued = 2; dropped = 1; written = 2.

  6. Step 6, line 6. Result: 2 written, 2 pending, 1 dropped. Those counts account for every arrival.

    arrivals = 5; drain = 2; queued = 2; dropped = 1; written = 2.

This recorder needs only two fields:

#[derive(Clone, Copy, Debug)]
struct GoldEvent {
elapsed_ms: u128,
value: u32,
}

A compact event is cheap to copy and hard to misuse. It does not contain a raw process handle, borrowed buffer, remote pointer, or String allocation.

For a call logger, the same shape could include:

struct CallEvent {
elapsed_micros: u128,
thread_id: u32,
call_site_rva: u32,
target_rva: u32,
}

Prefer module-relative addresses in durable logs. A live address alone loses meaning after ASLR chooses a new module base.

The channel has fixed capacity:

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:

Ok(())
Full
Disconnected
sender.try_send(event)
result?
continue sampling
dropped += 1
bail: writer stopped early

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.

match sender.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
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();
let written = 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.

Version the file before it contains history

Section titled “Version the file before it contains history”

The log begins:

# gha-gold-telemetry v1
# target=wesnoth.exe pid=4120 address=0x12345678
elapsed_ms,gold
0,100
50,100
100,75

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.

Design an explicit end record for a later format

Section titled “Design an explicit end record for a later format”

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:

  1. the header says how to interpret the run;
  2. the detail rows contain repeated observations;
  3. 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.

Sampling is an observation, not perfect time

Section titled “Sampling is an observation, not perfect time”

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.

Complete lab source: gold_telemetry.rs

gold_telemetry.rs

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

#[cfg(windows)]
mod app {
use std::{
fs::File,
io::{self, BufWriter, Write},
path::PathBuf,
sync::mpsc::{self, SyncSender, TrySendError},
thread::{self, JoinHandle},
time::{Duration, Instant},
};
use anyhow::Context;
use gha_windows_labs::Process;
const PLAYER_ROOT: usize = 0x017E_ED18;
const GAME_OFFSET: usize = 0x0A90;
const GOLD_OFFSET: usize = 0x0004;
const QUEUE_CAPACITY: usize = 128;
#[derive(Clone, Copy, Debug)]
struct GoldEvent {
elapsed_ms: u128,
value: u32,
}
struct TelemetrySink {
sender: Option<SyncSender<GoldEvent>>,
writer: Option<JoinHandle<io::Result<usize>>>,
dropped: usize,
}
impl TelemetrySink {
fn start(path: PathBuf, target: String) -> anyhow::Result<Self> {
let file = File::create(&path)
.with_context(|| format!("could not create {}", path.display()))?;
let (sender, receiver) = mpsc::sync_channel::<GoldEvent>(QUEUE_CAPACITY);
let writer = thread::Builder::new()
.name("gha-telemetry-writer".to_owned())
.spawn(move || {
let mut output = BufWriter::new(file);
writeln!(output, "# gha-gold-telemetry v1")?;
writeln!(output, "# target={target}")?;
writeln!(output, "elapsed_ms,gold")?;
let mut written = 0_usize;
while let Ok(event) = receiver.recv() {
writeln!(output, "{},{}", event.elapsed_ms, event.value)?;
written += 1;
}
output.flush()?;
Ok(written)
})?;
Ok(Self {
sender: Some(sender),
writer: Some(writer),
dropped: 0,
})
}
fn record(&mut self, event: GoldEvent) -> anyhow::Result<()> {
let sender = self.sender.as_ref().context("telemetry sink is closed")?;
match sender.try_send(event) {
Ok(()) => Ok(()),
// ⚠️ Never block the observation loop behind slow disk I/O.
Err(TrySendError::Full(_)) => {
self.dropped += 1;
Ok(())
}
Err(TrySendError::Disconnected(_)) => {
anyhow::bail!("telemetry writer stopped early")
}
}
}
fn finish(mut self) -> anyhow::Result<(usize, usize)> {
// 🧹 Closing the final sender lets the writer drain queued events,
// flush the file, and leave its receive loop without a magic sentinel.
self.sender.take();
let writer = self.writer.take().context("telemetry writer is missing")?;
let written = writer
.join()
.map_err(|_| anyhow::anyhow!("telemetry writer panicked"))??;
Ok((written, self.dropped))
}
}
impl Drop for TelemetrySink {
fn drop(&mut self) {
self.sender.take();
if let Some(writer) = self.writer.take() {
let _ = writer.join();
}
}
}
fn gold_address(process: &Process) -> anyhow::Result<usize> {
let player = process.read_u32(PLAYER_ROOT)? as usize;
anyhow::ensure!(player != 0, "start a local Wesnoth match first");
let side_pointer = player
.checked_add(GAME_OFFSET)
.context("player + game offset overflowed")?;
let side = process.read_u32(side_pointer)? as usize;
anyhow::ensure!(side != 0, "current side pointer is null");
side.checked_add(GOLD_OFFSET)
.context("side + gold offset overflowed")
}
pub fn run() -> anyhow::Result<()> {
let mut arguments = std::env::args().skip(1);
let seconds = arguments
.next()
.map(|text| text.parse::<u64>())
.transpose()
.context("duration must be whole seconds")?
.unwrap_or(10);
anyhow::ensure!(
(1..=300).contains(&seconds),
"duration must be 1..=300 seconds"
);
let path = arguments
.next()
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("wesnoth-gold.csv"));
anyhow::ensure!(
arguments.next().is_none(),
"usage: gold_telemetry [seconds] [output.csv]"
);
// 🔒 Telemetry observes only; the process handle has no write rights.
let process = Process::open_by_name("wesnoth.exe", false)?;
anyhow::ensure!(
process.is_32_bit()?,
"this profile requires 32-bit Wesnoth 1.14.9"
);
let address = gold_address(&process)?;
let target = format!(
"{} pid={} address={address:#010x}",
process.name(),
process.id()
);
let mut sink = TelemetrySink::start(path.clone(), target)?;
let started = Instant::now();
let duration = Duration::from_secs(seconds);
while started.elapsed() < duration {
let value = process.read_u32(address)?;
sink.record(GoldEvent {
elapsed_ms: started.elapsed().as_millis(),
value,
})?;
thread::sleep(Duration::from_millis(50));
}
let (written, dropped) = sink.finish()?;
println!(
"Wrote {written} samples to {} ({dropped} dropped).",
path.display()
);
Ok(())
}
}
#[cfg(windows)]
fn main() -> anyhow::Result<()> {
app::run()
}
#[cfg(not(windows))]
fn main() {
eprintln!("This live telemetry recorder must run on Windows.");
}

The same source is available as gold_telemetry.rs.

Windows VM

Start a local Wesnoth 1.14.9 match, then record 15 seconds:

Terminal window
cd windows-labs
cargo build --release --target i686-pc-windows-msvc --bin gold_telemetry
.\target\i686-pc-windows-msvc\release\gold_telemetry.exe 15 wesnoth-gold.csv

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:

  1. copy registers and module-relative addresses into a compact CallEvent;
  2. restore/re-arm the breakpoint;
  3. call try_send;
  4. continue the debug event;
  5. 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.

A useful test can start a queue of capacity one, hold the receiver, and send three events:

  • the first is accepted;
  • later events become counted drops;
  • the producer does not block;
  • dropping the sender lets the receiver finish;
  • the written count plus dropped count equals the attempted count.

Also simulate a writer that exits early. Disconnected must become an error rather than another drop.

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.

Knowledge check

Move only owned facts

What should a debugger copy into an asynchronous telemetry event?

Finished Lesson 6.11?

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.