concinnity-engine 0.19.2

Runtime engine for Concinnity: ECS schedule, graphics, spawn, streaming
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
//! The `App` value: a world plus the loop state that drives it.

use concinnity_host::store::paths::StateTree;

use crate::app::startup_error::StartupError;
use crate::blob;
use crate::ecs::{SYSTEMS, StepResult, World};
use crate::result::CnResult;
use crate::shutdown::ShutdownToken;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AppStatus {
    Created,
    Started,
}

#[derive(Debug)]
/// The application: a world plus the loop state that drives it.
pub struct App {
    status: AppStatus,
    world: World,
    // Where this app reads and writes, or `None` for an app with no tree: its
    // world runs, and everything that would touch disk does nothing. Published
    // to the world at `start` so the systems are told rather than resolving
    // paths of their own.
    state: Option<StateTree>,
    shutdown: ShutdownToken,
    // FPS-cap pacer, run before each world step so no system pays the sleep
    // inside its own step time (see `app::pacing`).
    pacer: crate::app::pacing::FramePacer,
    // Fixed-timestep accumulator; publishes the frame's `SimTiming` resource
    // before each world step (see `app::clock`).
    clock: crate::app::clock::SimClock,
}

impl Default for App {
    fn default() -> Self {
        Self::new()
    }
}

impl App {
    /// An app holding an empty world.
    pub fn new() -> Self {
        Self {
            status: AppStatus::Created,
            world: World::new(),
            state: None,
            shutdown: ShutdownToken::new(),
            pacer: Default::default(),
            clock: Default::default(),
        }
    }

    /// An app that reads and writes under `tree`: its blobs, its settings, its
    /// saves, and the caches it warms. Without one the app runs a world and
    /// touches no disk.
    #[must_use]
    pub fn in_tree(mut self, tree: StateTree) -> Self {
        self.state = Some(tree);
        self
    }

    /// The state tree this app runs against, if it has one.
    pub fn state_tree(&self) -> Option<&StateTree> {
        self.state.as_ref()
    }

    /// An app holding an already-built world, ready to start or run.
    pub fn from_world(world: World) -> Self {
        let mut app = Self::new();
        app.load_world(world);
        app
    }

    /// An app holding the world compiled into the blob file at `path`.
    /// Overflow payload blobs are its siblings named by index, so a world
    /// written to `data/0` reads `data/1`, `data/2`, ... beside it.
    ///
    /// Derives the state tree from the blob's own directory, so the settings
    /// and saves the app writes land with the world it read rather than under
    /// the directory it was launched from. A caller with a tree of its own
    /// builds the app with [`in_tree`](Self::in_tree) instead. The world's own
    /// `AppConfig.home` overrides either at `start`.
    pub fn from_blob(path: &std::path::Path) -> Result<Self, StartupError> {
        let loaded = blob::load_at(path)
            .map_err(|e| StartupError::from_blob_failure(path.to_path_buf(), e))?;
        let mut app = Self::new();
        app.state = state_dir_for_blob(path).map(StateTree::at);
        app.install(loaded);
        Ok(app)
    }

    /// Load assets and blob payload data from the primary blob under this app's
    /// state tree, and populate the world. Replaces any previously loaded
    /// world. `NoStateRoot` when the app has no tree to read from.
    pub fn load_blob(&mut self) -> Result<(), CnResult> {
        let primary = self.primary_blob().ok_or(CnResult::NoStateRoot)?;
        self.load_blob_from(&primary)?;
        Ok(())
    }

    /// The primary blob this app reads: blob 0 under its tree's `data/`.
    /// `None` for an app with no tree.
    pub fn primary_blob(&self) -> Option<std::path::PathBuf> {
        self.state
            .as_ref()
            .map(|tree| concinnity_host::store::blob::primary_in(&tree.data_dir()))
    }

    // `load_blob` against a primary blob file named directly, returning the
    // world's highest blob index so the caller can check the layout it resolved
    // can actually hold it.
    pub(crate) fn load_blob_from(&mut self, primary: &std::path::Path) -> Result<u32, CnResult> {
        let loaded = blob::load_at(primary)?;
        let max_blob_index = loaded.manifest.max_blob_index;
        self.install(loaded);
        Ok(max_blob_index)
    }

