Skip to main content

concinnity_core/app/
mod.rs

1//! The headless driver: a world, the fixed virtual timestep it runs on, and the
2//! loop that steps the two together.
3//!
4//! Time here is virtual. Every tick publishes the same [`SimTiming`] budget and
5//! steps the world once, with no sleep and no wall clock, so a run is
6//! reproducible and goes as fast as the host can step it. Pacing a frame
7//! against a display, accumulating real elapsed time, and catching a signal are
8//! a windowed host's concerns; a host that has them drives the world itself.
9//!
10//! [`SimTiming`]: crate::ecs::SimTiming
11
12#[cfg(debug_assertions)]
13mod alloc_guard;
14mod fixed_timestep;
15
16#[cfg(test)]
17mod headless_world_tests;
18#[cfg(test)]
19mod run_tests;
20
21use crate::ecs::{StepResult, SystemTable, World};
22use crate::result::CnResult;
23use fixed_timestep::FixedTimestep;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26enum AppStatus {
27    Created,
28    Started,
29}
30
31/// A world and the headless loop that runs it.
32///
33/// [`run`](App::run) drives the world until a system stops it or its last
34/// system finishes; [`run_for`](App::run_for) is the bounded form, for a test
35/// or a tool that wants a known number of ticks. Both start the world if the
36/// caller has not.
37///
38/// In dev builds the loop holds itself to a steady state of no allocation per
39/// tick, which is asserted rather than assumed. See [`run`](App::run).
40pub struct App {
41    world: World,
42    // The systems the world is started with. A host that contributes none runs
43    // its world's content and nothing over it.
44    table: &'static SystemTable,
45    status: AppStatus,
46    sim: FixedTimestep,
47    #[cfg(debug_assertions)]
48    allocs: alloc_guard::AllocGuard,
49}
50
51impl core::fmt::Debug for App {
52    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
53        f.debug_struct("App")
54            .field("ticks", &self.sim.ticks())
55            .field("status", &self.status)
56            .finish_non_exhaustive()
57    }
58}
59
60impl App {
61    /// An app that runs `world` with no systems over it: what a caller gets
62    /// when the host contributes no system table.
63    pub fn from_world(world: World) -> Self {
64        Self::with_systems(world, &SystemTable::EMPTY)
65    }
66
67    /// An app that runs `world` under `table`, which is what gives it systems.
68    pub fn with_systems(world: World, table: &'static SystemTable) -> Self {
69        Self {
70            world,
71            table,
72            status: AppStatus::Created,
73            sim: FixedTimestep::default(),
74            #[cfg(debug_assertions)]
75            allocs: alloc_guard::AllocGuard::new(),
76        }
77    }
78
79    /// Borrow the world being run.
80    pub fn world(&self) -> &World {
81        &self.world
82    }
83
84    /// Ticks stepped so far.
85    pub fn ticks(&self) -> u64 {
86        self.sim.ticks()
87    }
88
89    /// Build the world's systems and run their `init`. Runs once: a second
90    /// call is [`InvalidState`](CnResult::InvalidState) rather than a second
91    /// `init` over the running world.
92    pub fn start(&mut self) -> Result<(), CnResult> {
93        if self.status != AppStatus::Created {
94            return Err(CnResult::InvalidState);
95        }
96        self.world.start(self.table)?;
97        self.status = AppStatus::Started;
98        Ok(())
99    }
100
101    /// Step until a system stops the world or the last one finishes, returning
102    /// which of the two ended the run.
103    ///
104    /// Under `debug_assertions` the loop asserts its own steady state: past a
105    /// warmup, a settled world's tick allocates nothing, and a stretch of ticks
106    /// that all allocate is a cost that would recur every frame for the life of
107    /// the app, so the run panics naming the tick that allocated least. The
108    /// counters behind that are process-wide, so the check stands down where it
109    /// cannot trust them: where no binary installed the tracking allocator, and
110    /// where another thread is allocating alongside the loop.
111    pub fn run(&mut self) -> Result<StepResult, CnResult> {
112        self.start_if_created()?;
113        loop {
114            let result = self.tick();
115            if result != StepResult::Continue {
116                return Ok(result);
117            }
118        }
119    }
120
121    /// Step at most `ticks` times, returning what the last tick reported:
122    /// `Continue` when the full count ran, `Stop` or `Done` when the world
123    /// ended the run early. The bounded form of [`run`](App::run).
124    pub fn run_for(&mut self, ticks: u64) -> Result<StepResult, CnResult> {
125        self.start_if_created()?;
126        let mut result = StepResult::Continue;
127        for _ in 0..ticks {
128            result = self.tick();
129            if result != StepResult::Continue {
130                break;
131            }
132        }
133        Ok(result)
134    }
135
136    // Start the world unless the caller already did, so a run is one call.
137    fn start_if_created(&mut self) -> Result<(), CnResult> {
138        if self.status == AppStatus::Created {
139            self.start()?;
140        }
141        Ok(())
142    }
143
144    // One tick: the frame's fixed timing budget, then the world's step. The
145    // allocation invariant brackets both, since publishing the budget is as
146    // much a per-tick cost as stepping is.
147    fn tick(&mut self) -> StepResult {
148        #[cfg(debug_assertions)]
149        self.allocs.begin_tick();
150        self.world.insert_resource(self.sim.advance());
151        let result = self.world.step();
152        #[cfg(debug_assertions)]
153        self.allocs.end_tick();
154        result
155    }
156}