1use 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)]
18pub struct App {
20 status: AppStatus,
21 world: World,
22 state: Option<StateTree>,
27 shutdown: ShutdownToken,
28 pacer: crate::app::pacing::FramePacer,
31 clock: crate::app::clock::SimClock,
34}
35
36impl Default for App {
37 fn default() -> Self {
38 Self::new()
39 }
40}
41
42impl App {
43 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 #[must_use]
59 pub fn in_tree(mut self, tree: StateTree) -> Self {
60 self.state = Some(tree);
61 self
62 }
63
64 pub fn state_tree(&self) -> Option<&StateTree> {
66 self.state.as_ref()
67 }
68
69 pub fn from_world(world: World) -> Self {
71 let mut app = Self::new();
72 app.load_world(world);
73 app
74 }
75
76 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 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 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 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 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 world.reserve_components(&manifest.component_counts);
138 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 if let Some(budget) = physics_budget {
154 world.insert_resource(concinnity_core::ecs::WorldPhysicsBudget(budget));
155 }
156 crate::resource::install_resource_tables(&mut world, &mut resources);
160 self.world = world;
161 }
162
163 pub fn world(&self) -> &World {
165 &self.world
166 }
167
168 pub fn world_mut(&mut self) -> &mut World {
170 &mut self.world
171 }
172
173 pub fn shutdown_token(&self) -> ShutdownToken {
176 self.shutdown.clone()
177 }
178
179 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 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 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 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 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 pub fn into_world(self) -> World {
299 self.world
300 }
301
302 pub fn load_world(&mut self, world: World) {
305 self.world = world;
306 self.status = AppStatus::Created;
307 }
308
309 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 pub fn run(self) -> Result<(), CnResult> {
328 self.run_with(crate::app::run::RunOptions::default())
329 }
330
331 pub(crate) fn run_with(self, options: crate::app::run::RunOptions) -> Result<(), CnResult> {
334 crate::app::run::start_runtime(self, options)
335 }
336}
337
338fn 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
350fn 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 #[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 assert_eq!(memory.budget_bytes, 512 * 1024 * 1024);
387 }
388
389 #[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 #[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 #[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 let memory = crate::ecs::memory_budget(app.world()).expect("memory budget published");
439 assert_eq!(memory.budget_bytes, 256 * 1024 * 1024);
440 }
441
442 #[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 #[test]
462 fn home_resolves_absolute_verbatim_and_relative_against_the_content_root() {
463 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 assert_eq!(
480 resolve_home(absolute, std::path::Path::new("")),
481 Some(std::path::PathBuf::from(absolute))
482 );
483 }
484
485 #[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 #[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 #[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 #[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 assert_eq!(
551 state_dir_for_blob(Path::new("out/blobs/0")),
552 Some(PathBuf::from("out").join("blobs"))
553 );
554 assert_eq!(
556 state_dir_for_blob(Path::new("data/0")),
557 Some(PathBuf::new())
558 );
559 assert_eq!(state_dir_for_blob(Path::new("0")), None);
561 }
562
563 #[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}