Skip to main content

concinnity_engine/app/
run.rs

1//! The runtime player path. Loads compiled blob data and drives the system
2//! loop. Fully synchronous -- no Tokio runtime here. Systems that need async
3//! (HttpServerSystem, LlmSystem, etc.) spin up their own runtimes internally.
4//!
5//! On macOS the world loop is driven by CFRunLoopRunInMode so that AppKit
6//! (GLFW window creation, Metal pipeline compilation, event dispatch) can
7//! process its callbacks on the main thread each tick. On all other platforms
8//! a tight Rust loop is used, which is what VulkanRenderer expects.
9//!
10//! This is the `cn run` path only: no debug server, no WebSocket command
11//! channel, no in-memory rebuild. A shipped run is neither remotely inspectable
12//! nor remotely driven. The interpreted (`cn debug`) path with hot-reload and
13//! the command channel lives in the editor crate.
14
15use crate::app::runloop;
16use crate::app::startup_error::StartupError;
17use crate::app::state::App;
18use std::path::Path;
19use tracing_subscriber::EnvFilter;
20
21// Default tracing filter applied when RUST_LOG is unset: info for debug
22// builds, warn for release builds. A RUST_LOG value always takes precedence.
23fn default_log_directive() -> &'static str {
24    if cfg!(debug_assertions) {
25        "info"
26    } else {
27        "warn"
28    }
29}
30
31// Build the tracing filter from RUST_LOG, falling back to the build-profile
32// default when the variable is unset.
33fn log_filter() -> EnvFilter {
34    EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_log_directive()))
35}
36
37/// Install the global tracing subscriber. The single place the log level is
38/// configured: the CLI entry points call it directly, and the FFI entry point
39/// (cn_init) calls it for the macOS app. Safe to call once per process. The
40/// crash ring layer rides along so crash reports carry the recent log lines.
41pub fn init_logging() {
42    use tracing_subscriber::Layer;
43    use tracing_subscriber::layer::SubscriberExt;
44    use tracing_subscriber::util::SubscriberInitExt;
45
46    let fmt = tracing_subscriber::fmt::layer().with_filter(log_filter());
47    let _ = tracing_subscriber::registry()
48        .with(fmt)
49        .with(crate::crash::RingLayer)
50        .try_init();
51}
52
53/// Whether the runtime overlaps simulation and rendering on separate threads
54/// (the default) or steps both serially on the main thread (the editor's mode,
55/// and `cn run --serial` for A/B comparison and as an escape hatch).
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub enum PipelineMode {
58    #[default]
59    /// Simulation and rendering overlap on separate threads.
60    Pipelined,
61    /// Simulation and rendering step serially on the main thread.
62    Serial,
63}
64
65/// Runtime launch options beyond the world itself.
66#[derive(Debug, Default)]
67pub struct RunOptions {
68    /// Whether the runtime pipelines simulation and rendering.
69    pub mode: PipelineMode,
70    /// Whether systems may fan their internal work across the job pool
71    /// (default) or keep everything on the stepping thread
72    /// (`cn run --serial-schedule`, the determinism oracle).
73    pub schedule: crate::ecs::ScheduleMode,
74    /// Capture the last presented frame to this path when the run stops, for
75    /// headless verification of the runtime path (`cn run --screenshot`).
76    pub screenshot: Option<String>,
77    /// Override the world's `GraphicsConfig.max_frames`, bounding the run.
78    pub max_frames: Option<u64>,
79}
80
81/// Production entry point (`cn run`). Reads the compiled binary blobs from
82/// data/, written by a prior `cn build`. No debug server, no WebSocket command
83/// channel: a shipped run is neither remotely inspectable nor remotely driven.
84pub fn run(options: RunOptions) -> std::io::Result<()> {
85    init_logging();
86
87    let mut app = App::new();
88
89    if let Err(e) = app.load_blob() {
90        report_startup_error(match primary_blob_path() {
91            Some(blob) => StartupError::from_blob_failure(blob, e),
92            None => StartupError::NoStateRoot,
93        });
94        return Ok(());
95    }
96
97    start_runtime(app, options)
98}
99
100// The primary blob's path, which is what a load failure is reported against.
101// `None` when nothing anchored the state tree, so there is no path to name.
102fn primary_blob_path() -> Option<std::path::PathBuf> {
103    concinnity_host::store::blob::blob_path(0).map(std::path::PathBuf::from)
104}
105
106// Report a fatal startup failure: always to the log, and on screen as well when
107// a window can be stood up, so a packaged app that a user double-clicked says
108// something rather than exiting silently. The screen blocks until dismissed.
109fn report_startup_error(error: StartupError) {
110    tracing::error!("{}", error.log_line());
111    if !crate::error_screen::show("Concinnity", &error.user_message()) {
112        // No window, so the log line above is the whole report; repeat it on
113        // stderr, which a console user sees regardless of the tracing filter.
114        eprintln!("{}", error.log_line());
115    }
116}
117
118/// Where a shipped app's compiled world sits. Both forms make the same file
119/// blob 0; they differ in whether the world is allowed to spill into overflow
120/// payload blobs, which are always siblings named by index.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum BlobSource<'a> {
123    /// A directory holding blob 0 plus any overflow blobs beside it.
124    Directory(&'a Path),
125    /// A single self-contained blob file. A world that needs overflow blobs is
126    /// refused rather than half-loaded, since its siblings would be written
127    /// into whatever directory the file happens to sit in.
128    File(&'a Path),
129}
130
131impl BlobSource<'_> {
132    // The primary blob file: the named file itself, or blob 0 in the directory.
133    fn primary(&self) -> std::path::PathBuf {
134        match self {
135            BlobSource::Directory(dir) => dir.join("0"),
136            BlobSource::File(file) => file.to_path_buf(),
137        }
138    }
139
140    // The refusal a single-file source owes a world that spans more blobs.
141    fn check_span(&self, max_blob_index: u32) -> Option<StartupError> {
142        match self {
143            BlobSource::File(file) if max_blob_index > 0 => {
144                Some(StartupError::OverflowUnsupported {
145                    blob: file.to_path_buf(),
146                    needed: max_blob_index,
147                })
148            }
149            _ => None,
150        }
151    }
152}
153
154/// Production entry point for a shipped app: like `run`, but with the state root
155/// pinned to `state_dir` (the tree beside the executable or inside an app
156/// bundle, holding `saves/` and `settings`) and the world read from `blob`. A
157/// missing blob is a hard error rather than a silent no-op -- a packaged app
158/// without its data cannot do anything useful. The concinnity-run binary
159/// calls this.
160pub fn run_from(state_dir: &Path, blob: BlobSource<'_>) -> std::io::Result<()> {
161    init_logging();
162    concinnity_host::store::paths::set_state_dir(state_dir);
163
164    let primary = blob.primary();
165    let mut app = App::new();
166    let failure = match app.load_blob_from(&primary) {
167        Ok(max_blob_index) => blob.check_span(max_blob_index),
168        Err(e) => Some(StartupError::from_blob_failure(primary, e)),
169    };
170    if let Some(error) = failure {
171        report_startup_error(error.clone());
172        // The process still exits non-zero: the screen is how the user learns
173        // what happened, not a substitute for failing.
174        return Err(std::io::Error::new(error.io_kind(), error.log_line()));
175    }
176    start_runtime(app, RunOptions::default())
177}
178
179// Startup and loop entry once the App's world is populated. Registers the
180// CTRL+C handler, activates AppKit on macOS, starts the app, then drives
181// frames -- pipelined (sim thread + render half) or serial (the
182// single-threaded world loop) -- until the window closes, a system stops the
183// world, or CTRL+C is received. External callers reach this through
184// `App::run` / `App::run_with`.
185pub(crate) fn start_runtime(mut app: App, options: RunOptions) -> std::io::Result<()> {
186    // A host that installed its own subscriber keeps it (`try_init` no-ops),
187    // so an embedded app gets logs without wiring any up itself.
188    init_logging();
189    tracing::info!("Running app...");
190    runloop::install_ctrlc_handler(&app);
191
192    // Resolved before `start()` (while the GraphicsConfig is still present) and
193    // reused after, so the post-start loop choice doesn't depend on the config
194    // component, which `start()` drains.
195    let renders = crate::ecs::renders(app.world());
196
197    if let Some(max) = options.max_frames {
198        for config in app
199            .world_mut()
200            .query_mut::<crate::components::GraphicsConfig>()
201        {
202            config.max_frames = Some(max);
203        }
204    }
205    if options.screenshot.is_some() {
206        // Before `start()`, so graphics init arms the blit-readable path.
207        crate::app::dev_flags::set_capture(true);
208    }
209    app.world_mut().insert_resource(options.schedule);
210
211    #[cfg(target_os = "macos")]
212    if renders {
213        runloop::activate_app_macos();
214    }
215
216    if let Err(e) = app.start() {
217        // Returned rather than exiting the process, so the world's systems
218        // (and the GPU resources they hold) still drop on the way out.
219        tracing::error!("failed to start app: {e}");
220        return Err(std::io::Error::other(format!("failed to start app: {e}")));
221    }
222
223    match options.mode {
224        PipelineMode::Pipelined if renders => {
225            crate::app::pipeline::run_pipelined(app, options.screenshot.as_deref());
226        }
227        _ => {
228            // The serial loop: no per-tick hook; a rendering macOS world pumps
229            // the Cocoa run loop, every other case uses the tight loop.
230            runloop::run_loop(&mut app, cfg!(target_os = "macos") && renders, |_| {});
231            capture_exit_screenshot(&mut app, options.screenshot.as_deref());
232        }
233    }
234
235    Ok(())
236}
237
238// Capture the last presented frame on the way out of a serial run, when
239// requested. The backend is still parked in the world after the loop ends.
240fn capture_exit_screenshot(app: &mut App, path: Option<&str>) {
241    let Some(path) = path else { return };
242    let Some(mut backend) = crate::ecs::take_render_backend(app.world_mut()) else {
243        tracing::warn!("screenshot skipped: no live backend at exit");
244        return;
245    };
246    match backend.screenshot(path) {
247        Ok(saved) => tracing::info!("screenshot saved: {}", saved),
248        Err(e) => tracing::warn!("screenshot failed: {}", e),
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn default_directive_matches_build_profile() {
258        let expected = if cfg!(debug_assertions) {
259            "info"
260        } else {
261            "warn"
262        };
263        assert_eq!(default_log_directive(), expected);
264    }
265
266    #[test]
267    fn default_directive_is_a_valid_filter() {
268        // The fallback string must parse as an EnvFilter, otherwise log_filter
269        // would panic when RUST_LOG is unset.
270        EnvFilter::new(default_log_directive());
271    }
272
273    // Both forms make the same file blob 0: the file itself, or `0` inside the
274    // directory. This is what lets one runtime entry point serve both.
275    #[test]
276    fn each_blob_source_names_the_same_primary_file() {
277        let dir = Path::new("/apps/MyGame/data");
278        assert_eq!(
279            BlobSource::Directory(dir).primary(),
280            dir.join("0"),
281            "a directory holds blob 0"
282        );
283
284        let file = Path::new("/apps/MyGame/data");
285        assert_eq!(
286            BlobSource::File(file).primary(),
287            file.to_path_buf(),
288            "a single file is blob 0"
289        );
290    }
291
292    // Overflow blobs are siblings named by index, so only the directory form
293    // has somewhere to hold them. A single file whose world spans more is
294    // refused rather than half-loaded, and the message names the fix.
295    #[test]
296    fn a_single_file_source_refuses_a_world_that_overflows() {
297        let file = Path::new("/apps/MyGame/data");
298
299        assert_eq!(BlobSource::File(file).check_span(0), None);
300        assert_eq!(
301            BlobSource::File(file).check_span(2),
302            Some(StartupError::OverflowUnsupported {
303                blob: file.to_path_buf(),
304                needed: 2,
305            })
306        );
307
308        // The directory form carries any span, which is why export picks it.
309        let dir = Path::new("/apps/MyGame/data");
310        assert_eq!(BlobSource::Directory(dir).check_span(0), None);
311        assert_eq!(BlobSource::Directory(dir).check_span(7), None);
312    }
313
314    // A world that refuses to start reports it through the return value. The
315    // process stays alive, so the caller's cleanup and the world's own drops
316    // still run; an already-started app is the reproducible refusal.
317    #[test]
318    fn a_refused_start_returns_instead_of_exiting_the_process() {
319        let mut app = App::new();
320        app.start().expect("the first start succeeds");
321
322        let err = app
323            .run_with(RunOptions::default())
324            .expect_err("a second start is refused");
325        assert!(err.to_string().contains("failed to start app"), "{err}");
326    }
327}