renew_frame/lib.rs
1//! Fixed-timestep frame scheduling: the deterministic accumulator, the
2//! step budget that bounds a stall, and the interpolation factor for
3//! rendering between steps.
4//!
5//! The loop is a passive integer state machine. It owns no loop, drives no
6//! application, knows nothing of rendering, GPUs or windows, and never
7//! reads a clock — it *cannot*, having no dependency that offers one. Its
8//! whole job is one total function: [`FrameLoop::begin_frame`] answers
9//! *given the schedule so far and this instant, how many fixed steps are
10//! due, how many did the budget refuse, and how far between steps is the
11//! renderer.* The caller reads the one clock, executes the steps, and
12//! renders.
13//!
14//! ```
15//! use renew_frame::{FrameLoop, FrameStats, StepBudget, Timestamp, Timestep};
16//!
17//! let mut frame = FrameLoop::new(
18//! Timestep::HZ_60,
19//! StepBudget::DEFAULT,
20//! Timestamp::from_nanos(0),
21//! );
22//! let mut stats = FrameStats::new();
23//!
24//! // A headless driver: no clock is read, so the whole run is a pure
25//! // function of the timestamp sequence and is byte-comparable across
26//! // runs, processes and machines.
27//! for k in 1..=600u64 {
28//! let now = Timestamp::from_nanos(k.saturating_mul(16_666_667));
29//! let plan = frame.begin_frame(now);
30//! for step in plan.steps() {
31//! let _ = (step.tick, step.dt, step.sim_time); // advance the world here
32//! }
33//! // Render between steps with `renew_math::Alpha::new(...)`,
34//! // built from `plan.remainder()` and `plan.timestep()`.
35//! stats.absorb(&plan);
36//! }
37//!
38//! assert_eq!(stats.frames(), 600);
39//! assert_eq!(stats.ticks(), 600);
40//! assert_eq!(stats.steps_dropped(), 0);
41//! ```
42//!
43//! # Contract
44//!
45//! - **Deterministic.** For a fixed build and platform, [`FrameLoop`]
46//! is a pure function of `(timestep, budget, start, the sequence of
47//! timestamps passed to begin_frame)`. It reads no clock, allocates
48//! nothing, spawns nothing, and holds no iteration-order-dependent
49//! state. A headless run supplies that sequence synthetically and is
50//! reproducible; a realtime run supplies a measured one, which is a
51//! different *input trace*, not nondeterministic *code*.
52//! - **Nothing can fail.** Non-zero types, a saturating bank and a
53//! saturating delta between them leave no error to report, so
54//! `begin_frame` returns no `Result` — an uninhabitable error variant
55//! would be a lie about the API. Nothing here panics and nothing
56//! unwinds.
57//! - **The plan must be executed.** The one available contract violation —
58//! a caller that ignores its plan — is unobservable from inside, so it
59//! is contract text with `#[must_use]` as the mitigation rather than an
60//! assertion. A skipped plan silently desynchronizes the simulation from
61//! the tick counter.
62//! - **Clamp and discard, always reported.** Steps beyond the budget are
63//! discarded, never banked: keeping the surplus *is* the spiral of
64//! death. Simulation time therefore falls permanently behind the wall
65//! clock, and [`FramePlan::dropped`] is the exact, non-optional record
66//! of by how much.
67//! - **`alpha` is never an input to simulation.** It is a render-side hint
68//! in `[0, 1)`, and it is deliberately excluded from the schedule
69//! digest.
70//! - **Zero dependencies, and this crate never logs.** A dropped step is
71//! reported through the returned plan; whether that is a log line is the
72//! caller's decision.
73//!
74//! # Extension points
75//!
76//! None. There is no trait, no `dyn`, and no runtime polymorphism here —
77//! the manifest says so and CI holds the crate to it. The growth point is
78//! named rather than pre-built: a trait arrives when a second
79//! implementation exists.
80
81// This crate reports; it does not print. Diagnostics belong to the caller,
82// which is what keeps the dependency list empty.
83// The determinism rule in the language standard: a simulation crate does not
84// perform floating-point arithmetic whose result can reach digested state.
85// Denied here rather than left to review — the lint covers operators only, so
86// it is necessary and not sufficient, but what it does cover it covers with
87// teeth.
88//
89// This crate held the tree's only exemption: the interpolation factor was
90// computed here, with an `allow` at the expression. It is gone. The
91// factor moved to `renew-math` — a crate a simulation is mechanically
92// forbidden from reaching — and this crate now performs no floating-point
93// arithmetic at all. There is no `allow` below, and adding one would be a
94// change to the language standard, not a local decision.
95#![deny(clippy::print_stdout, clippy::print_stderr, clippy::float_arithmetic)]
96
97mod digest;
98mod report;
99mod schedule;
100mod time;
101
102pub use digest::StateHash;
103pub use report::{FrameStats, FrameStatsJson, FrameTiming, FrameTimingJson};
104pub use schedule::{FrameLoop, FramePlan, Step, Steps};
105pub use time::{Nanos, StepBudget, Timestamp, Timestep};