jevsnes.git / packages / replay / src / seek.rs
seek.rsannotatedseek.rssource190 lines · 9.0 KB · raw

Reconstruct the console at any recorded frame: load the nearest keyframe at or before it, then replay the logged pad and WRAM writes forward, headless - never the bot. Proven sound by apps/replay-probe's determinism run (~/brains/.../replay-timeline.md's Outstanding item): two independent replay passes over 10,000 logged frames were byte-identical to the original bot-driven recording, every frame.

8use std::path::Path;
9use std::time::{Duration, Instant};
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 {

branch is not in the tree, or owns no frame at or before target.

22    NoOwner { branch: String, target: u64 },

The recording was written by an older crate::header::FORMAT_VERSION (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}
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}

recording_dir is a recording's own directory (holding header.bin, tree.json and branches/); rom is the exact patched cartridge image the recording's console was booted from - not stored in the header itself, see header.rs's doc comment for why.

Returns the reconstructed console and how long reconstruction took, so a caller can show or log it (the design's own budget: "well under a 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}

Every [FrameEntry] from frame 1 up to (and stopping at) frame, along branch's own ancestry - possibly spanning several branches, oldest first, exactly as crate::rebuild::Rebuilder needs them to re-run a fresh bot over the whole history up to a point that may sit inside a nested branch. Unlike [seek_to] (which only ever needs ONE branch's own entries, since it starts from a keyframe already inside it), a 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}
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}