1use 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
52static 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 #[serde(default)]
69 preset: Option<String>,
70 #[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
137fn 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 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
255struct 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
343pub 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 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 let physics: Box<dyn PhysicsProvider> = Box::new(RapierPhysicsWorld::default());
390 runtime.backends.insert(Arc::new(Mutex::new(physics)));
391
392 let layout: Box<dyn LayoutSystem> = Box::new(TaffyLayoutSystem::new());
394 runtime.backends.insert(Arc::new(Mutex::new(layout)));
395
396 let text: Arc<dyn TextRenderer> = Arc::new(StandardTextRenderer::new(TEXT_WGSL.to_owned()));
398 runtime.backends.insert(text);
399
400 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 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}