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