Skip to main content
← PSoXide SDK guides and examples · SDK f2fec1c301f1 · All Cargo features enabled; check feature gates before use.

psx_tick/
lib.rs

1//! Fixed-timestep game clock shared by every PSoXide game.
2//!
3//! Game logic runs in fixed ticks of a whole number of VBlanks; rendering runs
4//! as often as the frame cost allows and shows the latest tick (optionally
5//! interpolated with [`FixedClock::phase_q12`]). Logic speed then never
6//! depends on how long a frame takes to draw.
7//!
8//! Each game picks its own [`TickConfig`] (tick rate and catch-up policy);
9//! the functions are the same everywhere, so behaviour and the
10//! [`TickStats`] consistency counters mean the same thing in every game.
11//!
12//! The clock never reads hardware. Callers pass the current VBlank count
13//! (`psx_rt::interrupts::vblank_count()` on the console), which keeps every
14//! rule host-testable.
15//!
16//! ```
17//! use psx_tick::{FixedClock, TickConfig, TickRate};
18//! let now = 100;
19//! let mut clock = FixedClock::new(TickConfig::new(TickRate::HZ60), now);
20//! // A frame that took three VBlanks owes three ticks.
21//! let mut ran = 0;
22//! while clock.due(now + 2) {
23//!     ran += 1; // simulate one tick
24//! }
25//! clock.end_frame();
26//! assert_eq!(ran, 3);
27//! ```
28#![no_std]
29
30/// Tick length in VBlanks: 1 is 60 Hz on NTSC, 2 is 30 Hz, 3 is 20 Hz.
31#[derive(Copy, Clone, Debug, PartialEq, Eq)]
32pub struct TickRate(u16);
33
34impl TickRate {
35    /// One tick per VBlank (60 Hz NTSC, 50 Hz PAL).
36    pub const HZ60: TickRate = TickRate(1);
37    /// One tick every second VBlank.
38    pub const HZ30: TickRate = TickRate(2);
39    /// One tick every third VBlank.
40    pub const HZ20: TickRate = TickRate(3);
41
42    /// One tick every `vblanks` VBlanks (0 is treated as 1).
43    pub const fn every_vblanks(vblanks: u16) -> TickRate {
44        TickRate(if vblanks == 0 { 1 } else { vblanks })
45    }
46
47    /// Tick length in VBlanks.
48    pub const fn vblanks(self) -> u16 {
49        self.0
50    }
51}
52
53/// What to do when a frame falls further behind than the game allows.
54#[derive(Copy, Clone, Debug, PartialEq, Eq)]
55pub enum CatchUp {
56    /// Run every owed tick before the next render. Logic time never drops.
57    Unbounded,
58    /// Run at most this many ticks per frame, render, and carry the rest of
59    /// the debt into the next frames. Logic time never drops; a long stall is
60    /// caught up over several rendered frames instead of one frozen burst.
61    Interleave(u16),
62    /// Run at most this many ticks per frame and drop the rest of the debt
63    /// (counted in [`TickStats::dropped`]). Game time then falls behind real
64    /// time, so use it only where a burst would be worse than a slowdown.
65    Cap(u16),
66}
67
68/// A game's clock settings.
69#[derive(Copy, Clone, Debug, PartialEq, Eq)]
70pub struct TickConfig {
71    /// Tick length.
72    pub rate: TickRate,
73    /// Catch-up policy.
74    pub catch_up: CatchUp,
75}
76
77impl TickConfig {
78    /// `rate` with unbounded catch-up.
79    pub const fn new(rate: TickRate) -> TickConfig {
80        TickConfig {
81            rate,
82            catch_up: CatchUp::Unbounded,
83        }
84    }
85
86    /// At most `max_ticks` ticks per frame, the rest carried forward.
87    pub const fn with_interleave(self, max_ticks: u16) -> TickConfig {
88        TickConfig {
89            rate: self.rate,
90            catch_up: CatchUp::Interleave(if max_ticks == 0 { 1 } else { max_ticks }),
91        }
92    }
93
94    /// At most `max_ticks` ticks per frame, the rest dropped.
95    pub const fn with_cap(self, max_ticks: u16) -> TickConfig {
96        TickConfig {
97            rate: self.rate,
98            catch_up: CatchUp::Cap(if max_ticks == 0 { 1 } else { max_ticks }),
99        }
100    }
101}
102
103/// Buckets in [`TickStats::ticks_per_frame`]; the last one collects
104/// every frame with that many ticks or more.
105pub const TICK_HISTOGRAM_BUCKETS: usize = 9;
106
107/// Frame-consistency counters since construction or [`FixedClock::reset_stats`].
108#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
109pub struct TickStats {
110    /// Frames closed with [`FixedClock::end_frame`].
111    pub frames: u32,
112    /// Ticks run.
113    pub ticks: u32,
114    /// Ticks the catch-up cap dropped (only [`CatchUp::Cap`] drops any).
115    pub dropped: u32,
116    /// Most ticks any single frame ran.
117    pub max_frame_ticks: u16,
118    /// `ticks_per_frame[k]`: frames that ran `k` ticks (the last bucket is
119    /// "that many or more"). A steady game sits in one or two buckets.
120    pub ticks_per_frame: [u32; TICK_HISTOGRAM_BUCKETS],
121}
122
123/// Fixed-timestep clock. See the crate docs.
124#[derive(Copy, Clone, Debug, PartialEq, Eq)]
125pub struct FixedClock {
126    rate: u16,
127    cap: u16,
128    drop_excess: bool,
129    next_due: u32,
130    tick: u32,
131    frame_ticks: u16,
132    stats: TickStats,
133}
134
135#[inline(always)]
136fn reached(now: u32, target: u32) -> bool {
137    now.wrapping_sub(target) < 0x8000_0000
138}
139
140impl FixedClock {
141    /// A clock whose first tick is due at VBlank `first_due`.
142    pub const fn new(config: TickConfig, first_due: u32) -> FixedClock {
143        FixedClock {
144            rate: config.rate.vblanks(),
145            cap: match config.catch_up {
146                CatchUp::Unbounded => 0,
147                CatchUp::Interleave(n) | CatchUp::Cap(n) => n,
148            },
149            drop_excess: matches!(config.catch_up, CatchUp::Cap(_)),
150            next_due: first_due,
151            tick: 0,
152            frame_ticks: 0,
153            stats: TickStats {
154                frames: 0,
155                ticks: 0,
156                dropped: 0,
157                max_frame_ticks: 0,
158                ticks_per_frame: [0; TICK_HISTOGRAM_BUCKETS],
159            },
160        }
161    }
162
163    /// Forget any owed time and make the next tick due at `first_due`.
164    /// Call after a load, a pause menu or anything else that should not be
165    /// caught up. Not counted as dropped ticks.
166    pub fn realign(&mut self, first_due: u32) {
167        self.next_due = first_due;
168    }
169
170    /// `true` when a tick is due at VBlank `now`: run one tick, then ask
171    /// again. The tick is counted as run when this returns `true`.
172    ///
173    /// Reading `now` fresh each time lets a frame keep catching up while its
174    /// own ticks take time, exactly as a `while vblank >= next` loop does.
175    pub fn due(&mut self, now: u32) -> bool {
176        if !reached(now, self.next_due) {
177            return false;
178        }
179        if self.cap != 0 && self.frame_ticks >= self.cap {
180            if !self.drop_excess {
181                return false;
182            }
183            let behind = now.wrapping_sub(self.next_due) / u32::from(self.rate) + 1;
184            self.stats.dropped = self.stats.dropped.saturating_add(behind);
185            self.next_due = self
186                .next_due
187                .wrapping_add(behind.wrapping_mul(u32::from(self.rate)));
188            return false;
189        }
190        self.consume();
191        true
192    }
193
194    /// `true` when a tick is due at VBlank `now`, without running it and
195    /// ignoring the catch-up policy. For schedulers that decide first and
196    /// commit later with [`consume`](Self::consume); game loops use
197    /// [`due`](Self::due).
198    pub fn is_due(&self, now: u32) -> bool {
199        reached(now, self.next_due)
200    }
201
202    /// Record one tick as run: advance the deadline and the counters. The
203    /// caller has already decided it was due (see [`is_due`](Self::is_due)).
204    pub fn consume(&mut self) {
205        self.next_due = self.next_due.wrapping_add(u32::from(self.rate));
206        self.tick = self.tick.wrapping_add(1);
207        self.frame_ticks = self.frame_ticks.saturating_add(1);
208        self.stats.ticks = self.stats.ticks.saturating_add(1);
209    }
210
211    /// Close the current frame: record how many ticks it ran and start
212    /// counting the next frame's.
213    pub fn end_frame(&mut self) {
214        let k = usize::from(self.frame_ticks).min(TICK_HISTOGRAM_BUCKETS - 1);
215        self.stats.ticks_per_frame[k] = self.stats.ticks_per_frame[k].saturating_add(1);
216        self.stats.frames = self.stats.frames.saturating_add(1);
217        self.stats.max_frame_ticks = self.stats.max_frame_ticks.max(self.frame_ticks);
218        self.frame_ticks = 0;
219    }
220
221    /// Ticks run since construction.
222    pub const fn tick(&self) -> u32 {
223        self.tick
224    }
225
226    /// Ticks run so far in the current frame.
227    pub const fn frame_ticks(&self) -> u16 {
228        self.frame_ticks
229    }
230
231    /// Tick length in VBlanks.
232    pub const fn rate(&self) -> TickRate {
233        TickRate(self.rate)
234    }
235
236    /// VBlank at which the next tick is due.
237    pub const fn next_due(&self) -> u32 {
238        self.next_due
239    }
240
241    /// How far VBlank `now` is from the last tick toward the next one, in
242    /// Q12 (0 = the last tick has just been reached, 4096 = the next one is
243    /// due). Renderers draw `lerp(previous, latest, phase)`: one tick of
244    /// latency in exchange for motion that moves on every rendered frame at
245    /// any tick rate. A game ticking every VBlank can simply draw the latest
246    /// state instead.
247    pub fn phase_q12(&self, now: u32) -> u16 {
248        let ahead = self.next_due.wrapping_sub(now) as i32;
249        let rate = i32::from(self.rate);
250        if ahead <= 0 {
251            4096
252        } else {
253            (((rate - ahead.min(rate)) << 12) / rate) as u16
254        }
255    }
256
257    /// Consistency counters.
258    pub const fn stats(&self) -> TickStats {
259        self.stats
260    }
261
262    /// Zero the consistency counters (for example at the start of a level).
263    pub fn reset_stats(&mut self) {
264        self.stats = TickStats::default();
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    fn run(clock: &mut FixedClock, now: u32) -> u32 {
273        let mut n = 0;
274        while clock.due(now) {
275            n += 1;
276        }
277        clock.end_frame();
278        n
279    }
280
281    #[test]
282    fn sixty_hz_runs_one_tick_per_vblank_and_catches_up_a_slow_frame() {
283        let mut c = FixedClock::new(TickConfig::new(TickRate::HZ60), 10);
284        assert_eq!(run(&mut c, 9), 0);
285        assert_eq!(run(&mut c, 10), 1);
286        assert_eq!(run(&mut c, 11), 1);
287        assert_eq!(run(&mut c, 15), 4);
288        assert_eq!(c.tick(), 6);
289        assert_eq!(c.stats().ticks_per_frame[..5], [1, 2, 0, 0, 1]);
290        assert_eq!(c.stats().max_frame_ticks, 4);
291        assert_eq!(c.stats().dropped, 0);
292    }
293
294    #[test]
295    fn twenty_hz_ticks_every_third_vblank() {
296        let mut c = FixedClock::new(TickConfig::new(TickRate::HZ20), 3);
297        let ticks: u32 = (0..=30).map(|now| run(&mut c, now)).sum();
298        assert_eq!(ticks, 10); // due at 3, 6, ..., 30
299        assert_eq!(c.next_due(), 33);
300    }
301
302    #[test]
303    fn logic_time_matches_real_time_whatever_the_frame_lengths() {
304        // Frames of 1..7 VBlanks in a fixed pattern: every tick owed by
305        // VBlank 1000 has run, the same count a 60 fps game would have run.
306        let mut c = FixedClock::new(TickConfig::new(TickRate::HZ30), 0);
307        let mut now = 0u32;
308        let mut k = 0u32;
309        while now < 1000 {
310            run(&mut c, now);
311            now += 1 + (k % 7);
312            k += 1;
313        }
314        run(&mut c, 1000);
315        assert_eq!(c.tick(), 501); // ticks at 0, 2, ..., 1000
316        assert_eq!(c.stats().dropped, 0);
317    }
318
319    #[test]
320    fn cap_drops_the_excess_and_counts_it() {
321        let mut c = FixedClock::new(TickConfig::new(TickRate::HZ60).with_cap(4), 0);
322        assert_eq!(run(&mut c, 0), 1);
323        // 10 ticks owed (1..=10), 4 allowed.
324        assert_eq!(run(&mut c, 10), 4);
325        assert_eq!(c.stats().dropped, 6);
326        assert_eq!(c.next_due(), 11);
327        assert_eq!(run(&mut c, 11), 1);
328    }
329
330    #[test]
331    fn interleave_renders_between_bursts_and_loses_no_time() {
332        let mut c = FixedClock::new(TickConfig::new(TickRate::HZ60).with_interleave(4), 0);
333        assert_eq!(run(&mut c, 0), 1);
334        assert_eq!(run(&mut c, 10), 4); // ticks 1..=4
335        assert_eq!(run(&mut c, 10), 4); // 5..=8
336        assert_eq!(run(&mut c, 11), 3); // 9..=11: caught up
337        assert_eq!(c.tick(), 12);
338        assert_eq!(c.stats().dropped, 0);
339    }
340
341    #[test]
342    fn is_due_and_consume_split_the_decision_from_the_commit() {
343        let mut c = FixedClock::new(TickConfig::new(TickRate::HZ60), 5);
344        assert!(!c.is_due(4));
345        assert!(c.is_due(5));
346        assert!(c.is_due(5)); // a query does not run the tick
347        c.consume();
348        assert!(!c.is_due(5));
349        assert_eq!((c.tick(), c.frame_ticks(), c.next_due()), (1, 1, 6));
350    }
351
352    #[test]
353    fn realign_forgets_debt_without_counting_it_dropped() {
354        let mut c = FixedClock::new(TickConfig::new(TickRate::HZ60), 0);
355        run(&mut c, 0);
356        c.realign(501); // a 500-VBlank load
357        assert_eq!(run(&mut c, 501), 1);
358        assert_eq!(c.stats().dropped, 0);
359    }
360
361    #[test]
362    fn phase_sweeps_from_last_tick_to_next() {
363        let mut c = FixedClock::new(TickConfig::new(TickRate::HZ20), 0);
364        run(&mut c, 0); // next due at 3
365        assert_eq!(c.phase_q12(0), 0);
366        assert_eq!(c.phase_q12(1), 1365);
367        assert_eq!(c.phase_q12(2), 2730);
368        assert_eq!(c.phase_q12(3), 4096);
369        let mut s = FixedClock::new(TickConfig::new(TickRate::HZ60), 0);
370        run(&mut s, 0);
371        assert_eq!(s.phase_q12(0), 0);
372        assert_eq!(s.phase_q12(1), 4096);
373    }
374
375    #[test]
376    fn survives_vblank_counter_wraparound() {
377        let start = u32::MAX - 2;
378        let mut c = FixedClock::new(TickConfig::new(TickRate::HZ60), start);
379        assert_eq!(run(&mut c, start.wrapping_add(5)), 6);
380        assert_eq!(c.next_due(), start.wrapping_add(6));
381    }
382}