The engine owns and updates the match; our tool owns only the values it copies. A gold scan gives us one value for one player. In a strategy match, that is rarely enough: several sides may have the same gold while their identities and income differ. An observer has to turn verified fields into a useful view of the whole match without pretending it owns the game’s changing state.
The remaining lessons grow that observer into a model of game behavior. Snapshots and tile grids explain what the world contains; coordinates describe where it is; feedback, paths, and events explain how a tool reacts. A bounded recorder makes observations useful over time. Finally, an N P C, non player character, shows how the game itself senses and chooses.
Section: What an observer does.
An observer reads state and presents it without trying to become the game’s state owner. It separates three layers:
Code example. The surrounding prose explains this example; the full lesson contains the exact code.
The remote process may change immediately after the read. PlayerSnapshot is therefore a copy of the values at one moment, not a live reference into Wesnoth. This distinction keeps the rest of the program safe and makes timestamps and failed reads honest.
In brief: A snapshot is a copy taken at one moment. The tool works on its copy because the game keeps changing underneath it.
📸 Snapshot idea: a snapshot is a photograph, not a live window. It records what the remote process looked like at one moment and may be stale a moment later.
A useful observer also has a sampling rate. Reading once per second answers strategy-level questions such as gold and income. Reading thousands of times per second adds overhead without making those slow values more truthful.
Section: From one player to a collection.
We already traced one player’s gold. Strategy games usually store several similar player records in an array, vector, or table.
An array places fixed-size records next to one another. A table may instead contain pointers to separately allocated records. The address pattern tells them apart:
Code example. The surrounding prose explains this example; the full lesson contains the exact code.
For an illustrative inline table starting at hexadecimal 1 0 0 0 with 32-byte records, player index 2 begins at hexadecimal 1 0 4 0: two strides of hexadecimal 2 0 bytes after the base. In a pointer table, that same calculation locates a pointer slot; reading the slot gives the record address. The two layouts require different read steps.
Alternative: Picture inline records as seats in a row: seat number times seat width gives the place. A pointer table is a row of tickets, each naming a seat elsewhere.
The inline formula applies only once the debugger shows a consistent stride between real players.
The goal is not to guess a distance and hope. We will use two known players and the debugger to learn how the game indexes them.
Section: Observe the income loop.
Set a breakpoint on the gold update used at the end of a turn. Each time the instruction runs, read the object pointer it uses.

If the function loops through players, the object pointer should change while the instruction stays the same. Nearby 32-bit x86 instructions may calculate an index in this shape:
Code example. The surrounding prose explains this example; the full lesson contains the exact code.
That pair is the collection indexing introduced in Lesson 1.3 and the scaled-offset formula examined in Lesson 3.10, written as two instructions. The three-operand imul multiplies the index in e d x by a constant and puts the result in e a x; the add then moves that distance from the table’s start. This is an illustrative shape, not a claimed byte-for-byte Wesnoth listing.
The constant is the useful part. You do not have to infer the record size by finding two players and subtracting their addresses — the instruction states it, because the compiler had to know the stride to generate this code at all. Read it off, then confirm it against two real players rather than trusting a single instruction.

This suggests:
Code example. The surrounding prose explains this example; the full lesson contains the exact code.
Two known players confirm it: their record addresses should differ by exactly one record size. That difference gives the stride, not yet the complete layout of a record.
Section: Model only confirmed fields.
Do not cast an unknown memory range to a giant struct. Read confirmed fields by offset into a snapshot:
Code example. The code’s comments explain: 📏 Keep index math separate from field offsets so each assumption is visible.
PlayerSnapshot owns copied values. It does not pretend that remote memory is a safe local reference.
The layout object keeps the offsets apart from the values read through them. gold: 4 means “gold sits four bytes from each record’s base in this build.” Storing the number does not make it true; range checks and behavior tests decide whether it still holds for this exact build.
Section: Read until the model says stop.
Find the player count or another verified boundary. Never scan player records forever.
Code example. The surrounding prose explains this example; the full lesson contains the exact code.
Compare that with silently skipping records that fail:
The original helper reads each player and keeps only successful rows. A failed row disappears without an error, so the returned list can look complete even when the layout is wrong or the game changed during capture.
Before: silently skip failed player reads
Code example. The surrounding prose explains this example; the full lesson contains the exact code.
Validate the known lab limit of sixteen players first, then collect results into one Result<Vec<_>>. Any failed read rejects the whole claimed snapshot instead of quietly shortening it.
Improved code
Code example. The surrounding prose explains this example; the full lesson contains the exact code.
Show the diff
Code example. The surrounding prose explains this example; the full lesson contains the exact code.
Takeaway: return a complete trusted snapshot or a visible error, rather than disguising missing rows.
A missing row means a bad layout, a changing process, or an unreadable record, not that the player does not exist. Collecting Result stops at the first untrusted row, so the dashboard never presents a partial table as complete. The count cap also prevents a corrupted count from turning one bad read into thousands of reads.
collect() stops on the first error and returns it with no half-trusted table.
Narration: A dashboard that quietly skips unreadable rows can look healthy while hiding a bad layout. Failing loudly shows the problem at once.
Section: Print a useful dashboard.
Once read_players has returned validated snapshots, display one line per side. The loop uses each snapshot’s slot, gold, and signed income; {:+} shows whether income adds to or drains gold. A failed read ends with an error before the dashboard can present a partial set as complete.
Code example. The surrounding prose explains this example; the full lesson contains the exact code.

Refresh slowly—once or twice per second is enough for a learning tool. Constant reads waste C P U, central processing unit, and make failures harder to follow.
Section: Exact Wesnoth 1.14.9 stat path.
This is the exercise the original course called the stathack.
The income breakpoint at this value reveals that Wesnoth advances through player records in steps of hexadecimal 2 7 0 bytes. The paths for the first two players are:
Code example. The surrounding prose explains this example; the full lesson contains the exact code.
Those paths become a real reader:
Code example. The surrounding prose explains this example; the full lesson contains the exact code.
Create a two-local-player match, end one turn for each player, and print indices 0 and 1. Spend gold with each side separately. Only the matching row should change. You have now rebuilt the original Wesnoth state observer.
Section: Validate the snapshot.
For each field:
observe it in the game;
read it from the tool;
change it through normal gameplay;
confirm the tool follows;
restart and re-resolve the table.
If one player has impossible values, stop and revisit the table base, record size, count, or offsets.
The key upgrade is conceptual: instead of “address plus magic number,” we now have a named layout, checked math, a bounded collection, and owned snapshots.