aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-runtime/src/lib.rs
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-22 12:12:27 +0300
committerValentin Popov <valentin@popov.link>2026-06-22 12:13:32 +0300
commitd0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 (patch)
treea0bd35c3940be62a5b5de1acc2366af377ffd181 /crates/fparkan-runtime/src/lib.rs
parent7416fdc7e9a48837fff5056e6dc8d0774e90964b (diff)
downloadfparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.tar.xz
fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.zip
feat: implement FParkan architecture foundation
Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation.
Diffstat (limited to 'crates/fparkan-runtime/src/lib.rs')
-rw-r--r--crates/fparkan-runtime/src/lib.rs1099
1 files changed, 1099 insertions, 0 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
new file mode 100644
index 0000000..2a05c4a
--- /dev/null
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -0,0 +1,1099 @@
+#![forbid(unsafe_code)]
+//! Runtime orchestration for headless and rendered modes.
+
+use fparkan_mission_format::{
+ decode_tma, decode_tma_land_path, LpString, MissionDocument, MissionError, TmaProfile,
+};
+use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
+use fparkan_prototype::{
+ build_prototype_graph_report, extend_graph_report_with_visual_dependencies, EffectivePrototype,
+ PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport,
+};
+use fparkan_resource::{resource_name, CachedResourceRepository};
+use fparkan_terrain::TerrainWorld;
+use fparkan_terrain_format::{
+ decode_build_dat, decode_land_map, decode_land_msh, BuildCategory, TerrainFormatError,
+};
+use fparkan_vfs::{Vfs, VfsError};
+use fparkan_world::{
+ construct_object, new as new_world, register_object, step, InputSnapshot, ObjectDraft,
+ OriginalObjectId, World, WorldConfig, WorldSnapshot,
+};
+use std::sync::Arc;
+
+/// Engine mode.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum EngineMode {
+ /// Headless.
+ Headless,
+ /// Rendered.
+ Rendered,
+}
+
+/// Scheduler phase.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum SchedulerPhase {
+ /// Collect platform events.
+ CollectPlatformEvents,
+ /// Build input snapshot.
+ BuildInputSnapshot,
+ /// Advance clock.
+ AdvanceGameClock,
+ /// Calculate world queue.
+ CalculateWorldQueue,
+ /// Apply deferred operations.
+ ApplyDeferredOperations,
+ /// Update animation/effects.
+ UpdateAnimationAndEffects,
+ /// Publish render snapshot.
+ PublishRenderSnapshot,
+ /// Render world.
+ RenderWorld,
+ /// End frame callbacks.
+ EndFrameCallbacks,
+ /// Maintenance.
+ Maintenance,
+}
+
+/// Engine config.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct EngineConfig {
+ /// Mode.
+ pub mode: EngineMode,
+}
+
+/// Injectable engine services used by composition roots.
+#[derive(Clone, Default)]
+pub struct EngineServices {
+ /// Resource filesystem.
+ pub vfs: Option<Arc<dyn Vfs>>,
+}
+
+impl EngineServices {
+ /// Creates services with a VFS.
+ #[must_use]
+ pub fn new(vfs: Arc<dyn Vfs>) -> Self {
+ Self { vfs: Some(vfs) }
+ }
+}
+
+/// Mission request.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MissionRequest {
+ /// Mission key/path.
+ pub key: String,
+}
+
+/// Mission loading phase captured for diagnostics and acceptance tests.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum MissionLoadPhase {
+ /// Resolve services and mission request context.
+ Context,
+ /// Decode and validate TMA.
+ Tma,
+ /// Decode and validate terrain map assets.
+ Map,
+ /// Expand object roots into a prototype graph.
+ Graph,
+ /// Prepare all reachable visual/resource dependencies.
+ Assets,
+ /// Construct all object drafts before registration.
+ Construct,
+ /// Register constructed objects.
+ Register,
+}
+
+/// Raw placed transform preserved by the mission loader.
+#[derive(Clone, Debug, PartialEq)]
+pub struct PlacedTransformProfile {
+ /// Object index in TMA order.
+ pub object_index: usize,
+ /// Raw position vector.
+ pub position: [f32; 3],
+ /// Raw orientation vector. No Euler order is inferred here.
+ pub orientation_raw: [f32; 3],
+ /// Raw scale vector.
+ pub scale: [f32; 3],
+}
+
+/// Mission loading trace.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct MissionLoadTrace {
+ /// Observed phases in execution order.
+ pub phases: Vec<MissionLoadPhase>,
+ /// Number of object drafts constructed before the first registration.
+ pub drafts_before_registration: usize,
+ /// Number of objects registered.
+ pub registered_objects: usize,
+ /// Raw transform profile for placed objects.
+ pub transforms: Vec<PlacedTransformProfile>,
+}
+
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+struct MissionLoadOptions {
+ fail_after_registered_objects: Option<usize>,
+}
+
+/// Loaded mission.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct LoadedMission {
+ /// Mission key.
+ pub key: String,
+ /// Decoded mission path count.
+ pub path_count: usize,
+ /// Decoded clan count.
+ pub clan_count: usize,
+ /// Decoded placed object count.
+ pub object_count: usize,
+ /// Decoded extra record count.
+ pub extra_count: usize,
+ /// `Land.msh` path.
+ pub land_msh_path: String,
+ /// `Land.map` path.
+ pub land_map_path: String,
+ /// Build category count.
+ pub build_category_count: usize,
+ /// Runtime navigation area count.
+ pub areal_count: usize,
+ /// Runtime surface triangle count.
+ pub surface_count: usize,
+ /// Registered world object count.
+ pub registered_objects: usize,
+ /// Mission resource roots that point to unit DAT files.
+ pub graph_unit_reference_count: usize,
+ /// Mission resource roots that point directly to prototype keys.
+ pub graph_direct_reference_count: usize,
+ /// Component records reached from unit DAT roots.
+ pub graph_unit_component_count: usize,
+ /// Mission prototype graph root count.
+ pub graph_root_count: usize,
+ /// Expanded prototype requests resolved to effective prototypes.
+ pub graph_resolved_count: usize,
+ /// Reached mesh dependency count.
+ pub graph_mesh_dependency_count: usize,
+ /// Graph failure count.
+ pub graph_failure_count: usize,
+ /// WEAR requests derived from graph meshes.
+ pub graph_wear_request_count: usize,
+ /// WEAR entries decoded.
+ pub graph_wear_resolved_count: usize,
+ /// WEAR material slots requested.
+ pub graph_material_slot_count: usize,
+ /// MAT0 entries decoded.
+ pub graph_material_resolved_count: usize,
+ /// Texture requests derived from MAT0 phases.
+ pub graph_texture_request_count: usize,
+ /// Texm texture entries decoded.
+ pub graph_texture_resolved_count: usize,
+ /// Lightmap requests declared by WEAR tables.
+ pub graph_lightmap_request_count: usize,
+ /// Lightmap Texm entries decoded.
+ pub graph_lightmap_resolved_count: usize,
+}
+
+/// Frame result.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct FrameResult {
+ /// Snapshot.
+ pub snapshot: WorldSnapshot,
+}
+
+/// Engine.
+pub struct Engine {
+ config: EngineConfig,
+ services: EngineServices,
+ world: World,
+ loaded: Option<LoadedMissionState>,
+}
+
+struct LoadedMissionState {
+ summary: LoadedMission,
+ mission: MissionDocument,
+ terrain: TerrainWorld,
+ build_categories: Vec<BuildCategory>,
+ prototype_graph: PrototypeGraph,
+ prototype_report: PrototypeGraphReport,
+ resolved_prototypes: Vec<EffectivePrototype>,
+}
+
+/// Engine error.
+#[derive(Debug)]
+pub enum EngineError {
+ /// Engine was created without a resource VFS.
+ MissingVfs,
+ /// Invalid resource path.
+ Path {
+ /// Path role.
+ role: &'static str,
+ /// Raw value.
+ value: String,
+ /// Source error.
+ source: PathError,
+ },
+ /// VFS error.
+ Vfs {
+ /// Resource path.
+ path: String,
+ /// Source error.
+ source: VfsError,
+ },
+ /// `NRes` decode error.
+ Nres {
+ /// Resource path.
+ path: String,
+ /// Source error.
+ source: fparkan_nres::NresError,
+ },
+ /// Mission decode error.
+ Mission {
+ /// Resource path.
+ path: String,
+ /// Source error.
+ source: MissionError,
+ },
+ /// Terrain disk format error.
+ TerrainFormat {
+ /// Resource path.
+ path: String,
+ /// Source error.
+ source: TerrainFormatError,
+ },
+ /// Terrain runtime build error.
+ Terrain(fparkan_terrain::TerrainError),
+ /// Prototype graph errors.
+ PrototypeGraph {
+ /// Root failures.
+ failures: Vec<PrototypeGraphFailure>,
+ },
+ /// World error.
+ World(fparkan_world::WorldError),
+ /// Staged mission world was torn down after a registration-phase failure.
+ RegistrationTeardown {
+ /// Registered objects before the forced failure.
+ registered_objects: usize,
+ /// Objects released by normal world shutdown.
+ released_objects: usize,
+ /// Managers were released after objects.
+ managers_released: bool,
+ },
+}
+
+impl From<fparkan_world::WorldError> for EngineError {
+ fn from(value: fparkan_world::WorldError) -> Self {
+ Self::World(value)
+ }
+}
+
+impl std::fmt::Display for EngineError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::MissingVfs => write!(f, "mission loading requires a VFS service"),
+ Self::Path {
+ role,
+ value,
+ source,
+ } => {
+ write!(f, "invalid {role} path '{value}': {source}")
+ }
+ Self::Vfs { path, source } => write!(f, "{path}: {source}"),
+ Self::Nres { path, source } => write!(f, "{path}: {source}"),
+ Self::Mission { path, source } => write!(f, "{path}: {source}"),
+ Self::TerrainFormat { path, source } => write!(f, "{path}: {source}"),
+ Self::Terrain(source) => write!(f, "{source}"),
+ Self::PrototypeGraph { failures } => {
+ write!(f, "mission prototype graph has {} failures", failures.len())
+ }
+ Self::World(source) => write!(f, "{source}"),
+ Self::RegistrationTeardown {
+ registered_objects,
+ released_objects,
+ managers_released,
+ } => write!(
+ f,
+ "mission registration failed after {registered_objects} objects; teardown released {released_objects}, managers_released={managers_released}"
+ ),
+ }
+ }
+}
+
+impl std::error::Error for EngineError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Path { source, .. } => Some(source),
+ Self::Vfs { source, .. } => Some(source),
+ Self::Nres { source, .. } => Some(source),
+ Self::Mission { source, .. } => Some(source),
+ Self::TerrainFormat { source, .. } => Some(source),
+ Self::Terrain(source) => Some(source),
+ Self::World(source) => Some(source),
+ Self::MissingVfs | Self::PrototypeGraph { .. } | Self::RegistrationTeardown { .. } => {
+ None
+ }
+ }
+ }
+}
+
+/// Creates engine.
+///
+/// # Errors
+///
+/// Currently this constructor is infallible, but it returns
+/// [`EngineError`] to keep the composition-root API stable as services become
+/// mandatory.
+pub fn create(config: EngineConfig, services: EngineServices) -> Result<Engine, EngineError> {
+ Ok(Engine {
+ config,
+ services,
+ world: new_world(WorldConfig),
+ loaded: None,
+ })
+}
+
+/// Loads mission transactionally.
+///
+/// # Errors
+///
+/// Returns [`EngineError`] when VFS services are missing, mission paths are
+/// invalid, required files cannot be read, disk formats fail validation, terrain
+/// runtime data cannot be built, prototype graph roots do not resolve, or
+/// object registration fails.
+pub fn load_mission(
+ engine: &mut Engine,
+ request: MissionRequest,
+) -> Result<LoadedMission, EngineError> {
+ load_mission_with_trace(engine, request).map(|(loaded, _trace)| loaded)
+}
+
+/// Loads mission transactionally and returns a diagnostic trace.
+///
+/// # Errors
+///
+/// Returns [`EngineError`] under the same conditions as [`load_mission`].
+pub fn load_mission_with_trace(
+ engine: &mut Engine,
+ request: MissionRequest,
+) -> Result<(LoadedMission, MissionLoadTrace), EngineError> {
+ load_mission_with_options(engine, request, MissionLoadOptions::default())
+}
+
+#[allow(clippy::too_many_lines)]
+fn load_mission_with_options(
+ engine: &mut Engine,
+ request: MissionRequest,
+ options: MissionLoadOptions,
+) -> Result<(LoadedMission, MissionLoadTrace), EngineError> {
+ let mut trace = MissionLoadTrace::default();
+ trace.phases.push(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);
+ let land_path = decode_tma_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| {
+ EngineError::Mission {
+ path: mission_path.as_str().to_string(),
+ source,
+ }
+ })?;
+ let (land_msh_path, land_map_path) = terrain_paths_from_land_path(&land_path)?;
+ let land_msh_nres = decode_nres(&vfs, &land_msh_path)?;
+ let land_map_nres = decode_nres(&vfs, &land_map_path)?;
+ let land_msh =
+ decode_land_msh(&land_msh_nres).map_err(|source| EngineError::TerrainFormat {
+ path: land_msh_path.as_str().to_string(),
+ source,
+ })?;
+ let land_map =
+ decode_land_map(&land_map_nres).map_err(|source| EngineError::TerrainFormat {
+ path: land_map_path.as_str().to_string(),
+ source,
+ })?;
+ let terrain =
+ TerrainWorld::from_land_assets(&land_msh, &land_map).map_err(EngineError::Terrain)?;
+
+ let build_dat_path = normalize_engine_path("BuildDat", "BuildDat.lst")?;
+ let build_dat = read_vfs(&vfs, &build_dat_path)?;
+ let build_categories =
+ decode_build_dat(&build_dat).map_err(|source| EngineError::TerrainFormat {
+ path: build_dat_path.as_str().to_string(),
+ source,
+ })?;
+ trace.phases.push(MissionLoadPhase::Tma);
+ let mission =
+ decode_tma(mission_bytes, TmaProfile::Strict).map_err(|source| EngineError::Mission {
+ path: mission_path.as_str().to_string(),
+ source,
+ })?;
+ let verified_terrain_paths = terrain_paths(&mission)?;
+ debug_assert_eq!(verified_terrain_paths.0.as_str(), land_msh_path.as_str());
+ debug_assert_eq!(verified_terrain_paths.1.as_str(), land_map_path.as_str());
+ trace.transforms = mission
+ .objects
+ .iter()
+ .enumerate()
+ .map(|(object_index, object)| PlacedTransformProfile {
+ object_index,
+ position: object.position,
+ orientation_raw: object.orientation,
+ scale: object.scale,
+ })
+ .collect();
+ trace.phases.push(MissionLoadPhase::Graph);
+ let repository = CachedResourceRepository::new(vfs.clone());
+ let graph_roots: Vec<_> = mission
+ .objects
+ .iter()
+ .map(|object| resource_name(&object.resource_name.raw))
+ .collect();
+ let (prototype_graph, resolved_prototypes, mut prototype_report) =
+ build_prototype_graph_report(&repository, vfs.as_ref(), &graph_roots);
+ extend_graph_report_with_visual_dependencies(
+ &repository,
+ &mut prototype_report,
+ &resolved_prototypes,
+ );
+ if !prototype_report.is_success() {
+ return Err(EngineError::PrototypeGraph {
+ failures: prototype_report.failures.clone(),
+ });
+ }
+ trace.phases.push(MissionLoadPhase::Assets);
+
+ let mut new_runtime_world = new_world(WorldConfig);
+ let mut handles = Vec::with_capacity(mission.objects.len());
+ trace.phases.push(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);
+ for handle in &handles {
+ if options.fail_after_registered_objects == Some(trace.registered_objects) {
+ let report = fparkan_world::shutdown(new_runtime_world);
+ return Err(EngineError::RegistrationTeardown {
+ registered_objects: trace.registered_objects,
+ released_objects: report.released_objects.len(),
+ managers_released: report.managers_released,
+ });
+ }
+ register_object(&mut new_runtime_world, *handle)?;
+ trace.registered_objects += 1;
+ }
+
+ let summary = LoadedMission {
+ key: request.key,
+ path_count: mission.paths.len(),
+ clan_count: mission.clans.len(),
+ object_count: mission.objects.len(),
+ extra_count: mission.extras.len(),
+ land_msh_path: land_msh_path.as_str().to_string(),
+ land_map_path: land_map_path.as_str().to_string(),
+ build_category_count: build_categories.len(),
+ areal_count: terrain.areal_count(),
+ surface_count: terrain.surface_count(),
+ registered_objects: handles.len(),
+ graph_unit_reference_count: prototype_report.unit_reference_count,
+ graph_direct_reference_count: prototype_report.direct_reference_count,
+ graph_unit_component_count: prototype_report.unit_component_count,
+ graph_root_count: prototype_report.root_count,
+ graph_resolved_count: prototype_report.resolved_count,
+ graph_mesh_dependency_count: prototype_report.mesh_dependency_count,
+ graph_failure_count: prototype_report.failures.len(),
+ graph_wear_request_count: prototype_report.wear_request_count,
+ graph_wear_resolved_count: prototype_report.wear_resolved_count,
+ graph_material_slot_count: prototype_report.material_slot_count,
+ graph_material_resolved_count: prototype_report.material_resolved_count,
+ graph_texture_request_count: prototype_report.texture_request_count,
+ graph_texture_resolved_count: prototype_report.texture_resolved_count,
+ graph_lightmap_request_count: prototype_report.lightmap_request_count,
+ graph_lightmap_resolved_count: prototype_report.lightmap_resolved_count,
+ };
+
+ engine.world = new_runtime_world;
+ engine.loaded = Some(LoadedMissionState {
+ summary: summary.clone(),
+ mission,
+ terrain,
+ build_categories,
+ prototype_graph,
+ prototype_report,
+ resolved_prototypes,
+ });
+ Ok((summary, trace))
+}
+
+/// Steps headless mode.
+///
+/// # Errors
+///
+/// Returns [`EngineError`] when the world step fails.
+pub fn step_headless(
+ engine: &mut Engine,
+ input: InputSnapshot,
+) -> Result<FrameResult, EngineError> {
+ let snapshot = step(&mut engine.world, &input)?;
+ Ok(FrameResult { snapshot })
+}
+
+/// Steps rendered mode.
+///
+/// # Errors
+///
+/// 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),
+ }
+}
+
+/// Shuts down engine.
+///
+/// # Errors
+///
+/// Currently shutdown is infallible, but the `Result` preserves the lifecycle
+/// API for future service teardown failures.
+pub fn shutdown(_engine: Engine) -> Result<(), EngineError> {
+ Ok(())
+}
+
+/// Returns the loaded mission summary.
+#[must_use]
+pub fn loaded_mission(engine: &Engine) -> Option<&LoadedMission> {
+ engine.loaded.as_ref().map(|state| &state.summary)
+}
+
+/// Returns the decoded mission document for the loaded mission.
+#[must_use]
+pub fn loaded_mission_document(engine: &Engine) -> Option<&MissionDocument> {
+ engine.loaded.as_ref().map(|state| &state.mission)
+}
+
+/// Returns terrain runtime data for the loaded mission.
+#[must_use]
+pub fn loaded_terrain(engine: &Engine) -> Option<&TerrainWorld> {
+ engine.loaded.as_ref().map(|state| &state.terrain)
+}
+
+/// Returns decoded build categories for the loaded game root.
+#[must_use]
+pub fn loaded_build_categories(engine: &Engine) -> Option<&[BuildCategory]> {
+ engine
+ .loaded
+ .as_ref()
+ .map(|state| state.build_categories.as_slice())
+}
+
+/// Returns the loaded prototype graph.
+#[must_use]
+pub fn loaded_prototype_graph(engine: &Engine) -> Option<&PrototypeGraph> {
+ engine.loaded.as_ref().map(|state| &state.prototype_graph)
+}
+
+/// Returns the loaded prototype graph report.
+#[must_use]
+pub fn loaded_prototype_graph_report(engine: &Engine) -> Option<&PrototypeGraphReport> {
+ engine.loaded.as_ref().map(|state| &state.prototype_report)
+}
+
+/// Returns resolved effective prototypes for the loaded mission.
+#[must_use]
+pub fn loaded_resolved_prototypes(engine: &Engine) -> Option<&[EffectivePrototype]> {
+ engine
+ .loaded
+ .as_ref()
+ .map(|state| state.resolved_prototypes.as_slice())
+}
+
+fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPath, EngineError> {
+ normalize_relative(value.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| {
+ EngineError::Path {
+ role,
+ value: value.to_string(),
+ source,
+ }
+ })
+}
+
+fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, EngineError> {
+ vfs.read(path).map_err(|source| EngineError::Vfs {
+ path: path.as_str().to_string(),
+ source,
+ })
+}
+
+fn decode_nres(
+ vfs: &Arc<dyn Vfs>,
+ path: &NormalizedPath,
+) -> Result<fparkan_nres::NresDocument, EngineError> {
+ let bytes = read_vfs(vfs, path)?;
+ fparkan_nres::decode(bytes, fparkan_nres::ReadProfile::Compatible).map_err(|source| {
+ EngineError::Nres {
+ path: path.as_str().to_string(),
+ source,
+ }
+ })
+}
+
+fn terrain_paths(
+ mission: &MissionDocument,
+) -> Result<(NormalizedPath, NormalizedPath), EngineError> {
+ terrain_paths_from_land_path(&mission.land_path)
+}
+
+fn terrain_paths_from_land_path(
+ land_path: &LpString,
+) -> Result<(NormalizedPath, NormalizedPath), EngineError> {
+ let land_path_raw = String::from_utf8_lossy(&land_path.raw).to_string();
+ let normalized =
+ normalize_relative(&land_path.raw, PathPolicy::StrictLegacy).map_err(|source| {
+ EngineError::Path {
+ role: "mission land",
+ value: land_path_raw.clone(),
+ source,
+ }
+ })?;
+ let Some((parent, _stem)) = normalized.as_str().rsplit_once('/') else {
+ return Err(EngineError::Path {
+ role: "mission land",
+ value: normalized.as_str().to_string(),
+ source: PathError::Empty,
+ });
+ };
+ let mesh = normalize_engine_path("Land.msh", &format!("{parent}/Land.msh"))?;
+ let map = normalize_engine_path("Land.map", &format!("{parent}/Land.map"))?;
+ Ok((mesh, map))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_vfs::{DirectoryVfs, VfsEntry, VfsMetadata};
+ use std::path::{Path, PathBuf};
+
+ #[test]
+ fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() {
+ let mut engine = create(
+ EngineConfig {
+ mode: EngineMode::Headless,
+ },
+ EngineServices::default(),
+ )
+ .expect("engine");
+ let before = step_headless(&mut engine, InputSnapshot).expect("step");
+ let err = load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".to_string(),
+ },
+ )
+ .expect_err("missing VFS");
+ assert!(matches!(err, EngineError::MissingVfs));
+ let after = step_headless(&mut engine, InputSnapshot).expect("step");
+ assert_eq!(before.snapshot.objects, after.snapshot.objects);
+ }
+
+ #[test]
+ fn load_trace_records_preparation_before_registration_and_raw_transforms() {
+ let root = workspace_root().join("testdata").join("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
+ let mut engine = create(
+ EngineConfig {
+ mode: EngineMode::Headless,
+ },
+ EngineServices::new(vfs),
+ )
+ .expect("engine");
+
+ let (loaded, trace) = load_mission_with_trace(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".to_string(),
+ },
+ )
+ .expect("load mission with trace");
+
+ assert_eq!(
+ trace.phases,
+ vec![
+ MissionLoadPhase::Context,
+ MissionLoadPhase::Map,
+ MissionLoadPhase::Tma,
+ MissionLoadPhase::Graph,
+ MissionLoadPhase::Assets,
+ MissionLoadPhase::Construct,
+ MissionLoadPhase::Register,
+ ]
+ );
+ assert_eq!(trace.drafts_before_registration, loaded.object_count);
+ assert_eq!(trace.registered_objects, loaded.object_count);
+ assert_eq!(trace.transforms.len(), loaded.object_count);
+ assert!(trace.transforms.iter().all(|transform| transform
+ .orientation_raw
+ .iter()
+ .all(|component| component.is_finite())));
+ }
+
+ #[test]
+ fn missing_map_and_missing_reachable_resource_fail_before_registration() {
+ let root = workspace_root().join("testdata").join("IS");
+ for (denied, mission) in [
+ (
+ DenyRule::Suffix("Land.map"),
+ MissionRequest {
+ key: "MISSIONS/Autodemo.00/data.tma".to_string(),
+ },
+ ),
+ (
+ DenyRule::Suffix("objects.rlb"),
+ MissionRequest {
+ key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
+ },
+ ),
+ ] {
+ let vfs: Arc<dyn Vfs> = Arc::new(DenyVfs {
+ inner: DirectoryVfs::new(&root),
+ denied,
+ });
+ let mut engine = create(
+ EngineConfig {
+ mode: EngineMode::Headless,
+ },
+ EngineServices::new(vfs),
+ )
+ .expect("engine");
+ let before = step_headless(&mut engine, InputSnapshot).expect("before");
+ let err = load_mission(&mut engine, mission).expect_err("load error");
+ match denied {
+ DenyRule::Suffix("Land.map") => assert!(matches!(err, EngineError::Vfs { .. })),
+ DenyRule::Suffix("objects.rlb") => {
+ assert!(matches!(err, EngineError::PrototypeGraph { .. }))
+ }
+ DenyRule::Suffix(unexpected) => panic!("unexpected deny rule {unexpected}"),
+ }
+ assert!(loaded_mission(&engine).is_none());
+ let after = step_headless(&mut engine, InputSnapshot).expect("after");
+ assert_eq!(before.snapshot.objects, after.snapshot.objects);
+ }
+ }
+
+ #[test]
+ fn registration_phase_failure_uses_normal_teardown_and_keeps_engine_world() {
+ let root = workspace_root().join("testdata").join("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
+ let mut engine = create(
+ EngineConfig {
+ mode: EngineMode::Headless,
+ },
+ EngineServices::new(vfs),
+ )
+ .expect("engine");
+ let before = step_headless(&mut engine, InputSnapshot).expect("before");
+
+ let err = load_mission_with_options(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
+ },
+ MissionLoadOptions {
+ fail_after_registered_objects: Some(1),
+ },
+ )
+ .expect_err("forced registration failure");
+
+ assert!(matches!(
+ err,
+ EngineError::RegistrationTeardown {
+ registered_objects: 1,
+ released_objects: 1,
+ managers_released: true,
+ }
+ ));
+ assert!(loaded_mission(&engine).is_none());
+ let after = step_headless(&mut engine, InputSnapshot).expect("after");
+ assert_eq!(before.snapshot.objects, after.snapshot.objects);
+ }
+
+ #[test]
+ fn selected_is_and_is2_missions_execute_10000_deterministic_ticks() {
+ for case in [
+ HeadlessCase {
+ root: "IS",
+ mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma",
+ object_count: 33,
+ expected_hash: [
+ 0x19, 0xdc, 0xd3, 0x9b, 0x35, 0xad, 0x90, 0x6c, 0x92, 0x2d, 0x83, 0x7b, 0x7a,
+ 0xb3, 0xa6, 0x15, 0xa6, 0x15, 0x92, 0x2d, 0x83, 0x7b, 0x7a, 0xb3, 0xe9, 0xcd,
+ 0x9a, 0x56, 0x48, 0xb6, 0x0c, 0xee,
+ ],
+ },
+ HeadlessCase {
+ root: "IS2",
+ mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
+ object_count: 10,
+ expected_hash: [
+ 0x59, 0x6e, 0x88, 0xcc, 0xd0, 0x3a, 0xd9, 0x68, 0x1b, 0x2d, 0xcb, 0x0d, 0x91,
+ 0x19, 0x5a, 0x27, 0x5a, 0x27, 0x1b, 0x2d, 0xcb, 0x0d, 0x91, 0x19, 0x44, 0x66,
+ 0x68, 0x9d, 0x6c, 0xb4, 0x2c, 0x37,
+ ],
+ },
+ ] {
+ let first = run_headless_case(case);
+ let second = run_headless_case(case);
+ assert_eq!(first, second);
+ assert_eq!(first.tick.0, 10_000);
+ assert_eq!(first.objects.len(), case.object_count);
+ assert_eq!(first.hash.0, case.expected_hash);
+ }
+ }
+
+ #[test]
+ fn licensed_corpora_load_all_mission_foundations() {
+ let root = workspace_root();
+ let part1 = load_all(&root.join("testdata").join("IS"));
+ assert_eq!(part1.missions, 29);
+ assert_eq!(part1.paths, 34);
+ assert_eq!(part1.clans, 101);
+ assert_eq!(part1.objects, 864);
+ assert_eq!(part1.extras, 28);
+ assert_eq!(part1.unit_references, 463);
+ assert_eq!(part1.direct_references, 401);
+ assert_eq!(part1.unit_components, 4_300);
+ assert_eq!(part1.prototype_requests, 4_701);
+ assert_eq!(part1.material_slots, 36_954);
+ assert_eq!(part1.texture_requests, 48_806);
+ assert_eq!(part1.lightmap_requests, 139);
+ assert_eq!(part1.graph_failures, 0);
+ assert_eq!(part1.wear_requests, part1.prototype_requests);
+ assert_eq!(part1.wear_requests, part1.wear_resolved);
+ assert_eq!(part1.material_slots, part1.material_resolved);
+ assert_eq!(part1.texture_requests, part1.texture_resolved);
+ assert_eq!(part1.lightmap_requests, part1.lightmap_resolved);
+
+ let part2 = load_all(&root.join("testdata").join("IS2"));
+ assert_eq!(part2.missions, 31);
+ assert_eq!(part2.paths, 61);
+ assert_eq!(part2.clans, 91);
+ assert_eq!(part2.objects, 885);
+ assert_eq!(part2.extras, 41);
+ assert_eq!(part2.unit_references, 561);
+ assert_eq!(part2.direct_references, 324);
+ assert_eq!(part2.unit_components, 5_521);
+ assert_eq!(part2.prototype_requests, 5_845);
+ assert_eq!(part2.material_slots, 50_888);
+ assert_eq!(part2.texture_requests, 68_603);
+ assert_eq!(part2.lightmap_requests, 214);
+ assert_eq!(part2.graph_failures, 0);
+ assert_eq!(part2.wear_requests, part2.prototype_requests);
+ assert_eq!(part2.wear_requests, part2.wear_resolved);
+ assert_eq!(part2.material_slots, part2.material_resolved);
+ assert_eq!(part2.texture_requests, part2.texture_resolved);
+ assert_eq!(part2.lightmap_requests, part2.lightmap_resolved);
+ }
+
+ #[derive(Default)]
+ struct LoadTotals {
+ missions: usize,
+ paths: usize,
+ clans: usize,
+ objects: usize,
+ extras: usize,
+ unit_references: usize,
+ direct_references: usize,
+ unit_components: usize,
+ prototype_requests: usize,
+ wear_requests: usize,
+ wear_resolved: usize,
+ material_slots: usize,
+ material_resolved: usize,
+ texture_requests: usize,
+ texture_resolved: usize,
+ lightmap_requests: usize,
+ lightmap_resolved: usize,
+ graph_failures: usize,
+ }
+
+ #[derive(Clone, Copy)]
+ struct HeadlessCase {
+ root: &'static str,
+ mission: &'static str,
+ object_count: usize,
+ expected_hash: [u8; 32],
+ }
+
+ fn run_headless_case(case: HeadlessCase) -> WorldSnapshot {
+ let root = workspace_root().join("testdata").join(case.root);
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
+ let mut engine = create(
+ EngineConfig {
+ mode: EngineMode::Headless,
+ },
+ EngineServices::new(vfs),
+ )
+ .expect("engine");
+ let loaded = load_mission(
+ &mut engine,
+ MissionRequest {
+ key: case.mission.to_string(),
+ },
+ )
+ .expect("load selected mission");
+ assert_eq!(loaded.object_count, case.object_count);
+
+ let mut snapshot = None;
+ for _ in 0..10_000 {
+ snapshot = Some(
+ step_headless(&mut engine, InputSnapshot)
+ .expect("selected mission deterministic tick")
+ .snapshot,
+ );
+ }
+ snapshot.expect("at least one tick")
+ }
+
+ fn load_all(root: &Path) -> LoadTotals {
+ assert!(root.is_dir(), "missing licensed corpus {}", root.display());
+ let mut missions = mission_paths(root);
+ missions.sort();
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
+ let mut totals = LoadTotals::default();
+ for mission in missions {
+ let mut engine = create(
+ EngineConfig {
+ mode: EngineMode::Headless,
+ },
+ EngineServices::new(vfs.clone()),
+ )
+ .expect("engine");
+ let loaded = load_mission(&mut engine, MissionRequest { key: mission })
+ .expect("load mission foundation");
+ assert_eq!(loaded.object_count, loaded.registered_objects);
+ assert_eq!(loaded.object_count, loaded.graph_root_count);
+ assert_eq!(
+ loaded.graph_direct_reference_count + loaded.graph_unit_component_count,
+ loaded.graph_resolved_count
+ );
+ assert_eq!(loaded.graph_failure_count, 0);
+ assert_eq!(
+ loaded.graph_wear_request_count,
+ loaded.graph_wear_resolved_count
+ );
+ assert_eq!(
+ loaded.graph_material_slot_count,
+ loaded.graph_material_resolved_count
+ );
+ assert_eq!(
+ loaded.graph_texture_request_count,
+ loaded.graph_texture_resolved_count
+ );
+ assert_eq!(
+ loaded.graph_lightmap_request_count,
+ loaded.graph_lightmap_resolved_count
+ );
+ assert_eq!(loaded.build_category_count, 12);
+ assert!(loaded.areal_count > 0);
+ assert!(loaded.surface_count > 0);
+ totals.missions += 1;
+ totals.paths += loaded.path_count;
+ totals.clans += loaded.clan_count;
+ totals.objects += loaded.object_count;
+ totals.extras += loaded.extra_count;
+ totals.unit_references += loaded.graph_unit_reference_count;
+ totals.direct_references += loaded.graph_direct_reference_count;
+ totals.unit_components += loaded.graph_unit_component_count;
+ totals.prototype_requests += loaded.graph_resolved_count;
+ totals.wear_requests += loaded.graph_wear_request_count;
+ totals.wear_resolved += loaded.graph_wear_resolved_count;
+ totals.material_slots += loaded.graph_material_slot_count;
+ totals.material_resolved += loaded.graph_material_resolved_count;
+ totals.texture_requests += loaded.graph_texture_request_count;
+ totals.texture_resolved += loaded.graph_texture_resolved_count;
+ totals.lightmap_requests += loaded.graph_lightmap_request_count;
+ totals.lightmap_resolved += loaded.graph_lightmap_resolved_count;
+ totals.graph_failures += loaded.graph_failure_count;
+ }
+ totals
+ }
+
+ fn mission_paths(root: &Path) -> Vec<String> {
+ let mut out = Vec::new();
+ collect_missions(root, root, &mut out);
+ out
+ }
+
+ fn collect_missions(root: &Path, dir: &Path, out: &mut Vec<String>) {
+ let mut children: Vec<PathBuf> = std::fs::read_dir(dir)
+ .expect("read dir")
+ .map(|entry| entry.expect("entry").path())
+ .collect();
+ children.sort();
+ for child in children {
+ if child.is_dir() {
+ collect_missions(root, &child, out);
+ } else if child
+ .file_name()
+ .and_then(|name| name.to_str())
+ .is_some_and(|name| name.eq_ignore_ascii_case("data.tma"))
+ {
+ let rel = child.strip_prefix(root).expect("relative");
+ let rel = rel.to_str().expect("utf8 path").replace('\\', "/");
+ out.push(rel);
+ }
+ }
+ }
+
+ fn workspace_root() -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .and_then(Path::parent)
+ .expect("workspace root")
+ .to_path_buf()
+ }
+
+ #[derive(Clone, Copy)]
+ enum DenyRule {
+ Suffix(&'static str),
+ }
+
+ struct DenyVfs {
+ inner: DirectoryVfs,
+ denied: DenyRule,
+ }
+
+ impl DenyVfs {
+ fn denied(&self, path: &NormalizedPath) -> bool {
+ match self.denied {
+ DenyRule::Suffix(suffix) => path
+ .as_str()
+ .to_ascii_uppercase()
+ .ends_with(&suffix.to_ascii_uppercase()),
+ }
+ }
+ }
+
+ impl Vfs for DenyVfs {
+ fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
+ if self.denied(path) {
+ return Err(VfsError::NotFound(path.as_str().to_string()));
+ }
+ self.inner.metadata(path)
+ }
+
+ fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
+ if self.denied(path) {
+ return Err(VfsError::NotFound(path.as_str().to_string()));
+ }
+ self.inner.read(path)
+ }
+
+ fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
+ self.inner.list(prefix).map(|entries| {
+ entries
+ .into_iter()
+ .filter(|entry| !self.denied(&entry.path))
+ .collect()
+ })
+ }
+ }
+}