Skip to content
Lesson 4.3Game State and Bounded Decisions

Why Snapshots Need Guarded Actions

Learn why live reads go stale and how snapshots, separate observation, and verified writes protect a game-state tool.

Lesson 4.3
Read or listenPrint chapter or book
About 26 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.3Key ideas, reference and questions

A one-page summary of “Why Snapshots Need Guarded Actions” you can keep beside your notes. Plain Markdown.

Lesson 4.3 — Why Snapshots Need Guarded Actions: cheatsheet

  • Remember: Treat every read as temporary evidence. Re-check it right before acting, and write only if nothing has changed.
  • Also useful: Think of looking both ways before crossing: if both looks agree, the road is probably quiet, but a car could still appear. Equal reads raise confidence, not certainty.

Key terms

  • observed: In the code below, observed is the snapshot the caller approved.
  • guarded action: It is a guarded action with a checked precondition and postcondition.

Formulas and patterns

  • find process → read pointer → calculate address → write value → print success

Review

  • Q: When should a captured remote address gain authority to become a write destination? A: Only after the profile, object path, current value, and fresh precondition all still agree
  • Q: The tool captured a valid gold address, but the second snapshot has a different side pointer. What should the advanced pipeline do? A: Stop without writing because the object identity changed
Download cheatsheet (.md)

The observer in the previous lesson copied player state for display. A tool that changes state faces a harder problem: gold can change after the read but before the write. If income arrives in that gap, a blind write may overwrite it. We need a pipeline that treats every observation as temporary evidence.

A beginner tool often grows as one long function:

find process → read pointer → calculate address → write value → print success

That can work once, but every step depends on facts collected earlier. The game may restart, change maps, end a turn, replace an object, or update the value while the tool is still running.

A more reliable tool separates six phases:

Repeated reads can expose a changing snapshot. This step-by-step trace needs JavaScript. Here is the code and where the run ends.

read the selected inputs
evaluate each stated condition
combine the required conditions
perform the effect only when accepted
otherwise retain the old state
report the result and its limits

Result: retain the current gold and recapture; the observed values did not stay stable.

Final values: income = 30; late = no; first_capture = 100; second_capture = 130; captures_agree = false; fresh_check_matches = true; accepted = false; gold_after = 130.

Read every step of the lesson’s example
  1. Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.

    income = 30; late = no.

  2. Step 2, line 2. Comparing observations can reject visible instability, but cannot prevent every change after the last read.

    income = 30; late = no; first_capture = 100; second_capture = 130; captures_agree = false; fresh_check_matches = true.

  3. Step 3, line 3. Accept only when the required conditions all hold.

    income = 30; late = no; first_capture = 100; second_capture = 130; captures_agree = false; fresh_check_matches = true; accepted = false.

  4. Step 4, line 5. The model retains its previous state.

    income = 30; late = no; first_capture = 100; second_capture = 130; captures_agree = false; fresh_check_matches = true; accepted = false; gold_after = 130.

  5. Step 5, line 6. Result: retain the current gold and recapture; the observed values did not stay stable.

    income = 30; late = no; first_capture = 100; second_capture = 130; captures_agree = false; fresh_check_matches = true; accepted = false; gold_after = 130.

Watch it happenCapture twice, recheck, then write, and read back

A pointer chain in game memory leads to a gold value of 100. The tool captures it twice, but income changes the gold to 130 in between, so the two captures differ and are discarded. Two new captures agree, the snapshot is kept, a fresh capture just before the write still matches, the tool writes 500, and reading back gives 500.

