1//! Reconstruct the console at any recorded frame: load the nearest keyframe 2//! at or before it, then replay the logged pad and WRAM writes forward, 3//! headless - never the bot. Proven sound by `apps/replay-probe`'s 4//! determinism run (`~/brains/.../replay-timeline.md`'s Outstanding item): 5//! two independent replay passes over 10,000 logged frames were 6//! byte-identical to the original bot-driven recording, every frame. 7 8use std::path::Path; 9use std::time::{Duration, Instant}; 10 11use console::{Console, Saves}; 12 13use crate::format::{FrameEntry, apply_wram_diff}; 14use crate::header::RunHeader; 15use crate::keyframes::KeyframeIndex; 16use crate::log; 17use crate::tree::Tree; 18 19#[derive(Debug)] 20pub enum SeekError { 21 /// `branch` is not in the tree, or owns no frame at or before `target`. 22 NoOwner { branch: String, target: u64 }, 23 /// The recording was written by an older `crate::header::FORMAT_VERSION` 24 /// (its keyframes may be in an encoding this build does not read). 25 Format { found: u32 }, 26 Io(std::io::Error), 27 Console(String), 28} 29 30impl std::fmt::Display for SeekError { 31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 32 match self { 33 Self::NoOwner { branch, target } => write!(f, "no branch in {branch}'s history reaches frame {target}"), 34 Self::Format { found } => write!(f, "recording format {found}, this build reads {}", crate::header::FORMAT_VERSION), 35 Self::Io(e) => write!(f, "{e}"), 36 Self::Console(e) => write!(f, "{e}"), 37 } 38 } 39} 40 41/// `recording_dir` is a recording's own directory (holding `header.bin`, 42/// `tree.json` and `branches/`); `rom` is the exact patched cartridge image 43/// the recording's console was booted from - not stored in the header 44/// itself, see `header.rs`'s doc comment for why. 45/// 46/// Returns the reconstructed console and how long reconstruction took, so a 47/// caller can show or log it (the design's own budget: "well under a 48/// second"). 49pub fn seek_to(recording_dir: &Path, tree: &Tree, header: &RunHeader, rom: Vec<u8>, branch: &str, frame: u64) -> Result<(Console, Duration), SeekError> { 50 let started = Instant::now(); 51 if header.format_version != crate::header::FORMAT_VERSION { 52 return Err(SeekError::Format { found: header.format_version }); 53 } 54 let mut console = Console::boot(rom, Saves::default()).map_err(|e| SeekError::Console(e.to_string()))?; 55 56 if frame == 0 { 57 console.restore(&header.starting_snapshot).map_err(|e| SeekError::Console(e.to_string()))?; 58 return Ok((console, started.elapsed())); 59 } 60 61 let owner = tree.owner_of(branch, frame).ok_or_else(|| SeekError::NoOwner { branch: branch.to_owned(), target: frame })?; 62 let branch_dir = recording_dir.join("branches").join(&owner.id); 63 let keyframes = KeyframeIndex::open(&branch_dir.join("keyframes")).map_err(SeekError::Io)?; 64 let keyframe_frame = keyframes.nearest_at_or_before(frame).filter(|&f| f >= owner.fork_frame); 65 66 // The header's snapshot is `Console::snapshot` (varint) and every 67 // keyframe is `Console::snapshot_fixed` (`crate::recorder`), so each is 68 // restored with its own decoder. 69 let replay_from = match keyframe_frame { 70 Some(kf) if kf != 0 => { 71 let bytes = keyframes.load(kf).map_err(SeekError::Io)?; 72 console.restore_fixed(&bytes).map_err(|e| SeekError::Console(e.to_string()))?; 73 kf 74 } 75 Some(_) => { 76 console.restore(&header.starting_snapshot).map_err(|e| SeekError::Console(e.to_string()))?; 77 0 78 } 79 None if owner.fork_frame == 0 => { 80 console.restore(&header.starting_snapshot).map_err(|e| SeekError::Console(e.to_string()))?; 81 0 82 } 83 None => { 84 // A non-root branch always gets its own keyframe at its fork 85 // point (`crate::recorder::Recorder::store_fork_keyframe`); this 86 // means that call was skipped when the branch was created. 87 return Err(SeekError::NoOwner { branch: branch.to_owned(), target: frame }); 88 } 89 }; 90 91 let entries = log::read_all(&branch_dir.join("log.bin")).map_err(SeekError::Io)?; 92 let start_index = (replay_from - owner.fork_frame) as usize; 93 let end_index = (frame - owner.fork_frame) as usize; 94 let Some(slice) = entries.get(start_index..end_index) else { 95 return Err(SeekError::NoOwner { branch: branch.to_owned(), target: frame }); 96 }; 97 for entry in slice { 98 apply_wram_diff(console.wram_mut(), &entry.wram_diff); 99 zbanks::apply_pad(&mut console, entry.console_pad); 100 console.run_frame().map_err(|e| SeekError::Console(e.to_string()))?; 101 // Nothing during a seek plays audio, and unlike the live path 102 // (`Sound::play`) nobody drains this buffer, so without this it 103 // grows for the whole replay and the returned console carries the 104 // backlog into live play. Not a speed-up: apps/replay-probe measured 105 // 1,800 replayed frames at 8.17 s with it against 8.3 s before 106 // (2026-09-22) - the core's own emulation is the cost, and it has no 107 // render-skip to turn off. 108 console.samples.0.clear(); 109 } 110 Ok((console, started.elapsed())) 111} 112 113/// Every [`FrameEntry`] from frame 1 up to (and stopping at) `frame`, along 114/// `branch`'s own ancestry - possibly spanning several branches, oldest 115/// first, exactly as `crate::rebuild::Rebuilder` needs them to re-run a 116/// fresh bot over the whole history up to a point that may sit inside a 117/// nested branch. Unlike [`seek_to`] (which only ever needs ONE branch's 118/// own entries, since it starts from a keyframe already inside it), a 119/// rebuild starts from frame 0 and must walk the chain from its root. 120pub fn entries_up_to(recording_dir: &Path, tree: &Tree, branch: &str, frame: u64) -> Result<Vec<FrameEntry>, SeekError> { 121 let chain = tree.chain(branch); 122 let mut out = Vec::new(); 123 for (i, b) in chain.iter().enumerate() { 124 let ceiling = chain.get(i + 1).map_or(frame, |next| next.fork_frame).min(frame); 125 if ceiling <= b.fork_frame { 126 continue; 127 } 128 let entries = log::read_all(&recording_dir.join("branches").join(&b.id).join("log.bin")).map_err(SeekError::Io)?; 129 let end = (ceiling - b.fork_frame) as usize; 130 out.extend(entries.into_iter().take(end)); 131 } 132 Ok(out) 133} 134 135#[cfg(test)] 136mod tests { 137 // seek_to needs a real console and a real ROM, so its correctness is 138 // proven by apps/replay-probe (byte-identical to a bot-driven run over 139 // 10,000 frames, twice) rather than a unit test here - there is no 140 // synthetic `Console` to substitute. What IS unit-testable without one: 141 // the branch/keyframe/log-slice arithmetic that decides WHERE to seek 142 // from and WHAT to replay (`crate::tree::owner_of`'s and 143 // `crate::keyframes::nearest_at_or_before`'s own test modules), and 144 // `entries_up_to`'s own cross-branch stitching, below. 145 use super::*; 146 use crate::log::LogWriter; 147 use crate::tree::Tree; 148 149 fn scratch(tag: &str) -> std::path::PathBuf { 150 let dir = std::env::temp_dir().join(format!("jev-replay-seek-test-{tag}-{}", std::process::id())); 151 let _ = std::fs::remove_dir_all(&dir); 152 dir 153 } 154 155 fn write_branch_log(recording_dir: &Path, branch: &str, pads: &[u16]) { 156 let dir = recording_dir.join("branches").join(branch); 157 std::fs::create_dir_all(&dir).unwrap(); 158 let mut w = LogWriter::create_or_append(&dir.join("log.bin")).unwrap(); 159 for &pad in pads { 160 w.append(&FrameEntry { console_pad: pad, human_pad: 0, wram_diff: Vec::new(), decision: None }).unwrap(); 161 } 162 w.flush().unwrap(); 163 } 164 165 #[test] 166 fn entries_up_to_stitches_a_root_and_a_child_branch_in_order() { 167 let dir = scratch("stitch"); 168 let mut tree = Tree::new(); 169 // root records frames 1..=10 (pad = frame number, for an easy check). 170 write_branch_log(&dir, Tree::ROOT, &(1u16..=10).collect::<Vec<_>>()); 171 tree.get_mut(Tree::ROOT).unwrap().last_frame = 10; 172 // forked at frame 4; its own log covers local frames 1..=6 (absolute 5..=10). 173 let child = tree.fork(Tree::ROOT, 4); 174 write_branch_log(&dir, &child.id, &(5u16..=10).collect::<Vec<_>>()); 175 tree.get_mut(&child.id).unwrap().last_frame = 10; 176 177 // Up to frame 7 along the child: root's 1..=4, then child's 5..=7. 178 let entries = entries_up_to(&dir, &tree, &child.id, 7).unwrap(); 179 assert_eq!(entries.iter().map(|e| e.console_pad).collect::<Vec<_>>(), vec![1, 2, 3, 4, 5, 6, 7]); 180 181 // Up to frame 4 (exactly the fork point): root's contribution only. 182 let entries = entries_up_to(&dir, &tree, &child.id, 4).unwrap(); 183 assert_eq!(entries.iter().map(|e| e.console_pad).collect::<Vec<_>>(), vec![1, 2, 3, 4]); 184 185 // Along the ROOT's own id, the child's frames never appear even 186 // though they cover the same absolute range. 187 let entries = entries_up_to(&dir, &tree, Tree::ROOT, 10).unwrap(); 188 assert_eq!(entries.iter().map(|e| e.console_pad).collect::<Vec<_>>(), (1u16..=10).collect::<Vec<_>>()); 189 } 190}