concinnity-dev 0.18.69

The Concinnity dev tooling library: world authoring, the in-engine editor, the debug server, docs and packaging
Documentation
// src/run.rs
//
// The interpreted (`cn debug`) run path: compiles world.jsonl fully in memory
// and drives the system loop with the per-frame debug hook. The production
// `cn run` path (compiled-blob playback) lives in the runtime crate's `app::run`.

use crate::app::state::App;
use crate::debug_hook::DebugHook;
use crate::world::find_world_jsonl;

/// The `cn debug` server path: start the localhost debug server on `port`,
/// then run interpreted with it as the per-frame hook. This is the entry point
/// the CLI binary calls; the hook assembly stays inside this crate.
pub fn run_debug(json_path: Option<&str>, port: u16) -> std::io::Result<()> {
    let debug_hook: Box<dyn DebugHook> = match crate::debug::DebugServer::start(port) {
        Ok(srv) => Box::new(srv),
        Err(e) => {
            eprintln!("error: could not start debug server: {e}");
            return Err(e);
        }
    };
    run_interpreted(json_path, Some(debug_hook))
}

// The world an interpreted run should load: the `-f` path when the caller gave
// one, otherwise whatever discovery finds.
//
// A `-f` that does not exist is an error rather than a fall back to discovery.
// Falling back runs a different world under the name the caller asked for, and
// says nothing: the loop starts, frames advance, and every trust check passes
// against a scene nobody asked for.
fn resolve_world_path(json_path: Option<&str>) -> std::io::Result<String> {
    match json_path {
        Some(p) if std::path::Path::new(p).exists() => Ok(p.to_string()),
        Some(p) => Err(std::io::Error::new(
            std::io::ErrorKind::NotFound,
            format!("world file not found: {p}"),
        )),
        None => find_world_jsonl(None),
    }
}

// Interpreted entry point (`cn debug`). Compiles world.jsonl fully in memory
// -- shaders, meshes, textures, and all -- then runs the app without reading
// or writing any binary blob files. Always paired with the localhost debug
// server.
pub(crate) fn run_interpreted(
    json_path: Option<&str>,
    debug: Option<Box<dyn DebugHook>>,
) -> std::io::Result<()> {
    concinnity_engine::app::run::init_logging();

    let resolved = resolve_world_path(json_path)?;
    let json_path = resolved.as_str();

    // Hand the resolved world path to the engine so its hot-reload watcher can
    // subscribe to world.jsonl. The engine no longer discovers it (that lookup
    // is authoring I/O in concinnity-cook, which the runtime does not link).
    concinnity_engine::app::dev_flags::set_world_jsonl_path(Some(json_path.to_string()));

    let mut app = App::new();
    *app.world_mut() = crate::build_world_from_path(json_path).map_err(|e| {
        tracing::error!("Could not build world from {json_path}: {e}");
        e
    })?;

    start_app(app, debug)
}

// Shared startup and loop entry once the App's world is populated. The world
// loop itself (and the platform event-pump + window activation) is the shared
// `concinnity_engine::app::runloop` driver; the interpreted path's only
// addition is ticking the debug hook each frame.
pub(crate) fn start_app(
    mut app: App,
    mut debug: Option<Box<dyn DebugHook>>,
) -> std::io::Result<()> {
    use crate::app::runloop;

    let shutdown = app.shutdown_token();
    runloop::install_ctrlc_handler(&app);

    // Hand the shutdown token to the debug hook so a debug client can request
    // a clean exit (the `shutdown` WS command). No-op when no hook is present.
    if let Some(hook) = debug.as_mut() {
        hook.attach_shutdown(shutdown.clone());
    }

    // Resolved before `start()` (while the GraphicsConfig is still present),
    // so the render-loop choice doesn't depend on the config component, which
    // `start()` drains. Only the macOS path branches on it.
    #[cfg(target_os = "macos")]
    let renders = concinnity_engine::ecs::renders(app.world());

    // On macOS, NSApplication is a per-process singleton. Activate it once
    // before the first NSWindow is created.
    #[cfg(target_os = "macos")]
    if renders {
        runloop::activate_app_macos();
    }

    if let Err(e) = app.start() {
        // Returned rather than exiting the process, so the world's systems
        // (and the GPU resources they hold) still drop on the way out.
        tracing::error!("failed to start app: {e}");
        return Err(std::io::Error::other(format!("failed to start app: {e}")));
    }

    // The interpreted path ticks its debug hook each frame before the world step.
    // After the tick (which sees only `&mut World`), the hook is given the whole
    // App so it can apply a pending world swap (the `cn editor` live SAVE).
    let on_tick = |app: &mut App| {
        if let Some(hook) = debug.as_deref_mut() {
            hook.tick(app.world_mut());
            hook.apply_world_swap(app);
        }
    };

    #[cfg(target_os = "macos")]
    runloop::run_loop(&mut app, renders, on_tick);
    #[cfg(not(target_os = "macos"))]
    runloop::run_loop(&mut app, false, on_tick);

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::resolve_world_path;

    #[test]
    fn an_existing_path_is_taken_as_given() {
        let dir = tempfile::tempdir().expect("temp dir");
        let world = dir.path().join("scene.jsonl");
        std::fs::write(&world, "").expect("write");
        let given = world.to_string_lossy().into_owned();
        assert_eq!(resolve_world_path(Some(&given)).expect("resolves"), given);
    }

    // The whole point of the arm: a named world that is not there fails loudly
    // instead of silently becoming whatever discovery turns up.
    #[test]
    fn a_missing_path_errors_rather_than_falling_back() {
        let dir = tempfile::tempdir().expect("temp dir");
        let missing = dir.path().join("absent.jsonl");
        let given = missing.to_string_lossy().into_owned();
        let err = resolve_world_path(Some(&given)).expect_err("missing world is an error");
        assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
        assert!(err.to_string().contains("absent.jsonl"), "{err}");
    }
}