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}