#![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] #[ignore = "requires licensed corpus"] 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] #[ignore = "requires licensed corpus"] 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] #[ignore = "requires licensed corpus"] 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] #[ignore = "requires licensed corpus"] 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] #[ignore = "requires licensed corpus"] 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() }) } } }