1use core::fmt;
8use core::time::Duration;
9
10#[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#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
21pub struct FixedStep {
22 pub index: u64,
24 pub delta: Duration,
26 pub steps: u64,
31}
32
33#[derive(Copy, Clone, Debug, Eq, PartialEq)]
34pub(crate) enum FixedStepError {
35 Exhausted,
36}
37
38#[derive(Copy, Clone, Debug, Eq, PartialEq)]
40pub struct FixedConfig {
41 delta: Duration,
42 max_substeps: u32,
43 debt_policy: FixedDebtPolicy,
44}
45
46#[derive(Copy, Clone, Debug, Eq, PartialEq)]
48pub enum FixedDebtPolicy {
49 DropWithDiagnostic,
51 Preserve,
53 Coalesce,
55}
56
57#[derive(Copy, Clone, Debug, Eq, PartialEq)]
59pub enum FixedConfigError {
60 NonPositiveDelta,
62 ZeroSubstepCap,
64}
65
66#[derive(Clone, Debug)]
68pub(crate) struct FixedAccumulator {
69 remainder: Duration,
70 next_index: u64,
71}
72
73#[derive(Copy, Clone, Debug, Eq, PartialEq)]
75pub struct FixedDebtDropped {
76 pub steps: u128,
78}
79
80#[derive(Copy, Clone, Debug, Eq, PartialEq)]
83pub struct FixedDebtCoalesced {
84 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#[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 pub const ZERO: Self = Self(0);
113
114 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 pub const DEFAULT_MAX_SUBSTEPS: u32 = 8;
133
134 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 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 pub fn delta(&self) -> Duration {
158 self.delta
159 }
160
161 pub fn max_substeps(&self) -> u32 {
163 self.max_substeps
164 }
165
166 pub fn with_debt_policy(mut self, debt_policy: FixedDebtPolicy) -> Self {
168 self.debt_policy = debt_policy;
169 self
170 }
171
172 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 pub const ZERO: Self = Self(0);
301
302 pub const fn from_raw(raw: u64) -> Self {
304 Self(raw)
305 }
306
307 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}