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}