Skip to main content

moirai/
time.rs

1//! Monotonic clocks and fixed-timestep planning for [`crate::App`] and [`crate::world::World`].
2//!
3//! [`WorldTick`] advances once per successful Update pass. [`ChangeTick`] tags component and
4//! resource mutation metadata. [`FixedConfig`] and [`FixedDebtPolicy`] control how overdue fixed
5//! intervals are scheduled, dropped, preserved, or coalesced.
6
7use core::fmt;
8use core::time::Duration;
9
10/// Monotonic frame counter advanced once per successful Update pass.
11#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Default)]
12pub struct WorldTick(u64);
13
14#[derive(Copy, Clone, Debug, Eq, PartialEq)]
15pub(crate) enum WorldTickError {
16    Exhausted,
17}
18
19/// Fixed simulation substep identity while FixedUpdate runs.
20#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
21pub struct FixedStep {
22    /// Index of the first fixed interval represented by this run.
23    pub index: u64,
24    /// Simulation time represented by this run.
25    pub delta: Duration,
26    /// Number of fixed intervals represented by this run.
27    ///
28    /// This is one for ordinary fixed updates. A coalesced update represents
29    /// more than one interval and advances the next index by this amount.
30    pub steps: u64,
31}
32
33#[derive(Copy, Clone, Debug, Eq, PartialEq)]
34pub(crate) enum FixedStepError {
35    Exhausted,
36}
37
38/// Host-provided fixed timestep: interval, substep cap, and debt policy.
39#[derive(Copy, Clone, Debug, Eq, PartialEq)]
40pub struct FixedConfig {
41    delta: Duration,
42    max_substeps: u32,
43    debt_policy: FixedDebtPolicy,
44}
45
46/// Policy used when a frame contains more whole fixed intervals than [`FixedConfig::max_substeps`].
47#[derive(Copy, Clone, Debug, Eq, PartialEq)]
48pub enum FixedDebtPolicy {
49    /// Run up to the cap, discard remaining whole intervals, and emit diagnostics.
50    DropWithDiagnostic,
51    /// Run up to the cap and carry remaining whole intervals into future Update passes.
52    Preserve,
53    /// Run one FixedUpdate with all overdue whole intervals combined into its delta.
54    Coalesce,
55}
56
57/// Invalid [`FixedConfig`] construction input.
58#[derive(Copy, Clone, Debug, Eq, PartialEq)]
59pub enum FixedConfigError {
60    /// Fixed interval must be strictly positive.
61    NonPositiveDelta,
62    /// `max_substeps` must be nonzero.
63    ZeroSubstepCap,
64}
65
66/// Debt-preserving fixed-step accumulator owned by `Schedule`.
67#[derive(Clone, Debug)]
68pub(crate) struct FixedAccumulator {
69    remainder: Duration,
70    next_index: u64,
71}
72
73/// Whole fixed intervals discarded when debt exceeds the substep cap.
74#[derive(Copy, Clone, Debug, Eq, PartialEq)]
75pub struct FixedDebtDropped {
76    /// Number of whole intervals dropped this frame.
77    pub steps: u128,
78}
79
80/// Whole fixed intervals represented by one coalesced FixedUpdate run.
81/// Whole fixed intervals combined into one coalesced FixedUpdate run.
82#[derive(Copy, Clone, Debug, Eq, PartialEq)]
83pub struct FixedDebtCoalesced {
84    /// Number of whole intervals represented by the coalesced run.
85    pub steps: u128,
86}
87
88#[derive(Copy, Clone, Debug, Eq, PartialEq)]
89pub(crate) enum FixedWork {
90    Steps(u32),
91    Coalesced { steps: u128, delta: Duration },
92}
93
94#[derive(Copy, Clone, Debug, Eq, PartialEq)]
95pub(crate) struct FixedPlan {
96    pub work: FixedWork,
97    pub dropped: Option<FixedDebtDropped>,
98    pub coalesced: Option<FixedDebtCoalesced>,
99}
100
101/// Monotonic world change counter for component/resource mutation metadata.
102#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Default)]
103pub struct ChangeTick(u64);
104
105#[derive(Copy, Clone, Debug, Eq, PartialEq)]
106pub(crate) enum ChangeTickError {
107    Exhausted,
108}
109
110impl WorldTick {
111    /// Initial world tick before the first Update pass.
112    pub const ZERO: Self = Self(0);
113
114    /// Returns the underlying counter value.
115    pub fn raw(self) -> u64 {
116        self.0
117    }
118
119    #[allow(dead_code)]
120    pub(crate) fn set_raw(&mut self, raw: u64) {
121        self.0 = raw;
122    }
123
124    pub(crate) fn advance(&mut self) -> Result<Self, WorldTickError> {
125        self.0 = self.0.checked_add(1).ok_or(WorldTickError::Exhausted)?;
126        Ok(Self(self.0))
127    }
128}
129
130impl FixedConfig {
131    /// Default substep cap used by [`FixedConfig::new`].
132    pub const DEFAULT_MAX_SUBSTEPS: u32 = 8;
133
134    /// Creates fixed configuration with [`Self::DEFAULT_MAX_SUBSTEPS`] and
135    /// [`FixedDebtPolicy::DropWithDiagnostic`].
136    pub fn new(delta: Duration) -> Result<Self, FixedConfigError> {
137        if delta.as_nanos() == 0 {
138            return Err(FixedConfigError::NonPositiveDelta);
139        }
140        Ok(Self {
141            delta,
142            max_substeps: Self::DEFAULT_MAX_SUBSTEPS,
143            debt_policy: FixedDebtPolicy::DropWithDiagnostic,
144        })
145    }
146
147    /// Sets the maximum fixed substeps executed per Update pass.
148    pub fn with_max_substeps(mut self, max_substeps: u32) -> Result<Self, FixedConfigError> {
149        if max_substeps == 0 {
150            return Err(FixedConfigError::ZeroSubstepCap);
151        }
152        self.max_substeps = max_substeps;
153        Ok(self)
154    }
155
156    /// Fixed simulation interval.
157    pub fn delta(&self) -> Duration {
158        self.delta
159    }
160
161    /// Maximum fixed substeps scheduled per Update pass.
162    pub fn max_substeps(&self) -> u32 {
163        self.max_substeps
164    }
165
166    /// Selects how overdue whole intervals are handled beyond the substep cap.
167    pub fn with_debt_policy(mut self, debt_policy: FixedDebtPolicy) -> Self {
168        self.debt_policy = debt_policy;
169        self
170    }
171
172    /// Active debt policy for overdue fixed intervals.
173    pub fn debt_policy(&self) -> FixedDebtPolicy {
174        self.debt_policy
175    }
176}
177
178impl FixedAccumulator {
179    pub fn new() -> Self {
180        Self {
181            remainder: Duration::ZERO,
182            next_index: 0,
183        }
184    }
185
186    #[cfg(test)]
187    pub(crate) fn set_next_index_for_test(&mut self, next_index: u64) {
188        self.next_index = next_index;
189    }
190
191    pub fn peek_plan(&self, frame_delta: Duration, config: &FixedConfig) -> FixedPlan {
192        Self::substep_plan(self.remainder.saturating_add(frame_delta), config).0
193    }
194
195    pub fn plan(&mut self, frame_delta: Duration, config: &FixedConfig) -> FixedPlan {
196        let total = self.remainder.saturating_add(frame_delta);
197        let (plan, remainder) = Self::substep_plan(total, config);
198        self.remainder = remainder;
199        plan
200    }
201
202    pub fn preflight_steps(&self, steps: u128) -> Result<(), FixedStepError> {
203        if steps == 0 {
204            return Ok(());
205        }
206        let steps = u64::try_from(steps).map_err(|_| FixedStepError::Exhausted)?;
207        let last = self
208            .next_index
209            .checked_add(steps - 1)
210            .ok_or(FixedStepError::Exhausted)?;
211        last.checked_add(1).ok_or(FixedStepError::Exhausted)?;
212        Ok(())
213    }
214
215    fn substep_plan(total: Duration, config: &FixedConfig) -> (FixedPlan, Duration) {
216        let delta_nanos = config.delta().as_nanos();
217        let total_nanos = total.as_nanos();
218        let due = total_nanos / delta_nanos;
219        let run = due.min(config.max_substeps() as u128) as u32;
220        let ordinary = FixedPlan {
221            work: FixedWork::Steps(run),
222            dropped: None,
223            coalesced: None,
224        };
225        if due <= config.max_substeps() as u128 {
226            return (ordinary, duration_from_nanos(total_nanos % delta_nanos));
227        }
228
229        match config.debt_policy() {
230            FixedDebtPolicy::DropWithDiagnostic => (
231                FixedPlan {
232                    dropped: Some(FixedDebtDropped {
233                        steps: due - run as u128,
234                    }),
235                    ..ordinary
236                },
237                duration_from_nanos(total_nanos % delta_nanos),
238            ),
239            FixedDebtPolicy::Preserve => {
240                let consumed = delta_nanos.saturating_mul(run as u128);
241                (ordinary, duration_from_nanos(total_nanos - consumed))
242            }
243            FixedDebtPolicy::Coalesce => {
244                let delta = duration_from_nanos(delta_nanos.saturating_mul(due));
245                (
246                    FixedPlan {
247                        work: FixedWork::Coalesced { steps: due, delta },
248                        dropped: None,
249                        coalesced: Some(FixedDebtCoalesced { steps: due }),
250                    },
251                    duration_from_nanos(total_nanos % delta_nanos),
252                )
253            }
254        }
255    }
256
257    pub fn next_step(&mut self, config: &FixedConfig) -> FixedStep {
258        let step = FixedStep {
259            index: self.next_index,
260            delta: config.delta(),
261            steps: 1,
262        };
263        self.next_index = self.next_index.saturating_add(step.steps);
264        step
265    }
266
267    pub fn next_coalesced(&mut self, steps: u64, delta: Duration) -> FixedStep {
268        let step = FixedStep {
269            index: self.next_index,
270            delta,
271            steps,
272        };
273        self.next_index = self.next_index.saturating_add(steps);
274        step
275    }
276}
277
278fn duration_from_nanos(nanos: u128) -> Duration {
279    const NANOS_PER_SECOND: u128 = 1_000_000_000;
280    Duration::new(
281        (nanos / NANOS_PER_SECOND) as u64,
282        (nanos % NANOS_PER_SECOND) as u32,
283    )
284}
285
286impl Default for FixedAccumulator {
287    fn default() -> Self {
288        Self::new()
289    }
290}
291
292impl fmt::Display for WorldTick {
293    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294        self.0.fmt(f)
295    }
296}
297
298impl ChangeTick {
299    /// Initial change tick before any mutation metadata is issued.
300    pub const ZERO: Self = Self(0);
301
302    /// Constructs a tick from a raw counter value.
303    pub const fn from_raw(raw: u64) -> Self {
304        Self(raw)
305    }
306
307    /// Returns the underlying counter value.
308    pub fn raw(self) -> u64 {
309        self.0
310    }
311
312    pub(crate) fn advance(&mut self) -> Result<Self, ChangeTickError> {
313        self.0 = self.0.checked_add(1).ok_or(ChangeTickError::Exhausted)?;
314        Ok(Self(self.0))
315    }
316
317    pub(crate) fn issue(&mut self) -> Result<Self, ChangeTickError> {
318        self.advance()
319    }
320
321    pub(crate) fn can_advance_n(&self, count: usize) -> bool {
322        let count = count as u64;
323        self.0.checked_add(count).is_some()
324    }
325}
326
327impl fmt::Display for ChangeTick {
328    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
329        self.0.fmt(f)
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::{FixedAccumulator, FixedConfig, FixedDebtPolicy, FixedWork};
336    use core::time::Duration;
337
338    use alloc::format;
339
340    use super::{ChangeTick, FixedConfigError, FixedStepError, WorldTick};
341
342    #[test]
343    fn fixed_config_rejects_non_positive_delta() {
344        assert!(matches!(
345            FixedConfig::new(Duration::ZERO),
346            Err(FixedConfigError::NonPositiveDelta)
347        ));
348    }
349
350    #[test]
351    fn fixed_config_rejects_zero_substep_cap() {
352        let config = FixedConfig::new(Duration::from_millis(1)).expect("delta");
353        assert!(matches!(
354            config.with_max_substeps(0),
355            Err(FixedConfigError::ZeroSubstepCap)
356        ));
357    }
358
359    #[test]
360    fn preflight_steps_zero_is_ok() {
361        let accumulator = FixedAccumulator::new();
362        accumulator.preflight_steps(0).expect("zero steps");
363    }
364
365    #[test]
366    fn preflight_steps_reports_exhaustion_near_u64_max() {
367        let mut accumulator = FixedAccumulator::new();
368        accumulator.set_next_index_for_test(u64::MAX);
369        assert!(matches!(
370            accumulator.preflight_steps(1),
371            Err(FixedStepError::Exhausted)
372        ));
373    }
374
375    #[test]
376    fn world_tick_and_change_tick_display_format_raw_values() {
377        assert_eq!(format!("{}", WorldTick::ZERO), "0");
378        assert_eq!(format!("{}", ChangeTick::from_raw(9)), "9");
379    }
380
381    #[test]
382    fn default_accumulator_matches_new() {
383        assert_eq!(
384            FixedAccumulator::default().peek_plan(
385                Duration::from_millis(1),
386                &FixedConfig::new(Duration::from_millis(1)).expect("delta")
387            ),
388            FixedAccumulator::new().peek_plan(
389                Duration::from_millis(1),
390                &FixedConfig::new(Duration::from_millis(1)).expect("delta")
391            )
392        );
393    }
394
395    #[test]
396    fn huge_deltas_drop_debt_without_iterating_or_preserving_whole_steps() {
397        let config = FixedConfig::new(Duration::from_millis(1))
398            .expect("positive delta")
399            .with_max_substeps(8)
400            .expect("cap");
401        let mut accumulator = FixedAccumulator::new();
402
403        let plan = accumulator.plan(Duration::MAX, &config);
404
405        assert_eq!(plan.work, FixedWork::Steps(8));
406        assert_eq!(
407            plan.dropped.expect("debt").steps,
408            Duration::MAX.as_nanos() / config.delta().as_nanos() - 8
409        );
410        assert!(accumulator.remainder < config.delta());
411    }
412
413    #[test]
414    fn preserve_debt_keeps_unrun_whole_steps() {
415        let config = FixedConfig::new(Duration::from_millis(10))
416            .expect("positive delta")
417            .with_max_substeps(2)
418            .expect("cap")
419            .with_debt_policy(FixedDebtPolicy::Preserve);
420        let mut accumulator = FixedAccumulator::new();
421
422        let first = accumulator.plan(Duration::from_millis(50), &config);
423        assert_eq!(first.work, FixedWork::Steps(2));
424        assert!(first.dropped.is_none());
425        let second = accumulator.plan(Duration::ZERO, &config);
426        assert_eq!(second.work, FixedWork::Steps(2));
427    }
428
429    #[test]
430    fn coalesce_represents_all_overdue_intervals_once() {
431        let config = FixedConfig::new(Duration::from_millis(10))
432            .expect("positive delta")
433            .with_max_substeps(2)
434            .expect("cap")
435            .with_debt_policy(FixedDebtPolicy::Coalesce);
436        let mut accumulator = FixedAccumulator::new();
437
438        let plan = accumulator.plan(Duration::from_millis(55), &config);
439        assert_eq!(
440            plan.work,
441            FixedWork::Coalesced {
442                steps: 5,
443                delta: Duration::from_millis(50),
444            }
445        );
446        assert_eq!(plan.coalesced.expect("coalesced").steps, 5);
447        assert_eq!(accumulator.remainder, Duration::from_millis(5));
448    }
449}