    // Populate the world from an already-decoded blob, replacing whatever the
    // app held.
    fn install(&mut self, loaded: blob::LoadedBlob) {
        let (assets, mut resources, scene_groups, mesh_bounds, physics_budget, manifest, blob_data) = (
            loaded.components,
            loaded.resources,
            loaded.scene_groups,
            loaded.mesh_bounds,
            loaded.physics_budget,
            loaded.manifest,
            loaded.blob,
        );

        let mut world = blob::world_from(blob_data);
        // The manifest's per-type counts size each column once up front, so
        // the bulk load below never reallocates mid-push.
        world.reserve_components(&manifest.component_counts);
        // Index every named component's entity as it is minted, so name
        // references resolve for any type (the decompose pass merges the
        // Prop-derived entries into this same map).
        let mut by_name = std::collections::BTreeMap::new();
        for (name, asset) in assets {
            let entity = world.add(asset);
            if let Some(id) = name {
                by_name.insert(id, entity);
            }
        }
        world.insert_resource(crate::ecs::decompose::EntityByName(by_name));
        world.insert_resource(crate::ecs::BlobSceneGroups(scene_groups));
        world.insert_resource(crate::ecs::BlobMeshBounds(mesh_bounds));
        // Absent for a world with no physics content, which is also a world
        // with no PhysicsSystem to read it.
        if let Some(budget) = physics_budget {
            world.insert_resource(concinnity_core::ecs::WorldPhysicsBudget(budget));
        }
        // Load the blob's resource stream into the per-kind tables the systems
        // read by handle. AudioSystem reads the AudioClipTable at init; the
        // renderer reads the TextureTable to build its shared texture pool.
        crate::resource::install_resource_tables(&mut world, &mut resources);
        self.world = world;
    }

    /// Borrow the app's world.
    pub fn world(&self) -> &World {
        &self.world
    }

    /// Mutably borrow the app's world.
    pub fn world_mut(&mut self) -> &mut World {
        &mut self.world
    }

    /// clone of the root cancellation token. Pass this to systems or the
    /// ctrl+c handler so they all share a single cancellation source
    pub fn shutdown_token(&self) -> ShutdownToken {
        self.shutdown.clone()
    }

    /// Build the world's systems and run their `init`. Must run once, before
    /// the first step.
    pub fn start(&mut self) -> Result<(), CnResult> {
        if self.status != AppStatus::Created {
            tracing::error!("App must be in Created state to start");
            return Err(CnResult::InvalidState);
        }
        self.install_home();
        self.publish_state_tree();
        self.install_budgets();
        // The world times each system against this; without it the profile's
        // per-system micros read zero.
        self.world
            .insert_resource(crate::ecs::Clock(crate::app::clock::monotonic_micros));
        self.world.start(SYSTEMS)?;
        self.status = AppStatus::Started;
        Ok(())
    }

    // Point the runtime-writable state (`settings`, `saves/`, `crashes/`, the
    // shader caches) at the world's `AppConfig.home`. Runs before
    // `world.start(SYSTEMS)`, which is where the systems that capture a save
    // directory are built, and before anything reads the settings file. An
    // empty `home` leaves the tree the host built, which is what keeps the
    // writability redirect a shipped player performs for itself in force.
    fn install_home(&mut self) {
        let Some(home) = self
            .world
            .query::<crate::components::AppConfig>()
            .next()
            .map(|c| c.home.clone())
            .filter(|h| !h.is_empty())
        else {
            return;
        };
        let Some(tree) = self.state.as_ref() else {
            tracing::warn!(
                "AppConfig home '{home}' has no state tree to resolve against; \
                 the app writes nowhere"
            );
            return;
        };
        let Some(dir) = resolve_home(&home, tree.content_root()) else {
            tracing::warn!(
                "AppConfig home '{home}' is relative but the state tree has no root; \
                 leaving writable state where it is"
            );
            return;
        };
        tracing::info!("Writable state: {}", dir.display());
        self.state = Some(tree.clone().with_writable(dir));
    }

