Skip to main content

rustynes_core/
zwinder.rs

1//! Zwinder-class compressed, density-tiered state manager
2//! (v1.7.0 "Forge" Workstream D2).
3//!
4//! This is the *compressed* successor to the v1.6.0 uncompressed greenzone
5//! (`rustynes-frontend::tastudio::greenzone::Greenzone`) and the v1.5.0
6//! keyframe-cached rewind ring ([`crate::rewind::RewindRing`]). It is the
7//! state engine that lets the `TAStudio` greenzone scale to feature-length
8//! TASes: thousands of frames of seekable history in a fixed RAM budget.
9//!
10//! # Source / lineage
11//!
12//! Clean-room port of the `BizHawk` Zwinder concept
13//! (`BizHawk/src/BizHawk.Client.Common/rewind/ZwinderBuffer.cs` +
14//! `tasproj/ZwinderStateManager.cs`), distilled to the determinism-critical
15//! essentials:
16//!
17//! - **Frame-keyed** snapshots stored sorted ascending by frame, like the
18//!   uncompressed greenzone.
19//! - **XOR-delta + LZ4 compression.** Each stored frame is either a *keyframe*
20//!   (a full LZ4-compressed snapshot) or a *delta* (an LZ4-compressed byte-XOR
21//!   against the nearest preceding keyframe). NES state changes slowly between
22//!   adjacent frames, so most delta bytes are zero and LZ4 crushes them.
23//! - **Density tiers** (current / recent / ancient). The frames near the
24//!   playhead are kept *dense* (cheap to seek into); the distant past decays
25//!   to a sparse skeleton, thinned first by eviction.
26//! - **Reserved anchors** (frame 0, markers, branch points) are never evicted
27//!   and are always stored as keyframes (so they decode without a dependency).
28//! - A **byte budget** with density-tiered eviction, identical in spirit to the
29//!   uncompressed greenzone's policy — but operating on the *compressed* sizes,
30//!   so the same RAM holds far more history.
31//!
32//! # Determinism contract (the D2 gate)
33//!
34//! Compression is **lossless**: `restore(compress(store(s))) == s` byte-for-byte.
35//! This is the determinism-critical invariant — the round-trip equality test
36//! (`round_trip_equality_lossless` in this module's `tests`, plus the
37//! integration test in `rustynes-test-harness`) is the gate. There is **no
38//! timebase change**: the
39//! manager only stores and returns the exact deterministic save-state blobs the
40//! core already produces, so it cannot perturb emulation.
41//!
42//! This type is `#![no_std]` + `alloc`-only and deliberately decoupled from the
43//! emulator: it stores opaque snapshot byte-blobs keyed by frame, so its
44//! compression / eviction / lookup logic is pure and unit-testable without a
45//! running [`crate::Nes`].
46
47extern crate alloc;
48use alloc::collections::BTreeSet;
49use alloc::{boxed::Box, format, string::String, vec, vec::Vec};
50
51use lz4_flex::block::{compress_prepend_size, decompress_size_prepended};
52use thiserror::Error;
53
54/// Default byte budget for the Zwinder store — 256 MiB. With XOR-delta + LZ4
55/// this holds tens of thousands of greenzone frames (feature-length TASes).
56pub const ZWINDER_DEFAULT_BUDGET_BYTES: usize = 256 * 1024 * 1024;
57
58/// Default keyframe interval: every Nth stored frame is a full keyframe.
59///
60/// The rest are deltas against the preceding keyframe. A smaller interval costs
61/// more bytes but bounds the per-restore delta-walk; 16 is a good balance.
62pub const ZWINDER_DEFAULT_KEYFRAME_INTERVAL: u32 = 16;
63
64/// Errors raised when a stored Zwinder frame can't be reconstructed.
65#[derive(Debug, Error)]
66#[non_exhaustive]
67pub enum ZwinderError {
68    /// LZ4 decompression of a stored body failed (corrupt entry).
69    #[error("zwinder LZ4 decompress: {0}")]
70    Decompress(String),
71    /// A delta entry's keyframe is missing or its length disagrees with the
72    /// delta (shouldn't happen for a stable build — snapshot shape is fixed).
73    #[error("zwinder delta length mismatch: keyframe {kf} bytes, delta {dl} bytes")]
74    LengthMismatch {
75        /// Keyframe length.
76        kf: usize,
77        /// Delta length.
78        dl: usize,
79    },
80    /// A delta referenced a keyframe that isn't present in the store. By
81    /// construction every delta is stored after a keyframe at-or-before its
82    /// frame; this indicates a corrupt store.
83    #[error("zwinder keyframe missing for delta at frame {0}")]
84    MissingKeyframe(u64),
85}
86
87/// The compressed body of one stored frame.
88#[derive(Clone)]
89enum Body {
90    /// LZ4-compressed full snapshot. Decompresses directly to the raw bytes.
91    Keyframe(Box<[u8]>),
92    /// LZ4-compressed XOR delta against the nearest preceding keyframe.
93    /// Decompresses to a `Vec<u8>` of the keyframe's length; byte-XOR with the
94    /// keyframe reconstructs the snapshot.
95    Delta(Box<[u8]>),
96}
97
98/// One stored frame: its frame index + compressed body + bookkeeping.
99struct Entry {
100    /// The frame index this state represents (the state *before* `frame`'s
101    /// input is applied — seeking to `frame` restores this).
102    frame: u64,
103    /// Compressed body.
104    body: Body,
105    /// Whether this entry is a self-contained keyframe.
106    is_keyframe: bool,
107    /// The uncompressed snapshot length (needed to validate deltas + report
108    /// the logical state size).
109    raw_len: usize,
110    /// Compressed in-memory size, charged against the byte budget.
111    approx_bytes: usize,
112}
113
114/// A compressed, density-tiered, byte-budgeted history of frame-keyed
115/// save-states. See the module docs for the design.
116pub struct ZwinderStateManager {
117    /// Entries kept sorted ascending by `frame` (no duplicate frames).
118    entries: Vec<Entry>,
119    /// Frames that must never be evicted (frame 0, markers, branch points).
120    /// Anchored frames are always stored as keyframes.
121    anchors: BTreeSet<u64>,
122    /// Soft byte budget over the *compressed* sizes.
123    budget_bytes: usize,
124    /// Running sum of all entries' `approx_bytes`.
125    used_bytes: usize,
126    /// Keyframe interval — every Nth non-anchor store forces a keyframe.
127    keyframe_interval: u32,
128    /// Non-anchor stores since the last keyframe.
129    since_keyframe: u32,
130}
131
132impl core::fmt::Debug for ZwinderStateManager {
133    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
134        f.debug_struct("ZwinderStateManager")
135            .field("frames", &self.entries.len())
136            .field("anchors", &self.anchors.len())
137            .field("budget_bytes", &self.budget_bytes)
138            .field("used_bytes", &self.used_bytes)
139            .field("keyframe_interval", &self.keyframe_interval)
140            .field("since_keyframe", &self.since_keyframe)
141            .finish_non_exhaustive()
142    }
143}
144
145impl Default for ZwinderStateManager {
146    fn default() -> Self {
147        Self::new(
148            ZWINDER_DEFAULT_BUDGET_BYTES,
149            ZWINDER_DEFAULT_KEYFRAME_INTERVAL,
150        )
151    }
152}
153
154impl ZwinderStateManager {
155    /// Create an empty manager with a compressed byte budget and keyframe
156    /// interval. Frame 0 is always an anchor (the power-on base must never be
157    /// evicted). `keyframe_interval` of 0 is treated as 1 (every entry a
158    /// keyframe).
159    #[must_use]
160    pub fn new(budget_bytes: usize, keyframe_interval: u32) -> Self {
161        let mut anchors = BTreeSet::new();
162        anchors.insert(0);
163        Self {
164            entries: Vec::new(),
165            anchors,
166            budget_bytes: budget_bytes.max(1),
167            used_bytes: 0,
168            keyframe_interval: keyframe_interval.max(1),
169            since_keyframe: 0,
170        }
171    }
172
173    /// Store (or replace) the save-state for `frame`, compressing it.
174    /// `cursor` is the editor/playhead frame — eviction protects the dense
175    /// neighbourhood around it. Enforces the byte budget after inserting.
176    ///
177    /// Anchored frames are always stored as self-contained keyframes (so they
178    /// never depend on a neighbour that eviction might thin). A non-anchor
179    /// frame is a delta against the nearest preceding keyframe, unless none
180    /// exists at-or-before it or the keyframe interval is due — then it is
181    /// promoted to a keyframe.
182    pub fn store(&mut self, frame: u64, snapshot: &[u8], cursor: u64) {
183        let is_anchor = self.anchors.contains(&frame);
184        // Decode the nearest preceding keyframe at most once: it is needed both
185        // to decide keyframe-vs-delta (its absence forces a keyframe) and, on the
186        // delta path, to XOR against. `preceding_keyframe_raw` performs an LZ4
187        // decompress + allocation, so calling it twice would double that work on
188        // every non-keyframe store (the greenzone hot path).
189        let anchor_or_interval = is_anchor || self.since_keyframe + 1 >= self.keyframe_interval;
190        // Only decode the preceding keyframe when a delta is actually possible —
191        // an anchor/interval boundary becomes a keyframe regardless.
192        let preceding_kf = if anchor_or_interval {
193            None
194        } else {
195            self.preceding_keyframe_raw(frame)
196        };
197        // Decide keyframe vs delta. Anchors and interval boundaries become
198        // keyframes; so does any frame with no preceding keyframe to delta
199        // against.
200        let force_keyframe = anchor_or_interval || preceding_kf.is_none();
201
202        let (body, is_keyframe, approx) = if force_keyframe {
203            let compressed = compress_prepend_size(snapshot);
204            let approx = compressed.len();
205            (Body::Keyframe(compressed.into_boxed_slice()), true, approx)
206        } else {
207            // Delta against the nearest preceding keyframe's raw bytes (decoded
208            // above, exactly once).
209            let kf = preceding_kf.expect("preceding keyframe checked present");
210            if kf.len() == snapshot.len() {
211                let mut delta = vec![0u8; snapshot.len()];
212                for ((slot, &s), &k) in delta.iter_mut().zip(snapshot).zip(kf.iter()) {
213                    *slot = s ^ k;
214                }
215                let compressed = compress_prepend_size(&delta);
216                let approx = compressed.len();
217                (Body::Delta(compressed.into_boxed_slice()), false, approx)
218            } else {
219                // Snapshot shape changed (shouldn't happen mid-run) — fall
220                // back to a self-contained keyframe rather than mis-delta.
221                let compressed = compress_prepend_size(snapshot);
222                let approx = compressed.len();
223                (Body::Keyframe(compressed.into_boxed_slice()), true, approx)
224            }
225        };
226
227        let entry = Entry {
228            frame,
229            body,
230            is_keyframe,
231            raw_len: snapshot.len(),
232            approx_bytes: approx,
233        };
234
235        match self.entries.binary_search_by_key(&frame, |e| e.frame) {
236            Ok(i) => {
237                self.used_bytes -= self.entries[i].approx_bytes;
238                self.used_bytes += approx;
239                self.entries[i] = entry;
240            }
241            Err(i) => {
242                self.used_bytes += approx;
243                self.entries.insert(i, entry);
244            }
245        }
246
247        if is_keyframe {
248            self.since_keyframe = 0;
249        } else {
250            self.since_keyframe += 1;
251        }
252
253        self.enforce_budget(cursor);
254    }
255
256    /// Decode and return the nearest cached state at or before `target`, as
257    /// `(frame, snapshot_bytes)`. `None` if nothing at-or-before `target` is
258    /// cached.
259    ///
260    /// # Errors
261    ///
262    /// Returns [`ZwinderError`] if the entry (or the keyframe a delta depends
263    /// on) can't be reconstructed.
264    pub fn nearest_at_or_before(
265        &self,
266        target: u64,
267    ) -> Option<Result<(u64, Vec<u8>), ZwinderError>> {
268        let idx = match self.entries.binary_search_by_key(&target, |e| e.frame) {
269            Ok(i) => i,
270            Err(0) => return None,
271            Err(i) => i - 1,
272        };
273        let frame = self.entries[idx].frame;
274        Some(self.decode_at(idx).map(|bytes| (frame, bytes)))
275    }
276
277    /// Decode and return the cached state for exactly `frame`, if present.
278    ///
279    /// # Errors
280    ///
281    /// Returns [`ZwinderError`] if the entry can't be reconstructed.
282    pub fn get(&self, frame: u64) -> Option<Result<Vec<u8>, ZwinderError>> {
283        let idx = self
284            .entries
285            .binary_search_by_key(&frame, |e| e.frame)
286            .ok()?;
287        Some(self.decode_at(idx))
288    }
289
290    /// `true` if a state is cached for exactly `frame`.
291    #[must_use]
292    pub fn has(&self, frame: u64) -> bool {
293        self.entries
294            .binary_search_by_key(&frame, |e| e.frame)
295            .is_ok()
296    }
297
298    /// Drop every cached state strictly after `frame` (the `InvalidateAfter`
299    /// operation — an edit at `frame` invalidates the downstream tail). Anchor
300    /// *registrations* after `frame` are kept (a later recapture re-pins them),
301    /// but their stored *state* is dropped.
302    pub fn invalidate_after(&mut self, frame: u64) {
303        let cut = self.entries.partition_point(|e| e.frame <= frame);
304        for e in self.entries.drain(cut..) {
305            self.used_bytes = self.used_bytes.saturating_sub(e.approx_bytes);
306        }
307        // The keyframe-cadence counter is only meaningful relative to the
308        // surviving tail; reset it so the next store re-anchors a keyframe
309        // cadence rather than emitting an orphaned delta.
310        self.since_keyframe = self.keyframe_interval;
311    }
312
313    /// Register `frame` as a permanent anchor (never evicted, always a
314    /// keyframe once stored).
315    pub fn add_anchor(&mut self, frame: u64) {
316        self.anchors.insert(frame);
317    }
318
319    /// Remove an anchor registration (frame 0 stays anchored regardless).
320    pub fn remove_anchor(&mut self, frame: u64) {
321        if frame != 0 {
322            self.anchors.remove(&frame);
323        }
324    }
325
326    /// `true` if `frame` is a permanent anchor.
327    #[must_use]
328    pub fn is_anchor(&self, frame: u64) -> bool {
329        self.anchors.contains(&frame)
330    }
331
332    /// Drop every anchor except frame 0 (used when the marker set is rebuilt
333    /// wholesale — a branch load or project load).
334    pub fn clear_non_default_anchors(&mut self) {
335        self.anchors.retain(|&f| f == 0);
336    }
337
338    /// Drop all cached states (anchor registrations are kept).
339    pub fn clear(&mut self) {
340        self.entries.clear();
341        self.used_bytes = 0;
342        self.since_keyframe = 0;
343    }
344
345    /// Number of cached frames.
346    #[must_use]
347    pub const fn len(&self) -> usize {
348        self.entries.len()
349    }
350
351    /// `true` if no states are cached.
352    #[must_use]
353    pub const fn is_empty(&self) -> bool {
354        self.entries.is_empty()
355    }
356
357    /// Total *compressed* bytes currently held.
358    #[must_use]
359    pub const fn used_bytes(&self) -> usize {
360        self.used_bytes
361    }
362
363    /// The compressed byte budget eviction targets.
364    #[must_use]
365    pub const fn budget_bytes(&self) -> usize {
366        self.budget_bytes
367    }
368
369    /// The set of frames a state is cached for (ascending). Used to colour
370    /// greenzone-resident rows.
371    pub fn cached_frames(&self) -> impl Iterator<Item = u64> + '_ {
372        self.entries.iter().map(|e| e.frame)
373    }
374
375    // --- internals -------------------------------------------------------- #
376
377    /// Decode the raw snapshot bytes of a keyframe-typed entry by index, or
378    /// `None` if that index is a delta.
379    fn decode_keyframe_at(&self, idx: usize) -> Option<Result<Vec<u8>, ZwinderError>> {
380        match &self.entries[idx].body {
381            Body::Keyframe(b) => Some(
382                decompress_size_prepended(b).map_err(|e| ZwinderError::Decompress(format!("{e}"))),
383            ),
384            Body::Delta(_) => None,
385        }
386    }
387
388    /// Return the raw (decoded) bytes of the nearest keyframe at-or-before
389    /// `frame`, or `None` if no keyframe precedes it. Used at *store* time to
390    /// XOR-delta a new non-anchor frame against its base keyframe.
391    fn preceding_keyframe_raw(&self, frame: u64) -> Option<Vec<u8>> {
392        // Rightmost entry whose frame <= `frame` that is a keyframe.
393        let upper = self.entries.partition_point(|e| e.frame <= frame);
394        for idx in (0..upper).rev() {
395            if self.entries[idx].is_keyframe {
396                // A keyframe always decodes (it's self-contained); on the rare
397                // corrupt-body case fall through to "no preceding keyframe",
398                // which forces the caller to store a fresh keyframe.
399                return self.decode_keyframe_at(idx).and_then(Result::ok);
400            }
401        }
402        None
403    }
404
405    /// Decode the snapshot at `entries[idx]`, walking back to its base
406    /// keyframe for delta entries.
407    fn decode_at(&self, idx: usize) -> Result<Vec<u8>, ZwinderError> {
408        let entry = &self.entries[idx];
409        match &entry.body {
410            Body::Keyframe(b) => {
411                decompress_size_prepended(b).map_err(|e| ZwinderError::Decompress(format!("{e}")))
412            }
413            Body::Delta(b) => {
414                // Find the nearest preceding keyframe.
415                let mut kf_idx = None;
416                for j in (0..idx).rev() {
417                    if self.entries[j].is_keyframe {
418                        kf_idx = Some(j);
419                        break;
420                    }
421                }
422                let kf_idx = kf_idx.ok_or(ZwinderError::MissingKeyframe(entry.frame))?;
423                let kf = decompress_size_prepended(match &self.entries[kf_idx].body {
424                    Body::Keyframe(kb) => kb,
425                    Body::Delta(_) => return Err(ZwinderError::MissingKeyframe(entry.frame)),
426                })
427                .map_err(|e| ZwinderError::Decompress(format!("{e}")))?;
428                let delta = decompress_size_prepended(b)
429                    .map_err(|e| ZwinderError::Decompress(format!("{e}")))?;
430                if delta.len() != kf.len() {
431                    return Err(ZwinderError::LengthMismatch {
432                        kf: kf.len(),
433                        dl: delta.len(),
434                    });
435                }
436                let mut out = vec![0u8; kf.len()];
437                for ((slot, &k), &d) in out.iter_mut().zip(kf.iter()).zip(delta.iter()) {
438                    *slot = k ^ d;
439                }
440                debug_assert_eq!(out.len(), entry.raw_len);
441                Ok(out)
442            }
443        }
444    }
445
446    /// Evict non-anchor entries until [`Self::used_bytes`] is within budget.
447    /// Density-tiered: among evictable frames, drop the one in the *densest*
448    /// local region (smallest frame-gap to a neighbour) that is *farthest*
449    /// from `cursor` — thinning the distant, over-sampled past first.
450    ///
451    /// To keep the compressed store self-consistent, only entries that no
452    /// surviving delta depends on are evictable: a keyframe is evictable only
453    /// when no later delta references it before the next keyframe. (Deltas are
454    /// always freely evictable.)
455    fn enforce_budget(&mut self, cursor: u64) {
456        while self.used_bytes > self.budget_bytes {
457            let Some(victim) = self.pick_victim(cursor) else {
458                break; // Nothing safely evictable.
459            };
460            self.used_bytes = self
461                .used_bytes
462                .saturating_sub(self.entries[victim].approx_bytes);
463            self.entries.remove(victim);
464        }
465    }
466
467    /// `true` if the keyframe at `idx` has a dependent delta after it (before
468    /// the next keyframe). Such a keyframe is NOT evictable.
469    fn keyframe_has_dependents(&self, idx: usize) -> bool {
470        for e in &self.entries[idx + 1..] {
471            if e.is_keyframe {
472                return false; // reached the next keyframe: no dependents
473            }
474            if matches!(e.body, Body::Delta(_)) {
475                return true;
476            }
477        }
478        false
479    }
480
481    /// Choose the index of the least-valuable evictable entry, or `None` if
482    /// nothing is safely evictable. Lower score = evict first.
483    fn pick_victim(&self, cursor: u64) -> Option<usize> {
484        let mut best: Option<(usize, (u64, core::cmp::Reverse<u64>))> = None;
485        for i in 0..self.entries.len() {
486            let f = self.entries[i].frame;
487            if self.anchors.contains(&f) {
488                continue;
489            }
490            // A keyframe with dependents can't be dropped without orphaning a
491            // delta — skip it (the dependent deltas get thinned first, then it
492            // becomes evictable).
493            if self.entries[i].is_keyframe && self.keyframe_has_dependents(i) {
494                continue;
495            }
496            // Local density: the smaller frame-gap to a neighbour. A small gap
497            // means this frame sits in a dense cluster and is cheap to lose.
498            let prev_gap = (i > 0).then(|| f - self.entries[i - 1].frame);
499            let next_gap = (i + 1 < self.entries.len()).then(|| self.entries[i + 1].frame - f);
500            let gap = match (prev_gap, next_gap) {
501                (Some(p), Some(n)) => p.min(n),
502                (Some(p), None) => p,
503                (None, Some(n)) => n,
504                (None, None) => u64::MAX, // sole entry; evicted last
505            };
506            let dist = f.abs_diff(cursor);
507            let key = (gap, core::cmp::Reverse(dist));
508            if best.as_ref().is_none_or(|(_, bk)| key < *bk) {
509                best = Some((i, key));
510            }
511        }
512        best.map(|(i, _)| i)
513    }
514}
515
516#[cfg(test)]
517#[allow(clippy::cast_possible_truncation)] // test-only frame<->index + byte casts
518mod tests {
519    use super::*;
520
521    /// A pseudo-random-but-deterministic snapshot whose content shifts a
522    /// little per frame, exercising real XOR-delta + LZ4 behaviour (not an
523    /// all-zero blob LZ4 would crush to nothing).
524    fn snap(frame: u64, len: usize) -> Vec<u8> {
525        let mut v = vec![0u8; len];
526        let mut x = frame.wrapping_mul(0x9E37_79B9_7F4A_7C15).wrapping_add(1);
527        for slot in &mut v {
528            x ^= x << 13;
529            x ^= x >> 7;
530            x ^= x << 17;
531            *slot = (x & 0xFF) as u8;
532        }
533        v
534    }
535
536    #[test]
537    fn store_and_nearest_lookup() {
538        let mut z = ZwinderStateManager::new(1 << 24, 4);
539        z.store(0, &snap(0, 4096), 0);
540        z.store(30, &snap(30, 4096), 30);
541        z.store(60, &snap(60, 4096), 60);
542        assert_eq!(z.len(), 3);
543        assert_eq!(z.nearest_at_or_before(0).unwrap().unwrap().0, 0);
544        assert_eq!(z.nearest_at_or_before(45).unwrap().unwrap().0, 30);
545        assert_eq!(z.nearest_at_or_before(60).unwrap().unwrap().0, 60);
546        assert_eq!(z.nearest_at_or_before(1000).unwrap().unwrap().0, 60);
547        assert!(z.nearest_at_or_before(0).unwrap().is_ok());
548    }
549
550    /// THE D2 GATE — lossless round-trip equality. The restored state MUST
551    /// byte-equal the saved state, across keyframes AND deltas, in storage
552    /// order and after eviction.
553    #[test]
554    fn round_trip_equality_lossless() {
555        let mut z = ZwinderStateManager::new(1 << 24, 8);
556        let mut originals = Vec::new();
557        // Store 100 frames of evolving 8 KiB state — a mix of keyframes and
558        // XOR-deltas (interval 8).
559        for f in 0..100u64 {
560            let s = snap(f, 8192);
561            originals.push(s.clone());
562            z.store(f, &s, f);
563        }
564        // Every cached frame decodes to EXACTLY its original bytes.
565        for f in 0..100u64 {
566            if let Some(res) = z.get(f) {
567                let got = res.expect("decode");
568                assert_eq!(got, originals[f as usize], "frame {f} round-trip mismatch");
569            }
570        }
571        // Spot-check: the deltas (non-keyframe frames) really round-trip.
572        let got = z.get(5).expect("frame 5 cached").expect("decode");
573        assert_eq!(got, originals[5], "delta frame 5 must be lossless");
574        let got = z.get(99).expect("frame 99 cached").expect("decode");
575        assert_eq!(got, originals[99], "tail frame 99 must be lossless");
576    }
577
578    #[test]
579    fn anchors_are_keyframes_and_survive_eviction() {
580        // A budget that holds a handful of (barely-compressible random) 4 KiB
581        // states but forces eviction once the dense 100..900 sweep lands — so
582        // the density tiering has room to keep the cursor neighbourhood while
583        // thinning the far past, rather than starving everything behind the
584        // two anchors.
585        let mut z = ZwinderStateManager::new(40 * 1024, 8);
586        z.add_anchor(0);
587        z.add_anchor(500); // a far marker
588        z.store(0, &snap(0, 4096), 900);
589        z.store(500, &snap(500, 4096), 900);
590        for f in (100..=900).step_by(10) {
591            z.store(f, &snap(f, 4096), 900);
592        }
593        // Both anchors survived and decode losslessly.
594        assert!(z.has(0), "frame-0 anchor must survive");
595        assert!(z.has(500), "far marker anchor must survive");
596        let a0 = z.get(0).unwrap().unwrap();
597        assert_eq!(a0, snap(0, 4096));
598        let a500 = z.get(500).unwrap().unwrap();
599        assert_eq!(a500, snap(500, 4096));
600        // Something near the cursor survived (seeking to the cursor stays cheap).
601        let near_cursor = z
602            .cached_frames()
603            .any(|f| f != 0 && f != 500 && f.abs_diff(900) <= 100);
604        assert!(near_cursor, "a frame near the cursor should be retained");
605    }
606
607    #[test]
608    fn invalidate_after_drops_the_downstream_tail() {
609        let mut z = ZwinderStateManager::new(1 << 24, 4);
610        for f in [0u64, 10, 20, 30, 40] {
611            z.store(f, &snap(f, 1024), f);
612        }
613        z.invalidate_after(20);
614        let kept: Vec<u64> = z.cached_frames().collect();
615        assert_eq!(kept, vec![0, 10, 20]);
616        // Surviving frames still decode losslessly after the tail drop.
617        assert_eq!(z.get(20).unwrap().unwrap(), snap(20, 1024));
618        z.invalidate_after(0);
619        assert_eq!(z.cached_frames().collect::<Vec<_>>(), vec![0]);
620        assert_eq!(z.get(0).unwrap().unwrap(), snap(0, 1024));
621    }
622
623    #[test]
624    fn store_replaces_existing_frame_and_tracks_bytes() {
625        let mut z = ZwinderStateManager::new(1 << 24, 1); // every frame a keyframe
626        z.store(10, &snap(10, 4096), 10);
627        let after_first = z.used_bytes();
628        assert!(after_first > 0);
629        z.store(10, &snap(11, 2048), 10); // replace with different content/len
630        assert_eq!(z.len(), 1);
631        assert_eq!(z.get(10).unwrap().unwrap(), snap(11, 2048));
632    }
633
634    #[test]
635    fn compression_beats_uncompressed_on_slowly_changing_state() {
636        // Mostly-static state with a few changing bytes per frame — the NES
637        // common case. The compressed store should hold all 200 frames in far
638        // less than the uncompressed 200 * 16 KiB = 3.2 MiB.
639        let mut z = ZwinderStateManager::new(1 << 26, 16);
640        let base = snap(0, 16384);
641        for f in 0..200u64 {
642            let mut s = base.clone();
643            // Mutate a handful of bytes deterministically.
644            for k in 0..8usize {
645                let idx = ((f as usize).wrapping_mul(37).wrapping_add(k * 101)) % s.len();
646                s[idx] = (f as u8).wrapping_add(k as u8);
647            }
648            z.store(f, &s, f);
649        }
650        assert_eq!(z.len(), 200);
651        let uncompressed = 200 * 16384;
652        assert!(
653            z.used_bytes() < uncompressed / 4,
654            "compressed {} should be well under uncompressed {}",
655            z.used_bytes(),
656            uncompressed
657        );
658    }
659
660    #[test]
661    fn clear_resets_state_keeps_anchors() {
662        let mut z = ZwinderStateManager::default();
663        z.add_anchor(100);
664        for f in 0..5u64 {
665            z.store(f, &snap(f, 256), f);
666        }
667        z.clear();
668        assert_eq!(z.len(), 0);
669        assert_eq!(z.used_bytes(), 0);
670        assert!(z.is_anchor(0));
671        assert!(z.is_anchor(100));
672    }
673}