From One Player to a Game-State Snapshot
See how one verified player layout becomes a bounded, typed snapshot of every side in a local match.
Cheatsheet for Lesson 4.2
A one-page summary of “From One Player to a Game-State Snapshot” you can keep beside your notes. Plain Markdown.
Lesson 4.2 — From One Player to a Game-State Snapshot: cheatsheet
- Remember: A snapshot is a copy taken at one moment. The tool works on its copy because the game keeps changing underneath it.
- Also useful: 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.
Key terms
PlayerSnapshot:PlayerSnapshotis therefore a copy of the values at one moment, not a live reference into Wesnoth.- stathack: This is the exercise the original course called the stathack.
Formulas and patterns
remote bytes → validated copied snapshot → display or analysis
Review
- Q: Why should a strategy-game observer build one validated snapshot before running rules? A: The rules get one coherent model, not scattered reads of changing memory
- Q: Why is a record read from another process called a snapshot and not a live reference? A: The other process may change right after the read, so it is one moment
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 NPC shows how the game itself senses and chooses.
What an observer does
Section titled “What an observer does”An observer reads state and presents it without trying to become the game’s state owner. It separates three layers:
remote bytes → validated copied snapshot → display or analysisThe 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.
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.
From one player to a collection
Section titled “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:
inline records: base + index × record_sizepointer table: read pointer at base + index × pointer_sizeFor an illustrative inline table starting at 0x1000 with 32-byte records,
player index 2 begins at 0x1040: two strides of 0x20 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.
This interactive lab needs JavaScript. The lesson text around it still explains the same idea.
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.
Observe the income loop
Section titled “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:
imul eax, edx, record_sizeadd eax, player_tableThat 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 edx by a constant and puts
the result in eax; 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:
record address = table base + player index × record sizeTwo 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.
Model only confirmed fields
Section titled “Model only confirmed fields”Do not cast an unknown memory range to a giant struct. Read confirmed fields by offset into a snapshot:
#[derive(Debug)]struct PlayerSnapshot { slot: u32, gold: u32, income: i32,}
struct PlayerLayout { record_size: usize, gold: usize, income: usize,}
fn read_player( process: &Process, table: usize, index: usize, layout: &PlayerLayout,) -> anyhow::Result<PlayerSnapshot> { let record_offset = index.checked_mul(layout.record_size) .context("player record offset overflowed")?; // 📏 Keep index math separate from field offsets so each assumption is visible. let base = table.checked_add(record_offset) .context("player record address overflowed")?;
let gold_address = base.checked_add(layout.gold) .context("gold field address overflowed")?; let income_address = base.checked_add(layout.income) .context("income field address overflowed")?;
Ok(PlayerSnapshot { slot: u32::try_from(index)?, // ✅ Reject an index that cannot fit the model. gold: process.read_u32(gold_address)?, income: process.read_i32(income_address)?, })}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.
Read until the model says stop
Section titled “Read until the model says stop”Find the player count or another verified boundary. Never scan player records forever.
fn read_players( process: &Process, table: usize, count: usize, layout: &PlayerLayout,) -> anyhow::Result<Vec<PlayerSnapshot>> { anyhow::ensure!(count <= 16, "unexpected player count: {count}");
(0..count) .map(|index| read_player(process, table, index, layout)) .collect()}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.
fn read_players( process: &Process, table: usize, count: usize, layout: &PlayerLayout,) -> anyhow::Result<Vec<PlayerSnapshot>> { let mut players = Vec::new(); for index in 0..count { if let Ok(player) = read_player(process, table, index, layout) { // Errors disappear. players.push(player); } } Ok(players)}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
fn read_players( process: &Process, table: usize, count: usize, layout: &PlayerLayout,) -> anyhow::Result<Vec<PlayerSnapshot>> { anyhow::ensure!(count <= 16, "unexpected player count: {count}"); (0..count) .map(|index| read_player(process, table, index, layout)) .collect()}Show the diff
fn read_players( process: &Process, table: usize, count: usize, layout: &PlayerLayout,) -> anyhow::Result<Vec<PlayerSnapshot>> { let mut players = Vec::new(); for index in 0..count { if let Ok(player) = read_player(process, table, index, layout) { // Errors disappear. players.push(player); } } Ok(players) anyhow::ensure!(count <= 16, "unexpected player count: {count}"); (0..count) .map(|index| read_player(process, table, index, layout)) .collect()}Takeaway: return a complete trusted snapshot or a visible error, rather than disguising missing rows.
collect() stops on the first error and returns it with no half-trusted table.
Print a useful dashboard
Section titled “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.
for player in read_players(&process, table, count, &layout)? { println!( "slot {:>2} | gold {:>4} | income {:+}", player.slot, player.gold, player.income );}
Refresh slowly—once or twice per second is enough for a learning tool. Constant reads waste CPU and make failures harder to follow.
Exact Wesnoth 1.14.9 stat path
Section titled “Exact Wesnoth 1.14.9 stat path”This is the exercise the original course called the stathack.
The income breakpoint at 0x009B4CE3 reveals that Wesnoth advances through player records in steps of 0x270 bytes. The paths for the first two players are:
player 1 gold: [[0x017E_ED18] + 0xA90] + 0x004player 2 gold: [[0x017E_ED18] + 0xA90] + 0x274 └─ 0x270 + 4Those paths become a real reader:
const PLAYER_ROOT: usize = 0x017E_ED18;const GAME_OFFSET: usize = 0x0A90;const PLAYER_RECORD_SIZE: usize = 0x0270;const GOLD_IN_RECORD: usize = 0x0004;
fn wesnoth_player_gold( process: &Process, player_index: usize,) -> anyhow::Result<u32> { anyhow::ensure!(player_index < 9, "unexpected player index"); let player_root = process.read_u32(PLAYER_ROOT)? as usize; let game = process.read_u32(player_root + GAME_OFFSET)? as usize; let record = player_index.checked_mul(PLAYER_RECORD_SIZE) .and_then(|offset| game.checked_add(offset)) .context("player record address overflowed")?; process.read_u32(record + GOLD_IN_RECORD).map_err(Into::into)}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.
Validate the snapshot
Section titled “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.
Observe before deciding
Why should a strategy-game observer build one validated snapshot before running rules?
Collection and validation belong in one layer; strategy can then work with meaningful bounded data.
Finished Lesson 4.2?
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.
Plain Markdown, saved in this browser.
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.