Read the steps
  1. The tool wants to write 500 into the game’s gold. It does not trust one read, so it first captures the whole pointer chain: player, side, gold address, and gold.
  2. Attempt 1, capture 1: the four values are read and kept together as one snapshot.
  3. Capture 2 reads the chain again. While it runs, income arrives and the game changes the gold from 100 to 130.
  4. The two snapshots differ in the gold field, so the tool refuses to act on either one. A blind write would have overwritten the income.
  5. Attempt 2: the tool throws the pair away and captures twice more. This time both captures agree in all four fields.
  6. The tool keeps that snapshot. The caller asked for Some(500), so it will propose a write. (None would mean observe only.)
  7. Recheck, then write: one more capture right before the write. Only if it still equals the snapshot does the write happen.
  8. Read back: the tool reads the gold again and compares it with the 500 it asked for. Equal means the write took effect.

This follows the write branch when snapshots agree; None remains observation-only and a changed snapshot stops the write.

The important idea is not extra code. It is that data crosses a gate before the tool acts on it. A raw address read once may already be out of date. A Snapshot captured twice with equal results was consistent at that moment. A write happens only if a fresh capture, taken immediately before it, still equals the snapshot.

Income changes gold before the writeA snapshot is evidence from one moment. A fresh capture right before the write catches a change the first reading could not know about.
  1. Data Snapshot: gold = 100

    Step 1: Game to Tool

    The tool reads every field it needs; gold is one field of the whole Snapshot. It now holds a picture that was true for one instant.

  2. Alone Income: gold 100 → 110

    Step 2: Game (alone)

    The game keeps running while the tool decides. The stored 100 is already out of date.

  3. Data Fresh Snapshot: gold = 110

    Step 3: Game to Tool

    The tool does not trust the old reading. It captures again immediately before the action, and the fresh Snapshot contains 110.

  4. Alone 110 ≠ 100: refuse 888

    Step 4: Tool (alone)

    Whole-Snapshot equality fails because one field changed, so the requested 888 is refused. This check still cannot make the game’s later changes atomic.

A snapshot is evidence from one moment. A fresh capture right before the write catches a change the first reading could not know about.

This lesson records the entire Wesnoth pointer path, not only the final gold number:

player: usize
side: usize
gold_address: usize
gold: u32
The whole Snapshot. Comparing all four fields catches a changed pointer link, not only a changed gold number.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Snapshot {
player: usize,
side: usize,
gold_address: usize,
gold: u32,
}

Why keep all four fields?

  • player changing means the root now reaches another object.
  • side changing can mean the active faction or match state changed.
  • gold_address is the exact destination a later action would touch.
  • gold is the value the learner actually observed.

Equality now asks a useful question: “Did the whole chain and its value remain the same?” It is stronger than comparing only the final address.

The tool captures twice and accepts the result only when both snapshots agree:

fn stable_snapshot(process: &Process) -> anyhow::Result<Snapshot> {
for attempt in 1..=5 {
let first = capture(process)?;
let second = capture(process)?;
if first == second {
return Ok(first);
}
eprintln!("snapshot attempt {attempt} changed while it was being read; retrying");
}
anyhow::bail!("Wesnoth state did not stay stable for two consecutive captures")
}

This is a small optimistic consistency check. The tool does not stop the game. It assumes a short read will usually see a quiet moment, then checks that assumption.

⚠️ Two equal reads are not a mathematical proof that no intermediate state existed. They are a practical gate for a tiny offline lab. A debugger pause, a game-provided snapshot API, or a version counter is stronger when a larger structure must be perfectly atomic.

The retry cap matters too. A loop that waits forever for stability can make a broken tool look frozen.

The command has two modes:

Terminal window
# Observation only: requests query + read access
snapshot_pipeline.exe
# Explicit local-lab change: also requests operation + write access
snapshot_pipeline.exe --set 888

The program decides whether it needs write rights before opening the process:

let process = Process::open_by_name("wesnoth.exe", replacement.is_some())?;

This is capability-oriented design in plain English: if the user asked only to look, the handle should be unable to write.

The write path has three gates:

  1. re-capture and require the same snapshot;
  2. write exactly four little-endian bytes to the verified gold field;
  3. read the field back and require the requested result.
