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}