1//! What a live window calls once per emulated frame: appends to the active 2//! branch's log, and keeps a keyframe every so often. Never touches the bot 3//! or Jev - it only writes down what already happened, which is what lets 4//! `crate::seek` and `crate::rebuild` treat the log as ground truth. 5 6use std::path::Path; 7use std::time::{Duration, Instant}; 8use std::{fs, io}; 9 10use console::Console; 11 12use crate::format::FrameEntry; 13use crate::keyframes::{self, KeyframeIndex}; 14use crate::log::LogWriter; 15 16/// How often a live recording writes a keyframe: the finest tier's spacing, 17/// ~2 s. `crate::keyframes::KeyframeIndex::prune` then thins them with age 18/// (10 s after ~5 minutes, 30 s after ~an hour). Measured by 19/// apps/replay-probe (2026-09-22): a 2 s keyframe is 115 KB on average (of 20/// a 1.30 MB fixed-width snapshot), each write costs 13 ms on average on 21/// the window's UI thread, and the first hour of play takes ~76 MB on disk. 22pub const RECORD_EVERY: u64 = keyframes::DENSE_FRAMES; 23 24pub struct Recorder { 25 log: LogWriter, 26 keyframes: KeyframeIndex, 27 /// The absolute frame of the last entry appended (this branch's 28 /// `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} 33 34impl Recorder { 35 /// Open (or resume appending to) the log for a branch already forked at 36 /// `fork_frame` - `crate::tree::Tree::fork`, or `fork_frame: 0` for a 37 /// fresh recording's root, which has no fork of its own. Keyframe 38 /// 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 } 46 47 /// A NEW branch's own keyframe at its fork point, so seeking within it 48 /// never has to consult its parent - `crate::tree`'s `owner_of` counts 49 /// on this existing. Call once, right after [`Self::open`], for every 50 /// branch except a fresh recording's root (whose "keyframe at frame 0" 51 /// 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 } 55 56 /// One emulated frame just ran. `snapshot` is called only when a 57 /// keyframe happens to be due ([`RECORD_EVERY`]), so the (comparatively expensive) 58 /// `console::Console::snapshot_fixed()` is never paid for on a frame that 59 /// doesn't need it. Every keyframe write also prunes aged-out tiers 60 /// (`crate::keyframes::KeyframeIndex::prune`), so the tiering keeps 61 /// itself thinned as recording continues rather than needing a separate 62 /// pass. Returns whether a keyframe was written, so a caller that also 63 /// enforces `crate::storage`'s disk cap knows when it is worth checking 64 /// 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 } 68 69 /// [`Self::record`] with the snapshot supplied by a thunk, so the 70 /// 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 } 86 87 pub fn flush(&mut self) -> io::Result<()> { 88 self.log.flush() 89 } 90} 91 92/// The one way a keyframe's snapshot is taken: fixed-width 93/// (`Console::snapshot_fixed`), because `crate::keyframe` deltas it at fixed 94/// offsets against its neighbour. `crate::seek` restores with the matching 95/// `restore_fixed`. Taking the console rather than bytes is what keeps a 96/// 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} 100 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}