jevsnes.git / packages / replay / src / speed.rs
speed.rsannotatedspeed.rssource165 lines · 6.2 KB · raw
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}