Skip to content
Lesson 4.2Game State and Bounded Decisions

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.

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

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: PlayerSnapshot is 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
Download cheatsheet (.md)

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.

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 analysis

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.

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.

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_size
pointer table: read pointer at base + index × pointer_size

For 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.

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.

An income breakpoint firing

The income breakpoint: each hit lets you read the object the instruction used.

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_size
add eax, player_table

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

An indexed address calculation

An indexed address calculation inside one instruction.

This suggests:

record address = table base + player index × record size

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.

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.

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.

Before: silently skip failed player reads
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.

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
);
}

A simple observer display

A simple observer that prints the values it reads.

Refresh slowly—once or twice per second is enough for a learning tool. Constant reads waste CPU and make failures harder to follow.

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] + 0x004
player 2 gold: [[0x017E_ED18] + 0xA90] + 0x274
└─ 0x270 + 4

Those 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.

For each field:

  1. observe it in the game;
  2. read it from the tool;
  3. change it through normal gameplay;
  4. confirm the tool follows;
  5. 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.

Knowledge check

Observe before deciding

Why should a strategy-game observer build one validated snapshot before running rules?

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.

Chapter 4: 10 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.