    // Hand the state tree to the world, and to the process-wide caches that
    // outlive any one call. Runs after `install_home`, so what the systems read
    // is the tree the world asked for, and before `world.start(SYSTEMS)`, which
    // is where the systems that capture a directory are built.
    fn publish_state_tree(&mut self) {
        let Some(tree) = self.state.clone() else {
            return;
        };
        concinnity_host::store::cache::anchor(
            concinnity_host::store::cache::CacheAnchor::new(tree.runtime_cache_path())
                .with_bundled(tree.bundled_runtime_cache_path()),
        );
        self.world.insert_resource(tree);
    }

    // Compute the process thread + memory budgets from the host machine and the
    // world's `AppConfig` overrides, size the shared job pool, and publish both
    // as world resources (read by the debug server and, later, the streaming
    // budget enforcement). Runs before `world.start(SYSTEMS)` so the pool is sized
    // before the first system uses it. Idempotent: a second start (the editor's
    // live rebuild) recomputes the same values and the pool sizing no-ops.
    fn install_budgets(&mut self) {
        use crate::app::{budget, sysmem};

        let config = self
            .world
            .query::<crate::components::AppConfig>()
            .next()
            .cloned()
            .unwrap_or_default();

        let threads = budget::ThreadBudget::compute(config.job_threads);
        let memory =
            budget::MemoryBudget::compute(sysmem::total_physical_bytes(), config.max_memory_mb);

        crate::jobs::configure(threads.job_threads);

        tracing::info!(
            "Thread budget: {} core(s), {} job worker(s){}",
            threads.total_cores,
            threads.job_threads,
            if config.job_threads > 0 {
                " [AppConfig override]"
            } else {
                ""
            }
        );
        tracing::info!(
            "Memory budget: {} MiB{} (total RAM {})",
            memory.budget_mib(),
            if memory.overridden {
                " [AppConfig override]"
            } else {
                ""
            },
            match memory.total_ram_bytes {
                Some(bytes) => format!("{} MiB", bytes / (1024 * 1024)),
                None => "unknown".to_string(),
            }
        );

        self.world.insert_resource(threads);
        self.world.insert_resource(memory);
    }

    /// Take the app's world back, so a caller can put it on a different loop.
    pub fn into_world(self) -> World {
        self.world
    }

    /// Replace the current world and reset to Created so start() can be called again.
    /// Used to load a new scene at runtime.
    pub fn load_world(&mut self, world: World) {
        self.world = world;
        self.status = AppStatus::Created;
    }

    // single world step, for callers that drive their own outer loop
    // (e.g. run_loop_macos in crate::app::run, which interleaves CFRunLoop pumps).
    // The FPS-cap pacer holds the step's start to its target interval first,
    // then the simulation clock publishes the frame's fixed-tick budget. The
    // menu state read is the previous frame's, the same one-frame lag the
    // pacer's clamp accepts.
    pub(crate) fn world_step(&mut self) -> StepResult {
        self.pacer.pace(&self.world);
        let paused = self
            .world
            .resource::<crate::ecs::MenuActive>()
            .is_some_and(|m| m.0);
        let timing = self.clock.advance(std::time::Instant::now(), paused);
        self.world.insert_resource(timing);
        self.world.step()
    }

    /// Run this app on the runtime loop with default options, consuming it.
    pub fn run(self) -> Result<(), CnResult> {
        self.run_with(crate::app::run::RunOptions::default())
    }

    // Run this app on the runtime loop, consuming it. Drives frames until the
    // window closes, a system stops the world, or CTRL+C is received.
    pub(crate) fn run_with(self, options: crate::app::run::RunOptions) -> Result<(), CnResult> {
        crate::app::run::start_runtime(self, options)
    }
}

// The state tree a named blob file implies: the directory holding it, stepping
// out of a `data` directory so the tree matches what a build produces (`data/`
// under the state dir, with `saves/` and `settings` beside it). `None` for a
// bare file name, which has no directory to anchor to.
fn state_dir_for_blob(primary: &std::path::Path) -> Option<std::path::PathBuf> {
    let dir = primary.parent().filter(|p| !p.as_os_str().is_empty())?;
    if dir.file_name() == Some(std::ffi::OsStr::new("data")) {
        return Some(dir.parent().unwrap_or(dir).to_path_buf());
    }
    Some(dir.to_path_buf())
}

