1//! Playback speed: how many emulated frames run per wall-clock second, 2//! relative to the console's own native rate. Applies both to replaying a 3//! recorded branch and to live bot play past its end - at over 1x the live 4//! bot simply ticks faster (the user's own ruling, 2026-09-22); Jev calls 5//! still block the frame loop, so live play at high speed stalls briefly 6//! per question, which is accepted rather than worked around. 7//! 8//! Six fixed steps, not a continuous slider - matches how BizHawk and 9//! RetroArch do it (`research/replay-timeline.md`'s Findings), and gives 10//! `-`/`=` a small, fixed ladder to walk instead of an unbounded range. 11 12use std::path::Path; 13use std::time::Duration; 14use std::{fs, io}; 15 16pub const STEPS: [f64; 6] = [0.25, 0.5, 1.0, 2.0, 4.0, 8.0]; 17const LABELS: [&str; 6] = ["0.25x", "0.5x", "1x", "2x", "4x", "8x"]; 18 19/// One of [`STEPS`], by index - never a float that could land off the ladder. 20#[derive(Debug, Clone, Copy, PartialEq, Eq)] 21pub struct Speed(usize); 22 23impl Default for Speed { 24 /// 1x. 25 fn default() -> Self { 26 Self(2) 27 } 28} 29 30impl Speed { 31 pub fn multiplier(self) -> f64 { 32 STEPS[self.0] 33 } 34 35 pub fn label(self) -> &'static str { 36 LABELS[self.0] 37 } 38 39 pub fn is_normal(self) -> bool { 40 self.0 == Self::default().0 41 } 42 43 /// One step down the ladder (the `-` key); already at the bottom, stays. 44 pub fn slower(self) -> Self { 45 Self(self.0.saturating_sub(1)) 46 } 47 48 /// One step up the ladder (the `=` key); already at the top, stays. 49 pub fn faster(self) -> Self { 50 Self((self.0 + 1).min(STEPS.len() - 1)) 51 } 52 53 /// How long to wait between emulated frames at this speed, given the 54 /// console's own native frame period (`1 / target_fps`). At 8x this is 55 /// an eighth of native; at 0.25x, four times native. 56 pub fn frame_period(self, native_period: Duration) -> Duration { 57 native_period.div_f64(self.multiplier()) 58 } 59 60 /// How many frames one "catch up" pass may run before waiting for the 61 /// next repaint - `base` is the 1x figure (`apps/native`'s own 62 /// `MAX_CATCH_UP`), scaled up so a sprint at high speed is still ONE 63 /// sprint per repaint rather than several stuttering ones, and never 64 /// below 1 so a very slow speed still makes progress every pass. 65 pub fn max_catch_up(self, base: u32) -> u32 { 66 ((f64::from(base) * self.multiplier()).ceil() as u32).max(1) 67 } 68 69 /// Read a remembered speed, alongside `resume` - a missing or 70 /// unreadable file is 1x, never an error a caller has to handle. 71 pub fn load(path: &Path) -> Self { 72 fs::read_to_string(path) 73 .ok() 74 .and_then(|s| s.trim().parse::<usize>().ok()) 75 .filter(|&i| i < STEPS.len()) 76 .map(Self) 77 .unwrap_or_default() 78 } 79 80 /// Keep this speed, atomically like every other small state file this 81 /// project writes beside a running window. 82 pub fn save(self, path: &Path) -> io::Result<()> { 83 let tmp = path.with_extension("part"); 84 fs::write(&tmp, self.0.to_string())?; 85 fs::rename(tmp, path) 86 } 87} 88 89#[cfg(test)] 90mod tests { 91 use super::*; 92 93 #[test] 94 fn default_is_one_x() { 95 assert_eq!(Speed::default().multiplier(), 1.0); 96 assert_eq!(Speed::default().label(), "1x"); 97 assert!(Speed::default().is_normal()); 98 } 99 100 #[test] 101 fn slower_and_faster_walk_the_ladder_and_clamp_at_the_ends() { 102 let mut s = Speed::default(); 103 s = s.slower(); 104 assert_eq!(s.label(), "0.5x"); 105 s = s.slower().slower().slower(); // past the bottom 106 assert_eq!(s.label(), "0.25x"); 107 for _ in 0..10 { 108 s = s.faster(); 109 } 110 assert_eq!(s.label(), "8x"); // past the top, clamped 111 assert!(!s.is_normal()); 112 } 113 114 #[test] 115 fn frame_period_scales_inversely_with_speed() { 116 let native = Duration::from_secs_f64(1.0 / 60.0988); 117 let half = Speed::default().slower(); 118 let double = Speed::default().faster(); 119 let eighth = Speed::default().faster().faster().faster(); 120 let quarter_speed = Speed::default().slower().slower(); 121 122 // Faster speed -> shorter period between frames. 123 assert!(double.frame_period(native) < native); 124 assert!(eighth.frame_period(native) < double.frame_period(native)); 125 // Slower speed -> longer period. 126 assert!(half.frame_period(native) > native); 127 assert!(quarter_speed.frame_period(native) > half.frame_period(native)); 128 129 // Exact: 8x runs 8 frames in the time 1x runs 1. 130 let approx_eq = |a: Duration, b: Duration| (a.as_secs_f64() - b.as_secs_f64()).abs() < 1e-9; 131 assert!(approx_eq(eighth.frame_period(native), native.div_f64(8.0))); 132 assert!(approx_eq(quarter_speed.frame_period(native), native.div_f64(0.25))); 133 } 134 135 #[test] 136 fn max_catch_up_scales_with_speed_and_never_reaches_zero() { 137 let base = 4; 138 assert_eq!(Speed::default().max_catch_up(base), 4); // 1x: unchanged 139 assert_eq!(Speed::default().faster().max_catch_up(base), 8); // 2x 140 assert_eq!(Speed::default().faster().faster().faster().max_catch_up(base), 32); // 8x 141 assert_eq!(Speed::default().slower().slower().max_catch_up(base), 1); // 0.25x: 1, not 0 142 } 143 144 #[test] 145 fn a_remembered_speed_round_trips_through_a_file() { 146 let path = std::env::temp_dir().join(format!("jev-replay-speed-test-{}.txt", std::process::id())); 147 let _ = std::fs::remove_file(&path); 148 let fast = Speed::default().faster().faster(); 149 fast.save(&path).expect("save"); 150 assert_eq!(Speed::load(&path), fast); 151 let _ = std::fs::remove_file(&path); 152 } 153 154 #[test] 155 fn a_missing_or_corrupt_speed_file_loads_as_one_x() { 156 let path = std::env::temp_dir().join(format!("jev-replay-speed-missing-{}.txt", std::process::id())); 157 let _ = std::fs::remove_file(&path); 158 assert_eq!(Speed::load(&path), Speed::default()); 159 std::fs::write(&path, "not a number").unwrap(); 160 assert_eq!(Speed::load(&path), Speed::default()); 161 std::fs::write(&path, "999").unwrap(); // out of range 162 assert_eq!(Speed::load(&path), Speed::default()); 163 let _ = std::fs::remove_file(&path); 164 } 165}