Snapshots as a Script Interface
Immutable, versioned observations and bounded action requests connect Lua to game state without exposing arbitrary pointers.
Cheatsheet for Lesson 11.5
A one-page summary of “Snapshots as a Script Interface” you can keep beside your notes. Plain Markdown.
Lesson 11.5 — Snapshots as a Script Interface: cheatsheet
- Remember: Expose named, validated values to Lua, and keep build-specific addresses inside the host observer.
- Also useful: The abstraction barrier is a translation desk: scripts ask about entities, while the observer translates those names into one build’s storage layout.
Key terms
- abstraction barrier: An abstraction barrier is a rule about who may know a representation.
Review
- Q: Why should a Lua observer receive copied entity snapshots rather than target-process addresses? A: Rust owns layout validation, and scripts cannot keep stale pointers
- Q: Why is giving scripts general read and write functions over raw memory addresses worse than giving them a small set of meaningful operations? A: Every layout rule and bound leaks into every script, and a typo gains great power
Do not turn Lua into an untyped memory API
Section titled “Do not turn Lua into an untyped memory API”After building a memory reader, it is tempting to expose this:
-- ❌ Every version rule, bound, and permission now leaks into every script.local bytes = memory.read(address, count)memory.write(address, replacement)That design gives a typo the power of the process handle. It also couples scripts to exact offsets and makes every mod responsible for pointer validation.
Expose domain meaning instead:
local entities = game.snapshot()game.request({ kind = "select_entity", id = entities[2].id })The host owns collection and validation
Section titled “The host owns collection and validation”The Windows layer should:
- open the target with the least required rights;
- verify game build and architecture;
- resolve versioned roots and pointer paths;
- copy fields with bounds and plausibility checks;
- create ordinary owned records;
- close or retain handles according to one clear owner.
Only step 5 crosses into Lua.
The boundary turns unstable bytes into stable meaning, then turns script intent back into checked data:
Addresses stop at the memory reader; Lua receives fields with names, units, and a snapshot version.
#[derive(Clone, Debug)]struct EntitySnapshot { id: u32, name: String, position: [f32; 3], alive: bool,}There are no process addresses in this type. A script cannot keep a stale pointer because it never receives one.
Why avoid raw pointers here? Lua can represent native objects through userdata. Full userdata has Lua-managed storage and a host-defined metatable; light userdata is essentially an unowned pointer value. If a native entity dies while a script retains its raw address, the same address may later belong to a different entity. A copied snapshot avoids that lifetime problem for observation. When a request needs persistent identity, use a validated handle rather than a remembered address.
A handle can pair a numbered slot with a generation counter. Every time a slot is reused for a new entity, its generation changes. Suppose Lua remembers slot 3, generation 6. If that entity dies and slot 3 is reused at generation 7, the host rejects the stale pair rather than acting on the new occupant:
Reply Handle: slot 3, generation 6
Step 1: Host entity table to Lua script
Lua remembers both numbers.
Alone Slot 3 reused as generation 7
Step 2: Host entity table (alone)
The host reuses the cheap slot, and bumps the generation to mark it as a new life.
Request Use handle: slot 3, generation 6
Step 3: Lua script to Host entity table
The script, unaware, still holds the old handle.
Control Rejected: slot 3 is generation 7, not 6
Step 4: Host entity table to Lua script
The slot matches but the generation does not, so the handle names a dead entity. The host refuses instead of acting on whoever lives in slot 3 now.
Checking both parts prevents an old handle from naming the new entity that reused its slot.
Lesson 3.10 developed the same identity rule for a game collection. Here it protects the boundary between a script’s request and a changing game world.
Here is one entity as a hypothetical build lays it out in game memory, and the same entity after the observer has copied it into a snapshot and the host has turned that into a Lua table:
u32f32f32 is 4 bytes wide, so x, y, and z sit at 0x30, 0x30 + 4 = 0x34, and 0x34 + 4 = 0x38. The one-byte alive flag at 0x3C is followed by 3 unused bytes, because an 8-byte pointer starts on a multiple of 8 and 0x40 (64 = 8 × 8) is the next one. The name itself is not stored here: the object holds the address of text kept elsewhere, which the observer follows and copies with a length limit.1 has become the boolean true, and no offset or pointer survives the copy.A snapshot has a time and version
Section titled “A snapshot has a time and version”Real snapshots should carry metadata:
#[derive(Clone, Debug)]struct GameSnapshot { sequence: u64, captured_at_ms: u64, profile: String, entities: Vec<EntitySnapshot>,}Put an abstraction barrier between bytes and game meaning
Section titled “Put an abstraction barrier between bytes and game meaning”An abstraction barrier is a rule about who may know a representation. The
observer below the barrier knows that one build stores an entity position at a
particular pointer path. Lua above the barrier knows only that an entity has an
id, name, position, and alive state.
Lua automation depends on: snapshot fields and bounded request meanings---------------- abstraction barrier ----------------memory observer depends on: Windows handles, build profiles, offsets, encodings, and boundsIf a later build moves the position field, only the observer and its target profile should change. Every script should not learn a new offset.
Make the source of snapshots replaceable too:
trait SnapshotSource { fn next_snapshot(&mut self) -> anyhow::Result<GameSnapshot>;}
fn collect_for_lua(source: &mut impl SnapshotSource) -> anyhow::Result<GameSnapshot> { let snapshot = source.next_snapshot()?; anyhow::ensure!( snapshot.entities.len() <= 256, "snapshot exceeds the published entity limit" ); Ok(snapshot)}A live Windows observer and a replay-file reader can both implement
SnapshotSource. The same Lua state machine can then run against recorded data
in a test and against the verified game in the lab. That is not just reuse: it
lets you test automation without giving the test a process handle.
When Lua requests an action, include the sequence it observed:
game.request({ kind = "select_entity", entity_id = target.id, based_on = snapshot.sequence,})The host can reject a request based on a snapshot that is too old. This is safer than pretending copied data is live.
The script did nothing wrong: entity 7 really was alive in snapshot 41. The
game moved on while the script was deciding, and based_on lets the host see
that the request describes a moment that has already passed.
A snapshot does not keep its entity alive. 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: refuse selection of an absent identity. Copying fields never keeps the original object alive.
Final values: id = 7; present = no; snapshot_sequence = 41; current_sequence = 42; entity_found_now = false; accepted = false; selected = none.
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.
id = 7; present = no.
Step 2, line 2. The copied archer remains in the script’s old table even when the live entity disappears.
id = 7; present = no; snapshot_sequence = 41; current_sequence = 42; entity_found_now = false.
Step 3, line 3. Accept only when the required conditions all hold.
id = 7; present = no; snapshot_sequence = 41; current_sequence = 42; entity_found_now = false; accepted = false.
Step 4, line 5. The model retains its previous state.
id = 7; present = no; snapshot_sequence = 41; current_sequence = 42; entity_found_now = false; accepted = false; selected = none.
Step 5, line 6. Result: refuse selection of an absent identity. Copying fields never keeps the original object alive.
id = 7; present = no; snapshot_sequence = 41; current_sequence = 42; entity_found_now = false; accepted = false; selected = none.
Entity 7 is an alive archer at (4, 9, 2) in sequence 41. Its copied snapshot moves to Lua. The live archer disappears at sequence 42 while the snapshot stays unchanged. A select_entity request based on 41 returns to the host, which cannot find entity 7 and sends a refusal without taking an action.
Read the steps
- At sequence 41, entity 7 is a living archer at position (4, 9, 2). The host validates that observation and creates owned snapshot fields.
- Give Lua snapshot 41. The table keeps its own values; the script does not hold a live reference to the archer in the game world.
- The world advances to sequence 42 and entity 7 despawns. Remove the live archer. Lua still owns the earlier snapshot, including alive: true.
- Lua requests select_entity for ID 7 and labels the request based_on 41. The structured request tells the host which observation produced the decision.
- The host revalidates at action time. Its current sequence is 42, and a lookup in the current world finds no entity 7. The earlier successful copy cannot authorize this action.
- Return a useful refusal to Lua and keep the host alive. The old snapshot remains a valid historical observation; selecting a missing entity is still refused.
Copying protects the observation from memory reuse. Current-state validation protects the later action.
Requests are data, not commands
Section titled “Requests are data, not commands”Bad interface:
game.execute("select " .. target.id) -- ❌ a command-language injection boundaryBetter interface:
game.request({ kind = "select_entity", id = target.id }) -- ✅ structured dataThe host matches an explicit enum:
enum RequestedAction { SelectEntity(u32), PlaceMarker { x: f32, y: f32, z: f32 },}Unknown kind values fail. No string becomes PowerShell, a console command, or arbitrary game code.
Validate again at action time
Section titled “Validate again at action time”Between snapshot and request processing, an entity may disappear. Check:
- the sequence is recent;
- the entity still exists;
- its current state permits the action;
- all numbers are finite;
- coordinates remain within known map bounds;
- the per-script action budget is not exhausted.
This is a time-of-check/time-of-use issue. Validation during snapshot creation does not guarantee future state.
Keep read and write capabilities separate
Section titled “Keep read and write capabilities separate”Most analysis scripts need only snapshots and logs. Give them an observer API with no request function at all.
When an offline automation lab needs actions, load it with a different capability set:
ObserverScript: snapshot + logAutomationScript: snapshot + log + two bounded requestsDeveloperScript: extra diagnostics in a disposable test buildDo not decide permissions from a script-provided name. The host chooses the API before executing the script.
Test the boundary without a game
Section titled “Test the boundary without a game”The lua-labs host uses a simulated snapshot. Add tests where Lua requests:
- a missing entity ID;
0/0or infinity as a coordinate;- an unknown action;
- too many actions;
- an action from an expired sequence.
The host should reject each request with a useful error and remain alive.
This architecture also makes the script portable. The same Lua logic can consume a recorded replay, a simulated world, or a live observer because it depends on the snapshot contract rather than Windows memory details.
Keep pointers in Rust
Why should a Lua observer receive copied entity snapshots rather than target-process addresses?
A domain snapshot keeps architecture, permissions, bounds, and lifetimes inside the typed host while giving scripts the meaning they need.
Finished Lesson 11.5?
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.