Why Snapshots Need Guarded Actions
Learn why live reads go stale and how snapshots, separate observation, and verified writes protect a game-state tool.
Cheatsheet for Lesson 4.3
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,observedis 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
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.
One long function hides where it can fail
Section titled “One long function hides where it can fail”A beginner tool often grows as one long function:
find process → read pointer → calculate address → write value → print successThat 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 limitsResult: 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
Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.
income = 30; late = no.
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.
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.
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.
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.
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
- 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.
- Attempt 1, capture 1: the four values are read and kept together as one snapshot.
- Capture 2 reads the chain again. While it runs, income arrives and the game changes the gold from 100 to 130.
- 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.
- Attempt 2: the tool throws the pair away and captures twice more. This time both captures agree in all four fields.
- The tool keeps that snapshot. The caller asked for Some(500), so it will propose a write. (None would mean observe only.)
- Recheck, then write: one more capture right before the write. Only if it still equals the snapshot does the write happen.
- 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.
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.
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.
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.
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.
Store one snapshot in one typed value
Section titled “Store one snapshot in one typed value”This lesson records the entire Wesnoth pointer path, not only the final gold number:
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?
playerchanging means the root now reaches another object.sidechanging can mean the active faction or match state changed.gold_addressis the exact destination a later action would touch.goldis 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.
Two equal reads are useful, not magical
Section titled “Two equal reads are useful, not magical”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.
Capabilities follow the chosen action
Section titled “Capabilities follow the chosen action”The command has two modes:
# Observation only: requests query + read accesssnapshot_pipeline.exe
# Explicit local-lab change: also requests operation + write accesssnapshot_pipeline.exe --set 888The 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.
Validate, write, verify, and recover
Section titled “Validate, write, verify, and recover”The write path has three gates:
- re-capture and require the same snapshot;
- write exactly four little-endian bytes to the verified gold field;
- read the field back and require the requested result.
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.
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.
Complete buildable tool
Section titled “Complete buildable tool”The code below is the complete binary used by the lesson.
Complete lab source: snapshot_pipeline.rs
snapshot_pipeline.rs
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.
Build and run against Wesnoth
Section titled “Build and run against Wesnoth”Use the exact 32-bit Wesnoth 1.14.9 course build, start a disposable local match, and build the Windows binary:
cd windows-labscargo build --release --target i686-pc-windows-msvc --bin snapshot_pipeline.\target\i686-pc-windows-msvc\release\snapshot_pipeline.exeConfirm that the printed address and value match Cheat Engine or x64dbg. Then perform one explicit change:
.\target\i686-pc-windows-msvc\release\snapshot_pipeline.exe --set 888The 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.
Read failures as information
Section titled “Read failures as information”| 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. 🛡️
Extend the architecture
Section titled “Extend the architecture”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::ObserveandAction::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.
What the write gate establishes
Section titled “What the write gate establishes”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.
Revalidate before writing
When should a captured remote address gain authority to become a write destination?
An address is valid only for the run and object it came from. A guarded action re-checks the target and object path immediately before the narrow write, then checks the result.
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.
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.