jevsnes.git / packages / replay / src / keyframes.rs
1//! A branch's keyframes, kept at three tiers so a scrubber click near the
2//! live edge - where it matters most - never has to replay far, while a
3//! long session still stays bounded on disk. BizHawk calls this a
4//! "greenzone": dense near the tip, thinning out with age.
5//!
6//! - **Dense** every [`DENSE_FRAMES`] (~2s) for the most recent
7//!   [`DENSE_RETAIN_FRAMES`] (~5 minutes).
8//! - **Medium** every [`MEDIUM_FRAMES`] (~10s) for the next
9//!   [`MEDIUM_RETAIN_FRAMES`] (~1 hour) beyond that.
10//! - **Coarse** every [`COARSE_FRAMES`] (~30s) forever beyond that.
11//!
12//! Every keyframe is delta-compressed (`crate::keyframe`) against the
13//! nearest earlier keyframe of ITS OWN TIER OR COARSER - never a finer one -
14//! so [`KeyframeIndex::prune`]ing a whole aged-out tier never orphans a
15//! survivor's reference. A dense keyframe references the dense-or-coarser
16//! keyframe `DENSE_FRAMES` earlier; a medium keyframe references the
17//! medium-or-coarser keyframe `MEDIUM_FRAMES` earlier; a coarse keyframe is
18//! a base. Chain depth is bounded by construction - at most
19//! `MEDIUM_FRAMES / DENSE_FRAMES - 1` dense hops before a medium-or-coarser
20//! keyframe, then at most `COARSE_FRAMES / MEDIUM_FRAMES - 1` medium hops
21//! before a coarse (base) one - so `BASE_EVERY`-style depth bookkeeping is
22//! no longer needed; the tier boundaries already bound it.
23
24use std::path::{Path, PathBuf};
25use std::{fs, io};
26
27use bincode::{Decode, Encode};
28
29use crate::keyframe;
30
31/// ~2 seconds at 60.0988 fps.
32pub const DENSE_FRAMES: u64 = 120;
33/// ~10 seconds; an exact multiple of `DENSE_FRAMES`.
34pub const MEDIUM_FRAMES: u64 = 600;
35/// ~30 seconds; an exact multiple of `MEDIUM_FRAMES`. Matches this
36/// project's earlier flat cadence, kept as the floor once history is old
37/// enough that nobody is likely to scrub it frame-precisely.
38pub const COARSE_FRAMES: u64 = 1_800;
39
40/// ~5 minutes: how long a keyframe stays at dense spacing before thinning.
41pub const DENSE_RETAIN_FRAMES: u64 = 18_046;
42/// ~1 hour: how long a keyframe stays at medium spacing before thinning to
43/// coarse-only. Beyond this, only coarse keyframes remain for that stretch.
44pub const MEDIUM_RETAIN_FRAMES: u64 = 216_559;
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Tier {
48    Dense,
49    Medium,
50    Coarse,
51}
52
53/// Which tier `frame` belongs to - the FINEST tier its frame number lands
54/// on. `MEDIUM_FRAMES` and `COARSE_FRAMES` are exact multiples of
55/// `DENSE_FRAMES`, so this is just divisibility.
56pub fn tier_of(frame: u64) -> Tier {
57    if frame % COARSE_FRAMES == 0 {
58        Tier::Coarse
59    } else if frame % MEDIUM_FRAMES == 0 {
60        Tier::Medium
61    } else {
62        Tier::Dense
63    }
64}
65
66/// The frame `frame`'s keyframe SHOULD be delta-compressed against, per its
67/// tier - `None` for a base. `KeyframeIndex::store` still falls back to a
68/// base if this frame is not actually present (a branch's own first
69/// keyframe, at an arbitrary fork point, has nothing earlier in ITS OWN
70/// directory to reference even if this says otherwise).
71fn preferred_reference(frame: u64) -> Option<u64> {
72    match tier_of(frame) {
73        Tier::Coarse => None,
74        Tier::Medium => Some(frame - MEDIUM_FRAMES),
75        Tier::Dense => Some(frame - DENSE_FRAMES),
76    }
77}
78
79const CONFIG: bincode::config::Configuration = bincode::config::standard();
80
81#[derive(Encode, Decode)]
82struct Stored {
83    ref_frame: Option<u64>,
84    original_len: u32,
85    payload: Vec<u8>,
86}
87
88pub struct KeyframeIndex {
89    dir: PathBuf,
90}
91
92fn parse_frame(name: &std::ffi::OsStr) -> Option<u64> {
93    name.to_str()?.strip_suffix(".kf")?.parse().ok()
94}
95
96impl KeyframeIndex {
97    pub fn open(dir: &Path) -> io::Result<Self> {
98        fs::create_dir_all(dir)?;
99        Ok(Self { dir: dir.to_owned() })
100    }
101
102    fn path(&self, frame: u64) -> PathBuf {
103        self.dir.join(format!("{frame:012}.kf"))
104    }
105
106    /// Where a keyframe's file lives on disk - for a caller that only wants
107    /// its size (`apps/replay-probe`'s measurement) without decoding it.
108    pub fn file_path(&self, frame: u64) -> PathBuf {
109        self.path(frame)
110    }
111
112    /// Every keyframe's frame number in this branch, ascending.
113    pub fn frames(&self) -> Vec<u64> {
114        let mut frames: Vec<u64> = fs::read_dir(&self.dir)
115            .into_iter()
116            .flatten()
117            .flatten()
118            .filter_map(|e| parse_frame(&e.file_name()))
119            .collect();
120        frames.sort_unstable();
121        frames
122    }
123
124    /// The largest keyframe frame `<= target`, if any.
125    pub fn nearest_at_or_before(&self, target: u64) -> Option<u64> {
126        self.frames().into_iter().filter(|&f| f <= target).max()
127    }
128
129    fn read_stored(&self, frame: u64) -> io::Result<Stored> {
130        let bytes = fs::read(self.path(frame))?;
131        bincode::decode_from_slice(&bytes, CONFIG)
132            .map(|(s, _)| s)
133            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
134    }
135
136    /// Decode the keyframe at `frame` back to a full snapshot. `frame` must
137    /// be one [`Self::frames`] lists.
138    pub fn load(&self, frame: u64) -> io::Result<Vec<u8>> {
139        let stored = self.read_stored(frame)?;
140        let reference = match stored.ref_frame {
141            None => None,
142            Some(rf) => Some(self.load(rf)?),
143        };
144        keyframe::decompress(reference.as_deref(), &stored.payload, stored.original_len as usize)
145    }
146
147    /// Keep `snapshot` under `frame`, delta-compressed per its tier
148    /// ([`preferred_reference`]) against whatever is actually on disk - a
149    /// branch's very first keyframe (at its fork point, any frame number)
150    /// has nothing to reference in ITS OWN directory yet regardless of what
151    /// tier its frame number would suggest, so it falls back to a base.
152    /// Written atomically, like every other file this project keeps beside
153    /// a running window.
154    pub fn store(&self, frame: u64, snapshot: &[u8]) -> io::Result<()> {
155        let existing = self.frames();
156        let ref_frame = preferred_reference(frame).filter(|rf| existing.contains(rf));
157        let reference = match ref_frame {
158            Some(rf) => Some(self.load(rf)?),
159            None => None,
160        };
161        let payload = keyframe::compress(reference.as_deref(), snapshot);
162        let stored = Stored { ref_frame, original_len: snapshot.len() as u32, payload };
163        let bytes = bincode::encode_to_vec(&stored, CONFIG).expect("encoding a keyframe never fails");
164        let tmp = self.dir.join(format!("{frame:012}.kf.part"));
165        fs::write(&tmp, bytes)?;
166        fs::rename(tmp, self.path(frame))
167    }
168
169    /// Thin keyframes that have aged out of a finer tier, relative to
170    /// `tip_frame` (the branch's current live edge - or, for a paused
171    /// branch, its last recorded frame). Deletes a WHOLE tier-bucket at
172    /// once, never a partial one, so a still-live keyframe's reference is
173    /// never removed out from under it (this module's own doc comment), and
174    /// never deletes the SMALLEST frame in the index - a branch's own
175    /// anchor keyframe at its fork point, which every seek into its early
176    /// history depends on regardless of what tier its frame number would
177    /// otherwise put it in. Returns how many files were removed.
178    pub fn prune(&self, tip_frame: u64) -> io::Result<usize> {
179        let frames = self.frames();
180        let Some(&anchor) = frames.first() else { return Ok(0) };
181        let mut removed = 0;
182        for frame in frames {
183            if frame == anchor {
184                continue;
185            }
186            let (bucket_width, tier_retain) = match tier_of(frame) {
187                Tier::Dense => (MEDIUM_FRAMES, DENSE_RETAIN_FRAMES),
188                Tier::Medium => (COARSE_FRAMES, MEDIUM_RETAIN_FRAMES),
189                Tier::Coarse => continue, // never thinned further
190            };
191            // The bucket's own last (youngest) member at this tier - only
192            // once IT has aged past the tier's retention window is the
193            // whole bucket safe to remove together.
194            let bucket_start = frame - frame % bucket_width;
195            let bucket_last = bucket_start + bucket_width - if tier_of(frame) == Tier::Dense { DENSE_FRAMES } else { MEDIUM_FRAMES };
196            if tip_frame.saturating_sub(bucket_last) > tier_retain {
197                fs::remove_file(self.path(frame))?;
198                removed += 1;
199            }
200        }
201        Ok(removed)
202    }
203
204    /// Bytes on disk of every keyframe in this branch.
205    pub fn bytes(&self) -> u64 {
206        self.frames().iter().map(|&f| fs::metadata(self.path(f)).map_or(0, |m| m.len())).sum()
207    }
208
209    /// Delete every dense and medium keyframe in the OLDEST `COARSE_FRAMES`
210    /// span that still has any, ahead of their age - what
211    /// `crate::storage::enforce_cap` does to the active recording once
212    /// nothing else is left to delete. The whole span goes at once for the
213    /// same reason [`Self::prune`] removes whole buckets: a finer keyframe
214    /// references one inside its own span, and a coarse keyframe references
215    /// nothing, so removing everything finer in one span can orphan nothing
216    /// that survives. Coarse keyframes and the anchor are never touched, so
217    /// every frame stays seekable, only slower. Returns the bytes freed, 0
218    /// once only coarse keyframes and the anchor remain.
219    pub fn thin_oldest_span(&self) -> io::Result<u64> {
220        let frames = self.frames();
221        let Some(&anchor) = frames.first() else { return Ok(0) };
222        let thinnable = |f: &u64| *f != anchor && tier_of(*f) != Tier::Coarse;
223        let Some(&oldest) = frames.iter().find(|f| thinnable(f)) else { return Ok(0) };
224        let span = oldest / COARSE_FRAMES;
225        let mut freed = 0;
226        for f in frames.iter().filter(|f| thinnable(f) && **f / COARSE_FRAMES == span) {
227            let path = self.path(*f);
228            freed += fs::metadata(&path).map_or(0, |m| m.len());
229            fs::remove_file(path)?;
230        }
231        Ok(freed)
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    fn scratch(tag: &str) -> KeyframeIndex {
240        let dir = std::env::temp_dir().join(format!("jev-replay-keyframes-test-{tag}-{}", std::process::id()));
241        let _ = fs::remove_dir_all(&dir);
242        KeyframeIndex::open(&dir).unwrap()
243    }
244
245    fn snapshot(seed: u8, len: usize) -> Vec<u8> {
246        (0..len).map(|i| seed.wrapping_add((i % 251) as u8)).collect()
247    }
248
249    #[test]
250    fn tier_of_matches_the_three_boundaries() {
251        assert_eq!(tier_of(0), Tier::Coarse);
252        assert_eq!(tier_of(1_800), Tier::Coarse);
253        assert_eq!(tier_of(3_600), Tier::Coarse);
254        assert_eq!(tier_of(600), Tier::Medium);
255        assert_eq!(tier_of(1_200), Tier::Medium);
256        assert_eq!(tier_of(120), Tier::Dense);
257        assert_eq!(tier_of(240), Tier::Dense);
258        assert_eq!(tier_of(1), Tier::Dense);
259    }
260
261    #[test]
262    fn a_lone_keyframe_round_trips() {
263        let idx = scratch("lone");
264        let data = snapshot(1, 5000);
265        idx.store(100, &data).unwrap();
266        assert_eq!(idx.frames(), vec![100]);
267        assert_eq!(idx.load(100).unwrap(), data);
268    }
269
270    #[test]
271    fn a_full_tier_ladder_all_round_trips() {
272        let idx = scratch("ladder");
273        // Every DENSE_FRAMES step for two full COARSE_FRAMES cycles, so
274        // every tier boundary and every in-between frame is exercised.
275        let mut frame = 0u64;
276        let mut seed = 0u8;
277        while frame <= COARSE_FRAMES * 2 {
278            let mut data = snapshot(seed, 4000);
279            data[10] = seed; // guarantee at least one real change per frame
280            idx.store(frame, &data).unwrap();
281            frame += DENSE_FRAMES;
282            seed = seed.wrapping_add(1);
283        }
284        let frames = idx.frames();
285        assert_eq!(frames.len() as u64, COARSE_FRAMES * 2 / DENSE_FRAMES + 1);
286        let mut seed = 0u8;
287        for f in frames {
288            let mut expected = snapshot(seed, 4000);
289            expected[10] = seed;
290            assert_eq!(idx.load(f).unwrap(), expected, "frame {f}");
291            seed = seed.wrapping_add(1);
292        }
293    }
294
295    #[test]
296    fn nearest_at_or_before_finds_the_right_one() {
297        let idx = scratch("nearest");
298        for f in [10u64, 50, 200] {
299            idx.store(f, &snapshot(1, 100)).unwrap();
300        }
301        assert_eq!(idx.nearest_at_or_before(9), None);
302        assert_eq!(idx.nearest_at_or_before(10), Some(10));
303        assert_eq!(idx.nearest_at_or_before(49), Some(10));
304        assert_eq!(idx.nearest_at_or_before(500), Some(200));
305    }
306
307    #[test]
308    fn a_branchs_first_keyframe_at_an_arbitrary_frame_is_a_base() {
309        // Not a multiple of anything: tier_of would call this Dense and
310        // want to reference frame-120, which does not exist in this fresh
311        // directory - it must fall back to a base instead of erroring.
312        let idx = scratch("arbitrary-anchor");
313        let data = snapshot(7, 2000);
314        idx.store(1_234_567, &data).unwrap();
315        assert_eq!(idx.load(1_234_567).unwrap(), data);
316    }
317
318    #[test]
319    fn pruning_removes_only_whole_aged_out_dense_buckets_and_never_the_anchor() {
320        let idx = scratch("prune-dense");
321        // Anchor at 0 (coarse, never touched), then a full dense ladder up
322        // to just short of the next coarse boundary.
323        let mut frame = 0u64;
324        while frame < COARSE_FRAMES {
325            idx.store(frame, &snapshot(frame as u8, 500)).unwrap();
326            frame += DENSE_FRAMES;
327        }
328        let before = idx.frames();
329        assert!(before.len() > 2, "need more than just the anchor and one dense frame");
330
331        // Not yet aged out: nothing removed.
332        let removed = idx.prune(DENSE_RETAIN_FRAMES).unwrap();
333        assert_eq!(removed, 0);
334        assert_eq!(idx.frames(), before);
335
336        // Aged out: every dense (and medium) frame in the first
337        // MEDIUM_FRAMES bucket goes, but frame 0 (the anchor, coarse
338        // tier) survives regardless of age.
339        let tip = COARSE_FRAMES - DENSE_FRAMES + DENSE_RETAIN_FRAMES + 1;
340        idx.prune(tip).unwrap();
341        let remaining = idx.frames();
342        assert!(remaining.contains(&0), "the anchor must never be pruned");
343        assert!(!remaining.contains(&DENSE_FRAMES), "an aged-out dense frame should be gone");
344        // Every surviving keyframe must still decode.
345        for f in &remaining {
346            idx.load(*f).expect("a surviving keyframe must still decode after pruning");
347        }
348    }
349
350    #[test]
351    fn pruning_a_medium_bucket_never_orphans_a_surviving_dense_reference() {
352        // A dense keyframe just past a medium boundary references the
353        // medium/coarse anchor of ITS OWN bucket, never the previous
354        // bucket's tail - so thinning one medium bucket must never break a
355        // dense keyframe that is still within its own retention window in
356        // the NEXT bucket.
357        let idx = scratch("prune-cross-bucket");
358        for frame in (0..=MEDIUM_FRAMES * 3).step_by(DENSE_FRAMES as usize) {
359            idx.store(frame, &snapshot(frame as u8, 300)).unwrap();
360        }
361        // Age everything past MEDIUM_RETAIN_FRAMES relative to a tip far in
362        // the future, but well within DENSE_RETAIN_FRAMES for the last
363        // bucket, whose own dense members must survive.
364        let tip = MEDIUM_FRAMES * 3 + 10;
365        idx.prune(tip).unwrap();
366        for f in idx.frames() {
367            idx.load(f).expect("every surviving keyframe must still decode");
368        }
369    }
370
371    #[test]
372    fn thinning_takes_the_oldest_span_whole_and_keeps_coarse_and_anchor() {
373        let idx = scratch("thin");
374        // Anchor at 240 (a dense frame number), then every dense step
375        // through three coarse spans.
376        let mut frame = 240;
377        while frame <= COARSE_FRAMES * 3 {
378            idx.store(frame, &snapshot(frame as u8, 400)).unwrap();
379            frame += DENSE_FRAMES;
380        }
381        assert!(idx.thin_oldest_span().unwrap() > 0);
382        let left = idx.frames();
383        assert!(left.contains(&240), "anchor kept");
384        assert!(left.contains(&COARSE_FRAMES), "coarse kept");
385        assert!(!left.iter().any(|&f| f != 240 && f < COARSE_FRAMES), "the first span's finer keyframes are gone");
386        assert!(left.contains(&(COARSE_FRAMES + DENSE_FRAMES)), "the next span is untouched");
387        for f in &left {
388            idx.load(*f).expect("every survivor still decodes");
389        }
390        while idx.thin_oldest_span().unwrap() > 0 {}
391        let left = idx.frames();
392        assert!(left.iter().all(|&f| f == 240 || tier_of(f) == Tier::Coarse));
393        for f in &left {
394            idx.load(*f).expect("every survivor still decodes");
395        }
396    }
397}