Skip to main content

concinnity_engine/app/
state.rs

1//! The `App` value: a world plus the loop state that drives it.
2
3use crate::app::startup_error::StartupError;
4use crate::blob;
5use crate::ecs::{SYSTEMS, StepResult, World};
6use crate::result::CnResult;
7use crate::shutdown::ShutdownToken;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub(crate) enum AppStatus {
11    Created,
12    Started,
13}
14
15#[derive(Debug)]
16/// The application: a world plus the loop state that drives it.
17pub struct App {
18    status: AppStatus,
19    world: World,
20    shutdown: ShutdownToken,
21    // FPS-cap pacer, run before each world step so no system pays the sleep
22    // inside its own step time (see `app::pacing`).
23    pacer: crate::app::pacing::FramePacer,
24    // Fixed-timestep accumulator; publishes the frame's `SimTiming` resource
25    // before each world step (see `app::clock`).
26    clock: crate::app::clock::SimClock,
27}
28
29impl Default for App {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl App {
36    /// An app holding an empty world.
37    pub fn new() -> Self {
38        Self {
39            status: AppStatus::Created,
40            world: World::new(),
41            shutdown: ShutdownToken::new(),
42            pacer: Default::default(),
43            clock: Default::default(),
44        }
45    }
46
47    /// An app holding an already-built world, ready to start or run.
48    pub fn from_world(world: World) -> Self {
49        let mut app = Self::new();
50        app.load_world(world);
51        app
52    }
53
54    /// An app holding the world compiled into the blob file at `path`.
55    /// Overflow payload blobs are its siblings named by index, so a world
56    /// written to `data/0` reads `data/1`, `data/2`, ... beside it.
57    ///
58    /// Anchors the state tree beside the blob unless a host already installed
59    /// one, so the settings and saves the app writes land with the world it
60    /// read rather than under the directory it was launched from. The world's
61    /// own `AppConfig.home` overrides that at `start`.
62    pub fn from_blob(path: &std::path::Path) -> Result<Self, StartupError> {
63        if concinnity_host::store::paths::state_dir().is_none()
64            && let Some(state) = state_dir_for_blob(path)
65        {
66            concinnity_host::store::paths::set_state_dir(state);
67        }
68        let loaded = blob::load_at(path)
69            .map_err(|e| StartupError::from_blob_failure(path.to_path_buf(), e))?;
70        let mut app = Self::new();
71        app.install(loaded);
72        Ok(app)
73    }
74
75    /// load assets and blob payload data from the primary blob and
76    /// populate the world. Replaces any previously loaded world
77    pub fn load_blob(&mut self) -> Result<(), CnResult> {
78        self.install(blob::load()?);
79        Ok(())
80    }
81
82    // `load_blob` against a primary blob file named directly, returning the
83    // world's highest blob index so the caller can check the layout it resolved
84    // can actually hold it.
85    pub(crate) fn load_blob_from(&mut self, primary: &std::path::Path) -> Result<u32, CnResult> {
86        let loaded = blob::load_at(primary)?;
87        let max_blob_index = loaded.manifest.max_blob_index;
88        self.install(loaded);
89        Ok(max_blob_index)
90    }
91
92    // Populate the world from an already-decoded blob, replacing whatever the
93    // app held.
94    fn install(&mut self, loaded: blob::LoadedBlob) {
95        let (assets, mut resources, scene_groups, mesh_bounds, physics_budget, manifest, blob_data) = (
96            loaded.components,
97            loaded.resources,
98            loaded.scene_groups,
99            loaded.mesh_bounds,
100            loaded.physics_budget,
101            loaded.manifest,
102            loaded.blob,
103        );
104
105        let mut world = blob::world_from(blob_data);
106        // The manifest's per-type counts size each column once up front, so
107        // the bulk load below never reallocates mid-push.
108        world.reserve_components(&manifest.component_counts);
109        // Index every named component's entity as it is minted, so name
110        // references resolve for any type (the decompose pass merges the
111        // Prop-derived entries into this same map).
112        let mut by_name = std::collections::BTreeMap::new();
113        for (name, asset) in assets {
114            let entity = world.add(asset);
115            if let Some(id) = name {
116                by_name.insert(id, entity);
117            }
118        }
119        world.insert_resource(crate::ecs::decompose::EntityByName(by_name));
120        world.insert_resource(crate::ecs::BlobSceneGroups(scene_groups));
121        world.insert_resource(crate::ecs::BlobMeshBounds(mesh_bounds));
122        // Absent for a world with no physics content, which is also a world
123        // with no PhysicsSystem to read it.
124        if let Some(budget) = physics_budget {
125            world.insert_resource(concinnity_core::ecs::WorldPhysicsBudget(budget));
126        }
127        // Load the blob's resource stream into the per-kind tables the systems
128        // read by handle. AudioSystem reads the AudioClipTable at init; the
129        // renderer reads the TextureTable to build its shared texture pool.
130        crate::resource::install_resource_tables(&mut world, &mut resources);
131        self.world = world;
132    }
133
134    /// Borrow the app's world.
135    pub fn world(&self) -> &World {
136        &self.world
137    }
138
139    /// Mutably borrow the app's world.
140    pub fn world_mut(&mut self) -> &mut World {
141        &mut self.world
142    }
143
144    /// clone of the root cancellation token. Pass this to systems or the
145    /// ctrl+c handler so they all share a single cancellation source
146    pub fn shutdown_token(&self) -> ShutdownToken {
147        self.shutdown.clone()
148    }
149
150    /// Build the world's systems and run their `init`. Must run once, before
151    /// the first step.
152    pub fn start(&mut self) -> Result<(), CnResult> {
153        if self.status != AppStatus::Created {
154            tracing::error!("App must be in Created state to start");
155            return Err(CnResult::InvalidState);
156        }
157        self.install_home();
158        self.install_budgets();
159        // The world times each system against this; without it the profile's
160        // per-system micros read zero.
161        self.world
162            .insert_resource(crate::ecs::Clock(crate::app::clock::monotonic_micros));
163        self.world.start(SYSTEMS)?;
164        self.status = AppStatus::Started;
165        Ok(())
166    }
167
168    // Point the runtime-writable state (`settings`, `saves/`, `crashes/`, the
169    // shader caches) at the world's `AppConfig.home`. Runs before
170    // `world.start(SYSTEMS)`, which is where the systems that capture a save directory
171    // are built, and before anything reads the settings file. An empty `home`
172    // leaves whatever the host installed, which is what keeps the writability
173    // redirect a shipped player performs for itself in force.
174    fn install_home(&mut self) {
175        let Some(home) = self
176            .world
177            .query::<crate::components::AppConfig>()
178            .next()
179            .map(|c| c.home.clone())
180            .filter(|h| !h.is_empty())
181        else {
182            return;
183        };
184        let Some(dir) = resolve_home(&home, concinnity_host::store::paths::state_dir().as_deref())
185        else {
186            tracing::warn!(
187                "AppConfig home '{home}' is relative but no state root is installed; \
188                 leaving writable state where it is"
189            );
190            return;
191        };
192        tracing::info!("Writable state: {}", dir.display());
193        concinnity_host::store::paths::set_writable_state_dir(dir);
194    }
195
196    // Compute the process thread + memory budgets from the host machine and the
197    // world's `AppConfig` overrides, size the shared job pool, and publish both
198    // as world resources (read by the debug server and, later, the streaming
199    // budget enforcement). Runs before `world.start(SYSTEMS)` so the pool is sized
200    // before the first system uses it. Idempotent: a second start (the editor's
201    // live rebuild) recomputes the same values and the pool sizing no-ops.
202    fn install_budgets(&mut self) {
203        use crate::app::{budget, sysmem};
204
205        let config = self
206            .world
207            .query::<crate::components::AppConfig>()
208            .next()
209            .cloned()
210            .unwrap_or_default();
211
212        let threads = budget::ThreadBudget::compute(config.job_threads);
213        let memory =
214            budget::MemoryBudget::compute(sysmem::total_physical_bytes(), config.max_memory_mb);
215
216        crate::jobs::configure(threads.job_threads);
217
218        tracing::info!(
219            "Thread budget: {} core(s), {} job worker(s){}",
220            threads.total_cores,
221            threads.job_threads,
222            if config.job_threads > 0 {
223                " [AppConfig override]"
224            } else {
225                ""
226            }
227        );
228        tracing::info!(
229            "Memory budget: {} MiB{} (total RAM {})",
230            memory.budget_mib(),
231            if memory.overridden {
232                " [AppConfig override]"
233            } else {
234                ""
235            },
236            match memory.total_ram_bytes {
237                Some(bytes) => format!("{} MiB", bytes / (1024 * 1024)),
238                None => "unknown".to_string(),
239            }
240        );
241
242        self.world.insert_resource(threads);
243        self.world.insert_resource(memory);
244    }
245
246    /// Take the app's world back, so a caller can put it on a different loop.
247    pub fn into_world(self) -> World {
248        self.world
249    }
250
251    /// Replace the current world and reset to Created so start() can be called again.
252    /// Used to load a new scene at runtime.
253    pub fn load_world(&mut self, world: World) {
254        self.world = world;
255        self.status = AppStatus::Created;
256    }
257
258    // single world step, for callers that drive their own outer loop
259    // (e.g. run_loop_macos in crate::app::run, which interleaves CFRunLoop pumps).
260    // The FPS-cap pacer holds the step's start to its target interval first,
261    // then the simulation clock publishes the frame's fixed-tick budget. The
262    // menu state read is the previous frame's, the same one-frame lag the
263    // pacer's clamp accepts.
264    pub(crate) fn world_step(&mut self) -> StepResult {
265        self.pacer.pace(&self.world);
266        let paused = self
267            .world
268            .resource::<crate::ecs::MenuActive>()
269            .is_some_and(|m| m.0);
270        let timing = self.clock.advance(std::time::Instant::now(), paused);
271        self.world.insert_resource(timing);
272        self.world.step()
273    }
274
275    /// Run this app on the runtime loop with default options, consuming it.
276    pub fn run(self) -> Result<(), CnResult> {
277        self.run_with(crate::app::run::RunOptions::default())
278    }
279
280    // Run this app on the runtime loop, consuming it. Drives frames until the
281    // window closes, a system stops the world, or CTRL+C is received.
282    pub(crate) fn run_with(self, options: crate::app::run::RunOptions) -> Result<(), CnResult> {
283        crate::app::run::start_runtime(self, options)
284    }
285}
286
287// The state tree a named blob file implies: the directory holding it, stepping
288// out of a `data` directory so the tree matches what a build produces (`data/`
289// under the state dir, with `saves/` and `settings` beside it). `None` for a
290// bare file name, which has no directory to anchor to.
291fn state_dir_for_blob(primary: &std::path::Path) -> Option<std::path::PathBuf> {
292    let dir = primary.parent().filter(|p| !p.as_os_str().is_empty())?;
293    if dir.file_name() == Some(std::ffi::OsStr::new("data")) {
294        return Some(dir.parent().unwrap_or(dir).to_path_buf());
295    }
296    Some(dir.to_path_buf())
297}
298
299// Resolve an authored `home` against the content root: an absolute path is used
300// verbatim, a relative one hangs off the state dir. `None` when a relative path
301// has no state dir to hang off, which leaves the host's own anchor in place
302// rather than resolving against the working directory.
303fn resolve_home(home: &str, state_dir: Option<&std::path::Path>) -> Option<std::path::PathBuf> {
304    let home = std::path::Path::new(home);
305    if home.is_absolute() {
306        return Some(home.to_path_buf());
307    }
308    state_dir.map(|state| state.join(home))
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use crate::components::AppConfig;
315
316    // Starting the app publishes the thread + memory budgets as world resources,
317    // honoring an `AppConfig`'s overrides. A world with no GraphicsConfig starts
318    // without building a GPU, so this exercises the budget install in isolation.
319    #[test]
320    fn start_publishes_budgets_honoring_app_config_limits() {
321        let mut app = App::new();
322        app.world_mut().add_component(AppConfig {
323            home: String::new(),
324            max_memory_mb: 512,
325            job_threads: 2,
326        });
327        app.start().unwrap();
328
329        let threads = crate::ecs::thread_budget(app.world()).expect("thread budget published");
330        assert_eq!(threads.job_threads, 2.min(threads.total_cores));
331
332        let memory = crate::ecs::memory_budget(app.world()).expect("memory budget published");
333        assert!(memory.overridden, "the AppConfig override is recorded");
334        // 512 MiB is well under 85% of any test machine's RAM, so it passes through.
335        assert_eq!(memory.budget_bytes, 512 * 1024 * 1024);
336    }
337
338    // With no AppConfig declared, the budgets are still published, computed
339    // from the host machine (no override).
340    #[test]
341    fn start_publishes_auto_budgets_without_an_app_config() {
342        let mut app = App::new();
343        app.start().unwrap();
344
345        let threads = crate::ecs::thread_budget(app.world()).expect("thread budget published");
346        assert_eq!(
347            threads.job_threads,
348            threads.total_cores.saturating_sub(1).max(1)
349        );
350        let memory = crate::ecs::memory_budget(app.world()).expect("memory budget published");
351        assert!(!memory.overridden);
352        assert!(memory.budget_bytes > 0);
353    }
354
355    // Only a Created app starts, and a default-constructed one is Created. The
356    // second call is refused by the status guard rather than re-initing every
357    // system on the running world.
358    #[test]
359    fn start_twice_is_rejected() {
360        let mut app = App::default();
361        assert_eq!(app.start(), Ok(()));
362        assert_eq!(app.start(), Err(CnResult::InvalidState));
363    }
364
365    // load_world swaps in a new world and resets to Created, so a started app
366    // can be started again on the new content (the runtime scene-load path).
367    #[test]
368    fn load_world_replaces_the_world_and_allows_a_restart() {
369        let mut app = App::new();
370        app.start().unwrap();
371        assert!(app.start().is_err(), "the app is Started");
372
373        let mut world = World::new();
374        world.add_component(AppConfig {
375            home: String::new(),
376            max_memory_mb: 256,
377            job_threads: 1,
378        });
379        app.load_world(world);
380
381        assert!(
382            app.world().query::<AppConfig>().next().is_some(),
383            "the loaded world replaced the empty one"
384        );
385        assert_eq!(app.start(), Ok(()), "the reset status permits a restart");
386        // The restart budgeted against the new world's limits, not the old one's.
387        let memory = crate::ecs::memory_budget(app.world()).expect("memory budget published");
388        assert_eq!(memory.budget_bytes, 256 * 1024 * 1024);
389    }
390
391    // from_world hands the app a world that is already populated, in the
392    // Created state so it can be started straight away.
393    #[test]
394    fn from_world_adopts_the_world_ready_to_start() {
395        let mut world = World::new();
396        world.add_component(AppConfig {
397            home: String::new(),
398            max_memory_mb: 128,
399            job_threads: 1,
400        });
401
402        let mut app = App::from_world(world);
403        assert!(app.world().query::<AppConfig>().next().is_some());
404        assert_eq!(app.start(), Ok(()), "an adopted world starts");
405    }
406
407    // `home` picks where the running app writes. An absolute path is taken
408    // verbatim; a relative one hangs off the content root, which is what puts a
409    // portable install's state in a subfolder of its own bundle.
410    #[test]
411    fn home_resolves_absolute_verbatim_and_relative_against_the_content_root() {
412        // What counts as absolute is platform-specific: Windows wants a drive
413        // prefix, and a rooted `/var/lib` there names the current drive rather
414        // than a whole path, so it takes the relative branch.
415        let (root, absolute) = if cfg!(windows) {
416            (r"C:\apps\MyGame", r"C:\ProgramData\mygame")
417        } else {
418            ("/apps/MyGame", "/var/lib/mygame")
419        };
420        let state = std::path::Path::new(root);
421
422        assert_eq!(
423            resolve_home("state", Some(state)),
424            Some(state.join("state"))
425        );
426        assert_eq!(
427            resolve_home(absolute, Some(state)),
428            Some(std::path::PathBuf::from(absolute))
429        );
430        // An absolute home needs no content root behind it.
431        assert_eq!(
432            resolve_home(absolute, None),
433            Some(std::path::PathBuf::from(absolute))
434        );
435    }
436
437    // A relative `home` with nothing to resolve against is declined rather than
438    // anchored to the working directory, so the host's own choice stands.
439    #[test]
440    fn a_relative_home_without_a_content_root_resolves_to_nothing() {
441        assert_eq!(resolve_home("state", None), None);
442    }
443
444    // A blob named directly anchors the state tree beside the world it holds,
445    // stepping out of a `data` directory so `saves/` and `settings` end up
446    // where a build would have put them.
447    #[test]
448    fn a_named_blob_anchors_the_state_tree_beside_its_world() {
449        use std::path::{Path, PathBuf};
450
451        assert_eq!(
452            state_dir_for_blob(Path::new("mygame/data/0")),
453            Some(PathBuf::from("mygame"))
454        );
455        // A blob directory called anything else is the state dir itself.
456        assert_eq!(
457            state_dir_for_blob(Path::new("out/blobs/0")),
458            Some(PathBuf::from("out").join("blobs"))
459        );
460        // `data/0` relative to the cwd leaves the tree at the cwd.
461        assert_eq!(
462            state_dir_for_blob(Path::new("data/0")),
463            Some(PathBuf::new())
464        );
465        // A bare file name has no directory to anchor to.
466        assert_eq!(state_dir_for_blob(Path::new("0")), None);
467    }
468
469    // With no FrameRateCap published the pacer has nothing to hold the frame
470    // to, so the step runs straight through; an empty world reports Done as it
471    // has no systems left to run.
472    #[test]
473    fn world_step_without_a_frame_rate_cap_runs_unpaced() {
474        let mut app = App::new();
475        app.start().unwrap();
476        assert!(
477            app.world().resource::<crate::ecs::FrameRateCap>().is_none(),
478            "no cap is published without a GraphicsConfig"
479        );
480        assert_eq!(app.world_step(), StepResult::Done);
481    }
482}