1use 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)]
16pub struct App {
18 status: AppStatus,
19 world: World,
20 shutdown: ShutdownToken,
21 pacer: crate::app::pacing::FramePacer,
24 clock: crate::app::clock::SimClock,
27}
28
29impl Default for App {
30 fn default() -> Self {
31 Self::new()
32 }
33}
34
35impl App {
36 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 pub fn from_world(world: World) -> Self {
49 let mut app = Self::new();
50 app.load_world(world);
51 app
52 }
53
54 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 pub fn load_blob(&mut self) -> Result<(), CnResult> {
78 self.install(blob::load()?);
79 Ok(())
80 }
81
82 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 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 world.reserve_components(&manifest.component_counts);
109 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 if let Some(budget) = physics_budget {
125 world.insert_resource(concinnity_core::ecs::WorldPhysicsBudget(budget));
126 }
127 crate::resource::install_resource_tables(&mut world, &mut resources);
131 self.world = world;
132 }
133
134 pub fn world(&self) -> &World {
136 &self.world
137 }
138
139 pub fn world_mut(&mut self) -> &mut World {
141 &mut self.world
142 }
143
144 pub fn shutdown_token(&self) -> ShutdownToken {
147 self.shutdown.clone()
148 }
149
150 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 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 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 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 pub fn into_world(self) -> World {
248 self.world
249 }
250
251 pub fn load_world(&mut self, world: World) {
254 self.world = world;
255 self.status = AppStatus::Created;
256 }
257
258 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 pub fn run(self) -> Result<(), CnResult> {
277 self.run_with(crate::app::run::RunOptions::default())
278 }
279
280 pub(crate) fn run_with(self, options: crate::app::run::RunOptions) -> Result<(), CnResult> {
283 crate::app::run::start_runtime(self, options)
284 }
285}
286
287fn 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
299fn 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 #[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 assert_eq!(memory.budget_bytes, 512 * 1024 * 1024);
336 }
337
338 #[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 #[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 #[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 let memory = crate::ecs::memory_budget(app.world()).expect("memory budget published");
388 assert_eq!(memory.budget_bytes, 256 * 1024 * 1024);
389 }
390
391 #[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 #[test]
411 fn home_resolves_absolute_verbatim_and_relative_against_the_content_root() {
412 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 assert_eq!(
432 resolve_home(absolute, None),
433 Some(std::path::PathBuf::from(absolute))
434 );
435 }
436
437 #[test]
440 fn a_relative_home_without_a_content_root_resolves_to_nothing() {
441 assert_eq!(resolve_home("state", None), None);
442 }
443
444 #[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 assert_eq!(
457 state_dir_for_blob(Path::new("out/blobs/0")),
458 Some(PathBuf::from("out").join("blobs"))
459 );
460 assert_eq!(
462 state_dir_for_blob(Path::new("data/0")),
463 Some(PathBuf::new())
464 );
465 assert_eq!(state_dir_for_blob(Path::new("0")), None);
467 }
468
469 #[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}