Skip to main content

khora_sdk/
run_default.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Default game runtime entry-point shared by `khora-runtime` and any
16//! native-Rust user project that doesn't need a custom `EngineApp`.
17//!
18//! [`run_default`] is the canonical "data-only Khora game" main:
19//!
20//! ```ignore
21//! fn main() -> anyhow::Result<()> {
22//!     khora_sdk::run_default()
23//! }
24//! ```
25//!
26//! It auto-detects whether to read assets from a packed archive
27//! (`<exe-dir>/data.pack` + `<exe-dir>/index.bin`) or a loose
28//! `<exe-dir>/assets/` directory, registers every default decoder, loads
29//! the scene named in `<exe-dir>/runtime.json`, and hands control to the
30//! main loop. Users who need custom components / agents / lanes write
31//! their own `EngineApp` impl and call [`crate::run_winit`] directly.
32
33use anyhow::{anyhow, Context, Result};
34use std::path::{Path, PathBuf};
35use std::sync::{Arc, Mutex, OnceLock};
36
37use crate::khora_core::asset::AssetUUID;
38use crate::khora_core::renderer::api::scene::Mesh;
39use crate::winit_adapters::WinitWindowProvider;
40use crate::{
41    run_winit, AgentProvider, AssetIo, AssetService, AssetWatcher, AudioDevice, AudioMixBus,
42    AudioStream, CpalAudioDevice, DccService, DefaultMixBus, EngineApp, FileLoader,
43    FileSystemResolver, GameWorld, IndexBuilder, InputEvent, LayoutSystem, MeshDispatcher,
44    MetricsRegistry, PackLoader, PhaseProvider, PhysicsProvider, PipelineSystem,
45    RapierPhysicsWorld, RenderSystem, Runtime, SceneFile, SerializationService, SoundData,
46    StandardTextRenderer, StreamInfo, SymphoniaDecoder, TaffyLayoutSystem, TextRenderer,
47    WgpuPipelineSystem, WgpuRenderSystem, WindowConfig, TEXT_WGSL,
48};
49use khora_io::asset::PackManifest;
50use serde::Deserialize;
51
52/// Runtime config the launcher (editor's "Build Game") drops next to the
53/// binary. Read at startup; sensible defaults are used when the file is
54/// missing (typical for engine contributors running the runtime against a
55/// loose `assets/` directory).
56static RUNTIME_CONFIG: OnceLock<RuntimeConfig> = OnceLock::new();
57
58#[derive(Debug, Clone, Deserialize)]
59struct RuntimeConfig {
60    #[serde(default = "default_project_name")]
61    project_name: String,
62    #[serde(default = "default_scene_rel_path")]
63    default_scene: String,
64    #[serde(default)]
65    window_title: Option<String>,
66    /// Build preset label written by the editor (debug/release/shipping).
67    /// Optional for older runtime.json files. Used for diagnostics.
68    #[serde(default)]
69    preset: Option<String>,
70    /// When `true`, the runtime hashes each loaded asset against
71    /// `manifest.bin` and aborts on mismatch. Defaults to `false` so
72    /// older packs (without a manifest) keep booting.
73    #[serde(default)]
74    verify_integrity: bool,
75}
76
77fn default_project_name() -> String {
78    "Khora Runtime".to_owned()
79}
80fn default_scene_rel_path() -> String {
81    "scenes/default.kscene".to_owned()
82}
83
84impl RuntimeConfig {
85    fn load_or_default(exe_dir: &Path) -> Self {
86        let path = exe_dir.join("runtime.json");
87        match std::fs::read_to_string(&path) {
88            Ok(text) => match serde_json::from_str::<RuntimeConfig>(&text) {
89                Ok(cfg) => {
90                    log::info!(
91                        "khora-sdk run_default: loaded {} (project='{}', scene='{}', preset={})",
92                        path.display(),
93                        cfg.project_name,
94                        cfg.default_scene,
95                        cfg.preset.as_deref().unwrap_or("<unspecified>")
96                    );
97                    cfg
98                }
99                Err(e) => {
100                    log::warn!(
101                        "khora-sdk run_default: malformed runtime.json ({}): {} — \
102                         falling back to defaults",
103                        path.display(),
104                        e
105                    );
106                    Self::defaults()
107                }
108            },
109            Err(_) => {
110                log::info!(
111                    "khora-sdk run_default: no runtime.json at {} — running in dev \
112                     mode with defaults",
113                    path.display()
114                );
115                Self::defaults()
116            }
117        }
118    }
119
120    fn defaults() -> Self {
121        Self {
122            project_name: default_project_name(),
123            default_scene: default_scene_rel_path(),
124            window_title: None,
125            preset: None,
126            verify_integrity: false,
127        }
128    }
129
130    fn window_title(&self) -> String {
131        self.window_title
132            .clone()
133            .unwrap_or_else(|| self.project_name.clone())
134    }
135}
136
137/// Builds an [`AssetService`] by auto-detecting the loader. See module
138/// docs for the precedence rules.
139///
140/// When `verify_integrity` is `true` and a packed runtime is detected,
141/// `manifest.bin` is read alongside `data.pack` and threaded into the
142/// service so each load is hashed against its recorded BLAKE3 digest.
143/// A missing or malformed manifest logs a warning but never aborts
144/// startup — the runtime stays bootable on packs built before manifests
145/// were emitted.
146fn build_asset_service(
147    exe_dir: &Path,
148    metrics: Arc<MetricsRegistry>,
149    verify_integrity: bool,
150) -> Result<AssetService> {
151    let pack = exe_dir.join("data.pack");
152    let idx = exe_dir.join("index.bin");
153    let assets = exe_dir.join("assets");
154
155    let (index_bytes, io, mode_label, gltf_root, is_pack): (
156        _,
157        Box<dyn AssetIo>,
158        &str,
159        PathBuf,
160        bool,
161    ) = if pack.is_file() && idx.is_file() {
162        let bytes =
163            std::fs::read(&idx).with_context(|| format!("Failed to read {}", idx.display()))?;
164        let pack_file = std::fs::File::open(&pack)
165            .with_context(|| format!("Failed to open {}", pack.display()))?;
166        let loader = PackLoader::new(pack_file)
167            .context("Pack header validation failed — refusing to start")?;
168        (
169            bytes,
170            Box::new(loader) as Box<dyn AssetIo>,
171            "PackLoader",
172            exe_dir.to_path_buf(),
173            true,
174        )
175    } else if assets.is_dir() {
176        let bytes = IndexBuilder::new(&assets)
177            .build_index_bytes()
178            .context("Failed to build dev-mode in-memory index")?;
179        (
180            bytes,
181            Box::new(FileLoader::new(&assets)),
182            "FileLoader",
183            assets.clone(),
184            false,
185        )
186    } else {
187        return Err(anyhow!(
188            "khora-sdk run_default cannot start: no `data.pack`+`index.bin` and no \
189             `assets/` next to the binary at {}",
190            exe_dir.display()
191        ));
192    };
193
194    // Manifest is a release-mode artifact emitted alongside `data.pack`.
195    // We only consult it when both the runtime explicitly asked for
196    // verification and we're booting against a real pack. In dev mode the
197    // flag is meaningless (bytes come straight off disk) — note it once
198    // and move on.
199    let manifest = if verify_integrity {
200        if is_pack {
201            let manifest_path = exe_dir.join("manifest.bin");
202            match std::fs::read(&manifest_path) {
203                Ok(bytes) => match PackManifest::decode(&bytes) {
204                    Ok(m) => {
205                        log::info!(
206                            "khora-sdk run_default: integrity verification enabled ({} entries)",
207                            m.len()
208                        );
209                        Some(m)
210                    }
211                    Err(e) => {
212                        log::warn!(
213                            "khora-sdk run_default: manifest.bin present but malformed ({}) — \
214                             integrity verification disabled",
215                            e
216                        );
217                        None
218                    }
219                },
220                Err(_) => {
221                    log::warn!(
222                        "khora-sdk run_default: verify_integrity=true but {} is missing — \
223                         integrity verification disabled",
224                        manifest_path.display()
225                    );
226                    None
227                }
228            }
229        } else {
230            log::info!(
231                "khora-sdk run_default: verify_integrity=true ignored in dev mode \
232                 (no pack to verify against)"
233            );
234            None
235        }
236    } else {
237        None
238    };
239
240    let mut svc = AssetService::new(&index_bytes, io, metrics, manifest)?;
241    svc.register_inventory_decoders();
242    svc.register_decoder::<SoundData>("audio", SymphoniaDecoder);
243    let gltf_resolver = Arc::new(FileSystemResolver::new(&gltf_root));
244    svc.register_decoder::<Mesh>("mesh", MeshDispatcher::new(gltf_resolver));
245
246    log::info!(
247        "khora-sdk run_default: using {} ({} assets indexed)",
248        mode_label,
249        svc.vfs().asset_count()
250    );
251
252    Ok(svc)
253}
254
255/// The default `EngineApp` used by [`run_default`]. It loads the scene
256/// named in `runtime.json` and ticks idly afterwards — gameplay scripts
257/// will hook into `update` once the scripting runtime lands.
258struct DefaultRuntimeApp {
259    frame_count: u64,
260}
261
262impl EngineApp for DefaultRuntimeApp {
263    fn window_config() -> WindowConfig {
264        let cfg = RUNTIME_CONFIG.get_or_init(RuntimeConfig::defaults);
265        WindowConfig {
266            title: cfg.window_title(),
267            ..WindowConfig::default()
268        }
269    }
270
271    fn new() -> Self {
272        log::info!("DefaultRuntimeApp: instantiated");
273        Self { frame_count: 0 }
274    }
275
276    fn setup(&mut self, world: &mut GameWorld, runtime: &Runtime) {
277        let svc = runtime.services.get::<Arc<Mutex<AssetService>>>().cloned();
278        let cfg = RUNTIME_CONFIG
279            .get()
280            .cloned()
281            .unwrap_or_else(RuntimeConfig::defaults);
282
283        let Some(svc) = svc else {
284            log::error!("DefaultRuntimeApp: AssetService missing from runtime.services");
285            return;
286        };
287
288        let uuid = AssetUUID::new_v5(&cfg.default_scene);
289        let bytes = match svc.lock() {
290            Ok(mut s) => s.load_raw(&uuid).ok(),
291            Err(_) => None,
292        };
293        let Some(bytes) = bytes else {
294            log::warn!(
295                "DefaultRuntimeApp: default scene '{}' not found in VFS — \
296                 starting with an empty world",
297                cfg.default_scene
298            );
299            return;
300        };
301
302        match SceneFile::from_bytes(&bytes) {
303            Ok(scene) => {
304                let serializer = SerializationService::new();
305                if let Err(e) = serializer.load_world(&scene, world.inner_world_mut()) {
306                    log::error!("Failed to load default scene: {:?}", e);
307                } else {
308                    log::info!(
309                        "khora-sdk run_default: loaded scene '{}' ({} bytes)",
310                        cfg.default_scene,
311                        bytes.len()
312                    );
313                }
314            }
315            Err(e) => log::error!(
316                "DefaultRuntimeApp: invalid scene file '{}': {:?}",
317                cfg.default_scene,
318                e
319            ),
320        }
321    }
322
323    fn update(&mut self, _world: &mut GameWorld, _inputs: &[InputEvent]) {
324        self.frame_count += 1;
325        if self.frame_count.is_multiple_of(600) {
326            log::info!("khora-sdk run_default: frame {}", self.frame_count);
327        }
328    }
329}
330
331impl AgentProvider for DefaultRuntimeApp {
332    fn register_agents(&self, _dcc: &DccService, _runtime: &mut Runtime) {}
333}
334impl PhaseProvider for DefaultRuntimeApp {
335    fn custom_phases(&self) -> Vec<crate::ExecutionPhase> {
336        Vec::new()
337    }
338    fn removed_phases(&self) -> Vec<crate::ExecutionPhase> {
339        Vec::new()
340    }
341}
342
343/// Boots a Khora game with the default runtime app: auto-detects pack vs
344/// loose assets, registers every default decoder, and loads the scene
345/// named in `runtime.json`. This is what the pre-built `khora-runtime`
346/// binary calls and what a user project's `src/main.rs` should call when
347/// it doesn't need to register custom components/agents/lanes.
348///
349/// Returns `Err` only on irrecoverable startup failures (no assets at
350/// all, missing exe path). Per-frame errors are logged and the loop
351/// continues.
352pub fn run_default() -> Result<()> {
353    let exe_dir = std::env::current_exe()
354        .context("Failed to query current_exe path")?
355        .parent()
356        .context("current_exe has no parent directory")?
357        .to_path_buf();
358
359    let cfg = RuntimeConfig::load_or_default(&exe_dir);
360    let _ = RUNTIME_CONFIG.set(cfg.clone());
361
362    log::info!(
363        "khora-sdk run_default: project='{}' from {} (preset={}, verify_integrity={})",
364        cfg.project_name,
365        exe_dir.display(),
366        cfg.preset.as_deref().unwrap_or("unset"),
367        cfg.verify_integrity,
368    );
369
370    let verify_integrity = cfg.verify_integrity;
371    run_winit::<WinitWindowProvider, DefaultRuntimeApp>(move |window, runtime, _event_loop| {
372        let mut rs = WgpuRenderSystem::new();
373        rs.init(window).expect("renderer init failed");
374        runtime.backends.insert(rs.graphics_device());
375        let rs_dyn: Box<dyn RenderSystem> = Box::new(rs);
376        runtime.backends.insert(Arc::new(Mutex::new(rs_dyn)));
377
378        // Shader / pipeline backend — wgpu + naga_oil. The app picks the
379        // backend; the engine core consumes it as `Arc<dyn PipelineSystem>`.
380        match WgpuPipelineSystem::new() {
381            Ok(sys) => {
382                let sys: Arc<dyn PipelineSystem> = Arc::new(sys);
383                runtime.resources.insert(sys);
384            }
385            Err(e) => log::error!("pipeline system init failed: {e}"),
386        }
387
388        // Physics — Rapier3D
389        let physics: Box<dyn PhysicsProvider> = Box::new(RapierPhysicsWorld::default());
390        runtime.backends.insert(Arc::new(Mutex::new(physics)));
391
392        // UI layout — Taffy
393        let layout: Box<dyn LayoutSystem> = Box::new(TaffyLayoutSystem::new());
394        runtime.backends.insert(Arc::new(Mutex::new(layout)));
395
396        // Text renderer — StandardTextRenderer
397        let text: Arc<dyn TextRenderer> = Arc::new(StandardTextRenderer::new(TEXT_WGSL.to_owned()));
398        runtime.backends.insert(text);
399
400        // Audio — shared mix bus + CPAL device. The opened AudioStream
401        // is stored as a backend handle; dropping it stops the stream.
402        let stream_info = StreamInfo {
403            channels: 2,
404            sample_rate: 48_000,
405        };
406        let mix_bus: Arc<dyn AudioMixBus> = Arc::new(DefaultMixBus::new(stream_info, 8192));
407        runtime.resources.insert(Arc::clone(&mix_bus));
408        let device: Box<dyn AudioDevice> = Box::new(CpalAudioDevice::new());
409        match device.open(mix_bus) {
410            Ok(stream) => {
411                let stream: Arc<dyn AudioStream> = Arc::from(stream);
412                runtime.backends.insert(stream);
413            }
414            Err(e) => log::error!("audio open failed: {}", e),
415        }
416
417        let metrics = Arc::new(MetricsRegistry::new());
418        match build_asset_service(&exe_dir, metrics, verify_integrity) {
419            Ok(svc) => {
420                runtime.services.insert(Arc::new(Mutex::new(svc)));
421            }
422            Err(e) => {
423                log::error!("khora-sdk run_default: AssetService init failed: {:#}", e);
424            }
425        }
426
427        // `.wgsl` hot-reload: when running against a loose `assets/shaders`
428        // tree, watch it so edits recompose shader modules and rebuild cached
429        // pipelines in place (the `shader_hot_reload` data system pumps the
430        // watcher each tick). With no such directory — packed runtime — no
431        // watcher is created and the backend serves its embedded sources.
432        let shader_dir = exe_dir.join("assets").join("shaders");
433        if shader_dir.is_dir() {
434            match AssetWatcher::new(&shader_dir) {
435                Ok(watcher) => {
436                    runtime.resources.insert(Arc::new(watcher));
437                    log::info!(
438                        "khora-sdk run_default: watching {} for shader hot-reload",
439                        shader_dir.display()
440                    );
441                }
442                Err(e) => log::warn!(
443                    "khora-sdk run_default: shader hot-reload disabled ({}): {:#}",
444                    shader_dir.display(),
445                    e
446                ),
447            }
448        }
449    })?;
450    Ok(())
451}