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