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}