Chapter 6: replay, a VCR for a machine
Watching a bot play is mostly waiting, and the interesting moment (Link walking into a wall for the ninth time) is always thirty seconds ago. This crate is the rewind button. While the bot plays, every emulated frame is recorded, and afterwards the window can pause, drag back to any frame already played, play it again, and even start a fresh bot from that frame as a new branch, without throwing away the future that was already there.
The obvious way to rewind an emulator is to keep a snapshot of every frame. A snapshot is 1.3 MB, and there are sixty a second, so that is out. The classic trick (Doom demos, Quake, BizHawk's TAS tools) is to record only the inputs: the machine is deterministic, so the same inputs from the same start replay the same game.
Aside: why the pad alone is not enough. The bot cheats. It writes infinite health, bombs and arrows into work RAM every frame, and those writes are inputs too, as far as the game is concerned. So each frame records the pad the console held, the human's pad, and a diff of the work RAM the bot's tick wrote. Replaying is: apply the diff, hold the pad, run the frame.
Seeking is then: restore the nearest keyframe before the target, and replay the log forward from there, headless, never asking the bot anything. Keyframes are kept in three tiers by age, so the cost of a seek is bounded:
| Age | A keyframe every | A click there seeks in about |
|---|---|---|
| the last ~5 minutes | 2 s (120 frames) | half a second |
| the last ~hour | 10 s | up to 3 s |
| older | 30 s | up to 8 s |
(Seek times measured by apps/replay-probe, chapter 14. The first hour of
play takes about 76 MB on disk.)
The bot is the hard part. The console can be rewound from a keyframe;
the bot cannot, because its state is C globals that no snapshot holds. So
"run the bot from here" starts a new process whose fresh bot is ticked
through the whole logged history up to that frame, with every goal choice
it makes answered from the log instead of from Jev. That is exact because
the bot is a pure function of RAM, the pad, rand() and those picks, and
the log fixes all four.
flowchart LR start["root: frame 0"] --> at["frame 500: run the bot from here"] at --> old["root goes on to frame 900, and is kept"] at --> new["root-at-500-1: frames 501 onwards, live"]
A branch's log holds only its own frames, after its fork point; the parent's
are not copied, and Tree::owner_of says which branch in the chain recorded
any given frame.
Try it.
nix develop -c buck2 test //packages/replay:testruns the codec, the tiers and their pruning, the torn-entry log, the branch tree, the speed steps and the disk cap, with no ROM. To prove the core deterministic on your own cartridge, run chapter 14.
For the people who maintain it
On disk
run/zbanks-window/replay/<recording-id>/
header.bin # RunHeader: the starting snapshot, the imported map, bot options
tree.json # the branch tree
branches/
root/
log.bin # FrameEntry values, one per emulated frame, length-prefixed
keyframes/
000000000120.kf # a compressed console snapshot at frame 120
...
root-at-500-1/ # "run the bot from here" at frame 500
log.bin # frames 501.. of THIS branch
keyframes/
000000000500.kf # this branch's own keyframe AT its fork point
...
Recordings are capped at 1 GiB (storage::DEFAULT_CAP_BYTES): the oldest
whole recordings go first, then the one being written is thinned to its
coarse keyframes, never deleted. A window starting deletes recordings in an
older FORMAT_VERSION, which it could not seek.
In this folder
| Path | What |
|---|---|
| src/lib.rs | The modules, and the re-exports FrameEntry, Pick, RunHeader, Tree, BranchMeta. |
| src/format.rs | FrameEntry (what one recorded frame holds) and Pick (which goal-choice option was used); diff_wram/apply_wram_diff. |
| src/keyframe.rs | The compression: XOR a fixed-width snapshot against its reference, then zstd (ruzstd, pure Rust). |
| src/keyframes.rs | A branch's keyframe files in three tiers (DENSE_FRAMES, MEDIUM_FRAMES, COARSE_FRAMES) and prune. Each keyframe is a delta against one of its own tier or coarser. |
| src/log.rs | A branch's append-only frame log: length-prefixed, so a killed process's torn last entry is dropped rather than corrupting the read. |
| src/tree.rs | The branch tree: fork, a branch's ancestry, and which branch owns an absolute frame. |
| src/header.rs | RunHeader, and FORMAT_VERSION. |
| src/recorder.rs | What a live window calls once per frame: append, keep a keyframe every RECORD_EVERY frames, prune aged-out tiers. |
| src/seek.rs | The console at any recorded frame: nearest keyframe, then the log forward, headless. |
| src/rebuild.rs | Rebuilder: catch a fresh bot up to a frame by re-running it over the log, a bounded slice per step, answering goal choices from the log. |
| src/storage.rs | The disk cap, and deleting older formats. |
| src/speed.rs | Playback speed steps (0.25x to 8x) and the frame-pacing maths. |
| BUCK | The rust_library and //packages/replay:test. |
Native only, like zbanks (which it depends on) and mcp: it writes files.
seek.rs, rebuild.rs and storage.rs were built only after
apps/replay-probe proved the core deterministic (see CLAUDE.md).
← Previous: Chapter 5, decisions/ · Up: packages · Next: Chapter 7, panels/ →