no
yes
no
yes
stable_snapshot() again
== observed
snapshot?
stop: nothing written
write_u32(gold_address,
replacement)
read_u32(gold_address)
== replacement?
report failure
success

In the code below, observed is the snapshot the caller approved. A fresh snapshot must match it before any write; afterward, a read-back must equal replacement. Each added line enforces one arrow in the diagram.

The original helper writes to the gold address in a previously captured snapshot, then reports success. The target may have changed since that snapshot; the function also never checks what was stored.

Before: write through a cached snapshot
fn replace_gold(
process: &Process,
observed: Snapshot,
replacement: u32,
) -> anyhow::Result<()> {
process.write_u32(observed.gold_address, replacement)?; // May be stale.
Ok(())
}

A guarded action checks its precondition immediately before writing and its postcondition afterward. Matching snapshots reduce stale-address mistakes; they still cannot prevent the game changing between the check and the write.

Improved code

fn replace_gold(
process: &Process,
observed: Snapshot,
replacement: u32,
) -> anyhow::Result<()> {
let current = stable_snapshot(process)?;
anyhow::ensure!(current == observed, "state changed; nothing was written");
process.write_u32(observed.gold_address, replacement)?;
let written = process.read_u32(observed.gold_address)?;
anyhow::ensure!(written == replacement, "write did not reach its postcondition");
Ok(())
}

Show the diff

fn replace_gold(
process: &Process,
observed: Snapshot,
replacement: u32,
) -> anyhow::Result<()> {
process.write_u32(observed.gold_address, replacement)?; // May be stale.
let current = stable_snapshot(process)?;
anyhow::ensure!(current == observed, "state changed; nothing was written");
process.write_u32(observed.gold_address, replacement)?;
let written = process.read_u32(observed.gold_address)?;
anyhow::ensure!(written == replacement, "write did not reach its postcondition");
Ok(())
}

Takeaway: recheck the state you approved, then verify the result of the write.

This is not a database transaction: Windows cannot automatically roll back the game. It is a guarded action with a checked precondition and postcondition. That pattern also fits toggles, patches, save editors, configuration tools, and mod installers.

The code below is the complete binary used by the lesson.

Complete lab source: snapshot_pipeline.rs

snapshot_pipeline.rs

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

