Skip to lesson
Game Hacking Academy
Preparing lesson text…
Exit reader mode
Voice and speed

Preparing read-aloud controls…

Lesson 6.11 Listening edition

Sampling Game State Under Load

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

About 35 minutes

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.

Section: The recorder must not stall the game.

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:

Lesson diagram

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

In brief: Split recording in two: a fast sampler captures facts, and a separate writer saves them, so a slow disk never stalls the game.

Section: 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:

Lesson table

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

Section: Define the smallest useful event.

This recorder needs only two fields:

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

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:

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

Prefer module-relative addresses in durable logs. A live address alone loses meaning after A S L R, address space layout randomization, chooses a new module base.

Section: Backpressure is a policy.

The channel has fixed capacity:

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

A normal blocking send would wait when all 128 slots are occupied. The producer uses try_send instead:

Lesson diagram

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.

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

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 milliseconds 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 milliseconds divided by 50 milliseconds equals 200 samples arrive. The first 128 fill the empty queue; the other 200 minus 128 equals 72 get Full, and each adds 1 to dropped.

A stalled writer and a bounded queue.

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:

Lesson table

Backpressure is not one library call. It is the decision a system makes when producers outrun consumers.

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

Section: Let channel closure be the stop message.

The sink owns an optional sender and writer thread. Finishing removes the final sender:

Labelled memory cells
Labelled memory cells

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

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.

Narration: Flushing hands bytes to the operating system, not to the disk platter. After a sudden power cut, the last few lines of a log can still be missing.

Drop repeats the close-and-join path as a fallback. Explicit finish is still better because it returns errors and counts to main.

Section: Version the file before it contains history.

The log begins:

Code example. The code’s comments explain: target=wesnoth.exe pid=4120 address=this value

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.

Section: 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:

the header says how to interpret the run;

the detail rows contain repeated observations;

the trailer says how the run ended.

For example:

Labelled memory cells

Code example. The code’s comments explain: target=wesnoth.exe build=1.14.9 sample_ms=50 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.

Section: Sampling is an observation, not perfect time.

The loop sleeps for 50 milliseconds, 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.

Section: Complete buildable recorder.

Additional detail: Complete lab source: gold_telemetry.rs.

Code example. The code’s comments explain: ⚠️ Never block the observation loop behind slow disk I/O. 🧹 Closing the final sender lets the writer drain queued events, flush the file, and leave its receive loop without a magic sentinel. 🔒 Telemetry observes only; the process handle has no write rights.

The same source is available as gold_telemetry.rs.

Section: Build and run it.

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

Terminal window

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

Spend or gain gold during the window. Inspect the file:

Terminal window

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

Or let PowerShell parse only the data rows:

Terminal window

Code example. The surrounding prose explains this example; the full lesson contains the exact code.

The tool should report roughly 300 accepted samples for 15 seconds (15 seconds times 20 samples per second equals 300) and zero drops on a normal disk. A nonzero drop count is not hidden; it means the writer or chosen rate needs attention.

Section: Connect this design to the call logger.

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.

Section: Test the pipeline without a game.

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.

Section: What the recording can claim.

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.