Skip to content
Lesson 11.5NPC Policy, Lua, and Script Execution

Snapshots as a Script Interface

Immutable, versioned observations and bounded action requests connect Lua to game state without exposing arbitrary pointers.

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

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

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 Windows layer should:

  1. open the target with the least required rights;
  2. verify game build and architecture;
  3. resolve versioned roots and pointer paths;
  4. copy fields with bounds and plausibility checks;
  5. create ordinary owned records;
  6. 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:

Game memory
Memory reader
Validated snapshot
Lua policy
Typed request
Host revalidates
Bounded local action

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:

The slot number stays 3, the generation moves from 6 to 7Checking both parts prevents an old handle from naming the new entity that reused its slot.
  1. Reply Handle: slot 3, generation 6

    Step 1: Host entity table to Lua script

    Lua remembers both numbers.

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

  3. Request Use handle: slot 3, generation 6

    Step 3: Lua script to Host entity table

    The script, unaware, still holds the old handle.

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

Handle: slot and generation
slot 3 reused,
generation now 6 + 1 = 7
Lua holds slot 3,
generation 6
6 does not match 7:
the host refuses
Light userdata: a raw pointer
native object freed,
memory reused
Lua holds address 0x2A40
the pointer now reaches
whatever lives there

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:

+0x107
+0x304.0
+0x349.0
+0x382.0
+0x3C1
+0x400x1F2A8C40
id, u32
position, three f32
alive byte
name pointer
Game memory, labelled by offset from the start of the object; only the fields the snapshot needs are shown. Each f32 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.
id7
namearcher
position.x4.0
position.y9.0
position.z2.0
alivetrue
The Lua table the script receives. Every value arrives under a name, the alive byte 1 has become the boolean true, and no offset or pointer survives the copy.

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 bounds

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

Lua scriptHostGameLua scriptHostGamestate at sequence 41snapshot 41, entity 7 aliveentity 7 despawns at sequence 42request select_entity 7, based_on 41check entity 7 against sequence 42refused, entity 7 no longer exists

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 limits

Result: 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
  1. Step 1, line 1. Start with the selected inputs and follow how the next operation changes the state.

    id = 7; present = no.

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

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

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

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

Watch it happenKeep the snapshot; refuse the stale action

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
  1. At sequence 41, entity 7 is a living archer at position (4, 9, 2). The host validates that observation and creates owned snapshot fields.
  2. 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.
  3. The world advances to sequence 42 and entity 7 despawns. Remove the live archer. Lua still owns the earlier snapshot, including alive: true.
  4. 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.
  5. 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.
  6. 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.

Bad interface:

game.execute("select " .. target.id) -- ❌ a command-language injection boundary

Better interface:

game.request({ kind = "select_entity", id = target.id }) -- ✅ structured data

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

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.

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 + log
AutomationScript: snapshot + log + two bounded requests
DeveloperScript: extra diagnostics in a disposable test build

Do not decide permissions from a script-provided name. The host chooses the API before executing the script.

The lua-labs host uses a simulated snapshot. Add tests where Lua requests:

  • a missing entity ID;
  • 0/0 or 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.

Knowledge check

Keep pointers in Rust

Why should a Lua observer receive copied entity snapshots rather than target-process addresses?

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.

Chapter 11: 11 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.