//! Pure pacing only: no clock reads, simulation, input-device, or death policy. //! //! The worker supplies monotonic nanoseconds, calls `observe` once per iteration, //! and executes each `next_tick` permit before calling `complete_tick`. Heading //! values must already be resolved into world space. Death/save/session handling //! belongs to the caller. Never execute a permit more than once. pub const TICK_NS: u64 = 100_000_000; pub const MAX_TICKS_PER_ITERATION: u8 = 5; pub const MAX_ACCUMULATOR_NS: u64 = TICK_NS * MAX_TICKS_PER_ITERATION as u64; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Policy { #[default] ActionPaced, RealTimeTactical, RealTimeContinuous, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Pause { Running, Tactical, System, OverloadPause, } /// Authoritative state reported by the simulation, not inferred by pacing. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Settlement { pub grounded: bool, pub pending_action: bool, pub pending_fall: bool, } impl Settlement { pub const IDLE: Self = Self { grounded: true, pending_action: false, pending_fall: false, }; pub fn is_settled(self) -> bool { self.grounded && !self.pending_action && !self.pending_fall } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Error { ClockWentBackwards, TickInFlight, NoTickInFlight, NotInteractive, NotSettled, WrongPolicy, } /// `None` advances time without beginning a new movement action. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct TickPermit { pub movement: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Step { Wait, CompleteAction(H), /// Continue an arbitrary action already admitted by the caller. PendingAction, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Snapshot { pub policy: Policy, pub pause: Pause, pub tactical_pause_requested: bool, pub accumulator_ns: u64, /// Unadmitted overload delta and elapsed time observed while paused. pub discarded_ns: u128, pub accepted_movement: bool, pub tick_in_flight: bool, } #[derive(Clone, Debug)] pub struct Scheduler { policy: Policy, pause: Pause, baseline_ns: u64, accumulator_ns: u64, discarded_ns: u128, remaining: u8, settlement: Settlement, held: Option, // Whole overdue ticks plus the next fresh tick. A fractional remainder // belongs to that fresh tick and must not force an extra tick of delay. accepted: Option<(H, u64)>, in_flight: bool, tactical_requested: bool, step: Option>, step_started: bool, } impl Scheduler { pub fn new(now_ns: u64, settlement: Settlement) -> Self { Self { policy: Policy::default(), pause: Pause::Running, baseline_ns: now_ns, accumulator_ns: 0, discarded_ns: 0, remaining: 0, settlement, held: None, accepted: None, in_flight: false, tactical_requested: false, step: None, step_started: false, } } pub fn snapshot(&self) -> Snapshot { Snapshot { policy: self.policy, pause: self.pause, tactical_pause_requested: self.tactical_requested, accumulator_ns: self.accumulator_ns, discarded_ns: self.discarded_ns, accepted_movement: self.accepted.is_some(), tick_in_flight: self.in_flight, } } fn settled(&self) -> bool { self.settlement.is_settled() && self.accepted.is_none() && !self.in_flight } fn check_time(&self, now_ns: u64) -> Result { now_ns .checked_sub(self.baseline_ns) .ok_or(Error::ClockWentBackwards) } /// Admit time before processing this iteration's input. The entire delta is /// rejected on overload, rather than clamped or partially admitted. pub fn observe(&mut self, now_ns: u64) -> Result<(), Error> { let delta = self.check_time(now_ns)?; if self.in_flight { return Err(Error::TickInFlight); } self.baseline_ns = now_ns; self.remaining = MAX_TICKS_PER_ITERATION; if self.pause != Pause::Running { self.discarded_ns = self.discarded_ns.saturating_add(delta as u128); return Ok(()); } let active = self.policy != Policy::ActionPaced || !self.settled() || self.held.is_some(); if active { if delta > MAX_ACCUMULATOR_NS - self.accumulator_ns { self.discarded_ns = self.discarded_ns.saturating_add(delta as u128); self.pause = Pause::OverloadPause; self.held = None; self.step = None; return Ok(()); } self.sample_held(); self.accumulator_ns += delta; } Ok(()) } fn sample_held(&mut self) { if self.settled() && !self.tactical_requested { if let Some(heading) = self.held { self.accepted = Some((heading, (self.accumulator_ns / TICK_NS + 1) * TICK_NS)); } } } /// Call after `observe(now)` for a press/change. Release is also valid while /// paused and clears only unadmitted intent, never an accepted sample. /// Repeated calls do not replenish the iteration's tick budget. pub fn set_heading(&mut self, heading: Option) -> Result<(), Error> { if heading.is_some() && (self.pause != Pause::Running || self.tactical_requested) { return Err(Error::NotInteractive); } self.held = heading; if self.pause == Pause::Running { self.sample_held(); } Ok(()) } /// At most five permits between observations, with one permit in flight. pub fn next_tick(&mut self) -> Result>, Error> { if self.in_flight { return Err(Error::TickInFlight); } if self.remaining == 0 { return Ok(None); } let movement; if let Some(step) = self.step { if self.pause != Pause::Tactical { return Ok(None); } movement = if self.step_started { None } else { match step { Step::Wait | Step::PendingAction => None, Step::CompleteAction(h) => Some(h), } }; self.step_started = true; } else { if self.pause != Pause::Running || self.accumulator_ns < TICK_NS { return Ok(None); } self.accumulator_ns -= TICK_NS; movement = match self.accepted { Some((h, delay)) if delay <= TICK_NS && self.settlement.is_settled() => { self.accepted = None; Some(h) } Some((h, delay)) => { self.accepted = Some((h, delay.saturating_sub(TICK_NS))); None } None => None, }; } self.remaining -= 1; self.in_flight = true; Ok(Some(TickPermit { movement })) } pub fn complete_tick(&mut self, settlement: Settlement) -> Result<(), Error> { if !self.in_flight { return Err(Error::NoTickInFlight); } self.in_flight = false; self.settlement = settlement; if matches!(self.step, Some(Step::Wait)) || self.settled() { self.step = None; } if self.pause == Pause::Running && self.tactical_requested && self.settled() { self.pause = Pause::Tactical; self.tactical_requested = false; } Ok(()) } pub fn request_tactical_pause(&mut self) -> Result<(), Error> { if self.policy != Policy::RealTimeTactical { return Err(Error::WrongPolicy); } if self.pause != Pause::Running { return Err(Error::NotInteractive); } self.held = None; self.tactical_requested = true; if self.settled() { self.pause = Pause::Tactical; self.tactical_requested = false; } Ok(()) } /// Call after observing the pause event's timestamp. Accepted movement and /// all previously admitted time remain owed, even in a system pause. pub fn system_pause(&mut self) { self.pause = Pause::System; self.held = None; self.step = None; } /// Explicitly resume, accounting for but never admitting paused elapsed time. pub fn resume(&mut self, now_ns: u64) -> Result<(), Error> { let delta = self.check_time(now_ns)?; if self.in_flight { return Err(Error::TickInFlight); } if self.pause == Pause::Running { return Err(Error::NotInteractive); } self.discarded_ns = self.discarded_ns.saturating_add(delta as u128); self.baseline_ns = now_ns; self.pause = Pause::Running; self.tactical_requested = false; self.step = None; self.remaining = 0; // Next worker iteration must observe the fresh baseline. Ok(()) } /// Tactical stepping bypasses wall-clock debt: one wait tick, or enough /// ticks to settle one supplied movement action. Long actions continue over /// iterations; each observation still permits no more than five ticks. pub fn begin_step(&mut self, step: Step) -> Result<(), Error> { if self.pause != Pause::Tactical || self.step.is_some() { return Err(Error::NotInteractive); } if !self.settled() { return Err(Error::NotSettled); } if matches!(step, Step::PendingAction) { return Err(Error::NotSettled); // Use begin_action_step with busy state. } self.step = Some(step); self.step_started = false; Ok(()) } /// Report caller-side action admission or settlement without fabricating a /// tick. Call after observing the event timestamp. Busy state blocks new /// movement, including accepted movement (which is retained until settled). /// This is not permission to replace an action: caller validates admission. pub fn update_settlement(&mut self, settlement: Settlement) -> Result<(), Error> { if self.in_flight { return Err(Error::TickInFlight); } if matches!(self.pause, Pause::System | Pause::OverloadPause) { return Err(Error::NotInteractive); } self.settlement = settlement; if self.settled() { self.step = None; if self.tactical_requested { self.pause = Pause::Tactical; self.tactical_requested = false; } } Ok(()) } /// Begin tactical completion of an arbitrary caller-admitted action. The /// caller supplies its resulting busy settlement; permits carry no heading. /// Call only after validating action admission, before advancing simulation. pub fn begin_action_step(&mut self, settlement: Settlement) -> Result<(), Error> { if self.pause != Pause::Tactical || self.step.is_some() { return Err(Error::NotInteractive); } if !self.settled() || settlement.is_settled() { return Err(Error::NotSettled); } self.settlement = settlement; self.step = Some(Step::PendingAction); self.step_started = false; Ok(()) } /// No held input, accepted sample, simulation debt, or admitted time may /// cross a policy boundary. Only running or tactical-paused callers may switch. pub fn set_policy(&mut self, policy: Policy) -> Result<(), Error> { if matches!(self.pause, Pause::System | Pause::OverloadPause) { return Err(Error::NotInteractive); } if !self.settled() || self.held.is_some() || self.accumulator_ns != 0 || self.step.is_some() || self.tactical_requested { return Err(Error::NotSettled); } self.policy = policy; if self.pause == Pause::Tactical { self.pause = Pause::System; // Switching never implicitly resumes time. } Ok(()) } } #[cfg(test)] mod tests { use super::*; const FALL: Settlement = Settlement { grounded: false, pending_action: false, pending_fall: true, }; fn drain(s: &mut Scheduler, state: Settlement) -> Vec> { let mut result = Vec::new(); while let Some(p) = s.next_tick().unwrap() { result.push(p.movement); s.complete_tick(state).unwrap(); } result } #[test] fn pacing_fake_clock_policy_matrix() { // Policy-only coverage: death/session integration is external. for policy in [ Policy::ActionPaced, Policy::RealTimeTactical, Policy::RealTimeContinuous, ] { let mut s = Scheduler::::new(0, Settlement::IDLE); s.set_policy(policy).unwrap(); s.observe(TICK_NS).unwrap(); assert_eq!( drain(&mut s, Settlement::IDLE).len(), if policy == Policy::ActionPaced { 0 } else { 1 } ); s.set_heading(Some(3)).unwrap(); s.set_heading(None).unwrap(); s.observe(2 * TICK_NS).unwrap(); assert_eq!(drain(&mut s, FALL), vec![Some(3)]); s.observe(3 * TICK_NS).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![None]); } } #[test] fn scheduler_stall_saturation_release_order() { let mut s = Scheduler::new(0, Settlement::IDLE); s.set_policy(Policy::RealTimeContinuous).unwrap(); s.observe(MAX_ACCUMULATOR_NS).unwrap(); s.set_heading(Some(4)).unwrap(); s.set_heading(Some(5)).unwrap(); // Cannot replace accepted world heading. s.set_heading(None).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![None; 5]); s.observe(MAX_ACCUMULATOR_NS + TICK_NS).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![Some(4)]); s.observe(MAX_ACCUMULATOR_NS + 2 * TICK_NS).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![None]); } #[test] fn tick_overload_retains_debt() { let mut s = Scheduler::new(0, Settlement::IDLE); s.set_heading(Some(2)).unwrap(); s.observe(TICK_NS).unwrap(); s.observe(7 * TICK_NS).unwrap(); let snap = s.snapshot(); assert_eq!(snap.pause, Pause::OverloadPause); assert_eq!(snap.accumulator_ns, TICK_NS); assert_eq!(snap.discarded_ns, 6 * TICK_NS as u128); assert!(snap.accepted_movement); s.set_heading(None).unwrap(); assert!(drain(&mut s, Settlement::IDLE).is_empty()); s.observe(9 * TICK_NS).unwrap(); s.resume(10 * TICK_NS).unwrap(); s.observe(10 * TICK_NS).unwrap(); assert_eq!(drain(&mut s, FALL), vec![Some(2)]); assert_eq!(s.snapshot().discarded_ns, 9 * TICK_NS as u128); s.observe(11 * TICK_NS).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![None]); } #[test] fn policy_switch_boundaries() { let mut s = Scheduler::new(0, FALL); assert_eq!( s.set_policy(Policy::RealTimeContinuous), Err(Error::NotSettled) ); s.observe(TICK_NS).unwrap(); drain(&mut s, Settlement::IDLE); s.set_policy(Policy::RealTimeTactical).unwrap(); s.set_heading(Some(1)).unwrap(); s.set_heading(None).unwrap(); assert_eq!(s.set_policy(Policy::ActionPaced), Err(Error::NotSettled)); s.request_tactical_pause().unwrap(); assert_eq!(s.snapshot().pause, Pause::Running); s.observe(2 * TICK_NS).unwrap(); drain(&mut s, FALL); s.observe(3 * TICK_NS).unwrap(); drain(&mut s, Settlement::IDLE); assert_eq!(s.snapshot().pause, Pause::Tactical); s.set_policy(Policy::ActionPaced).unwrap(); assert_eq!(s.snapshot().pause, Pause::System); } #[test] fn steps_and_system_pause_are_explicit() { let mut s = Scheduler::::new(0, Settlement::IDLE); s.set_policy(Policy::RealTimeTactical).unwrap(); s.request_tactical_pause().unwrap(); s.observe(TICK_NS).unwrap(); s.begin_step(Step::Wait).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![None]); s.begin_step(Step::CompleteAction(6)).unwrap(); assert_eq!(s.next_tick().unwrap().unwrap().movement, Some(6)); assert_eq!(s.next_tick(), Err(Error::TickInFlight)); s.complete_tick(FALL).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![None]); s.resume(2 * TICK_NS).unwrap(); s.set_heading(Some(2)).unwrap(); s.observe(3 * TICK_NS).unwrap(); s.system_pause(); assert_eq!(s.set_heading(Some(1)), Err(Error::NotInteractive)); assert!(drain(&mut s, Settlement::IDLE).is_empty()); s.resume(100 * TICK_NS).unwrap(); s.observe(100 * TICK_NS).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![Some(2)]); } #[test] fn system_pause_wins_over_deferred_tactical_pause() { let mut s = Scheduler::new(0, Settlement::IDLE); s.set_policy(Policy::RealTimeTactical).unwrap(); s.set_heading(Some(1)).unwrap(); s.observe(TICK_NS).unwrap(); s.next_tick().unwrap().unwrap(); s.request_tactical_pause().unwrap(); s.system_pause(); s.complete_tick(Settlement::IDLE).unwrap(); assert_eq!(s.snapshot().pause, Pause::System); assert_eq!(s.begin_step(Step::Wait), Err(Error::NotInteractive)); } #[test] fn held_cadence_at_30_60_144_hz() { for hz in [30_u64, 60, 144] { for policy in [ Policy::ActionPaced, Policy::RealTimeTactical, Policy::RealTimeContinuous, ] { let mut s = Scheduler::new(0, Settlement::IDLE); s.set_policy(policy).unwrap(); s.set_heading(Some(7)).unwrap(); let mut emitted = Vec::new(); for frame in 1..=hz * 3 { let now = frame * 1_000_000_000 / hz; s.observe(now).unwrap(); for movement in drain(&mut s, Settlement::IDLE) { assert_eq!(movement, Some(7), "hz={hz} policy={policy:?}"); emitted.push(now); } } assert_eq!(emitted.len(), 30, "hz={hz} policy={policy:?}"); for (i, now) in emitted.into_iter().enumerate() { let deadline = (i as u64 + 1) * TICK_NS; assert!(now >= deadline && now - deadline <= 1_000_000_000 / hz + 1); } } } } #[test] fn fractional_backlog_skips_only_whole_overdue_ticks() { let mut s = Scheduler::new(0, Settlement::IDLE); s.set_policy(Policy::RealTimeContinuous).unwrap(); s.observe(216_000_000).unwrap(); s.set_heading(Some(8)).unwrap(); s.set_heading(None).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![None, None]); s.observe(300_000_000).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![Some(8)]); } #[test] fn caller_action_admission_and_busy_movement_retention() { let busy = Settlement { pending_action: true, ..Settlement::IDLE }; let mut s = Scheduler::new(0, Settlement::IDLE); s.update_settlement(busy).unwrap(); // No fabricated tick needed. s.set_heading(Some(9)).unwrap(); s.observe(TICK_NS).unwrap(); assert_eq!(drain(&mut s, busy), vec![None]); assert!(!s.snapshot().accepted_movement); s.update_settlement(Settlement::IDLE).unwrap(); s.observe(2 * TICK_NS).unwrap(); // A held sample now exists, but a caller-side action can make us busy. assert!(s.snapshot().accepted_movement); s.update_settlement(busy).unwrap(); s.set_heading(None).unwrap(); assert_eq!(drain(&mut s, busy), vec![None]); s.observe(3 * TICK_NS).unwrap(); assert_eq!(drain(&mut s, busy), vec![None]); assert!(s.snapshot().accepted_movement); s.update_settlement(Settlement::IDLE).unwrap(); s.observe(4 * TICK_NS).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![Some(9)]); } #[test] fn tactical_generic_action_completes_without_heading() { let busy = Settlement { pending_action: true, ..Settlement::IDLE }; let mut s = Scheduler::::new(0, Settlement::IDLE); s.set_policy(Policy::RealTimeTactical).unwrap(); s.request_tactical_pause().unwrap(); s.begin_action_step(busy).unwrap(); s.observe(TICK_NS).unwrap(); assert_eq!(drain(&mut s, busy), vec![None; 5]); s.observe(2 * TICK_NS).unwrap(); assert_eq!(drain(&mut s, Settlement::IDLE), vec![None]); assert_eq!(s.snapshot().pause, Pause::Tactical); assert_eq!(s.snapshot().accumulator_ns, 0); } #[test] fn noninteractive_pauses_reject_policy_and_action_mutation() { for overload in [false, true] { let mut s = Scheduler::::new(0, Settlement::IDLE); s.set_policy(Policy::RealTimeContinuous).unwrap(); if overload { s.observe(MAX_ACCUMULATOR_NS + 1).unwrap(); } else { s.system_pause(); } let before = s.snapshot(); assert_eq!( s.set_policy(Policy::ActionPaced), Err(Error::NotInteractive) ); assert_eq!(s.update_settlement(FALL), Err(Error::NotInteractive)); assert_eq!(s.begin_action_step(FALL), Err(Error::NotInteractive)); assert_eq!(s.begin_step(Step::Wait), Err(Error::NotInteractive)); assert_eq!(s.set_heading(Some(1)), Err(Error::NotInteractive)); assert_eq!(s.snapshot(), before); } } #[test] fn checked_clock_is_transactional() { let mut s = Scheduler::::new(u64::MAX - 1, Settlement::IDLE); let before = s.snapshot(); assert_eq!(s.observe(0), Err(Error::ClockWentBackwards)); assert_eq!(s.snapshot(), before); s.set_policy(Policy::RealTimeContinuous).unwrap(); s.observe(u64::MAX).unwrap(); assert_eq!(s.snapshot().accumulator_ns, 1); } }