Skip to main content

concinnity_core/ecs/
tick.rs

1// Monotonic change tick and the wrapping-relative comparison change detection
2// uses. Ticks are u32 and wrap. A system records the tick it last ran at and
3// asks whether a column changed since. A naive `>` breaks at wraparound, so the
4// comparison treats the 32-bit difference as signed (a half-range window), and
5// `clamp_to` keeps a stored tick from drifting more than half the range behind
6// the current tick over a long session.
7
8/// Half the u32 range. A tick older than this relative to the current tick is
9/// clamped forward so the signed-window comparison never aliases.
10pub const MAX_CHANGE_AGE: u32 = u32::MAX / 2;
11
12/// A change-detection stamp. Wraps; comparisons use a signed window bounded
13/// by [`MAX_CHANGE_AGE`].
14#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
15pub struct Tick(
16    /// The raw counter value.
17    pub u32,
18);
19
20impl Tick {
21    /// The tick a freshly created column starts at.
22    pub const ZERO: Tick = Tick(0);
23
24    /// The raw counter value.
25    pub fn get(self) -> u32 {
26        self.0
27    }
28
29    /// Advance to the next tick (wrapping) and return the new value.
30    pub fn bump(&mut self) -> Tick {
31        self.0 = self.0.wrapping_add(1);
32        *self
33    }
34
35    // Whether `self` is strictly newer than `other`, robust to u32 wraparound.
36    // The difference is interpreted as signed: positive means ahead, within the
37    // half-range window the clamp guarantees.
38    pub(crate) fn is_newer_than(self, other: Tick) -> bool {
39        (self.0.wrapping_sub(other.0) as i32) > 0
40    }
41
42    /// Pull a stored tick forward if it has fallen more than MAX_CHANGE_AGE
43    /// behind `now`, so the signed-window comparison stays valid no matter how
44    /// long the world runs. Recent ticks are returned unchanged.
45    pub fn clamp_to(self, now: Tick) -> Tick {
46        if now.0.wrapping_sub(self.0) > MAX_CHANGE_AGE {
47            Tick(now.0.wrapping_sub(MAX_CHANGE_AGE))
48        } else {
49            self
50        }
51    }
52}
53
54/// The change-tick counter shared across a storage's columns, atomic so writers
55/// of disjoint columns can stamp edits concurrently without sharing `&mut`.
56/// Relaxed ordering: the counter only supplies monotonic values; the column
57/// data it stamps is synchronized by the scheduler (join/channel edges), never
58/// by the counter itself. Values stay globally monotonic but their exact
59/// assignment is interleaving-dependent under concurrency, so tick values must
60/// never be hashed, persisted, or compared across columns.
61#[derive(Debug, Default)]
62pub struct AtomicTick(core::sync::atomic::AtomicU32);
63
64impl AtomicTick {
65    /// Advance to the next tick (wrapping) and return the new value.
66    pub fn bump(&self) -> Tick {
67        use core::sync::atomic::Ordering;
68        Tick(self.0.fetch_add(1, Ordering::Relaxed).wrapping_add(1))
69    }
70
71    /// The most recently issued tick.
72    pub fn get(&self) -> Tick {
73        Tick(self.0.load(core::sync::atomic::Ordering::Relaxed))
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn default_is_zero() {
83        assert_eq!(Tick::default(), Tick::ZERO);
84    }
85
86    #[test]
87    fn bump_advances() {
88        let mut t = Tick::ZERO;
89        assert_eq!(t.bump(), Tick(1));
90        assert_eq!(t.bump(), Tick(2));
91        assert_eq!(t.get(), 2);
92    }
93
94    #[test]
95    fn newer_than_is_strict() {
96        assert!(Tick(2).is_newer_than(Tick(1)));
97        assert!(!Tick(1).is_newer_than(Tick(1)));
98        assert!(!Tick(1).is_newer_than(Tick(2)));
99    }
100
101    #[test]
102    fn newer_than_survives_wraparound() {
103        // Just after wrap, Tick(0) is newer than Tick(u32::MAX).
104        assert!(Tick(0).is_newer_than(Tick(u32::MAX)));
105        assert!(Tick(5).is_newer_than(Tick(u32::MAX - 2)));
106        assert!(!Tick(u32::MAX).is_newer_than(Tick(0)));
107    }
108
109    #[test]
110    fn clamp_pulls_stale_ticks_forward() {
111        let now = Tick(MAX_CHANGE_AGE + 100);
112        // A tick at 0 is too old: clamped to now - MAX_CHANGE_AGE.
113        assert_eq!(Tick::ZERO.clamp_to(now), Tick(100));
114        // Comparisons stay correct after clamping.
115        assert!(now.is_newer_than(Tick::ZERO.clamp_to(now)));
116    }
117
118    #[test]
119    fn clamp_leaves_recent_ticks_untouched() {
120        let now = Tick(1000);
121        assert_eq!(Tick(990).clamp_to(now), Tick(990));
122    }
123}