// Resolve an authored `home` against the content root: an absolute path is used
// verbatim, a relative one hangs off the content root. `None` when a relative
// path has no root to hang off, which leaves the host's own tree in place
// rather than resolving against the working directory.
fn resolve_home(home: &str, content_root: &std::path::Path) -> Option<std::path::PathBuf> {
    let home = std::path::Path::new(home);
    if home.is_absolute() {
        return Some(home.to_path_buf());
    }
    (!content_root.as_os_str().is_empty()).then(|| content_root.join(home))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::components::AppConfig;

    // Starting the app publishes the thread + memory budgets as world resources,
    // honoring an `AppConfig`'s overrides. A world with no GraphicsConfig starts
    // without building a GPU, so this exercises the budget install in isolation.
    #[test]
    fn start_publishes_budgets_honoring_app_config_limits() {
        let mut app = App::new();
        app.world_mut().add_component(AppConfig {
            home: String::new(),
            max_memory_mb: 512,
            job_threads: 2,
        });
        app.start().unwrap();

        let threads = crate::ecs::thread_budget(app.world()).expect("thread budget published");
        assert_eq!(threads.job_threads, 2.min(threads.total_cores));

        let memory = crate::ecs::memory_budget(app.world()).expect("memory budget published");
        assert!(memory.overridden, "the AppConfig override is recorded");
        // 512 MiB is well under 85% of any test machine's RAM, so it passes through.
        assert_eq!(memory.budget_bytes, 512 * 1024 * 1024);
    }

    // With no AppConfig declared, the budgets are still published, computed
    // from the host machine (no override).
    #[test]
    fn start_publishes_auto_budgets_without_an_app_config() {
        let mut app = App::new();
        app.start().unwrap();

        let threads = crate::ecs::thread_budget(app.world()).expect("thread budget published");
        assert_eq!(
            threads.job_threads,
            threads.total_cores.saturating_sub(1).max(1)
        );
        let memory = crate::ecs::memory_budget(app.world()).expect("memory budget published");
        assert!(!memory.overridden);
        assert!(memory.budget_bytes > 0);
    }

    // Only a Created app starts, and a default-constructed one is Created. The
    // second call is refused by the status guard rather than re-initing every
    // system on the running world.
    #[test]
    fn start_twice_is_rejected() {
        let mut app = App::default();
        assert_eq!(app.start(), Ok(()));
        assert_eq!(app.start(), Err(CnResult::InvalidState));
    }

    // load_world swaps in a new world and resets to Created, so a started app
    // can be started again on the new content (the runtime scene-load path).
    #[test]
    fn load_world_replaces_the_world_and_allows_a_restart() {
        let mut app = App::new();
        app.start().unwrap();
        assert!(app.start().is_err(), "the app is Started");

        let mut world = World::new();
        world.add_component(AppConfig {
            home: String::new(),
            max_memory_mb: 256,
            job_threads: 1,
        });
        app.load_world(world);

        assert!(
            app.world().query::<AppConfig>().next().is_some(),
            "the loaded world replaced the empty one"
        );
        assert_eq!(app.start(), Ok(()), "the reset status permits a restart");
        // The restart budgeted against the new world's limits, not the old one's.
        let memory = crate::ecs::memory_budget(app.world()).expect("memory budget published");
        assert_eq!(memory.budget_bytes, 256 * 1024 * 1024);
    }

    // from_world hands the app a world that is already populated, in the
    // Created state so it can be started straight away.
    #[test]
    fn from_world_adopts_the_world_ready_to_start() {
        let mut world = World::new();
        world.add_component(AppConfig {
            home: String::new(),
            max_memory_mb: 128,
            job_threads: 1,
        });

        let mut app = App::from_world(world);
        assert!(app.world().query::<AppConfig>().next().is_some());
        assert_eq!(app.start(), Ok(()), "an adopted world starts");
    }

    // `home` picks where the running app writes. An absolute path is taken
    // verbatim; a relative one hangs off the content root, which is what puts a
    // portable install's state in a subfolder of its own bundle.
    #[test]
    fn home_resolves_absolute_verbatim_and_relative_against_the_content_root() {
        // What counts as absolute is platform-specific: Windows wants a drive
        // prefix, and a rooted `/var/lib` there names the current drive rather
        // than a whole path, so it takes the relative branch.
        let (root, absolute) = if cfg!(windows) {
            (r"C:\apps\MyGame", r"C:\ProgramData\mygame")
        } else {
            ("/apps/MyGame", "/var/lib/mygame")
        };
        let state = std::path::Path::new(root);

        assert_eq!(resolve_home("state", state), Some(state.join("state")));
        assert_eq!(
            resolve_home(absolute, state),
            Some(std::path::PathBuf::from(absolute))
        );
        // An absolute home needs no content root behind it.
        assert_eq!(
            resolve_home(absolute, std::path::Path::new("")),
            Some(std::path::PathBuf::from(absolute))
        );
    }

    // A relative `home` with nothing to resolve against is declined rather than
    // anchored to the working directory, so the host's own choice stands.
    #[test]
    fn a_relative_home_without_a_content_root_resolves_to_nothing() {
        assert_eq!(resolve_home("state", std::path::Path::new("")), None);
    }

    // A world's `home` splits the writable root off the tree the host built,
    // leaving the content (and the blobs the app reads) where it was.
    #[test]
    fn an_app_config_home_moves_only_the_writable_root() {
        let root = if cfg!(windows) {
            r"C:\apps\MyGame"
        } else {
            "/apps/MyGame"
        };
        let mut app = App::new().in_tree(StateTree::at(root));
        app.world_mut().add_component(AppConfig {
            home: "state".to_string(),
            max_memory_mb: 0,
            job_threads: 0,
        });
        app.start().unwrap();

        let tree = app.state_tree().expect("the app kept its tree");
        assert_eq!(tree.content_root(), std::path::Path::new(root));
        assert_eq!(
            tree.saves_dir(),
            std::path::Path::new(root).join("state").join("saves")
        );
        assert_eq!(
            tree.data_dir(),
            std::path::Path::new(root).join("data"),
            "the world's home never moves what a build wrote"
        );
        assert_eq!(
            app.world().resource::<StateTree>(),
            Some(tree),
            "the systems are handed the same tree the app resolved"
        );
    }

    // An app with no tree touches no disk, and publishes nothing for the
    // systems to read: a world runs, everything it would persist does nothing.
    #[test]
    fn an_app_without_a_tree_publishes_none() {
        let mut app = App::new();
        assert_eq!(app.primary_blob(), None);
        assert_eq!(app.load_blob(), Err(CnResult::NoStateRoot));
        app.start().unwrap();
        assert!(app.world().resource::<StateTree>().is_none());
    }

    // A blob named directly anchors the state tree beside the world it holds,
    // stepping out of a `data` directory so `saves/` and `settings` end up
    // where a build would have put them.
    #[test]
    fn a_named_blob_anchors_the_state_tree_beside_its_world() {
        use std::path::{Path, PathBuf};

        assert_eq!(
            state_dir_for_blob(Path::new("mygame/data/0")),
            Some(PathBuf::from("mygame"))
        );
        // A blob directory called anything else is the state dir itself.
        assert_eq!(
            state_dir_for_blob(Path::new("out/blobs/0")),
            Some(PathBuf::from("out").join("blobs"))
        );
        // `data/0` relative to the cwd leaves the tree at the cwd.
        assert_eq!(
            state_dir_for_blob(Path::new("data/0")),
            Some(PathBuf::new())
        );
        // A bare file name has no directory to anchor to.
        assert_eq!(state_dir_for_blob(Path::new("0")), None);
    }

    // With no FrameRateCap published the pacer has nothing to hold the frame
    // to, so the step runs straight through; an empty world reports Done as it
    // has no systems left to run.
    #[test]
    fn world_step_without_a_frame_rate_cap_runs_unpaced() {
        let mut app = App::new();
        app.start().unwrap();
        assert!(
            app.world().resource::<crate::ecs::FrameRateCap>().is_none(),
            "no cap is published without a GraphicsConfig"
        );
        assert_eq!(app.world_step(), StepResult::Done);
    }
}