Show the codeHide the code
#[cfg(windows)]
fn main() -> anyhow::Result<()> {
    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 MAX_GOLD: u32 = 1_000_000;

    #[derive(Clone, Copy, Debug, Eq, PartialEq)]
    struct Snapshot {
        player: usize,
        side: usize,
        gold_address: usize,
        gold: u32,
    }

    fn pointer32(process: &Process, address: usize, label: &str) -> anyhow::Result<usize> {
        // 📏 Pointer width belongs to the 32-bit game, not to the Rust tool.
        let value = process
            .read_u32(address)
            .with_context(|| format!("could not read {label} at {address:#010x}"))?
            as usize;
        anyhow::ensure!(value != 0, "{label} was null; start a local match first");
        Ok(value)
    }

    fn capture(process: &Process) -> anyhow::Result<Snapshot> {
        // 🧭 Resolve every address with checked arithmetic before reading it.
        let player = pointer32(process, PLAYER_ROOT, "player root")?;
        let side_pointer = player
            .checked_add(GAME_OFFSET)
            .context("player + game offset overflowed")?;
        let side = pointer32(process, side_pointer, "current side")?;
        let gold_address = side
            .checked_add(GOLD_OFFSET)
            .context("side + gold offset overflowed")?;
        let gold = process.read_u32(gold_address)?;

        Ok(Snapshot {
            player,
            side,
            gold_address,
            gold,
        })
    }

    fn stable_snapshot(process: &Process) -> anyhow::Result<Snapshot> {
        for attempt in 1..=5 {
            let first = capture(process)?;
            let second = capture(process)?;
            if first == second {
                return Ok(first);
            }
            eprintln!("snapshot attempt {attempt} changed while it was being read; retrying");
        }
        anyhow::bail!("Wesnoth state did not stay stable for two consecutive captures")
    }

    let mut arguments = std::env::args().skip(1);
    let replacement = match arguments.next().as_deref() {
        None => None,
        Some("--set") => Some(
            arguments
                .next()
                .context("--set requires a gold value")?
                .parse::<u32>()
                .context("gold must be an unsigned decimal number")?,
        ),
        Some(other) => anyhow::bail!("unknown argument {other:?}; use no argument or --set GOLD"),
    };
    anyhow::ensure!(arguments.next().is_none(), "too many arguments");
    if let Some(value) = replacement {
        anyhow::ensure!(
            value <= MAX_GOLD,
            "gold exceeds the lab limit of {MAX_GOLD}"
        );
    }

    // 🔒 Read-only mode never requests VM_WRITE or VM_OPERATION rights.
    let process = Process::open_by_name("wesnoth.exe", replacement.is_some())?;
    anyhow::ensure!(
        process.is_32_bit()?,
        "this profile requires 32-bit Wesnoth 1.14.9"
    );
    let observed = stable_snapshot(&process)?;

    println!("Stable Wesnoth snapshot:");
    println!("  player:      {:#010x}", observed.player);
    println!("  side:        {:#010x}", observed.side);
    println!("  gold address:{:#010x}", observed.gold_address);
    println!("  gold:        {}", observed.gold);

    if let Some(value) = replacement {
        // ⚠️ The game kept running after observation. Rebuild the whole pointer
        // path and require the exact same evidence immediately before writing.
        let current = stable_snapshot(&process)?;
        anyhow::ensure!(
            current == observed,
            "state changed after confirmation; nothing was written"
        );
        process.write_u32(observed.gold_address, value)?;

        // ✅ Read back the postcondition instead of assuming the write succeeded.
        let written = process.read_u32(observed.gold_address)?;
        anyhow::ensure!(
            written == value,
            "read-back value was {written}, expected {value}"
        );
        println!("Changed gold from {} to {written}.", observed.gold);
    } else {
        println!("Observation only. Run with `--set GOLD` for the guarded local-lab write.");
    }
    Ok(())
}

