Skip to main content

concinnity_engine/app/
state.rs

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