Playback speed: how many emulated frames run per wall-clock second, relative to the console's own native rate. Applies both to replaying a recorded branch and to live bot play past its end - at over 1x the live bot simply ticks faster (the user's own ruling, 2026-09-22); Jev calls still block the frame loop, so live play at high speed stalls briefly per question, which is accepted rather than worked around.
Six fixed steps, not a continuous slider - matches how BizHawk and
RetroArch do it (research/replay-timeline.md's Findings), and gives
-/= a small, fixed ladder to walk instead of an unbounded range.
One of [STEPS], by index - never a float that could land off the ladder.
23impl Default for Speed {
One step down the ladder (the - key); already at the bottom, stays.
One step up the ladder (the = key); already at the top, stays.
How long to wait between emulated frames at this speed, given the
console's own native frame period (1 / target_fps). At 8x this is
an eighth of native; at 0.25x, four times native.
How many frames one "catch up" pass may run before waiting for the
next repaint - base is the 1x figure (apps/native's own
MAX_CATCH_UP), scaled up so a sprint at high speed is still ONE
sprint per repaint rather than several stuttering ones, and never
below 1 so a very slow speed still makes progress every pass.
Read a remembered speed, alongside resume - a missing or
unreadable file is 1x, never an error a caller has to handle.
Keep this speed, atomically like every other small state file this project writes beside a running window.
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}