aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-22 16:32:56 +0300
committerValentin Popov <valentin@popov.link>2026-06-22 16:32:56 +0300
commit83d763dd70ef20b7d30a905c15cad3d5531ebc6a (patch)
tree46a60b51306ff3b957e3a2d0ad35db1836225458
parent162de8ccabdd3ccf55e1da28532cad6e8345093d (diff)
downloadfparkan-83d763dd70ef20b7d30a905c15cad3d5531ebc6a.tar.xz
fparkan-83d763dd70ef20b7d30a905c15cad3d5531ebc6a.zip
fix: trace runtime scheduler phases
-rw-r--r--crates/fparkan-runtime/src/lib.rs178
1 files changed, 172 insertions, 6 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
index 7cfb541..1fc0137 100644
--- a/crates/fparkan-runtime/src/lib.rs
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -196,6 +196,15 @@ pub struct LoadedMission {
pub struct FrameResult {
/// Snapshot.
pub snapshot: WorldSnapshot,
+ /// Scheduler phases executed for this frame.
+ pub trace: FrameTrace,
+}
+
+/// Scheduler trace for a completed frame.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct FrameTrace {
+ /// Frame phases in execution order.
+ pub phases: Vec<SchedulerPhase>,
}
/// Engine.
@@ -267,6 +276,13 @@ pub enum EngineError {
},
/// World error.
World(fparkan_world::WorldError),
+ /// Scheduler phase order was violated.
+ SchedulerPhaseOrder {
+ /// Previous phase.
+ previous: SchedulerPhase,
+ /// Current phase.
+ current: SchedulerPhase,
+ },
/// Staged mission world was torn down after a registration-phase failure.
RegistrationTeardown {
/// Registered objects before the forced failure.
@@ -304,6 +320,10 @@ impl std::fmt::Display for EngineError {
write!(f, "mission prototype graph has {} failures", failures.len())
}
Self::World(source) => write!(f, "{source}"),
+ Self::SchedulerPhaseOrder { previous, current } => write!(
+ f,
+ "scheduler phase order regressed from {previous:?} to {current:?}"
+ ),
Self::RegistrationTeardown {
registered_objects,
released_objects,
@@ -326,9 +346,10 @@ impl std::error::Error for EngineError {
Self::TerrainFormat { source, .. } => Some(source),
Self::Terrain(source) => Some(source),
Self::World(source) => Some(source),
- Self::MissingVfs | Self::PrototypeGraph { .. } | Self::RegistrationTeardown { .. } => {
- None
- }
+ Self::MissingVfs
+ | Self::PrototypeGraph { .. }
+ | Self::SchedulerPhaseOrder { .. }
+ | Self::RegistrationTeardown { .. } => None,
}
}
}
@@ -533,8 +554,7 @@ pub fn step_headless(
engine: &mut Engine,
input: InputSnapshot,
) -> Result<FrameResult, EngineError> {
- let snapshot = step(&mut engine.world, &input)?;
- Ok(FrameResult { snapshot })
+ run_frame(engine, input, SchedulerPresentation::Headless)
}
/// Steps rendered mode.
@@ -544,7 +564,8 @@ pub fn step_headless(
/// Returns [`EngineError`] when the world step fails.
pub fn frame(engine: &mut Engine) -> Result<FrameResult, EngineError> {
match engine.config.mode {
- EngineMode::Headless | EngineMode::Rendered => step_headless(engine, InputSnapshot),
+ EngineMode::Headless => step_headless(engine, InputSnapshot),
+ EngineMode::Rendered => run_frame(engine, InputSnapshot, SchedulerPresentation::Rendered),
}
}
@@ -606,6 +627,78 @@ pub fn loaded_resolved_prototypes(engine: &Engine) -> Option<&[EffectivePrototyp
.map(|state| state.resolved_prototypes.as_slice())
}
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum SchedulerPresentation {
+ Headless,
+ Rendered,
+}
+
+#[derive(Clone, Debug, Default)]
+struct Scheduler {
+ phase: Option<SchedulerPhase>,
+ trace: FrameTrace,
+}
+
+impl Scheduler {
+ fn enter(&mut self, phase: SchedulerPhase) -> Result<(), EngineError> {
+ if let Some(previous) = self.phase {
+ if scheduler_phase_index(phase) <= scheduler_phase_index(previous) {
+ return Err(EngineError::SchedulerPhaseOrder {
+ previous,
+ current: phase,
+ });
+ }
+ }
+ self.phase = Some(phase);
+ self.trace.phases.push(phase);
+ Ok(())
+ }
+
+ fn finish(self) -> FrameTrace {
+ self.trace
+ }
+}
+
+fn run_frame(
+ engine: &mut Engine,
+ input: InputSnapshot,
+ presentation: SchedulerPresentation,
+) -> Result<FrameResult, EngineError> {
+ let mut scheduler = Scheduler::default();
+ scheduler.enter(SchedulerPhase::CollectPlatformEvents)?;
+ scheduler.enter(SchedulerPhase::BuildInputSnapshot)?;
+ scheduler.enter(SchedulerPhase::AdvanceGameClock)?;
+ scheduler.enter(SchedulerPhase::CalculateWorldQueue)?;
+ let snapshot = step(&mut engine.world, &input)?;
+ scheduler.enter(SchedulerPhase::ApplyDeferredOperations)?;
+ scheduler.enter(SchedulerPhase::UpdateAnimationAndEffects)?;
+ if presentation == SchedulerPresentation::Rendered {
+ scheduler.enter(SchedulerPhase::PublishRenderSnapshot)?;
+ scheduler.enter(SchedulerPhase::RenderWorld)?;
+ }
+ scheduler.enter(SchedulerPhase::EndFrameCallbacks)?;
+ scheduler.enter(SchedulerPhase::Maintenance)?;
+ Ok(FrameResult {
+ snapshot,
+ trace: scheduler.finish(),
+ })
+}
+
+fn scheduler_phase_index(phase: SchedulerPhase) -> u8 {
+ match phase {
+ SchedulerPhase::CollectPlatformEvents => 0,
+ SchedulerPhase::BuildInputSnapshot => 1,
+ SchedulerPhase::AdvanceGameClock => 2,
+ SchedulerPhase::CalculateWorldQueue => 3,
+ SchedulerPhase::ApplyDeferredOperations => 4,
+ SchedulerPhase::UpdateAnimationAndEffects => 5,
+ SchedulerPhase::PublishRenderSnapshot => 6,
+ SchedulerPhase::RenderWorld => 7,
+ SchedulerPhase::EndFrameCallbacks => 8,
+ SchedulerPhase::Maintenance => 9,
+ }
+}
+
fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPath, EngineError> {
normalize_relative(value.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| {
EngineError::Path {
@@ -695,6 +788,79 @@ mod tests {
}
#[test]
+ fn headless_scheduler_trace_skips_presentation_phases() {
+ let mut engine = create(
+ EngineConfig {
+ mode: EngineMode::Headless,
+ },
+ EngineServices::default(),
+ )
+ .expect("engine");
+
+ let result = frame(&mut engine).expect("frame");
+
+ assert_eq!(result.snapshot.tick.0, 1);
+ assert_eq!(
+ result.trace.phases,
+ vec![
+ SchedulerPhase::CollectPlatformEvents,
+ SchedulerPhase::BuildInputSnapshot,
+ SchedulerPhase::AdvanceGameClock,
+ SchedulerPhase::CalculateWorldQueue,
+ SchedulerPhase::ApplyDeferredOperations,
+ SchedulerPhase::UpdateAnimationAndEffects,
+ SchedulerPhase::EndFrameCallbacks,
+ SchedulerPhase::Maintenance,
+ ]
+ );
+ }
+
+ #[test]
+ fn rendered_scheduler_trace_includes_presentation_after_simulation() {
+ let mut engine = create(
+ EngineConfig {
+ mode: EngineMode::Rendered,
+ },
+ EngineServices::default(),
+ )
+ .expect("engine");
+
+ let result = frame(&mut engine).expect("frame");
+
+ assert_eq!(
+ result.trace.phases,
+ vec![
+ SchedulerPhase::CollectPlatformEvents,
+ SchedulerPhase::BuildInputSnapshot,
+ SchedulerPhase::AdvanceGameClock,
+ SchedulerPhase::CalculateWorldQueue,
+ SchedulerPhase::ApplyDeferredOperations,
+ SchedulerPhase::UpdateAnimationAndEffects,
+ SchedulerPhase::PublishRenderSnapshot,
+ SchedulerPhase::RenderWorld,
+ SchedulerPhase::EndFrameCallbacks,
+ SchedulerPhase::Maintenance,
+ ]
+ );
+ }
+
+ #[test]
+ fn scheduler_rejects_phase_regressions() {
+ let mut scheduler = Scheduler::default();
+ scheduler
+ .enter(SchedulerPhase::BuildInputSnapshot)
+ .expect("enter build input");
+
+ assert!(matches!(
+ scheduler.enter(SchedulerPhase::CollectPlatformEvents),
+ Err(EngineError::SchedulerPhaseOrder {
+ previous: SchedulerPhase::BuildInputSnapshot,
+ current: SchedulerPhase::CollectPlatformEvents,
+ })
+ ));
+ }
+
+ #[test]
#[ignore = "requires licensed corpus"]
fn load_trace_records_preparation_before_registration_and_raw_transforms() {
let root = licensed_root("IS");