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