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