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}