Skip to main content

renew_frame/
time.rs

1//! The time vocabulary: a duration, an instant, the fixed timestep, and
2//! the per-frame step budget.
3//!
4//! Durations and instants are separate newtypes because confusing them is
5//! otherwise silent: feed a duration where an instant belongs and the
6//! schedule sees time running permanently backwards, freezing the
7//! simulation while the window keeps drawing. Distinct types make that a
8//! compile error.
9//!
10//! `core::time::Duration` is deliberately not used anywhere in this
11//! crate. It is 12–16 bytes where 8 do, its `Sub` panics on underflow —
12//! banned in engine code — and its `as_secs_f32` is exactly the float-time
13//! door this engine does not have. Integer nanoseconds are the whole
14//! determinism argument: with `f32` seconds the banked time accumulates
15//! representation error and the step count becomes a function of rounding
16//! history, while with `u64` every operation is exact and the step count
17//! is a pure integer function of the input sequence.
18
19use core::num::{NonZeroU32, NonZeroU64};
20
21/// A span of time in whole nanoseconds.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
23pub struct Nanos(u64);
24
25impl Nanos {
26    /// No time at all — what a backwards clock yields.
27    pub const ZERO: Self = Self(0);
28
29    #[must_use]
30    pub const fn from_nanos(nanos: u64) -> Self {
31        Self(nanos)
32    }
33
34    #[must_use]
35    pub const fn get(self) -> u64 {
36        self.0
37    }
38}
39
40/// A point on a monotonic timeline, in nanoseconds since an origin the
41/// caller chooses. Only differences between timestamps mean anything; the
42/// origin itself never enters the schedule.
43///
44/// Absolute timestamps rather than per-frame deltas, on purpose. One
45/// subtraction happens in one place (every caller computing its own can
46/// compute it wrongly), a clock that went the wrong way becomes
47/// [`Nanos::ZERO`] instead of 1.1 trillion phantom steps, the first-frame
48/// branch disappears into the constructor's `start` argument, and
49/// resynchronizing after a known pause is trivially correct.
50#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
51pub struct Timestamp(u64);
52
53impl Timestamp {
54    #[must_use]
55    pub const fn from_nanos(nanos: u64) -> Self {
56        Self(nanos)
57    }
58
59    /// The raw offset from the caller's own origin. Meaningful only to
60    /// whoever chose that origin — for logging and for building a
61    /// synthetic timeline, never for arithmetic against another clock.
62    #[must_use]
63    pub const fn get(self) -> u64 {
64        self.0
65    }
66
67    /// The span from `earlier` to `self`, or [`Nanos::ZERO`] if `self` is
68    /// the earlier of the two. A backwards clock is a defined, harmless
69    /// input rather than a wrapped `u64`.
70    #[must_use]
71    pub const fn saturating_since(self, earlier: Self) -> Nanos {
72        Nanos(self.0.saturating_sub(earlier.0))
73    }
74
75    /// `self + span`, saturating at the end of the representable
76    /// timeline (~584 years).
77    #[must_use]
78    pub const fn saturating_add(self, span: Nanos) -> Self {
79        Self(self.0.saturating_add(span.0))
80    }
81}
82
83/// The fixed simulation timestep, in nanoseconds. Non-zero by type, so no
84/// division in the schedule can trap and no constructor can fail: the
85/// type carries the guarantee, so nothing has to check for it.
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub struct Timestep(NonZeroU64);
88
89impl Timestep {
90    /// 60 Hz, rounded to whole nanoseconds.
91    ///
92    /// 60 Hz is not representable: `60 × 16_666_667 = 1_000_000_020`, so
93    /// sixty ticks run 20 ns long against the wall. That is closed by
94    /// definition rather than by rounding — a step's `sim_time` and the
95    /// loop's `simulated()` are `tick × dt`, so the simulation's own clock
96    /// is exact by construction and the 20 ns is a property of the wall
97    /// clock's relation to the simulation, never of the simulation's own
98    /// arithmetic. Exact divisors exist (64 Hz = `15_625_000`,
99    /// 125 Hz = `8_000_000`) if ticks ever need to land on whole seconds.
100    // The `match` runs at compile time and emits no code: `unwrap` is
101    // unavailable under the crate's panic policy, so a literal that was
102    // edited to zero would select the fallback instead of failing loudly.
103    // Every test in this file compares against the literal, so that edit
104    // cannot pass unnoticed.
105    pub const HZ_60: Self = Self(match NonZeroU64::new(16_666_667) {
106        Some(nanos) => nanos,
107        None => NonZeroU64::MIN,
108    });
109
110    #[must_use]
111    pub const fn from_nanos(nanos: NonZeroU64) -> Self {
112        Self(nanos)
113    }
114
115    /// The timestep in nanoseconds, still carrying its non-zero proof so
116    /// a consumer computing its own exact interpolation needs no guard.
117    #[must_use]
118    pub const fn nanos(self) -> NonZeroU64 {
119        self.0
120    }
121}
122
123/// The most simulation steps one frame may execute. Everything beyond it
124/// is discarded and reported, never banked.
125///
126/// The budget is the only guard on per-frame work, deliberately. An
127/// elapsed-time clamp in front of it was considered and rejected: it only
128/// changes how much time is discarded versus reported, destroying the
129/// information about how big the hitch was in exchange for a second knob,
130/// a second branch, and a second coverage obligation.
131#[derive(Clone, Copy, Debug, PartialEq, Eq)]
132pub struct StepBudget(NonZeroU32);
133
134impl StepBudget {
135    /// Five steps — about 83 ms of simulation per frame at 60 Hz.
136    ///
137    /// Whether five is the right number is an open question the first
138    /// frame-time capture on the reference machine settles, not an
139    /// argument.
140    // Compile-time `match` for the same reason as `Timestep::HZ_60`.
141    pub const DEFAULT: Self = Self(match NonZeroU32::new(5) {
142        Some(steps) => steps,
143        None => NonZeroU32::MIN,
144    });
145
146    #[must_use]
147    pub const fn new(steps: NonZeroU32) -> Self {
148        Self(steps)
149    }
150
151    #[must_use]
152    pub const fn get(self) -> NonZeroU32 {
153        self.0
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::{Nanos, StepBudget, Timestamp, Timestep};
160    use core::num::{NonZeroU32, NonZeroU64};
161
162    #[test]
163    fn nanos_round_trips_and_zero_is_zero() {
164        assert_eq!(Nanos::ZERO.get(), 0);
165        assert_eq!(Nanos::from_nanos(7).get(), 7);
166        assert_eq!(Nanos::from_nanos(u64::MAX).get(), u64::MAX);
167        assert!(Nanos::ZERO < Nanos::from_nanos(1));
168    }
169
170    #[test]
171    fn a_timestamp_reports_the_span_since_an_earlier_one() {
172        let earlier = Timestamp::from_nanos(1_000);
173        let later = Timestamp::from_nanos(1_700);
174        assert_eq!(later.saturating_since(earlier), Nanos::from_nanos(700));
175        assert_eq!(earlier.get(), 1_000);
176    }
177
178    #[test]
179    fn a_backwards_clock_yields_zero_rather_than_a_wrapped_span() {
180        let earlier = Timestamp::from_nanos(1_000);
181        let later = Timestamp::from_nanos(1_700);
182        assert_eq!(earlier.saturating_since(later), Nanos::ZERO);
183        assert_eq!(later.saturating_since(later), Nanos::ZERO);
184    }
185
186    #[test]
187    fn adding_a_span_saturates_at_the_end_of_the_timeline() {
188        let start = Timestamp::from_nanos(5);
189        assert_eq!(
190            start.saturating_add(Nanos::from_nanos(10)),
191            Timestamp::from_nanos(15)
192        );
193        assert_eq!(
194            start.saturating_add(Nanos::from_nanos(u64::MAX)),
195            Timestamp::from_nanos(u64::MAX)
196        );
197    }
198
199    /// The constant is compared against its literal here so that editing
200    /// it to zero — the one input the compile-time fallback would swallow
201    /// — cannot pass unnoticed.
202    #[test]
203    fn the_sixty_hertz_timestep_is_the_rounded_nanosecond_value() {
204        assert_eq!(Timestep::HZ_60.nanos().get(), 16_666_667);
205        // The rounding, stated as a test rather than as a comment: sixty
206        // ticks run 20 ns long against the wall.
207        assert_eq!(60 * Timestep::HZ_60.nanos().get(), 1_000_000_020);
208    }
209
210    #[test]
211    fn a_timestep_round_trips_through_its_non_zero_nanoseconds() {
212        let step = Timestep::from_nanos(NonZeroU64::new(8_000_000).expect("non-zero"));
213        assert_eq!(step.nanos().get(), 8_000_000);
214        assert_ne!(step, Timestep::HZ_60);
215    }
216
217    #[test]
218    fn the_default_step_budget_is_five() {
219        assert_eq!(StepBudget::DEFAULT.get().get(), 5);
220    }
221
222    #[test]
223    fn a_step_budget_round_trips_through_its_non_zero_count() {
224        let budget = StepBudget::new(NonZeroU32::new(12).expect("non-zero"));
225        assert_eq!(budget.get().get(), 12);
226        assert_ne!(budget, StepBudget::DEFAULT);
227    }
228}