jevsnes.git / packages / replay / src / recorder.rs

What a live window calls once per emulated frame: appends to the active branch's log, and keeps a keyframe every so often. Never touches the bot or Jev - it only writes down what already happened, which is what lets crate::seek and crate::rebuild treat the log as ground truth.

6use std::path::Path;
7use std::time::{Duration, Instant};
8use std::{fs, io};
10use console::Console;
11
12use crate::format::FrameEntry;
13use crate::keyframes::{self, KeyframeIndex};
14use crate::log::LogWriter;

How often a live recording writes a keyframe: the finest tier's spacing, ~2 s. crate::keyframes::KeyframeIndex::prune then thins them with age (10 s after ~5 minutes, 30 s after ~an hour). Measured by apps/replay-probe (2026-09-22): a 2 s keyframe is 115 KB on average (of a 1.30 MB fixed-width snapshot), each write costs 13 ms on average on the window's UI thread, and the first hour of play takes ~76 MB on disk.

22pub const RECORD_EVERY: u64 = keyframes::DENSE_FRAMES;
24pub struct Recorder {
25    log: LogWriter,
26    keyframes: KeyframeIndex,

The absolute frame of the last entry appended (this branch's fork_frame if nothing has been recorded to it yet).

29    pub last_frame: u64,
30    last_flush_at: Instant,
31    flush_every: Duration,
32}
34impl Recorder {

Open (or resume appending to) the log for a branch already forked at fork_frame - crate::tree::Tree::fork, or fork_frame: 0 for a fresh recording's root, which has no fork of its own. Keyframe cadence is [RECORD_EVERY].

39    pub fn open(branch_dir: &Path, fork_frame: u64) -> io::Result<Self> {
40        fs::create_dir_all(branch_dir)?;
41        let log = LogWriter::create_or_append(&branch_dir.join("log.bin"))?;
42        let keyframes = KeyframeIndex::open(&branch_dir.join("keyframes"))?;
43        let last_frame = keyframes.frames().into_iter().max().unwrap_or(fork_frame).max(fork_frame);
44        Ok(Self { log, keyframes, last_frame, last_flush_at: Instant::now(), flush_every: Duration::from_secs(1) })
45    }

A NEW branch's own keyframe at its fork point, so seeking within it never has to consult its parent - crate::tree's owner_of counts on this existing. Call once, right after [Self::open], for every branch except a fresh recording's root (whose "keyframe at frame 0" is the recording's own header instead).

52    pub fn store_fork_keyframe(&mut self, fork_frame: u64, console: &Console) -> io::Result<()> {
53        self.keyframes.store(fork_frame, &keyframe_snapshot(console)?)
54    }

One emulated frame just ran. snapshot is called only when a keyframe happens to be due ([RECORD_EVERY]), so the (comparatively expensive) console::Console::snapshot_fixed() is never paid for on a frame that doesn't need it. Every keyframe write also prunes aged-out tiers (crate::keyframes::KeyframeIndex::prune), so the tiering keeps itself thinned as recording continues rather than needing a separate pass. Returns whether a keyframe was written, so a caller that also enforces crate::storage's disk cap knows when it is worth checking again rather than doing it every frame.

65    pub fn record(&mut self, entry: FrameEntry, console: &Console) -> io::Result<bool> {
66        self.record_snapshot(entry, || keyframe_snapshot(console))
67    }

[Self::record] with the snapshot supplied by a thunk, so the cadence and pruning are testable without booting a cartridge.

71    fn record_snapshot(&mut self, entry: FrameEntry, snapshot: impl FnOnce() -> io::Result<Vec<u8>>) -> io::Result<bool> {
72        self.log.append(&entry)?;
73        self.last_frame += 1;
74        let now = Instant::now();
75        if now.duration_since(self.last_flush_at) >= self.flush_every {
76            self.log.flush()?;
77            self.last_flush_at = now;
78        }
79        if self.last_frame % RECORD_EVERY == 0 {
80            self.keyframes.store(self.last_frame, &snapshot()?)?;
81            self.keyframes.prune(self.last_frame)?;
82            return Ok(true);
83        }
84        Ok(false)
85    }
87    pub fn flush(&mut self) -> io::Result<()> {
88        self.log.flush()
89    }
90}

The one way a keyframe's snapshot is taken: fixed-width (Console::snapshot_fixed), because crate::keyframe deltas it at fixed offsets against its neighbour. crate::seek restores with the matching restore_fixed. Taking the console rather than bytes is what keeps a varint snapshot from ever reaching the keyframe store.

97fn keyframe_snapshot(console: &Console) -> io::Result<Vec<u8>> {
98    console.snapshot_fixed().map_err(|e| io::Error::other(format!("keyframe snapshot: {e}")))
99}
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use crate::format::FrameEntry;
105
106    fn scratch(tag: &str) -> std::path::PathBuf {
107        let dir = std::env::temp_dir().join(format!("jev-replay-recorder-test-{tag}-{}", std::process::id()));
108        let _ = fs::remove_dir_all(&dir);
109        dir
110    }
111
112    fn entry() -> FrameEntry {
113        FrameEntry { console_pad: 0, human_pad: 0, wram_diff: Vec::new(), decision: None }
114    }
115
116    #[test]
117    fn recording_appends_to_the_log_and_tracks_last_frame() {
118        let dir = scratch("append");
119        let mut rec = Recorder::open(&dir, 0).unwrap();
120        for i in 0..5u64 {
121            let wrote = rec.record_snapshot(entry(), || panic!("no keyframe should be due")).unwrap();
122            assert!(!wrote, "frame {} is not a multiple of RECORD_EVERY", i + 1);
123        }
124        assert_eq!(rec.last_frame, 5);
125        rec.flush().unwrap();
126        let back = crate::log::read_all(&dir.join("log.bin")).unwrap();
127        assert_eq!(back.len(), 5);
128    }
129
130    #[test]
131    fn a_keyframe_is_written_every_record_every_frames() {
132        let dir = scratch("keyframe-due");
133        let mut rec = Recorder::open(&dir, 0).unwrap();
134        for i in 1..=RECORD_EVERY {
135            let wrote = rec.record_snapshot(entry(), || Ok(vec![1, 2, 3])).unwrap();
136            assert_eq!(wrote, i == RECORD_EVERY, "frame {i}");
137        }
138        let idx = KeyframeIndex::open(&dir.join("keyframes")).unwrap();
139        assert_eq!(idx.frames(), vec![RECORD_EVERY]);
140        assert_eq!(idx.load(RECORD_EVERY).unwrap(), vec![1, 2, 3]);
141    }
142
143    #[test]
144    fn reopening_a_branch_resumes_last_frame_from_its_keyframes() {
145        let dir = scratch("resume");
146        {
147            let mut rec = Recorder::open(&dir, 0).unwrap();
148            for _ in 1..=RECORD_EVERY {
149                rec.record_snapshot(entry(), || Ok(vec![9; 10])).unwrap();
150            }
151        }
152        let rec = Recorder::open(&dir, 0).unwrap();
153        assert_eq!(rec.last_frame, RECORD_EVERY);
154    }
155
156    #[test]
157    fn opening_a_forked_branch_starts_at_its_fork_frame() {
158        // store_fork_keyframe itself needs a booted cartridge; the probe
159        // (apps/replay-probe) exercises it against the real console.
160        let dir = scratch("fork-open");
161        let rec = Recorder::open(&dir, 500).unwrap();
162        assert_eq!(rec.last_frame, 500);
163    }
164}