1use 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)]
15pub struct App {
17 status: AppStatus,
18 world: World,
19 shutdown: ShutdownToken,
20 pacer: crate::app::pacing::FramePacer,
23 clock: crate::app::clock::SimClock,
26}
27
28impl Default for App {
29 fn default() -> Self {
30 Self::new()
31 }
32}
33
34impl App {
35 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 pub fn from_world(world: World) -> Self {
48 let mut app = Self::new();
49 app.load_world(world);
50 app
51 }
52
53 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 pub fn load_blob(&mut self) -> Result<(), CnResult> {
75 self.install(blob::load()?);
76 Ok(())
77 }
78
79 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 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 world.reserve_components(&manifest.component_counts);
106 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 if let Some(budget) = physics_budget {
122 world.insert_resource(concinnity_core::ecs::WorldPhysicsBudget(budget));
123 }
124 crate::resource::install_resource_tables(&mut world, &mut resources);
128 self.world = world;
129 }
130
131 pub fn world(&self) -> &World {
133 &self.world
134 }
135
136 pub fn world_mut(&mut self) -> &mut World {
138 &mut self.world
139 }
140
141 pub fn shutdown_token(&self) -> ShutdownToken {
144 self.shutdown.clone()
145 }
146
147 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 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 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 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 pub fn load_world(&mut self, world: World) {
246 self.world = world;
247 self.status = AppStatus::Created;
248 }
249
250 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 pub fn run(self) -> std::io::Result<()> {
269 self.run_with(crate::app::run::RunOptions::default())
270 }
271
272 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
279fn 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
291fn 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 #[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 assert_eq!(memory.budget_bytes, 512 * 1024 * 1024);
328 }
329
330 #[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 #[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 #[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 let memory = crate::ecs::memory_budget(app.world()).expect("memory budget published");
380 assert_eq!(memory.budget_bytes, 256 * 1024 * 1024);
381 }
382
383 #[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 #[test]
403 fn home_resolves_absolute_verbatim_and_relative_against_the_content_root() {
404 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 assert_eq!(
424 resolve_home(absolute, None),
425 Some(std::path::PathBuf::from(absolute))
426 );
427 }
428
429 #[test]
432 fn a_relative_home_without_a_content_root_resolves_to_nothing() {
433 assert_eq!(resolve_home("state", None), None);
434 }
435
436 #[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 assert_eq!(
449 state_dir_for_blob(Path::new("out/blobs/0")),
450 Some(PathBuf::from("out").join("blobs"))
451 );
452 assert_eq!(
454 state_dir_for_blob(Path::new("data/0")),
455 Some(PathBuf::new())
456 );
457 assert_eq!(state_dir_for_blob(Path::new("0")), None);
459 }
460
461 #[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}