Skip to main content

mulciber_runtime/
lib.rs

1//! Game-loop timing and input snapshots for Mulciber.
2//!
3//! The first runtime slice decouples a fixed-rate simulation from variable-rate rendering. It owns
4//! the accumulator and bounded catch-up policy while leaving previous/current game state and its
5//! interpolation with the application.
6//!
7//! The runtime also owns the display-interval frame pacer. Drain the graphics surface's
8//! presentation feedback into [`Runtime::record_presented`] every frame and
9//! [`Runtime::begin_frame`] advances simulation time by whole display intervals of the observed
10//! cadence instead of by wall-clock gaps between build starts — wall-clock gaps reintroduce
11//! visible judder on a steadily presenting display even with fixed simulation steps. Skipping the
12//! feedback drain observably degrades every frame to the wall-clock fallback; check
13//! [`RuntimeFrame::schedule`] or [`Runtime::pacing_report`] rather than assuming pacing engaged.
14//!
15//! The canonical loop, with presented instants standing in for a drained
16//! `Surface::take_present_feedback`:
17//!
18//! ```
19//! use std::time::Instant;
20//! use mulciber_runtime::{Runtime, RuntimeConfig};
21//!
22//! # fn main() -> Result<(), mulciber_runtime::RuntimeConfigError> {
23//! let mut runtime = Runtime::new(RuntimeConfig::fixed_hz(120)?, Instant::now());
24//! // Every frame, before beginning it: drain presentation feedback into the runtime.
25//! runtime.record_presented(Instant::now());
26//! // Then begin the frame; once a cadence is estimated, deltas follow the display.
27//! let frame = runtime.begin_frame(Instant::now());
28//! let plan = frame.plan();
29//! for _ in 0..plan.fixed_steps() {
30//!     // fixed_update(plan.fixed_step());
31//! }
32//! // render(previous, current, plan.interpolation());
33//! # Ok(())
34//! # }
35//! ```
36
37mod input;
38mod pacing;
39mod timing;
40
41use std::time::Instant;
42
43pub use input::{InputSnapshot, ScrollSample};
44use mulciber_platform::{InputEvent, WindowEvent};
45pub use pacing::{FramePacer, FrameSchedule, IntervalSummary, PacingDiagnostics, PacingReport};
46pub use timing::{FramePlan, RuntimeConfig, RuntimeConfigError};
47
48/// Coordinates frame-scoped input, a fixed-rate simulation clock, and presentation pacing.
49#[derive(Debug)]
50pub struct Runtime {
51    input: InputSnapshot,
52    clock: timing::FrameClock,
53    pacer: FramePacer,
54}
55
56/// One scoped runtime frame containing its timing plan and immutable input snapshot.
57///
58/// Dropping the frame clears pressed/released transitions and scroll samples while preserving held
59/// controls. This includes early returns from surface acquisition or rendering errors.
60#[derive(Debug)]
61#[must_use = "a runtime frame must be consumed by update/render work"]
62pub struct RuntimeFrame<'runtime> {
63    input: &'runtime mut InputSnapshot,
64    plan: FramePlan,
65    schedule: FrameSchedule,
66}
67
68impl RuntimeFrame<'_> {
69    /// Returns the fixed/variable timing work and render interpolation for this frame.
70    #[must_use]
71    pub const fn plan(&self) -> FramePlan {
72        self.plan
73    }
74
75    /// Returns how this frame's delta was derived: paced onto the observed display cadence, or
76    /// the wall-clock fallback while presentation feedback is missing or stale.
77    pub const fn schedule(&self) -> FrameSchedule {
78        self.schedule
79    }
80
81    /// Returns the held state and transitions accumulated for this frame.
82    #[must_use]
83    pub const fn input(&self) -> &InputSnapshot {
84        self.input
85    }
86}
87
88impl Drop for RuntimeFrame<'_> {
89    fn drop(&mut self) {
90        self.input.end_frame();
91    }
92}
93
94impl Runtime {
95    /// Starts a runtime clock at `started_at` with no accumulated simulation debt.
96    #[must_use]
97    pub fn new(config: RuntimeConfig, started_at: Instant) -> Self {
98        let mut pacer = FramePacer::new();
99        pacer.resume(started_at);
100        Self {
101            input: InputSnapshot::default(),
102            clock: timing::FrameClock::new(config),
103            pacer,
104        }
105    }
106
107    /// Records one presented frame with the display time the backend reported for it.
108    ///
109    /// Drain the graphics surface's presentation feedback into this method (or
110    /// [`Self::record_untimed_presented`]) every frame. Once the recorded timestamps yield a
111    /// cadence estimate, [`Self::begin_frame`] advances simulation time by whole display
112    /// intervals instead of wall-clock gaps.
113    pub fn record_presented(&mut self, presented_at: Instant) {
114        self.pacer.record_presented(presented_at);
115    }
116
117    /// Records one frame whose presentation completed without a reported display time, such as
118    /// while the window is off screen.
119    pub fn record_untimed_presented(&mut self) {
120        self.pacer.record_untimed_presented();
121    }
122
123    /// Summarizes the presentation pacing recorded so far.
124    #[must_use]
125    pub fn pacing_report(&self) -> PacingReport {
126        self.pacer.report()
127    }
128
129    /// Adds one ordered native input transition to the current snapshot.
130    pub fn handle_input(&mut self, event: InputEvent) {
131        self.input.handle_event(event);
132    }
133
134    /// Applies the input and rendering-lifecycle parts of one platform window event.
135    ///
136    /// Redraw, metrics, and close policy remain with the application. Lower-level input, suspend,
137    /// and resume methods remain available when an application uses a different coordination shape.
138    pub fn handle_window_event(&mut self, event: WindowEvent) {
139        match event {
140            WindowEvent::Input(input) => self.handle_input(input),
141            WindowEvent::RenderingSuspended => self.suspend(),
142            WindowEvent::RenderingResumed(_) => self.resume(Instant::now()),
143            _ => {}
144        }
145    }
146
147    /// Returns the held state and transitions accumulated for the current frame.
148    #[must_use]
149    pub const fn input(&self) -> &InputSnapshot {
150        &self.input
151    }
152
153    /// Begins a scoped frame with fixed simulation work, input, and render interpolation.
154    ///
155    /// While recorded presentation feedback yields a fresh cadence estimate, the frame delta is a
156    /// whole number of display intervals; call this once per frame that will be presented, since
157    /// every paced call advances at least one interval. Without feedback, or when it goes stale,
158    /// the delta observably falls back to the wall-clock gap since the previous frame — see
159    /// [`RuntimeFrame::schedule`].
160    ///
161    /// Dropping the returned frame automatically ends the input snapshot, including on early return.
162    pub fn begin_frame(&mut self, now: Instant) -> RuntimeFrame<'_> {
163        let (plan, schedule) = if self.clock.suspended() {
164            (self.clock.idle_plan(), FrameSchedule::idle())
165        } else {
166            let schedule = self.pacer.schedule(now);
167            (self.clock.advance_by(schedule.frame_delta()), schedule)
168        };
169        RuntimeFrame {
170            input: &mut self.input,
171            plan,
172            schedule,
173        }
174    }
175
176    /// Pauses frame timing and releases every held input control.
177    ///
178    /// The fractional fixed-step accumulator is preserved so rendering can resume without a small
179    /// interpolation jump. Calls to [`Self::begin_frame`] while suspended schedule no updates.
180    pub fn suspend(&mut self) {
181        self.clock.suspend();
182        self.input.release_all();
183    }
184
185    /// Resumes frame timing from `now` without treating the suspended interval as elapsed game time.
186    pub fn resume(&mut self, now: Instant) {
187        self.clock.resume();
188        self.pacer.resume(now);
189    }
190
191    /// Returns whether frame timing is currently suspended.
192    #[must_use]
193    pub const fn suspended(&self) -> bool {
194        self.clock.suspended()
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use std::time::{Duration, Instant};
201
202    use mulciber_platform::{ButtonState, InputEvent, KeyCode, Modifiers, WindowEvent};
203
204    use super::{Runtime, RuntimeConfig};
205
206    const STEP: Duration = Duration::from_micros(16_667);
207
208    /// Returns a runtime fed `count` steady presents along with the last presented instant.
209    fn runtime_after_steady_presents(count: u32) -> (Runtime, Instant) {
210        let mut at = Instant::now();
211        let mut runtime = Runtime::new(RuntimeConfig::fixed_hz(60).unwrap(), at);
212        for _ in 1..count {
213            runtime.record_presented(at);
214            at += STEP;
215        }
216        runtime.record_presented(at);
217        (runtime, at)
218    }
219
220    #[test]
221    fn recorded_feedback_paces_jittered_frame_starts_onto_the_display_cadence() {
222        let (mut runtime, mut presented) = runtime_after_steady_presents(30);
223        let mut now = presented + Duration::from_millis(1);
224        drop(runtime.begin_frame(now));
225        for jitter_ms in [3_u64, 18, 2, 17] {
226            now += Duration::from_millis(jitter_ms);
227            let frame = runtime.begin_frame(now);
228            assert!(frame.schedule().paced(), "jitter {jitter_ms} ms");
229            assert_eq!(frame.plan().frame_delta(), STEP, "jitter {jitter_ms} ms");
230            drop(frame);
231            presented += STEP;
232            runtime.record_presented(presented);
233        }
234        assert_eq!(runtime.pacing_report().estimated_cadence, Some(STEP));
235    }
236
237    #[test]
238    fn without_feedback_frames_observably_fall_back_to_wall_clock_gaps() {
239        let start = Instant::now();
240        let mut runtime = Runtime::new(RuntimeConfig::fixed_hz(10).unwrap(), start);
241        let frame = runtime.begin_frame(start + Duration::from_millis(40));
242        assert!(!frame.schedule().paced());
243        assert_eq!(frame.plan().fixed_steps(), 0);
244        assert!((frame.plan().interpolation() - 0.4).abs() < f64::EPSILON);
245        drop(frame);
246        let frame = runtime.begin_frame(start + Duration::from_millis(125));
247        assert!(!frame.schedule().paced());
248        assert_eq!(frame.plan().fixed_steps(), 1);
249        assert!((frame.plan().interpolation() - 0.25).abs() < f64::EPSILON);
250    }
251
252    #[test]
253    fn suspension_advances_nothing_and_resume_discards_the_suspended_interval() {
254        let (mut runtime, presented) = runtime_after_steady_presents(30);
255        let now = presented + Duration::from_millis(1);
256        drop(runtime.begin_frame(now));
257
258        runtime.suspend();
259        let idle = runtime.begin_frame(now + Duration::from_millis(120));
260        assert!(!idle.schedule().paced());
261        assert_eq!(idle.plan().frame_delta(), Duration::ZERO);
262        assert_eq!(idle.plan().fixed_steps(), 0);
263        drop(idle);
264
265        // Presents recorded while suspended keep feedback fresh; the suspended interval must
266        // still not enter the first resumed frame as elapsed time.
267        let resumed_at = now + Duration::from_millis(120);
268        runtime.record_presented(presented + Duration::from_millis(120));
269        runtime.resume(resumed_at);
270        let frame = runtime.begin_frame(resumed_at + Duration::from_millis(2));
271        assert!(frame.schedule().paced());
272        assert_eq!(frame.plan().frame_delta(), STEP);
273    }
274
275    #[test]
276    fn scoped_frame_cleanup_and_window_suspension_release_input() {
277        let mut runtime = Runtime::new(RuntimeConfig::fixed_hz(60).unwrap(), Instant::now());
278        runtime.handle_window_event(WindowEvent::Input(InputEvent::Keyboard {
279            key: KeyCode::KeyW,
280            state: ButtonState::Pressed,
281            repeat: false,
282            modifiers: Modifiers::default(),
283        }));
284        let frame = runtime.begin_frame(Instant::now());
285        assert!(frame.input().key_pressed(KeyCode::KeyW));
286        drop(frame);
287        assert!(!runtime.input().key_pressed(KeyCode::KeyW));
288        assert!(runtime.input().key_held(KeyCode::KeyW));
289
290        runtime.handle_window_event(WindowEvent::RenderingSuspended);
291        assert!(runtime.suspended());
292        assert!(!runtime.input().key_held(KeyCode::KeyW));
293        assert!(runtime.input().key_released(KeyCode::KeyW));
294    }
295}