Skip to main content

rustynes_core/
rewind.rs

1//! Rewind ring buffer.
2//!
3//! Per `to-dos/phase-5-frontend-tooling/sprint-2-save-rewind.md` T-52-004 /
4//! T-52-005. The rewind ring captures one entry per emulated frame, then
5//! lets the caller step backwards through history one entry at a time. The
6//! frontend's `F5`-held UX uses [`Self::pop_back`] to restore the previous
7//! state on every redraw.
8//!
9//! # Memory budget
10//!
11//! Target: 60 s @ 60 fps in ≤ 32 MiB. That leaves ~9 KiB per snapshot.
12//! Raw chip state (CPU + PPU + APU + mappers minus large arrays) is on
13//! the order of a few KiB; the heavy hitters are the framebuffer
14//! (256×240×4 = 245,760 B) and PRG-RAM (up to 32 KiB on MMC3/MMC5).
15//!
16//! Strategy: every `keyframe_period` frames we store a full LZ4-compressed
17//! snapshot; in between we store an LZ4-compressed XOR delta against the
18//! most recent keyframe. NES screen content changes slowly, so the deltas
19//! compress aggressively (most bytes are 0).
20//!
21//! # Restore semantics
22//!
23//! [`Self::pop_back`] returns the most recent buffered snapshot bytes
24//! (after delta-applying against the keyframe) and removes that entry from
25//! the ring. Calling repeatedly walks back in time. Once the ring is
26//! drained, [`Self::pop_back`] returns `None`.
27
28// `alloc::collections::VecDeque` is the same type as `std::collections::VecDeque`
29// (std re-exports from alloc). Using the alloc path keeps this module portable
30// to `#![no_std]` consumers. See `docs/architecture.md` §no_std migration. The
31// remaining blocker for actual `#![no_std]` on `rustynes-core` is `thiserror = "1.0"`
32// (std-only); upgrade to `thiserror = "2.0"` (core::error::Error) is tracked
33// separately.
34extern crate alloc;
35use alloc::collections::VecDeque;
36use alloc::{boxed::Box, string::String, vec::Vec};
37use alloc::{format, vec};
38
39use lz4_flex::block::{compress_prepend_size, decompress_size_prepended};
40use thiserror::Error;
41
42/// Default ring capacity in bytes — 60 s × 60 fps with our typical
43/// compression ratio fits comfortably under 32 MiB.
44pub const REWIND_DEFAULT_MAX_BYTES: usize = 32 * 1024 * 1024;
45
46/// Default keyframe period (1 keyframe per second of capture).
47pub const REWIND_DEFAULT_KEYFRAME_PERIOD: u32 = 60;
48
49/// Errors raised by [`RewindRing::pop_back`] when an entry can't be
50/// reconstructed.
51#[derive(Debug, Error)]
52#[non_exhaustive]
53pub enum RewindError {
54    /// The keyframe needed to delta-apply this entry was already evicted.
55    #[error("rewind keyframe missing for delta entry")]
56    MissingKeyframe,
57    /// LZ4 decompression failed (corrupt entry).
58    #[error("rewind LZ4 decompress: {0}")]
59    Decompress(String),
60    /// Snapshot lengths after delta-application don't match.
61    #[error("rewind delta length mismatch: keyframe {kf} bytes, delta {dl} bytes")]
62    LengthMismatch {
63        /// Keyframe length.
64        kf: usize,
65        /// Delta length.
66        dl: usize,
67    },
68}
69
70#[derive(Debug, Clone)]
71enum Body {
72    /// LZ4-compressed full snapshot. Decompresses to the raw snapshot bytes.
73    Keyframe(Box<[u8]>),
74    /// LZ4-compressed XOR delta against the most recent keyframe.
75    /// Decompresses to a `Vec<u8>` of the same length as the keyframe;
76    /// applying byte-XOR with the keyframe reconstructs the snapshot.
77    Delta(Box<[u8]>),
78}
79
80/// One ring entry: a frame index + compressed body.
81#[derive(Debug, Clone)]
82struct Entry {
83    frame: u64,
84    body: Body,
85    /// Index of the keyframe entry this delta refers to. Same as `self`'s
86    /// position for keyframes; otherwise the index of the most recent
87    /// keyframe at capture time. We resolve by walking backward from the
88    /// delta to find the closest keyframe still in the buffer (deque
89    /// shuffling makes any cached index unstable).
90    is_keyframe: bool,
91    /// Approximate in-memory size in bytes of this entry. Used by the
92    /// `max_bytes` eviction policy.
93    approx_bytes: usize,
94}
95
96/// Rewind ring buffer.
97///
98/// Configure once via [`Self::new`] (or [`Self::default`]); call
99/// [`Self::push`] after each emulated frame; call [`Self::pop_back`] to
100/// step backwards in time during rewind playback.
101#[derive(Debug)]
102pub struct RewindRing {
103    entries: VecDeque<Entry>,
104    /// Soft byte cap — entries are evicted from the front until satisfied.
105    max_bytes: usize,
106    /// Current accumulated byte count (sum of `approx_bytes`).
107    cur_bytes: usize,
108    /// Captures since the last keyframe (counts both kinds).
109    since_keyframe: u32,
110    /// Keyframe interval — every Nth call to [`Self::push`] forces a
111    /// keyframe.
112    keyframe_period: u32,
113    /// The most recent keyframe's decompressed snapshot bytes, cached so
114    /// that subsequent deltas can be reconstructed without re-decompressing
115    /// it for every push.
116    last_keyframe_decoded: Option<Vec<u8>>,
117    /// v2.8.0 Phase 3 — reused scratch for the per-push XOR delta (kills a
118    /// ~250 KiB allocation on every non-keyframe capture).
119    delta_scratch: Vec<u8>,
120}
121
122impl Default for RewindRing {
123    fn default() -> Self {
124        Self::new(REWIND_DEFAULT_MAX_BYTES, REWIND_DEFAULT_KEYFRAME_PERIOD)
125    }
126}
127
128impl RewindRing {
129    /// New ring with `max_bytes` of in-memory budget and a keyframe every
130    /// `keyframe_period` captures.
131    ///
132    /// `keyframe_period` of 0 is treated as 1 (every entry is a keyframe).
133    #[must_use]
134    pub fn new(max_bytes: usize, keyframe_period: u32) -> Self {
135        Self {
136            entries: VecDeque::new(),
137            max_bytes,
138            cur_bytes: 0,
139            since_keyframe: 0,
140            keyframe_period: keyframe_period.max(1),
141            last_keyframe_decoded: None,
142            delta_scratch: Vec::new(),
143        }
144    }
145
146    /// Number of buffered entries (each one is a frame).
147    #[must_use]
148    pub fn len(&self) -> usize {
149        self.entries.len()
150    }
151
152    /// `true` if no frames are buffered.
153    #[must_use]
154    pub fn is_empty(&self) -> bool {
155        self.entries.is_empty()
156    }
157
158    /// Approximate memory footprint in bytes.
159    #[must_use]
160    pub const fn bytes_used(&self) -> usize {
161        self.cur_bytes
162    }
163
164    /// Drop every entry.
165    pub fn clear(&mut self) {
166        self.entries.clear();
167        self.cur_bytes = 0;
168        self.since_keyframe = 0;
169        self.last_keyframe_decoded = None;
170    }
171
172    /// Capture one frame's worth of state.
173    ///
174    /// `frame` is informational; pass the emulator's frame counter so
175    /// debug output can label entries.
176    pub fn push(&mut self, frame: u64, snapshot: &[u8]) {
177        // Decide keyframe vs delta. Force keyframe on first push, when
178        // there's no keyframe to delta against, or every Nth push.
179        let force_keyframe =
180            self.last_keyframe_decoded.is_none() || self.since_keyframe + 1 >= self.keyframe_period;
181        let is_keyframe = force_keyframe;
182
183        let body_bytes: Box<[u8]>;
184        let approx;
185
186        if is_keyframe {
187            // Compress the full snapshot.
188            let compressed = compress_prepend_size(snapshot);
189            approx = compressed.len();
190            body_bytes = compressed.into_boxed_slice();
191            // Stash the decoded keyframe so future deltas can XOR against it.
192            // v1.5.0 "Lens" Workstream H3 — reuse the cache buffer's
193            // allocation (overwrite in place) instead of a fresh `to_vec()`
194            // every keyframe; bit-identical bytes, no per-keyframe ~9 KiB
195            // alloc in steady state.
196            cache_in_place(&mut self.last_keyframe_decoded, snapshot);
197            self.since_keyframe = 0;
198        } else {
199            // Build a XOR delta against the cached keyframe. If lengths
200            // disagree (e.g. the snapshot grew because a chip's state shape
201            // changed mid-run, which shouldn't happen for a stable build),
202            // emit a keyframe instead.
203            let kf = self
204                .last_keyframe_decoded
205                .as_ref()
206                .expect("keyframe cached on non-first push");
207            if kf.len() != snapshot.len() {
208                let compressed = compress_prepend_size(snapshot);
209                approx = compressed.len();
210                body_bytes = compressed.into_boxed_slice();
211                cache_in_place(&mut self.last_keyframe_decoded, snapshot);
212                self.since_keyframe = 0;
213                self.entries.push_back(Entry {
214                    frame,
215                    body: Body::Keyframe(body_bytes),
216                    is_keyframe: true,
217                    approx_bytes: approx,
218                });
219                self.cur_bytes += approx;
220                self.evict_to_budget();
221                return;
222            }
223            // v2.8.0 Phase 3 — reuse the scratch (resize is a no-op in
224            // steady state; snapshot shape is stable within a run).
225            // Phase 4b — zip instead of indexing: no per-byte bounds
226            // checks, so LLVM auto-vectorizes the XOR (pure integer —
227            // trivially bit-identical).
228            self.delta_scratch.resize(snapshot.len(), 0);
229            for ((slot, &s), &k) in self.delta_scratch.iter_mut().zip(snapshot).zip(kf.iter()) {
230                *slot = s ^ k;
231            }
232            let compressed = compress_prepend_size(&self.delta_scratch);
233            approx = compressed.len();
234            body_bytes = compressed.into_boxed_slice();
235            self.since_keyframe += 1;
236        }
237
238        let body = if is_keyframe {
239            Body::Keyframe(body_bytes)
240        } else {
241            Body::Delta(body_bytes)
242        };
243        self.entries.push_back(Entry {
244            frame,
245            body,
246            is_keyframe,
247            approx_bytes: approx,
248        });
249        let _ = snapshot.len(); // placeholder for future schema use
250        self.cur_bytes += approx;
251        self.evict_to_budget();
252    }
253
254    /// Pop the most recent entry and return its decoded snapshot bytes.
255    ///
256    /// Returns `None` when the ring is empty. Walking back across a
257    /// keyframe boundary is allowed; once the keyframe itself is consumed,
258    /// the next pop reads its predecessor (which must itself be a
259    /// keyframe — by construction, since deltas refer forward to a
260    /// keyframe).
261    ///
262    /// # Errors
263    ///
264    /// Returns [`RewindError`] when an entry can't be reconstructed
265    /// (corrupt LZ4 payload, etc.).
266    pub fn pop_back(&mut self) -> Option<Result<Vec<u8>, RewindError>> {
267        let entry = self.entries.pop_back()?;
268        self.cur_bytes = self.cur_bytes.saturating_sub(entry.approx_bytes);
269        let result = self.decode_entry(&entry);
270
271        // Maintain `last_keyframe_decoded`: after popping, find the new
272        // most-recent keyframe if any, decode it, and cache.
273        self.refresh_keyframe_cache();
274
275        Some(result)
276    }
277
278    /// Borrow the most recent entry's frame number, if any.
279    #[must_use]
280    pub fn back_frame(&self) -> Option<u64> {
281        self.entries.back().map(|e| e.frame)
282    }
283
284    fn decode_entry(&self, entry: &Entry) -> Result<Vec<u8>, RewindError> {
285        match &entry.body {
286            Body::Keyframe(b) => {
287                decompress_size_prepended(b).map_err(|e| RewindError::Decompress(format!("{e}")))
288            }
289            Body::Delta(b) => {
290                let kf = self
291                    .last_keyframe_decoded
292                    .as_ref()
293                    .ok_or(RewindError::MissingKeyframe)?;
294                let delta = decompress_size_prepended(b)
295                    .map_err(|e| RewindError::Decompress(format!("{e}")))?;
296                if delta.len() != kf.len() {
297                    return Err(RewindError::LengthMismatch {
298                        kf: kf.len(),
299                        dl: delta.len(),
300                    });
301                }
302                let mut out = vec![0u8; kf.len()];
303                for (i, slot) in out.iter_mut().enumerate() {
304                    *slot = kf[i] ^ delta[i];
305                }
306                Ok(out)
307            }
308        }
309    }
310
311    fn refresh_keyframe_cache(&mut self) {
312        // Find the rightmost keyframe and decode it. If none, drop the cache.
313        let kf_idx = self.entries.iter().rposition(|e| e.is_keyframe);
314        match kf_idx {
315            Some(i) => {
316                let entry = self.entries.get(i).cloned().expect("indexed entry exists");
317                let decoded = match &entry.body {
318                    Body::Keyframe(b) => decompress_size_prepended(b).ok(),
319                    Body::Delta(_) => None,
320                };
321                self.last_keyframe_decoded = decoded;
322            }
323            None => self.last_keyframe_decoded = None,
324        }
325    }
326
327    fn evict_to_budget(&mut self) {
328        while self.cur_bytes > self.max_bytes {
329            let Some(front) = self.entries.pop_front() else {
330                self.cur_bytes = 0;
331                return;
332            };
333            self.cur_bytes = self.cur_bytes.saturating_sub(front.approx_bytes);
334            // After evicting we may have orphaned deltas at the front
335            // (leading deltas without a keyframe). Drop those.
336            while let Some(e) = self.entries.front() {
337                if e.is_keyframe {
338                    break;
339                }
340                let bytes = e.approx_bytes;
341                self.entries.pop_front();
342                self.cur_bytes = self.cur_bytes.saturating_sub(bytes);
343            }
344        }
345    }
346}
347
348/// v1.5.0 "Lens" Workstream H3 — overwrite the keyframe cache `Option<Vec<u8>>`
349/// with `bytes` IN PLACE, reusing the existing allocation when one is present
350/// (no per-keyframe ~9 KiB `to_vec()` in steady state). Bit-identical to the
351/// prior `Some(bytes.to_vec())`: the cache holds exactly `bytes` afterward.
352fn cache_in_place(cache: &mut Option<Vec<u8>>, bytes: &[u8]) {
353    match cache {
354        Some(buf) => {
355            buf.clear();
356            buf.extend_from_slice(bytes);
357        }
358        None => *cache = Some(bytes.to_vec()),
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    fn make_payload(size: usize, fill: u8) -> Vec<u8> {
367        vec![fill; size]
368    }
369
370    #[test]
371    fn push_and_pop_back_roundtrips() {
372        let mut r = RewindRing::new(1024 * 1024, 4);
373        let p1 = make_payload(256, 0x11);
374        let p2 = make_payload(256, 0x22);
375        r.push(1, &p1);
376        r.push(2, &p2);
377        assert_eq!(r.len(), 2);
378        let got2 = r.pop_back().unwrap().unwrap();
379        assert_eq!(got2, p2);
380        let got1 = r.pop_back().unwrap().unwrap();
381        assert_eq!(got1, p1);
382        assert!(r.is_empty());
383        assert!(r.pop_back().is_none());
384    }
385
386    #[test]
387    fn keyframe_period_inserts_keyframes() {
388        let mut r = RewindRing::new(1024 * 1024, 3);
389        for i in 0..7u8 {
390            r.push(u64::from(i), &make_payload(64, i));
391        }
392        // Indexes 0, 3, 6 are keyframes.
393        assert!(r.entries[0].is_keyframe);
394        assert!(!r.entries[1].is_keyframe);
395        assert!(!r.entries[2].is_keyframe);
396        assert!(r.entries[3].is_keyframe);
397        assert!(!r.entries[4].is_keyframe);
398        assert!(!r.entries[5].is_keyframe);
399        assert!(r.entries[6].is_keyframe);
400    }
401
402    #[test]
403    fn delta_round_trip_through_keyframe_boundary() {
404        let mut r = RewindRing::new(1024 * 1024, 3);
405        let mut payloads = Vec::new();
406        for i in 0..10u8 {
407            let p = make_payload(64, i);
408            payloads.push(p.clone());
409            r.push(u64::from(i), &p);
410        }
411        for i in (0..10u8).rev() {
412            let got = r.pop_back().unwrap().unwrap();
413            assert_eq!(got, payloads[usize::from(i)], "frame {i}");
414        }
415        assert!(r.is_empty());
416    }
417
418    #[test]
419    fn budget_eviction_drops_oldest_first() {
420        // Tiny budget that holds maybe 2 small entries. Use random-ish
421        // payloads so LZ4 can't crush them into a couple of bytes.
422        let mut r = RewindRing::new(80, 1); // every entry is a keyframe
423        for i in 0..5u8 {
424            // Pseudo-random fill: each byte unique within the payload.
425            let mut p = vec![0u8; 256];
426            for (j, slot) in p.iter_mut().enumerate() {
427                *slot = u8::try_from(j & 0xFF)
428                    .unwrap_or(0)
429                    .wrapping_mul(31)
430                    .wrapping_add(i.wrapping_mul(17));
431            }
432            r.push(u64::from(i), &p);
433        }
434        assert!(r.bytes_used() <= 80);
435        assert!(r.len() < 5);
436    }
437
438    #[test]
439    fn clear_resets_state() {
440        let mut r = RewindRing::default();
441        for i in 0..5u8 {
442            r.push(u64::from(i), &make_payload(64, i));
443        }
444        r.clear();
445        assert_eq!(r.len(), 0);
446        assert_eq!(r.bytes_used(), 0);
447    }
448}