diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 0da0ba8..90be507 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -34,8 +34,10 @@ use fparkan_render_vulkan::{ VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticMesh, }; use fparkan_runtime::{ - create, frame, load_mission, load_mission_static_preview, loaded_mission_assets, EngineConfig, - EngineMode, EngineServices, MissionAssets, MissionObjectDraft, MissionRequest, + create, frame, load_mission, load_mission_static_preview, + load_mission_static_preview_with_progress, load_mission_with_progress, loaded_mission_assets, + EngineConfig, EngineMode, EngineServices, MissionAssets, MissionLoadPhase, MissionObjectDraft, + MissionRequest, }; use fparkan_vfs::DirectoryVfs; #[cfg(test)] @@ -74,15 +76,7 @@ fn run(args: &[String]) -> Result { services, ) .map_err(|err| err.to_string())?; - let request = MissionRequest { - key: args.mission.clone(), - }; - let loaded = if args.backend == RenderBackendMode::StaticVulkan { - load_mission_static_preview(&mut engine, request) - } else { - load_mission(&mut engine, request) - } - .map_err(|err| err.to_string())?; + let loaded = load_requested_mission(&mut engine, &args)?; if args.backend == RenderBackendMode::StaticVulkan { let mission_assets = loaded_mission_assets(&engine) @@ -137,6 +131,60 @@ fn run(args: &[String]) -> Result { )) } +fn load_requested_mission( + engine: &mut fparkan_runtime::Engine, + args: &Args, +) -> Result { + let request = MissionRequest { + key: args.mission.clone(), + }; + if let Some(progress_path) = args.load_progress.as_ref() { + prepare_load_progress_path(progress_path)?; + let mut write_error = None; + let loaded = if args.backend == RenderBackendMode::StaticVulkan { + load_mission_static_preview_with_progress(engine, request, |phase| { + if write_error.is_none() { + if let Err(err) = write_load_progress(progress_path, phase) { + write_error = Some(err); + } + } + }) + } else { + load_mission_with_progress(engine, request, |phase| { + if write_error.is_none() { + if let Err(err) = write_load_progress(progress_path, phase) { + write_error = Some(err); + } + } + }) + } + .map_err(|err| err.to_string())?; + if let Some(err) = write_error { + return Err(err); + } + std::fs::write(progress_path, "Complete\n") + .map_err(|err| format!("{}: {err}", progress_path.display()))?; + return Ok(loaded); + } + if args.backend == RenderBackendMode::StaticVulkan { + load_mission_static_preview(engine, request) + } else { + load_mission(engine, request) + } + .map_err(|err| err.to_string()) +} + +fn prepare_load_progress_path(path: &std::path::Path) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?; + } + std::fs::write(path, "Starting\n").map_err(|err| format!("{}: {err}", path.display())) +} + +fn write_load_progress(path: &std::path::Path, phase: MissionLoadPhase) -> Result<(), String> { + std::fs::write(path, format!("{phase:?}\n")).map_err(|err| format!("{}: {err}", path.display())) +} + fn run_static_vulkan_mode( mesh: VulkanStaticMesh, target_frames: u64, @@ -457,6 +505,7 @@ struct Args { mission: String, frames: u64, backend: RenderBackendMode, + load_progress: Option, } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -471,6 +520,7 @@ impl Args { let mut mission = None; let mut frames = 1; let mut backend = RenderBackendMode::Planning; + let mut load_progress = None; let mut iter = args.iter(); while let Some(arg) = iter.next() { match arg.as_str() { @@ -506,6 +556,13 @@ impl Args { _ => return Err("--backend must be planning or static-vulkan".to_string()), }; } + "--load-progress" => { + load_progress = Some( + iter.next() + .map(PathBuf::from) + .ok_or_else(|| "--load-progress requires a path".to_string())?, + ); + } _ => return Err(usage()), } } @@ -519,6 +576,7 @@ impl Args { mission, frames, backend, + load_progress, }) } } @@ -555,7 +613,7 @@ fn json_hash(hash: &[u8; 32]) -> String { } fn usage() -> String { - "usage: fparkan-game --root --mission [--frames ] [--backend ]".to_string() + "usage: fparkan-game --root --mission [--frames ] [--backend ] [--load-progress ]".to_string() } #[cfg(test)] @@ -585,6 +643,7 @@ mod tests { mission: "MISSIONS/Autodemo.00/data.tma".to_string(), frames: 3, backend: RenderBackendMode::Planning, + load_progress: None, }) ); } @@ -605,6 +664,28 @@ mod tests { mission: "MISSIONS/Autodemo.00/data.tma".to_string(), frames: 1, backend: RenderBackendMode::StaticVulkan, + load_progress: None, + }) + ); + } + + #[test] + fn parses_load_progress_path() { + assert_eq!( + Args::parse(&strings(&[ + "--root", + "testdata/IS", + "--mission", + "MISSIONS/Autodemo.00/data.tma", + "--load-progress", + "target/probe.txt", + ])), + Ok(Args { + root: PathBuf::from("testdata/IS"), + mission: "MISSIONS/Autodemo.00/data.tma".to_string(), + frames: 1, + backend: RenderBackendMode::Planning, + load_progress: Some(PathBuf::from("target/probe.txt")), }) ); } diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index a7c9932..1d7c499 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -489,13 +489,30 @@ pub fn load_mission_static_preview( engine: &mut Engine, request: MissionRequest, ) -> Result { - load_mission_with_options( + load_mission_static_preview_with_progress(engine, request, |_| {}) +} + +/// Loads a static preview while synchronously reporting entered loading phases. +/// +/// This has the same bounded asset scope as [`load_mission_static_preview`]. +/// +/// # Errors +/// +/// Returns [`EngineError`] under the same conditions as +/// [`load_mission_static_preview`]. +pub fn load_mission_static_preview_with_progress( + engine: &mut Engine, + request: MissionRequest, + mut on_phase: impl FnMut(MissionLoadPhase), +) -> Result { + load_mission_with_options_and_progress( engine, request, MissionLoadOptions { asset_scope: MissionAssetScope::FirstMeshPreview, ..MissionLoadOptions::default() }, + Some(&mut on_phase), ) .map(|(loaded, _trace)| loaded) } @@ -509,22 +526,46 @@ pub fn load_mission_with_trace( engine: &mut Engine, request: MissionRequest, ) -> Result<(LoadedMission, MissionLoadTrace), EngineError> { - load_mission_with_options(engine, request, MissionLoadOptions::default()) + load_mission_with_options_and_progress(engine, request, MissionLoadOptions::default(), None) +} + +/// Loads a mission while synchronously reporting each entered loading phase. +/// +/// The observer runs immediately after the phase is recorded in the returned +/// trace. It is intended for diagnostic progress reporting; it does not alter +/// loader ordering, validation, or transaction behavior. +/// +/// # Errors +/// +/// Returns [`EngineError`] under the same conditions as [`load_mission`]. +pub fn load_mission_with_progress( + engine: &mut Engine, + request: MissionRequest, + mut on_phase: impl FnMut(MissionLoadPhase), +) -> Result { + load_mission_with_options_and_progress( + engine, + request, + MissionLoadOptions::default(), + Some(&mut on_phase), + ) + .map(|(loaded, _trace)| loaded) } #[allow(clippy::too_many_lines)] -fn load_mission_with_options( +fn load_mission_with_options_and_progress( engine: &mut Engine, request: MissionRequest, options: MissionLoadOptions, + mut on_phase: Option<&mut dyn FnMut(MissionLoadPhase)>, ) -> Result<(LoadedMission, MissionLoadTrace), EngineError> { let mut trace = MissionLoadTrace::default(); - trace.phases.push(MissionLoadPhase::Context); + record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Context); let vfs = engine.services.vfs.clone().ok_or(EngineError::MissingVfs)?; let mission_path = normalize_engine_path("mission", &request.key)?; let mission_bytes = read_vfs(&vfs, &mission_path)?; - trace.phases.push(MissionLoadPhase::Map); + record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Map); let land_path = decode_mission_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| { EngineError::Mission { @@ -564,7 +605,7 @@ fn load_mission_with_options( TerrainPreparationError::Runtime(source) => EngineError::Terrain(source), } })?; - trace.phases.push(MissionLoadPhase::Tma); + record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Tma); let mission = decode_mission_payload(mission_bytes, TmaProfile::Strict).map_err(|source| { EngineError::Mission { path: mission_path.as_str().to_string(), @@ -582,7 +623,7 @@ fn load_mission_with_options( scale: object.scale, }) .collect(); - trace.phases.push(MissionLoadPhase::Graph); + record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Graph); let repository = CachedResourceRepository::new(vfs.clone()); let graph_roots: Vec<_> = mission .objects @@ -641,18 +682,18 @@ fn load_mission_with_options( .collect(), }) .collect(); - trace.phases.push(MissionLoadPhase::Assets); + record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Assets); let mut new_runtime_world = new_world(WorldConfig); let mut handles = Vec::with_capacity(mission.objects.len()); - trace.phases.push(MissionLoadPhase::Construct); + record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Construct); for (index, _object) in mission.objects.iter().enumerate() { let original_id = u32::try_from(index).ok().map(OriginalObjectId); let handle = construct_object(&mut new_runtime_world, ObjectDraft { original_id })?; handles.push(handle); } trace.drafts_before_registration = handles.len(); - trace.phases.push(MissionLoadPhase::Register); + record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Register); for handle in &handles { if options.fail_after_registered_objects == Some(trace.registered_objects) { let report = fparkan_world::shutdown(new_runtime_world); @@ -717,6 +758,17 @@ fn load_mission_with_options( Ok((summary, trace)) } +fn record_load_phase( + trace: &mut MissionLoadTrace, + on_phase: &mut Option<&mut dyn FnMut(MissionLoadPhase)>, + phase: MissionLoadPhase, +) { + trace.phases.push(phase); + if let Some(observer) = on_phase.as_deref_mut() { + observer(phase); + } +} + fn prepare_first_mesh_preview_assets( asset_manager: &AssetManager, root_spans: &[std::ops::Range], @@ -943,6 +995,30 @@ mod tests { assert_eq!(before.snapshot.objects, after.snapshot.objects); } + #[test] + fn load_progress_reports_context_before_missing_vfs_error() { + let mut engine = create( + EngineConfig { + mode: EngineMode::Headless, + }, + EngineServices::default(), + ) + .expect("engine"); + let mut phases = Vec::new(); + + let err = load_mission_with_progress( + &mut engine, + MissionRequest { + key: "MISSIONS/Autodemo.00/data.tma".to_string(), + }, + |phase| phases.push(phase), + ) + .expect_err("missing VFS"); + + assert!(matches!(err, EngineError::MissingVfs)); + assert_eq!(phases, vec![MissionLoadPhase::Context]); + } + #[test] fn headless_scheduler_trace_skips_presentation_phases() { let mut engine = create( @@ -1116,7 +1192,7 @@ mod tests { .expect("engine"); let before = step_headless(&mut engine, InputSnapshot).expect("before"); - let err = load_mission_with_options( + let err = load_mission_with_options_and_progress( &mut engine, MissionRequest { key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(), @@ -1125,6 +1201,7 @@ mod tests { fail_after_registered_objects: Some(1), ..MissionLoadOptions::default() }, + None, ) .expect_err("forced registration failure"); diff --git a/docs/rendering/renderer_truth_table.md b/docs/rendering/renderer_truth_table.md index 14cf198..c4f0ac1 100644 --- a/docs/rendering/renderer_truth_table.md +++ b/docs/rendering/renderer_truth_table.md @@ -38,9 +38,11 @@ `VulkanSmokeRenderer` с пустым списком materials (явный white-fallback). Режим использует отдельный bounded preview loader: normal `load_mission` по-прежнему готовит все reachable assets, тогда как preview останавливает asset preparation после первого root с mesh-backed - model. Это не является rendered acceptance: fresh GOG `MISSIONS/Autodemo.00/data.tma` test - с этим scope всё равно не дошёл до окна за локальный 120-second runner limit, поэтому нет - live report, pixel artifact или validation evidence именно для game path. + model. `--load-progress ` writes the last entered loader phase synchronously for timeout + diagnosis. This is not rendered acceptance: fresh GOG `MISSIONS/Autodemo.00/data.tma` test + with this scope still did not reach a window in the local 120-second runner; its checkpoint + was `Graph`, therefore no live report, pixel artifact or validation evidence exists for the + game path. - `apps/fparkan-viewer` сейчас inspection-only CLI и не открывает live Vulkan asset viewer. - Следующий реальный milestone для rendered acceptance: `VulkanAssetRenderer` diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 34bf873..d55b63b 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -956,8 +956,11 @@ loading remains full and transactional; the preview scope walks root spans in TMA order and stops asset preparation after the first mesh-backed model. It is therefore not a hidden relaxation of gameplay validation. A second GOG `Autodemo.00` attempt with this narrower asset scope still exceeded 120 seconds -before opening a window, which locates the remaining startup cost before the -Vulkan draw loop but does not identify it as terrain versus graph work. +before opening a window. The diagnostic `--load-progress ` checkpoint +reported `Graph`, which proves the remaining startup cost is in prototype graph +construction/visual-dependency expansion rather than terrain decode, asset +preparation, window creation or the Vulkan draw loop. The timeout probe does +not distinguish individual graph suboperations; no renderer acceptance follows. `Land.msh` использует отдельный geometry-only bridge: validated `TerrainFace28` сохраняет source triangle order, а его positions и packed UV0 попадают в тот же