#[cfg(not(windows))]
fn main() {
    eprintln!("This live snapshot pipeline must run on Windows.");
}
#[cfg(windows)]
fn main() -> anyhow::Result<()> {
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 MAX_GOLD: u32 = 1_000_000;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct Snapshot {
player: usize,
side: usize,
gold_address: usize,
gold: u32,
}
fn pointer32(process: &Process, address: usize, label: &str) -> anyhow::Result<usize> {
// 📏 Pointer width belongs to the 32-bit game, not to this tool.
let value = process
.read_u32(address)
.with_context(|| format!("could not read {label} at {address:#010x}"))?
as usize;
anyhow::ensure!(value != 0, "{label} was null; start a local match first");
Ok(value)
}
fn capture(process: &Process) -> anyhow::Result<Snapshot> {
// 🧭 Resolve every address with checked arithmetic before reading it.
let player = pointer32(process, PLAYER_ROOT, "player root")?;
let side_pointer = player
.checked_add(GAME_OFFSET)
.context("player + game offset overflowed")?;
let side = pointer32(process, side_pointer, "current side")?;
let gold_address = side
.checked_add(GOLD_OFFSET)
.context("side + gold offset overflowed")?;
let gold = process.read_u32(gold_address)?;
Ok(Snapshot {
player,
side,
gold_address,
gold,
})
}
fn stable_snapshot(process: &Process) -> anyhow::Result<Snapshot> {
for attempt in 1..=5 {
let first = capture(process)?;
let second = capture(process)?;
if first == second {
return Ok(first);
}
eprintln!("snapshot attempt {attempt} changed while it was being read; retrying");
}
anyhow::bail!("Wesnoth state did not stay stable for two consecutive captures")
}
let mut arguments = std::env::args().skip(1);
let replacement = match arguments.next().as_deref() {
None => None,
Some("--set") => Some(
arguments
.next()
.context("--set requires a gold value")?
.parse::<u32>()
.context("gold must be an unsigned decimal number")?,
),
Some(other) => anyhow::bail!("unknown argument {other:?}; use no argument or --set GOLD"),
};
anyhow::ensure!(arguments.next().is_none(), "too many arguments");
if let Some(value) = replacement {
anyhow::ensure!(
value <= MAX_GOLD,
"gold exceeds the lab limit of {MAX_GOLD}"
);
}
// 🔒 Read-only mode never requests VM_WRITE or VM_OPERATION rights.
let process = Process::open_by_name("wesnoth.exe", replacement.is_some())?;
anyhow::ensure!(
process.is_32_bit()?,
"this profile requires 32-bit Wesnoth 1.14.9"
);
let observed = stable_snapshot(&process)?;
println!("Stable Wesnoth snapshot:");
println!(" player: {:#010x}", observed.player);
println!(" side: {:#010x}", observed.side);
println!(" gold address:{:#010x}", observed.gold_address);
println!(" gold: {}", observed.gold);
if let Some(value) = replacement {
// ⚠️ The game kept running after observation. Rebuild the whole pointer
// path and require the exact same evidence immediately before writing.
let current = stable_snapshot(&process)?;
anyhow::ensure!(
current == observed,
"state changed after confirmation; nothing was written"
);
process.write_u32(observed.gold_address, value)?;
// ✅ Read back the postcondition instead of assuming the write succeeded.
let written = process.read_u32(observed.gold_address)?;
anyhow::ensure!(
written == value,
"read-back value was {written}, expected {value}"
);
println!("Changed gold from {} to {written}.", observed.gold);
} else {
println!("Observation only. Run with `--set GOLD` for the guarded local-lab write.");
}
Ok(())
}
#[cfg(not(windows))]
fn main() {
eprintln!("This live snapshot pipeline must run on Windows.");
}

The same source is available as snapshot_pipeline.rs.

Windows VM

Use the exact 32-bit Wesnoth 1.14.9 course build, start a disposable local match, and build the Windows binary:

Terminal window
cd windows-labs
cargo build --release --target i686-pc-windows-msvc --bin snapshot_pipeline
.\target\i686-pc-windows-msvc\release\snapshot_pipeline.exe

Confirm that the printed address and value match Cheat Engine or x64dbg. Then perform one explicit change:

Terminal window
.\target\i686-pc-windows-msvc\release\snapshot_pipeline.exe --set 888

The visible gold should become 888. End the match and run it again: the tool should report a null or unstable object instead of writing to address zero.

Failure What it means Correct response
Player root is null No supported local match is ready Start the match; do not invent an address
Snapshot keeps changing The pointer path changes between captures Stop after the retry cap
Architecture is not 32-bit This profile does not describe the target Refuse the run
State differs before write The earlier snapshot is out of date Write nothing and capture again
Read-back differs The requested postcondition was not reached Report failure; do not print success

A trustworthy tool has more useful stop paths than force paths. 🛡️

Once this pipeline is clear, try these controlled upgrades:

  • add a target-profile name and executable hash to the snapshot;
  • attach a timestamp and module base to each capture;
  • replace two reads with a game-owned frame counter when one is known;
  • make Action::Observe and Action::SetGold(u32) an enum;
  • unit-test decision logic with fake snapshots, without opening a process.

Keep remote I/O at the edge. The middle of the program should compare ordinary typed values.

The snapshot carries player and side identity along with gold and its address, so a changed object path invalidates a proposed action. Two equal captures are a bounded consistency check, not a lock on the running game. Observation mode therefore keeps a read-only handle. When an action is requested, a fresh precondition check guards the only write, and a read-back checks its postcondition before the tool reports success.

Knowledge check

Revalidate before writing

When should a captured remote address gain authority to become a write destination?

Finished Lesson 4.3?

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.