aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-07-18 08:42:34 +0300
committerValentin Popov <valentin@popov.link>2026-07-18 08:42:34 +0300
commit0eaa56c10bc2707d81b538a6a531004fcb10bc57 (patch)
tree8f36d209775af835f673f687be24705ceb1b4598
parent697cbbfd36bb8d82dd2b419bc181889cc3c46f6a (diff)
downloadfparkan-0eaa56c10bc2707d81b538a6a531004fcb10bc57.tar.xz
fparkan-0eaa56c10bc2707d81b538a6a531004fcb10bc57.zip
feat(runtime): report mission load phases
-rw-r--r--apps/fparkan-game/src/main.rs105
-rw-r--r--crates/fparkan-runtime/src/lib.rs99
-rw-r--r--docs/rendering/renderer_truth_table.md8
-rw-r--r--docs/tomes/05-render.md7
4 files changed, 191 insertions, 28 deletions
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<String, String> {
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<String, String> {
))
}
+fn load_requested_mission(
+ engine: &mut fparkan_runtime::Engine,
+ args: &Args,
+) -> Result<fparkan_runtime::LoadedMission, String> {
+ 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<PathBuf>,
}
#[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 <path> --mission <path> [--frames <n>] [--backend <planning|static-vulkan>]".to_string()
+ "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--backend <planning|static-vulkan>] [--load-progress <path>]".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<LoadedMission, EngineError> {
- 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<LoadedMission, EngineError> {
+ 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<LoadedMission, EngineError> {
+ 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<R: ResourceRepository>(
asset_manager: &AssetManager<R>,
root_spans: &[std::ops::Range<usize>],
@@ -944,6 +996,30 @@ mod tests {
}
#[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(
EngineConfig {
@@ -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 <file>` 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 <file>` 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 попадают в тот же