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 simulation-latched 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 a frame that schedules at least one fixed update clears pressed/released transitions
59/// and scroll samples while preserving held controls. A zero-update render frame retains those
60/// transients for the next simulation-bearing frame, so one-shot input cannot fall between ticks.
61#[derive(Debug)]
62#[must_use = "a runtime frame must be consumed by update/render work"]
63pub struct RuntimeFrame<'runtime> {
64    input: &'runtime mut InputSnapshot,
65    plan: FramePlan,
66    schedule: FrameSchedule,
67}
68
69impl RuntimeFrame<'_> {
70    /// Returns the fixed/variable timing work and render interpolation for this frame.
71    #[must_use]
72    pub const fn plan(&self) -> FramePlan {
73        self.plan
74    }
75
76    /// Returns how this frame's delta was derived: paced onto the observed display cadence, or
77    /// the wall-clock fallback while presentation feedback is missing or stale.
78    pub const fn schedule(&self) -> FrameSchedule {
79        self.schedule
80    }
81
82    /// Returns held state and transitions accumulated since the last simulation-bearing frame.
83    #[must_use]
84    pub const fn input(&self) -> &InputSnapshot {
85        self.input
86    }
87}
88
89impl Drop for RuntimeFrame<'_> {
90    fn drop(&mut self) {
91        if self.plan.fixed_steps() != 0 {
92            self.input.end_frame();
93        }
94    }
95}
96
97impl Runtime {
98    /// Starts a runtime clock at `started_at` with no accumulated simulation debt.
99    #[must_use]
100    pub fn new(config: RuntimeConfig, started_at: Instant) -> Self {
101        let mut pacer = FramePacer::new();
102        pacer.resume(started_at);
103        Self {
104            input: InputSnapshot::default(),
105            clock: timing::FrameClock::new(config),
106            pacer,
107        }
108    }
109
110    /// Records one presented frame with the display time the backend reported for it.
111    ///
112    /// Drain the graphics surface's presentation feedback into this method (or
113    /// [`Self::record_untimed_presented`]) every frame. Once the recorded timestamps yield a
114    /// cadence estimate, [`Self::begin_frame`] advances simulation time by whole display
115    /// intervals instead of wall-clock gaps.
116    pub fn record_presented(&mut self, presented_at: Instant) {
117        self.pacer.record_presented(presented_at);
118    }
119
120    /// Records one frame whose presentation completed without a reported display time, such as
121    /// while the window is off screen.
122    pub fn record_untimed_presented(&mut self) {
123        self.pacer.record_untimed_presented();
124    }
125
126    /// Summarizes the presentation pacing recorded so far.
127    #[must_use]
128    pub fn pacing_report(&self) -> PacingReport {
129        self.pacer.report()
130    }
131
132    /// Adds one ordered native input transition to the current snapshot.
133    pub fn handle_input(&mut self, event: InputEvent) {
134        self.input.handle_event(event);
135    }
136
137    /// Applies the input and rendering-lifecycle parts of one platform window event.
138    ///
139    /// Redraw, metrics, and close policy remain with the application. Lower-level input, suspend,
140    /// and resume methods remain available when an application uses a different coordination shape.
141    pub fn handle_window_event(&mut self, event: WindowEvent) {
142        match event {
143            WindowEvent::Input(input) => self.handle_input(input),
144            WindowEvent::RenderingSuspended => self.suspend(),
145            WindowEvent::RenderingResumed(_) => self.resume(Instant::now()),
146            _ => {}
147        }
148    }
149
150    /// Returns held state and transitions accumulated since the last simulation-bearing frame.
151    #[must_use]
152    pub const fn input(&self) -> &InputSnapshot {
153        &self.input
154    }
155
156    /// Begins a scoped frame with fixed simulation work, input, and render interpolation.
157    ///
158    /// While recorded presentation feedback yields a fresh cadence estimate, the frame delta is a
159    /// whole number of display intervals; call this once per frame that will be presented, since
160    /// every paced call advances at least one interval. Without feedback, or when it goes stale,
161    /// the delta observably falls back to the wall-clock gap since the previous frame — see
162    /// [`RuntimeFrame::schedule`].
163    ///
164    /// Dropping a frame with fixed updates consumes transient input, including on early return.
165    /// A frame with no fixed update retains it for the next simulation-bearing frame.
166    pub fn begin_frame(&mut self, now: Instant) -> RuntimeFrame<'_> {
167        let (plan, schedule) = if self.clock.suspended() {
168            (self.clock.idle_plan(), FrameSchedule::idle())
169        } else {
170            let schedule = self.pacer.schedule(now);
171            (self.clock.advance_by(schedule.frame_delta()), schedule)
172        };
173        RuntimeFrame {
174            input: &mut self.input,
175            plan,
176            schedule,
177        }
178    }
179
180    /// Pauses frame timing and releases every held input control.
181    ///
182    /// The fractional fixed-step accumulator is preserved so rendering can resume without a small
183    /// interpolation jump. Calls to [`Self::begin_frame`] while suspended schedule no updates.
184    pub fn suspend(&mut self) {
185        self.clock.suspend();
186        self.input.release_all();
187    }
188
189    /// Resumes frame timing from `now` without treating the suspended interval as elapsed game time.
190    pub fn resume(&mut self, now: Instant) {
191        self.clock.resume();
192        self.pacer.resume(now);
193    }
194
195    /// Returns whether frame timing is currently suspended.
196    #[must_use]
197    pub const fn suspended(&self) -> bool {
198        self.clock.suspended()
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use std::time::{Duration, Instant};
205
206    use mulciber_platform::{ButtonState, InputEvent, KeyCode, Modifiers, WindowEvent};
207
208    use super::{Runtime, RuntimeConfig};
209
210    const STEP: Duration = Duration::from_micros(16_667);
211
212    /// Returns a runtime fed `count` steady presents along with the last presented instant.
213    fn runtime_after_steady_presents(count: u32) -> (Runtime, Instant) {
214        let mut at = Instant::now();
215        let mut runtime = Runtime::new(RuntimeConfig::fixed_hz(60).unwrap(), at);
216        for _ in 1..count {
217            runtime.record_presented(at);
218            at += STEP;
219        }
220        runtime.record_presented(at);
221        (runtime, at)
222    }
223
224    #[test]
225    fn recorded_feedback_paces_jittered_frame_starts_onto_the_display_cadence() {
226        let (mut runtime, mut presented) = runtime_after_steady_presents(30);
227        let mut now = presented + Duration::from_millis(1);
228        drop(runtime.begin_frame(now));
229        for jitter_ms in [3_u64, 18, 2, 17] {
230            now += Duration::from_millis(jitter_ms);
231            let frame = runtime.begin_frame(now);
232            assert!(frame.schedule().paced(), "jitter {jitter_ms} ms");
233            assert_eq!(frame.plan().frame_delta(), STEP, "jitter {jitter_ms} ms");
234            drop(frame);
235            presented += STEP;
236            runtime.record_presented(presented);
237        }
238        assert_eq!(runtime.pacing_report().estimated_cadence, Some(STEP));
239    }
240
241    #[test]
242    fn without_feedback_frames_observably_fall_back_to_wall_clock_gaps() {
243        let start = Instant::now();
244        let mut runtime = Runtime::new(RuntimeConfig::fixed_hz(10).unwrap(), start);
245        let frame = runtime.begin_frame(start + Duration::from_millis(40));
246        assert!(!frame.schedule().paced());
247        assert_eq!(frame.plan().fixed_steps(), 0);
248        assert!((frame.plan().interpolation() - 0.4).abs() < f64::EPSILON);
249        drop(frame);
250        let frame = runtime.begin_frame(start + Duration::from_millis(125));
251        assert!(!frame.schedule().paced());
252        assert_eq!(frame.plan().fixed_steps(), 1);
253        assert!((frame.plan().interpolation() - 0.25).abs() < f64::EPSILON);
254    }
255
256    #[test]
257    fn suspension_advances_nothing_and_resume_discards_the_suspended_interval() {
258        let (mut runtime, presented) = runtime_after_steady_presents(30);
259        let now = presented + Duration::from_millis(1);
260        drop(runtime.begin_frame(now));
261
262        runtime.suspend();
263        let idle = runtime.begin_frame(now + Duration::from_millis(120));
264        assert!(!idle.schedule().paced());
265        assert_eq!(idle.plan().frame_delta(), Duration::ZERO);
266        assert_eq!(idle.plan().fixed_steps(), 0);
267        drop(idle);
268
269        // Presents recorded while suspended keep feedback fresh; the suspended interval must
270        // still not enter the first resumed frame as elapsed time.
271        let resumed_at = now + Duration::from_millis(120);
272        runtime.record_presented(presented + Duration::from_millis(120));
273        runtime.resume(resumed_at);
274        let frame = runtime.begin_frame(resumed_at + Duration::from_millis(2));
275        assert!(frame.schedule().paced());
276        assert_eq!(frame.plan().frame_delta(), STEP);
277    }
278
279    #[test]
280    fn scoped_frame_cleanup_and_window_suspension_release_input() {
281        let started = Instant::now();
282        let mut runtime = Runtime::new(RuntimeConfig::fixed_hz(60).unwrap(), started);
283        runtime.handle_window_event(WindowEvent::Input(InputEvent::Keyboard {
284            key: KeyCode::KeyW,
285            state: ButtonState::Pressed,
286            repeat: false,
287            modifiers: Modifiers::default(),
288        }));
289        let frame = runtime.begin_frame(started + STEP);
290        assert_eq!(frame.plan().fixed_steps(), 1);
291        assert!(frame.input().key_pressed(KeyCode::KeyW));
292        drop(frame);
293        assert!(!runtime.input().key_pressed(KeyCode::KeyW));
294        assert!(runtime.input().key_held(KeyCode::KeyW));
295
296        runtime.handle_window_event(WindowEvent::RenderingSuspended);
297        assert!(runtime.suspended());
298        assert!(!runtime.input().key_held(KeyCode::KeyW));
299        assert!(runtime.input().key_released(KeyCode::KeyW));
300    }
301
302    #[test]
303    fn zero_step_frame_preserves_transitions_until_simulation_can_consume_them() {
304        const DISPLAY_75_HZ_FRAME: Duration = Duration::from_nanos(13_333_333);
305
306        let started = Instant::now();
307        let mut runtime = Runtime::new(RuntimeConfig::fixed_hz(60).unwrap(), started);
308        runtime.handle_input(InputEvent::Keyboard {
309            key: KeyCode::Space,
310            state: ButtonState::Pressed,
311            repeat: false,
312            modifiers: Modifiers::default(),
313        });
314
315        let render_only = runtime.begin_frame(started + DISPLAY_75_HZ_FRAME);
316        assert_eq!(render_only.plan().fixed_steps(), 0);
317        assert!(render_only.input().key_pressed(KeyCode::Space));
318        drop(render_only);
319        assert!(runtime.input().key_pressed(KeyCode::Space));
320
321        let simulation_frame = runtime.begin_frame(started + DISPLAY_75_HZ_FRAME * 2);
322        assert_eq!(simulation_frame.plan().fixed_steps(), 1);
323        assert!(simulation_frame.input().key_pressed(KeyCode::Space));
324        drop(simulation_frame);
325        assert!(!runtime.input().key_pressed(KeyCode::Space));
326        assert!(runtime.input().key_held(KeyCode::Space));
327    }
328}