From d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Mon, 22 Jun 2026 13:12:27 +0400 Subject: 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. --- crates/fparkan-runtime/Cargo.toml | 22 + crates/fparkan-runtime/src/lib.rs | 1099 +++++++++++++++++++++++++++++++++++++ 2 files changed, 1121 insertions(+) create mode 100644 crates/fparkan-runtime/Cargo.toml create mode 100644 crates/fparkan-runtime/src/lib.rs (limited to 'crates/fparkan-runtime') diff --git a/crates/fparkan-runtime/Cargo.toml b/crates/fparkan-runtime/Cargo.toml new file mode 100644 index 0000000..17d95c1 --- /dev/null +++ b/crates/fparkan-runtime/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "fparkan-runtime" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +fparkan-mission-format = { path = "../fparkan-mission-format" } +fparkan-nres = { path = "../fparkan-nres" } +fparkan-path = { path = "../fparkan-path" } +fparkan-platform = { path = "../fparkan-platform" } +fparkan-prototype = { path = "../fparkan-prototype" } +fparkan-render = { path = "../fparkan-render" } +fparkan-resource = { path = "../fparkan-resource" } +fparkan-terrain = { path = "../fparkan-terrain" } +fparkan-terrain-format = { path = "../fparkan-terrain-format" } +fparkan-vfs = { path = "../fparkan-vfs" } +fparkan-world = { path = "../fparkan-world" } + +[lints] +workspace = true 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>, +} + +impl EngineServices { + /// Creates services with a VFS. + #[must_use] + pub fn new(vfs: Arc) -> 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, + /// 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, +} + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct MissionLoadOptions { + fail_after_registered_objects: Option, +} + +/// 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, +} + +struct LoadedMissionState { + summary: LoadedMission, + mission: MissionDocument, + terrain: TerrainWorld, + build_categories: Vec, + prototype_graph: PrototypeGraph, + prototype_report: PrototypeGraphReport, + resolved_prototypes: Vec, +} + +/// 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, + }, + /// 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 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 { + 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 { + 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 { + 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 { + 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 { + normalize_relative(value.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| { + EngineError::Path { + role, + value: value.to_string(), + source, + } + }) +} + +fn read_vfs(vfs: &Arc, path: &NormalizedPath) -> Result, EngineError> { + vfs.read(path).map_err(|source| EngineError::Vfs { + path: path.as_str().to_string(), + source, + }) +} + +fn decode_nres( + vfs: &Arc, + path: &NormalizedPath, +) -> Result { + 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 = 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 = 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 = 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 = 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 = 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 { + let mut out = Vec::new(); + collect_missions(root, root, &mut out); + out + } + + fn collect_missions(root: &Path, dir: &Path, out: &mut Vec) { + let mut children: Vec = 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 { + if self.denied(path) { + return Err(VfsError::NotFound(path.as_str().to_string())); + } + self.inner.metadata(path) + } + + fn read(&self, path: &NormalizedPath) -> Result, VfsError> { + if self.denied(path) { + return Err(VfsError::NotFound(path.as_str().to_string())); + } + self.inner.read(path) + } + + fn list(&self, prefix: &NormalizedPath) -> Result, VfsError> { + self.inner.list(prefix).map(|entries| { + entries + .into_iter() + .filter(|entry| !self.denied(&entry.path)) + .collect() + }) + } + } +} -- cgit v1.2.3