jevsnes.git / packages / replay / README.md
README.mdpreviewREADME.mdsource108 lines · 5.7 KB · raw

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:

AgeA keyframe everyA click there seeks in about
the last ~5 minutes2 s (120 frames)half a second
the last ~hour10 sup to 3 s
older30 sup 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:test runs 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

PathWhat
src/lib.rsThe modules, and the re-exports FrameEntry, Pick, RunHeader, Tree, BranchMeta.
src/format.rsFrameEntry (what one recorded frame holds) and Pick (which goal-choice option was used); diff_wram/apply_wram_diff.
src/keyframe.rsThe compression: XOR a fixed-width snapshot against its reference, then zstd (ruzstd, pure Rust).
src/keyframes.rsA 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.rsA 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.rsThe branch tree: fork, a branch's ancestry, and which branch owns an absolute frame.
src/header.rsRunHeader, and FORMAT_VERSION.
src/recorder.rsWhat a live window calls once per frame: append, keep a keyframe every RECORD_EVERY frames, prune aged-out tiers.
src/seek.rsThe console at any recorded frame: nearest keyframe, then the log forward, headless.
src/rebuild.rsRebuilder: 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.rsThe disk cap, and deleting older formats.
src/speed.rsPlayback speed steps (0.25x to 8x) and the frame-pacing maths.
BUCKThe 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/ →