diff options
Diffstat (limited to 'crates')
50 files changed, 145 insertions, 5239 deletions
diff --git a/crates/fparkan-animation/Cargo.toml b/crates/fparkan-animation/Cargo.toml index a3e5d9e..153f9f2 100644 --- a/crates/fparkan-animation/Cargo.toml +++ b/crates/fparkan-animation/Cargo.toml @@ -6,6 +6,3 @@ license.workspace = true repository.workspace = true [dependencies] - -[lints] -workspace = true diff --git a/crates/fparkan-animation/src/lib.rs b/crates/fparkan-animation/src/lib.rs index 34a3fa7..c07c5b8 100644 --- a/crates/fparkan-animation/src/lib.rs +++ b/crates/fparkan-animation/src/lib.rs @@ -1,23 +1,5 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] -#![allow(clippy::cast_precision_loss)] + //! Deterministic animation sampling contracts. //! //! The current sampler is a portable reference path. Compatibility profiles diff --git a/crates/fparkan-assets/Cargo.toml b/crates/fparkan-assets/Cargo.toml index 468698b..ce3cb61 100644 --- a/crates/fparkan-assets/Cargo.toml +++ b/crates/fparkan-assets/Cargo.toml @@ -6,19 +6,14 @@ license.workspace = true repository.workspace = true [dependencies] -fparkan-material = { path = "../fparkan-material", version = "0.1.0" } -fparkan-msh = { path = "../fparkan-msh", version = "0.1.0" } -fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" } -fparkan-path = { path = "../fparkan-path", version = "0.1.0" } -fparkan-mission-format = { path = "../fparkan-mission-format", version = "0.1.0" } -fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" } -fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" } -fparkan-texm = { path = "../fparkan-texm", version = "0.1.0" } -fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" } -fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" } - -[dev-dependencies] -fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" } - -[lints] -workspace = true +fparkan-material = { path = "../fparkan-material" } +fparkan-msh = { path = "../fparkan-msh" } +fparkan-nres = { path = "../fparkan-nres" } +fparkan-path = { path = "../fparkan-path" } +fparkan-mission-format = { path = "../fparkan-mission-format" } +fparkan-prototype = { path = "../fparkan-prototype" } +fparkan-resource = { path = "../fparkan-resource" } +fparkan-texm = { path = "../fparkan-texm" } +fparkan-terrain = { path = "../fparkan-terrain" } +fparkan-terrain-format = { path = "../fparkan-terrain-format" } +fparkan-vfs = { path = "../fparkan-vfs" } diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index 708993f..3193e3f 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/src/lib.rs @@ -1,23 +1,4 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] //! Asset manager ports and transactional preparation models. use fparkan_material::{ @@ -35,24 +16,24 @@ use fparkan_prototype::{ PrototypeGraphFailure, PrototypeGraphNodeKind, PrototypeGraphProvenance, PrototypeGraphReport, PrototypeGraphRequiredness, }; -use fparkan_resource::{ResourceError, ResourceKey, ResourceRepository}; +use fparkan_resource::{CachedResourceRepository, ResourceError, ResourceKey, ResourceRepository}; pub use fparkan_terrain::{TerrainError, TerrainWorld}; use fparkan_terrain_format::{decode_build_dat, decode_land_map, decode_land_msh}; pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError}; use fparkan_texm::{decode_mip_rgba8, decode_texm, RgbaImage, TexmDocument, TexmError}; +use fparkan_vfs::DirectoryVfs; use std::collections::{hash_map::Entry, HashMap, HashSet}; use std::fmt; use std::hash::{Hash, Hasher}; use std::marker::PhantomData; +use std::path::Path; use std::sync::Arc; const TEXTURES_ARCHIVE: &str = "textures.lib"; const LIGHTMAP_ARCHIVE: &str = "lightmap.lib"; -const VISUAL_DEPENDENCY_PROGRESS_INTERVAL: usize = 64; type WearValidationCache = HashMap<(NormalizedPath, Vec<u8>), Result<fparkan_material::WearTable, String>>; -type TextureValidationCache = HashMap<Vec<u8>, Result<(), String>>; /// Canonical terrain archive paths derived from a mission land reference. #[derive(Clone, Debug, Eq, PartialEq)] @@ -188,6 +169,37 @@ pub fn decode_nres_payload( decode_nres(bytes, ReadProfile::Compatible) } +/// Loads a phase-zero diffuse texture from a map-local standalone WEAR file. +/// +/// The sidecar stays beside `Land.msh`; MAT0 and TEXM remain in the normal +/// game-root archives. +pub fn load_standalone_wear_material_texture_mip0_rgba8_from_root( + root: &Path, + wear_path: &Path, + material_index: u16, +) -> Result<RgbaImage, String> { + let wear = decode_wear( + &std::fs::read(wear_path).map_err(|err| format!("{}: {err}", wear_path.display()))?, + ) + .map_err(|err| err.to_string())?; + let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root))); + let material = + resolve_material(&repository, &wear, material_index).map_err(|err| err.to_string())?; + let texture = material.document.primary_texture().ok_or_else(|| { + "MAT0 phase zero declares an intentionally untextured material".to_string() + })?; + let key = ResourceKey { + archive: parse_path(TEXTURES_ARCHIVE).map_err(|err| err.to_string())?, + name: texture.clone(), + type_id: None, + }; + let bytes = read_optional_key(&repository, &key, Some("texture")) + .map_err(|err| err.to_string())? + .ok_or_else(|| format!("missing texture {texture:?}"))?; + let document = decode_texm(bytes).map_err(|err| err.to_string())?; + decode_mip_rgba8(&document, 0).map_err(|err| err.to_string()) +} + /// Decodes terrain documents and builds immutable terrain state. /// /// # Errors @@ -494,56 +506,6 @@ pub struct AssetPreparationReport { pub texture_pixels: u64, } -/// Asset preparation checkpoint emitted while resolving visual dependencies. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum AssetPreparationPhase { - /// Decode and validate an MSH model. - ModelMesh, - /// Decode the corresponding WEAR table. - WearTable, - /// Resolve MAT0 material documents referenced by a WEAR table. - Materials, - /// Decode diffuse textures and baked lightmaps. - Textures, -} - -/// Visual dependency class currently being expanded into a prototype graph. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum VisualDependencyPhase { - /// Resolve a mesh's WEAR table. - Wear, - /// Resolve MAT0 material documents selected by WEAR. - Material, - /// Validate diffuse TEXM textures and lightmaps. - Texture, -} - -/// Validation-cache entries retained while expanding visual dependencies. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub struct VisualDependencyCacheCounts { - /// Distinct WEAR archive/name keys resolved so far. - pub wear_entries: usize, - /// Distinct MAT0 raw names resolved so far. - pub material_entries: usize, - /// Distinct TEXM archive/name keys resolved so far. - pub texture_entries: usize, -} - -/// Throttled visual-dependency expansion progress. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct VisualDependencyProgress { - /// Dependency class currently being expanded. - pub phase: VisualDependencyPhase, - /// Number of requests reached in this class during the current expansion. - pub request_count: usize, - /// Resource graph nodes materialized before this request is resolved. - pub graph_node_count: usize, - /// Resource graph edges materialized before this request is resolved. - pub graph_edge_count: usize, - /// Validation-cache entries retained before this request is resolved. - pub cache_entries: VisualDependencyCacheCounts, -} - /// Errors raised while preparing CPU-side assets. #[derive(Debug)] pub enum AssetError { @@ -660,25 +622,6 @@ impl<R: ResourceRepository> AssetManager<R> { prepare_mission_assets_with_repository(&self.repository, root_prototype_spans, prototypes) } - /// Builds mission assets and reports the active dependency class. - /// - /// # Errors - /// - /// Returns [`AssetError`] if any visual dependency is missing or malformed. - pub fn prepare_mission_assets_with_progress( - &self, - root_prototype_spans: &[std::ops::Range<usize>], - prototypes: &[EffectivePrototype], - on_phase: impl FnMut(AssetPreparationPhase), - ) -> Result<MissionAssets, AssetError> { - prepare_mission_assets_with_repository_with_progress( - &self.repository, - root_prototype_spans, - prototypes, - on_phase, - ) - } - /// Builds mission assets together with a bounded preparation report. /// /// # Errors @@ -764,28 +707,6 @@ pub fn prepare_mission_assets_with_repository<R: ResourceRepository>( .0) } -/// Builds mission assets and reports the active dependency class. -/// -/// # Errors -/// -/// Returns [`AssetError`] if any visual dependency is missing or malformed. -pub fn prepare_mission_assets_with_repository_with_progress<R: ResourceRepository>( - repository: &R, - root_prototype_spans: &[std::ops::Range<usize>], - prototypes: &[EffectivePrototype], - mut on_phase: impl FnMut(AssetPreparationPhase), -) -> Result<MissionAssets, AssetError> { - Ok(prepare_mission_assets_with_repository_internal( - repository, - root_prototype_spans, - prototypes, - AssetIdentityPolicy::default(), - AssetPreparationLimits::default(), - &mut on_phase, - )? - .0) -} - /// Builds mission assets while enforcing explicit preparation limits. /// /// # Errors @@ -798,27 +719,24 @@ pub fn prepare_mission_assets_profiled_with_repository<R: ResourceRepository>( prototypes: &[EffectivePrototype], limits: AssetPreparationLimits, ) -> Result<(MissionAssets, AssetPreparationReport), AssetError> { - let mut ignore_phase = |_| {}; prepare_mission_assets_with_repository_internal( repository, root_prototype_spans, prototypes, AssetIdentityPolicy::default(), limits, - &mut ignore_phase, ) } // This orchestration intentionally keeps preparation, deduplication, and limit enforcement // together so all fallible asset construction remains transactional. -#[allow(clippy::too_many_lines)] + fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>( repository: &R, root_prototype_spans: &[std::ops::Range<usize>], prototypes: &[EffectivePrototype], identity_policy: AssetIdentityPolicy, limits: AssetPreparationLimits, - on_phase: &mut dyn FnMut(AssetPreparationPhase), ) -> Result<(MissionAssets, AssetPreparationReport), AssetError> { if prototypes.is_empty() { return Ok((MissionAssets::default(), AssetPreparationReport::default())); @@ -858,7 +776,6 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>( proto, identity_policy, &mut preparation_cache, - on_phase, )?; if bundle.visual.id != visual_id { // Defensive check. stable IDs are deterministic for the same inputs. @@ -1173,73 +1090,17 @@ fn enforce_asset_preparation_limits( /// the path policy, which indicates a programming error in this module. // The graph expansion is deliberately a single transactional traversal so every generated // edge receives the correct parent provenance and node/edge sequence. -#[allow(clippy::too_many_lines)] -pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>( - repository: &R, - report: &mut PrototypeGraphReport, - graph: &mut PrototypeGraph, - prototypes: &[EffectivePrototype], -) { - extend_graph_report_with_visual_dependencies_and_progress( - repository, - report, - graph, - prototypes, - |_| {}, - ); -} -/// Extends a prototype dependency report while reporting each visual dependency class. -/// -/// # Panics -/// -/// Panics only if the hard-coded, host-compatible `material.lib` path stops -/// satisfying the path policy, which indicates a programming error in this module. -#[allow(clippy::too_many_lines)] -pub fn extend_graph_report_with_visual_dependencies_and_progress<R: ResourceRepository>( - repository: &R, - report: &mut PrototypeGraphReport, - graph: &mut PrototypeGraph, - prototypes: &[EffectivePrototype], - mut on_phase: impl FnMut(VisualDependencyPhase), -) { - let mut last_phase = None; - extend_graph_report_with_visual_dependencies_with_progress( - repository, - report, - graph, - prototypes, - |progress| { - if last_phase != Some(progress.phase) { - on_phase(progress.phase); - last_phase = Some(progress.phase); - } - }, - ); -} - -/// Extends a prototype dependency report with throttled request progress. -/// -/// The callback receives each dependency-class entrance, its first request, -/// and every subsequent multiple of 64 requests. Graph traversal, edge order -/// and validation remain unchanged. -/// -/// # Panics -/// -/// Panics only if the hard-coded, host-compatible `material.lib` path stops -/// satisfying the path policy, which indicates a programming error in this module. -#[allow(clippy::too_many_lines)] -pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRepository>( +pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>( repository: &R, report: &mut PrototypeGraphReport, graph: &mut PrototypeGraph, prototypes: &[EffectivePrototype], - mut on_progress: impl FnMut(VisualDependencyProgress), ) { if graph.visual_dependencies_expanded { return; } - #[allow(clippy::expect_used)] + let material_archive = parse_path("material.lib") .expect("static material archive path must satisfy host-compatible normalization"); let mut next_node = graph @@ -1258,9 +1119,6 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep let mut lightmap_texture_validation = HashMap::new(); let mut material_validation = HashMap::new(); let mut wear_validation = HashMap::new(); - let mut wear_requests = 0usize; - let mut material_requests = 0usize; - let mut texture_requests = 0usize; // Capture all base-graph anchors before this traversal appends visual // resources and edges. Looking them up again from the growing graph would // make each later prototype scan an ever larger collection. @@ -1288,22 +1146,6 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep let unit_component_index = mesh_parent_edge.and_then(|edge| unit_component_index_for_edge(graph, edge)); - wear_requests = wear_requests.saturating_add(1); - report_visual_dependency_progress( - &mut on_progress, - VisualDependencyProgress { - phase: VisualDependencyPhase::Wear, - request_count: wear_requests, - graph_node_count: graph.nodes.len(), - graph_edge_count: graph.edges.len(), - cache_entries: visual_dependency_cache_counts( - &wear_validation, - &material_validation, - &diffuse_texture_validation, - &lightmap_texture_validation, - ), - }, - ); match resolve_wear_table_cached(repository, mesh, &mut wear_validation) { Ok(table) => { report.wear_resolved_count += 1; @@ -1343,35 +1185,7 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep &mut next_edge, ); report.material_slot_count += table.entries.len(); - on_progress(VisualDependencyProgress { - phase: VisualDependencyPhase::Material, - request_count: material_requests, - graph_node_count: graph.nodes.len(), - graph_edge_count: graph.edges.len(), - cache_entries: visual_dependency_cache_counts( - &wear_validation, - &material_validation, - &diffuse_texture_validation, - &lightmap_texture_validation, - ), - }); for (material_index, _entry) in table.entries.iter().enumerate() { - material_requests = material_requests.saturating_add(1); - report_visual_dependency_progress( - &mut on_progress, - VisualDependencyProgress { - phase: VisualDependencyPhase::Material, - request_count: material_requests, - graph_node_count: graph.nodes.len(), - graph_edge_count: graph.edges.len(), - cache_entries: visual_dependency_cache_counts( - &wear_validation, - &material_validation, - &diffuse_texture_validation, - &lightmap_texture_validation, - ), - }, - ); let Ok(material_index) = u16::try_from(material_index) else { push_visual_failure( report, @@ -1418,22 +1232,6 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep &mut next_edge, ); for texture in material.document.texture_requests() { - texture_requests = texture_requests.saturating_add(1); - report_visual_dependency_progress( - &mut on_progress, - VisualDependencyProgress { - phase: VisualDependencyPhase::Texture, - request_count: texture_requests, - graph_node_count: graph.nodes.len(), - graph_edge_count: graph.edges.len(), - cache_entries: visual_dependency_cache_counts( - &wear_validation, - &material_validation, - &diffuse_texture_validation, - &lightmap_texture_validation, - ), - }, - ); report.texture_request_count += 1; match resolve_texm_validation_cached( repository, @@ -1495,22 +1293,6 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep } } for lightmap in &table.lightmaps { - texture_requests = texture_requests.saturating_add(1); - report_visual_dependency_progress( - &mut on_progress, - VisualDependencyProgress { - phase: VisualDependencyPhase::Texture, - request_count: texture_requests, - graph_node_count: graph.nodes.len(), - graph_edge_count: graph.edges.len(), - cache_entries: visual_dependency_cache_counts( - &wear_validation, - &material_validation, - &diffuse_texture_validation, - &lightmap_texture_validation, - ), - }, - ); report.lightmap_request_count += 1; match resolve_texm_validation_cached( repository, @@ -1574,34 +1356,6 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep graph.visual_dependencies_expanded = true; } -fn report_visual_dependency_progress( - on_progress: &mut impl FnMut(VisualDependencyProgress), - progress: VisualDependencyProgress, -) { - if progress.request_count == 1 - || progress - .request_count - .is_multiple_of(VISUAL_DEPENDENCY_PROGRESS_INTERVAL) - { - on_progress(progress); - } -} - -fn visual_dependency_cache_counts( - wear_validation: &WearValidationCache, - material_validation: &HashMap<Vec<u8>, Result<ResolvedMaterial, String>>, - diffuse_texture_validation: &TextureValidationCache, - lightmap_texture_validation: &TextureValidationCache, -) -> VisualDependencyCacheCounts { - VisualDependencyCacheCounts { - wear_entries: wear_validation.len(), - material_entries: material_validation.len(), - texture_entries: diffuse_texture_validation - .len() - .saturating_add(lightmap_texture_validation.len()), - } -} - fn push_graph_resource_node( graph: &mut PrototypeGraph, kind: PrototypeGraphNodeKind, @@ -1738,7 +1492,6 @@ pub fn prepare_visual_with_repository<R: ResourceRepository>( proto, AssetIdentityPolicy::default(), &mut preparation_cache, - &mut |_| {}, )? .visual) } @@ -1761,13 +1514,12 @@ struct PreparationCache { } // Keeps the complete model -> WEAR -> material -> texture transaction in one fallible boundary. -#[allow(clippy::too_many_lines)] + fn prepare_visual_with_repository_internal<R: ResourceRepository>( repository: &R, proto: &EffectivePrototype, identity_policy: AssetIdentityPolicy, preparation_cache: &mut PreparationCache, - on_phase: &mut dyn FnMut(AssetPreparationPhase), ) -> Result<PreparedVisualBundle, AssetError> { let PrototypeGeometry::Mesh(mesh_key) = &proto.geometry else { return Ok(PreparedVisualBundle { @@ -1779,7 +1531,6 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>( }); }; - on_phase(AssetPreparationPhase::ModelMesh); let model = prepare_model_cached(repository, mesh_key, preparation_cache)?; let model_id = AssetId::new((identity_policy.model)(proto)); let prepared_model = PreparedModel { @@ -1795,7 +1546,6 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>( name: wear_name, type_id: Some(WEAR_KIND), }; - on_phase(AssetPreparationPhase::WearTable); let wear = prepare_wear_cached(repository, &wear_key, preparation_cache)?; let wear_id = AssetId::new((identity_policy.wear)(proto)); let prepared_wear = PreparedWear { @@ -1817,7 +1567,6 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>( .iter() .map(|lightmap| lightmap.lightmap.clone()) .collect(); - on_phase(AssetPreparationPhase::Materials); for material_index in 0..wear.entries.len() { let material_index = u16::try_from(material_index).map_err(|_| { AssetError::InvalidPrototype("material index does not fit archive format".to_string()) @@ -1847,7 +1596,6 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>( }); for texture in texture_requests { - on_phase(AssetPreparationPhase::Textures); let prepared_texture = prepare_texture_cached( repository, &texture, @@ -1862,7 +1610,6 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>( } for lightmap in &wear.lightmaps { - on_phase(AssetPreparationPhase::Textures); let prepared_lightmap = prepare_texture_cached( repository, &lightmap.lightmap, @@ -2451,66 +2198,6 @@ mod tests { use std::path::PathBuf; #[test] - fn visual_dependency_progress_is_throttled_at_fixed_request_intervals() { - let mut progress = Vec::new(); - for request_count in 1..=129 { - report_visual_dependency_progress( - &mut |event| progress.push(event), - VisualDependencyProgress { - phase: VisualDependencyPhase::Texture, - request_count, - graph_node_count: 37, - graph_edge_count: 41, - cache_entries: VisualDependencyCacheCounts { - wear_entries: 3, - material_entries: 5, - texture_entries: 7, - }, - }, - ); - } - - assert_eq!( - progress, - vec![ - VisualDependencyProgress { - phase: VisualDependencyPhase::Texture, - request_count: 1, - graph_node_count: 37, - graph_edge_count: 41, - cache_entries: VisualDependencyCacheCounts { - wear_entries: 3, - material_entries: 5, - texture_entries: 7, - }, - }, - VisualDependencyProgress { - phase: VisualDependencyPhase::Texture, - request_count: 64, - graph_node_count: 37, - graph_edge_count: 41, - cache_entries: VisualDependencyCacheCounts { - wear_entries: 3, - material_entries: 5, - texture_entries: 7, - }, - }, - VisualDependencyProgress { - phase: VisualDependencyPhase::Texture, - request_count: 128, - graph_node_count: 37, - graph_edge_count: 41, - cache_entries: VisualDependencyCacheCounts { - wear_entries: 3, - material_entries: 5, - texture_entries: 7, - }, - }, - ] - ); - } - - #[test] fn wear_validation_cache_replays_archive_qualified_failure() { let repository = repository_with_archives(&[]); let mesh = ResourceKey { @@ -3315,7 +3002,6 @@ mod tests { &prototypes, policy, AssetPreparationLimits::default(), - &mut |_| {}, ) .expect_err("collision should fail"); diff --git a/crates/fparkan-binary/Cargo.toml b/crates/fparkan-binary/Cargo.toml index 2bab5be..cec2aa3 100644 --- a/crates/fparkan-binary/Cargo.toml +++ b/crates/fparkan-binary/Cargo.toml @@ -6,6 +6,3 @@ license.workspace = true repository.workspace = true [dependencies] - -[lints] -workspace = true diff --git a/crates/fparkan-binary/src/lib.rs b/crates/fparkan-binary/src/lib.rs index be9e8d9..bb9f118 100644 --- a/crates/fparkan-binary/src/lib.rs +++ b/crates/fparkan-binary/src/lib.rs @@ -1,23 +1,4 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] //! Bounded little-endian binary cursor and checked layout helpers. use std::fmt; @@ -399,7 +380,6 @@ pub fn sha256_hex(digest: &Sha256Digest) -> String { out } -#[allow(clippy::many_single_char_names)] fn compress_sha256_chunk(h: &mut [u32; 8], chunk: &[u8], k: &[u32; 64]) { let mut w = [0u32; 64]; for (idx, word) in w.iter_mut().take(16).enumerate() { diff --git a/crates/fparkan-corpus/Cargo.toml b/crates/fparkan-corpus/Cargo.toml deleted file mode 100644 index 50bf798..0000000 --- a/crates/fparkan-corpus/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "fparkan-corpus" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] -fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" } -fparkan-fx = { path = "../fparkan-fx", version = "0.1.0" } -fparkan-material = { path = "../fparkan-material", version = "0.1.0" } -fparkan-msh = { path = "../fparkan-msh", version = "0.1.0" } -fparkan-mission-format = { path = "../fparkan-mission-format", version = "0.1.0" } -fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" } -fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" } -fparkan-path = { path = "../fparkan-path", version = "0.1.0" } -fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" } -fparkan-texm = { path = "../fparkan-texm", version = "0.1.0" } -fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" } - -[lints] -workspace = true diff --git a/crates/fparkan-corpus/src/lib.rs b/crates/fparkan-corpus/src/lib.rs deleted file mode 100644 index 48eaf9e..0000000 --- a/crates/fparkan-corpus/src/lib.rs +++ /dev/null @@ -1,1239 +0,0 @@ -#![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] -//! Licensed corpus discovery and aggregate reports. - -use fparkan_binary::{sha256, sha256_hex, Sha256Digest}; -use fparkan_fx::{decode_fxid, FXID_KIND}; -use fparkan_material::{decode_mat0, decode_wear, MAT0_KIND, WEAR_KIND}; -use fparkan_mission_format::{decode_tma, TmaProfile}; -use fparkan_msh::{decode_msh, validate_msh}; -use fparkan_nres::NresDocument; -use fparkan_path::{ascii_lookup_key, normalize_relative, PathPolicy}; -use fparkan_prototype::{decode_unit_dat, decode_unit_dat_binding}; -use fparkan_rsli::{decode as decode_rsli, ReadProfile}; -use fparkan_terrain_format::{decode_land_map, decode_land_msh}; -use fparkan_texm::decode_texm; -use std::collections::{BTreeMap, BTreeSet}; -use std::fmt; -use std::fs; -use std::io::Write; -#[cfg(unix)] -use std::os::unix::ffi::OsStrExt; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -const TEXM_KIND: u32 = 0x6d78_6554; - -/// Corpus kind. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum CorpusKind { - /// Demo corpus. - Demo, - /// Part 1 full game. - Part1, - /// Part 2 full game. - Part2, - /// Unknown local directory. - Unknown, -} - -/// Corpus root. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct CorpusRoot(pub PathBuf); - -/// Discovery options. -#[derive(Clone, Copy, Debug, Default)] -pub struct DiscoverOptions { - /// Whether symlinks may be traversed. - pub follow_symlinks: bool, -} - -/// File manifest entry. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ManifestEntry { - /// Normalized relative path. - pub path: String, - /// Byte-exact relative host path used for reopening corpus files. - pub host_rel_path: PathBuf, - /// File size in bytes. - pub size: u64, - /// SHA-256 content fingerprint. - pub hash: Sha256Digest, -} - -/// Corpus manifest. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct CorpusManifest { - /// Kind. - pub kind: CorpusKind, - /// Sorted files. - pub files: Vec<ManifestEntry>, - /// Casefold collisions. - pub casefold_collisions: Vec<Vec<String>>, -} - -/// Aggregate report. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct CorpusReport { - /// Schema version. - pub schema: u32, - /// Kind. - pub kind: CorpusKind, - /// Total files. - pub files: usize, - /// Total bytes. - pub bytes: u64, - /// Metrics. - pub metrics: BTreeMap<String, u64>, - /// Casefold collision count. - pub casefold_collisions: usize, - /// Manifest fingerprint. - pub fingerprint: Sha256Digest, - /// Per-file status records. - pub records: Vec<CorpusFileRecord>, - /// Number of files with report errors. - pub failures: usize, -} - -/// Per-file report status. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum CorpusFileStatus { - /// File was inspected successfully. - Ok, - /// File was inspected but produced a non-fatal warning. - Warning, - /// File could not be inspected. - Error, -} - -/// Per-file report record. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct CorpusFileRecord { - /// Normalized relative path. - pub path: String, - /// Inspection status. - pub status: CorpusFileStatus, - /// Detected file variant. - pub variant: String, - /// Optional status message. - pub message: Option<String>, -} - -/// Corpus error. -#[derive(Debug)] -pub enum CorpusError { - /// I/O failure. - Io { - /// Path where I/O failed. - path: PathBuf, - /// Source error. - source: std::io::Error, - }, - /// Invalid root. - InvalidRoot(PathBuf), - /// Invalid path. - InvalidPath(String), - /// Aggregate report failure. - Report { - /// Path where reporting failed. - path: String, - /// Failure message. - message: String, - }, -} - -impl fmt::Display for CorpusError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Io { path, source } => write!(f, "{}: {source}", path.display()), - Self::InvalidRoot(path) => write!(f, "invalid corpus root: {}", path.display()), - Self::InvalidPath(path) => write!(f, "invalid corpus path: {path}"), - Self::Report { path, message } => write!(f, "{path}: {message}"), - } - } -} - -impl std::error::Error for CorpusError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - Self::Io { source, .. } => Some(source), - Self::InvalidRoot(_) | Self::InvalidPath(_) | Self::Report { .. } => None, - } - } -} - -/// Discovers a corpus under a root directory. -/// -/// # Errors -/// -/// Returns [`CorpusError`] if the root is invalid, traversal encounters an I/O -/// error, or a discovered path cannot be represented by the legacy path policy. -pub fn discover(root: &Path, options: DiscoverOptions) -> Result<CorpusManifest, CorpusError> { - if !root.is_dir() { - return Err(CorpusError::InvalidRoot(root.to_path_buf())); - } - let mut files = Vec::new(); - walk(root, root, options, &mut files)?; - files.sort_by(|a, b| a.host_rel_path.cmp(&b.host_rel_path)); - - let kind = classify(root, &files); - let casefold_collisions = detect_casefold_collisions(&files); - Ok(CorpusManifest { - kind, - files, - casefold_collisions, - }) -} - -fn walk( - root: &Path, - dir: &Path, - options: DiscoverOptions, - out: &mut Vec<ManifestEntry>, -) -> Result<(), CorpusError> { - let read_dir = fs::read_dir(dir).map_err(|source| CorpusError::Io { - path: dir.to_path_buf(), - source, - })?; - let mut entries = Vec::new(); - for entry in read_dir { - let entry = entry.map_err(|source| CorpusError::Io { - path: dir.to_path_buf(), - source, - })?; - entries.push(entry.path()); - } - entries.sort(); - for path in entries { - if path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.starts_with('.')) - { - continue; - } - let metadata = fs::symlink_metadata(&path).map_err(|source| CorpusError::Io { - path: path.clone(), - source, - })?; - if metadata.file_type().is_symlink() && !options.follow_symlinks { - continue; - } - if metadata.is_dir() { - walk(root, &path, options, out)?; - continue; - } - if !metadata.is_file() { - continue; - } - let rel = path - .strip_prefix(root) - .map_err(|_| CorpusError::InvalidPath(path.display().to_string()))?; - #[cfg(unix)] - let rel_bytes = rel.as_os_str().as_bytes(); - #[cfg(not(unix))] - let rel_bytes = rel - .to_str() - .ok_or_else(|| CorpusError::InvalidPath(path.display().to_string()))? - .as_bytes(); - let normalized = normalize_relative(rel_bytes, PathPolicy::HostCompatible) - .map_err(|_| CorpusError::InvalidPath(path.display().to_string()))?; - let bytes = fs::read(&path).map_err(|source| CorpusError::Io { - path: path.clone(), - source, - })?; - out.push(ManifestEntry { - path: normalized.display_lossy().to_string(), - host_rel_path: rel.to_path_buf(), - size: metadata.len(), - hash: sha256(&bytes), - }); - } - Ok(()) -} - -fn classify(root: &Path, files: &[ManifestEntry]) -> CorpusKind { - let name = root - .file_name() - .and_then(|v| v.to_str()) - .unwrap_or_default() - .to_ascii_uppercase(); - if name == "IS" { - CorpusKind::Part1 - } else if name == "IS2" { - CorpusKind::Part2 - } else if files - .iter() - .any(|f| f.path.eq_ignore_ascii_case("iron_3d.exe")) - { - CorpusKind::Part1 - } else { - CorpusKind::Unknown - } -} - -fn detect_casefold_collisions(files: &[ManifestEntry]) -> Vec<Vec<String>> { - let mut grouped: BTreeMap<Vec<u8>, BTreeSet<String>> = BTreeMap::new(); - for file in files { - grouped - .entry(ascii_lookup_key(path_identity_bytes(&file.host_rel_path)).0) - .or_default() - .insert(file.path.clone()); - } - grouped - .into_values() - .filter(|paths| paths.len() > 1) - .map(|paths| paths.into_iter().collect()) - .collect() -} - -/// Builds aggregate report. -/// -/// # Errors -/// -/// Returns [`CorpusError`] when the aggregate report cannot be constructed. -/// Per-file inspection failures are represented in [`CorpusReport::records`] -/// and counted in [`CorpusReport::failures`]. -pub fn report(root: &Path, manifest: &CorpusManifest) -> Result<CorpusReport, CorpusError> { - let mut metrics = empty_report_metrics(); - let mut records = Vec::with_capacity(manifest.files.len()); - let mut failures = 0usize; - - for entry in &manifest.files { - let record = inspect_report_file(root, entry, &mut metrics); - if record.status == CorpusFileStatus::Error { - failures = failures.saturating_add(1); - } - records.push(record); - } - - Ok(CorpusReport { - schema: 1, - kind: manifest.kind, - files: manifest.files.len(), - bytes: manifest.files.iter().map(|f| f.size).sum(), - metrics, - casefold_collisions: manifest.casefold_collisions.len(), - fingerprint: fingerprint(manifest), - records, - failures, - }) -} - -fn empty_report_metrics() -> BTreeMap<String, u64> { - let mut metrics = BTreeMap::new(); - metrics.insert("nres_files".to_string(), 0); - metrics.insert("nres_entries".to_string(), 0); - metrics.insert("rsli_files".to_string(), 0); - metrics.insert("tma_files".to_string(), 0); - metrics.insert("land_msh_files".to_string(), 0); - metrics.insert("land_map_files".to_string(), 0); - metrics.insert("unit_dat_files".to_string(), 0); - metrics.insert("msh_entries".to_string(), 0); - metrics.insert("mat0_entries".to_string(), 0); - metrics.insert("texm_entries".to_string(), 0); - metrics.insert("fxid_entries".to_string(), 0); - metrics.insert("wear_entries".to_string(), 0); - metrics -} - -fn inspect_report_file( - root: &Path, - entry: &ManifestEntry, - metrics: &mut BTreeMap<String, u64>, -) -> CorpusFileRecord { - let lower = entry.path.to_ascii_lowercase(); - let mut variant = inspect_path_metrics(&lower, metrics); - let path = root.join(&entry.host_rel_path); - let bytes = match fs::read(&path) { - Ok(bytes) => bytes, - Err(source) => { - return CorpusFileRecord { - path: entry.path.clone(), - status: CorpusFileStatus::Error, - variant, - message: Some(source.to_string()), - }; - } - }; - if bytes.starts_with(b"NRes") { - if variant == "file" { - variant = "nres".to_string(); - } - bump(metrics, "nres_files", 1); - if let Err(message) = inspect_nres_metrics(&bytes, metrics) { - return CorpusFileRecord { - path: entry.path.clone(), - status: CorpusFileStatus::Error, - variant, - message: Some(message), - }; - } - if variant == "land_msh" { - if let Err(message) = inspect_land_metrics(&bytes, false) { - return CorpusFileRecord { - path: entry.path.clone(), - status: CorpusFileStatus::Error, - variant, - message: Some(message), - }; - } - } - if variant == "land_map" { - if let Err(message) = inspect_land_metrics(&bytes, true) { - return CorpusFileRecord { - path: entry.path.clone(), - status: CorpusFileStatus::Error, - variant, - message: Some(message), - }; - } - } - } else if bytes.starts_with(b"NL") { - variant = "rsli".to_string(); - bump(metrics, "rsli_files", 1); - if let Err(message) = inspect_rsli_metrics(&bytes) { - return CorpusFileRecord { - path: entry.path.clone(), - status: CorpusFileStatus::Error, - variant, - message: Some(message), - }; - } - } else if lower.ends_with("data.tma") { - if let Err(message) = inspect_tma_metrics(&bytes) { - return CorpusFileRecord { - path: entry.path.clone(), - status: CorpusFileStatus::Error, - variant: "tma".to_string(), - message: Some(message), - }; - } - } else if has_extension(&lower, "dat") - && (lower.starts_with("units/") || lower.contains("/units/")) - { - variant = "unit_dat".to_string(); - if let Err(message) = inspect_unit_dat_metrics(&bytes) { - return CorpusFileRecord { - path: entry.path.clone(), - status: CorpusFileStatus::Error, - variant, - message: Some(message), - }; - } - } - CorpusFileRecord { - path: entry.path.clone(), - status: CorpusFileStatus::Ok, - variant, - message: None, - } -} - -fn path_identity_bytes(path: &Path) -> &[u8] { - #[cfg(unix)] - { - path.as_os_str().as_bytes() - } - #[cfg(not(unix))] - { - path.to_str().unwrap_or_default().as_bytes() - } -} - -fn inspect_path_metrics(lower: &str, metrics: &mut BTreeMap<String, u64>) -> String { - let mut variant = "file"; - if lower.ends_with("data.tma") { - bump(metrics, "tma_files", 1); - variant = "tma"; - } - if lower.ends_with("land.msh") { - bump(metrics, "land_msh_files", 1); - variant = "land_msh"; - } - if lower.ends_with("land.map") { - bump(metrics, "land_map_files", 1); - variant = "land_map"; - } - if has_extension(lower, "dat") && (lower.starts_with("units/") || lower.contains("/units/")) { - bump(metrics, "unit_dat_files", 1); - variant = "unit_dat"; - } - variant.to_string() -} - -fn inspect_nres_metrics(bytes: &[u8], metrics: &mut BTreeMap<String, u64>) -> Result<(), String> { - let document = inspect_nres_document(bytes)?; - bump(metrics, "nres_entries", document.entries().len() as u64); - for entry in document.entries() { - let name = String::from_utf8_lossy(entry.name_bytes()).to_ascii_lowercase(); - if has_extension(&name, "msh") { - bump(metrics, "msh_entries", 1); - validate_nres_msh_payload(&document, entry)?; - } - match entry.meta().type_id { - MAT0_KIND => { - bump(metrics, "mat0_entries", 1); - validate_nres_mat0_payload(&document, entry)?; - } - TEXM_KIND => { - bump(metrics, "texm_entries", 1); - validate_nres_texm_payload(&document, entry)?; - } - FXID_KIND => { - bump(metrics, "fxid_entries", 1); - validate_nres_fxid_payload(&document, entry)?; - } - WEAR_KIND => { - bump(metrics, "wear_entries", 1); - validate_nres_wear_payload(&document, entry)?; - } - _ => {} - } - } - Ok(()) -} - -fn validate_nres_msh_payload( - document: &NresDocument, - entry: &fparkan_nres::NresEntry, -) -> Result<(), String> { - let payload = document - .payload(entry.id()) - .map_err(|err| err.to_string())?; - let nested = fparkan_nres::decode( - Arc::from(payload.to_vec().into_boxed_slice()), - fparkan_nres::ReadProfile::Compatible, - ) - .map_err(|err| err.to_string())?; - let model = decode_msh(&nested).map_err(|err| err.to_string())?; - validate_msh(&model).map_err(|err| err.to_string())?; - Ok(()) -} - -fn validate_nres_mat0_payload( - document: &NresDocument, - entry: &fparkan_nres::NresEntry, -) -> Result<(), String> { - let payload = document - .payload(entry.id()) - .map_err(|err| err.to_string())?; - decode_mat0(payload, entry.meta().attr2).map_err(|err| err.to_string())?; - Ok(()) -} - -fn validate_nres_wear_payload( - document: &NresDocument, - entry: &fparkan_nres::NresEntry, -) -> Result<(), String> { - let payload = document - .payload(entry.id()) - .map_err(|err| err.to_string())?; - decode_wear(payload).map_err(|err| err.to_string())?; - Ok(()) -} - -fn validate_nres_texm_payload( - document: &NresDocument, - entry: &fparkan_nres::NresEntry, -) -> Result<(), String> { - let payload = document - .payload(entry.id()) - .map_err(|err| err.to_string())?; - decode_texm(Arc::from(payload.to_vec().into_boxed_slice())).map_err(|err| err.to_string())?; - Ok(()) -} - -fn validate_nres_fxid_payload( - document: &NresDocument, - entry: &fparkan_nres::NresEntry, -) -> Result<(), String> { - let payload = document - .payload(entry.id()) - .map_err(|err| err.to_string())?; - decode_fxid(Arc::from(payload.to_vec().into_boxed_slice())).map_err(|err| err.to_string())?; - Ok(()) -} - -fn inspect_rsli_metrics(bytes: &[u8]) -> Result<(), String> { - let _ = decode_rsli( - Arc::from(bytes.to_vec().into_boxed_slice()), - ReadProfile::Compatible, - ) - .map_err(|err| err.to_string())?; - Ok(()) -} - -fn inspect_tma_metrics(bytes: &[u8]) -> Result<(), String> { - let _ = decode_tma( - Arc::from(bytes.to_vec().into_boxed_slice()), - TmaProfile::Strict, - ) - .map_err(|err| err.to_string())?; - Ok(()) -} - -fn inspect_unit_dat_metrics(bytes: &[u8]) -> Result<(), String> { - if decode_unit_dat(bytes).is_err() && decode_unit_dat_binding(bytes).is_err() { - return Err("failed to parse unit.dat payload as unit or binding format".to_string()); - } - Ok(()) -} - -fn inspect_land_metrics(bytes: &[u8], is_map: bool) -> Result<(), String> { - let document = inspect_nres_document(bytes)?; - if is_map { - decode_land_map(&document).map_err(|err| err.to_string())?; - } else { - decode_land_msh(&document).map_err(|err| err.to_string())?; - } - Ok(()) -} - -fn inspect_nres_document(bytes: &[u8]) -> Result<NresDocument, String> { - fparkan_nres::decode( - Arc::from(bytes.to_vec().into_boxed_slice()), - fparkan_nres::ReadProfile::Compatible, - ) - .map_err(|err| err.to_string()) -} - -fn bump(metrics: &mut BTreeMap<String, u64>, key: &str, delta: u64) { - if let Some(value) = metrics.get_mut(key) { - *value = value.saturating_add(delta); - } -} - -fn has_extension(path: &str, expected: &str) -> bool { - Path::new(path) - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case(expected)) -} - -/// Computes stable manifest fingerprint. -#[must_use] -pub fn fingerprint(manifest: &CorpusManifest) -> Sha256Digest { - let mut bytes = Vec::new(); - for file in &manifest.files { - bytes.extend_from_slice(file.path.as_bytes()); - bytes.push(0); - bytes.extend_from_slice(&file.size.to_le_bytes()); - bytes.extend_from_slice(&file.hash); - } - sha256(&bytes) -} - -/// Writes report atomically. -/// -/// # Errors -/// -/// Returns [`CorpusError`] if the parent directory, temporary file, write, or -/// final rename operation fails. -pub fn write_report_atomic(path: &Path, report: &CorpusReport) -> Result<(), CorpusError> { - let tmp = path.with_extension("tmp"); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|source| CorpusError::Io { - path: parent.to_path_buf(), - source, - })?; - } - let mut file = fs::File::create(&tmp).map_err(|source| CorpusError::Io { - path: tmp.clone(), - source, - })?; - file.write_all(render_report_json(report).as_bytes()) - .map_err(|source| CorpusError::Io { - path: tmp.clone(), - source, - })?; - file.sync_all().map_err(|source| CorpusError::Io { - path: tmp.clone(), - source, - })?; - fs::rename(&tmp, path).map_err(|source| CorpusError::Io { - path: path.to_path_buf(), - source, - })?; - Ok(()) -} - -/// Renders report JSON. -#[must_use] -pub fn render_report_json(report: &CorpusReport) -> String { - let mut out = format!( - "{{\"schema_version\":\"fparkan-corpus-report-v1\",\"schema\":{},\"kind\":\"{:?}\",\"files\":{},\"bytes\":{},\"casefold_collisions\":{},\"fingerprint\":\"{}\",\"failures\":{},\"record_count\":{},\"metrics\":{{", - report.schema, - report.kind, - report.files, - report.bytes, - report.casefold_collisions, - sha256_hex(&report.fingerprint), - report.failures, - report.records.len() - ); - for (idx, (key, value)) in report.metrics.iter().enumerate() { - if idx > 0 { - out.push(','); - } - out.push('"'); - out.push_str(key); - out.push_str("\":"); - out.push_str(&value.to_string()); - } - out.push_str("}}"); - out.push('}'); - out -} - -#[cfg(test)] -mod tests { - use super::*; - use fparkan_path::join_under; - use std::time::{SystemTime, UNIX_EPOCH}; - - #[test] - #[ignore = "requires licensed corpus"] - fn report_for_testdata_roots() { - let root = licensed_root("IS"); - let manifest = discover(&root, DiscoverOptions::default()).expect("manifest"); - let report = report(&root, &manifest).expect("report"); - assert!(report.files > 0); - assert!(report.metrics["nres_files"] > 0); - } - - #[test] - #[ignore = "requires licensed corpus"] - fn licensed_part1_manifest_profile_and_counts_match_baseline() { - let root = testdata_root("IS"); - let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest"); - let report = report(&root, &manifest).expect("report"); - - assert_eq!(manifest.kind, CorpusKind::Part1); - assert_eq!(report.files, 1_017); - assert_eq!(report.metrics["nres_files"], 120); - assert_eq!(report.metrics["rsli_files"], 2); - assert_eq!(report.metrics["tma_files"], 29); - assert_eq!(report.metrics["land_msh_files"], 33); - assert_eq!(report.metrics["land_map_files"], 33); - assert_eq!(report.metrics["unit_dat_files"], 425); - } - - #[test] - #[ignore = "requires licensed corpus"] - fn licensed_part2_manifest_profile_and_counts_match_baseline() { - let root = testdata_root("IS2"); - let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest"); - let report = report(&root, &manifest).expect("report"); - - assert_eq!(manifest.kind, CorpusKind::Part2); - assert_eq!(report.files, 1_302); - assert_eq!(report.metrics["nres_files"], 134); - assert_eq!(report.metrics["rsli_files"], 2); - assert_eq!(report.metrics["tma_files"], 31); - assert_eq!(report.metrics["land_msh_files"], 32); - assert_eq!(report.metrics["land_map_files"], 32); - assert_eq!(report.metrics["unit_dat_files"], 676); - } - - #[test] - #[ignore = "requires licensed corpus"] - fn licensed_part1_has_no_casefold_relative_path_collisions() { - let root = testdata_root("IS"); - let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest"); - - assert!(manifest.casefold_collisions.is_empty()); - } - - #[test] - #[ignore = "requires licensed corpus"] - fn licensed_part2_has_no_casefold_relative_path_collisions() { - let root = testdata_root("IS2"); - let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest"); - - assert!(manifest.casefold_collisions.is_empty()); - } - - #[test] - #[ignore = "requires licensed corpus"] - fn licensed_part1_paths_stay_under_root() { - assert_discovered_paths_stay_under_root("IS"); - } - - #[test] - #[ignore = "requires licensed corpus"] - fn licensed_part2_paths_stay_under_root() { - assert_discovered_paths_stay_under_root("IS2"); - } - - #[test] - fn report_json_contains_metrics_and_hashes_not_paths_or_payloads() { - let manifest = CorpusManifest { - kind: CorpusKind::Part1, - files: vec![manifest_entry("secret/payload.bin", 4, sha256(b"DATA"))], - casefold_collisions: Vec::new(), - }; - let report = report(Path::new("."), &manifest).expect("report"); - let json = render_report_json(&report); - - assert!(json.contains("\"schema_version\":\"fparkan-corpus-report-v1\"")); - assert!(json.contains("\"fingerprint\":")); - assert!(json.contains("\"failures\":1")); - assert!(json.contains("\"record_count\":1")); - assert!(json.contains("\"metrics\":")); - assert!(!json.contains("secret/payload.bin")); - assert!(!json.contains("DATA")); - } - - #[test] - fn report_records_missing_manifest_files_as_failures() { - let root = temp_dir("report-missing"); - let manifest = CorpusManifest { - kind: CorpusKind::Unknown, - files: vec![manifest_entry("missing.lib", 1, sha256(b"missing"))], - casefold_collisions: Vec::new(), - }; - - let report = report(&root, &manifest).expect("report"); - - assert_eq!(report.failures, 1); - assert_eq!(report.records.len(), 1); - assert_eq!(report.records[0].path, "missing.lib"); - assert_eq!(report.records[0].status, CorpusFileStatus::Error); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn report_records_malformed_nres_as_failure() { - let root = temp_dir("report-bad-nres"); - fs::write(root.join("bad.lib"), b"NRes").expect("bad nres"); - let manifest = CorpusManifest { - kind: CorpusKind::Unknown, - files: vec![manifest_entry("bad.lib", 4, sha256(b"NRes"))], - casefold_collisions: Vec::new(), - }; - - let report = report(&root, &manifest).expect("report"); - - assert_eq!(report.failures, 1); - assert_eq!(report.records[0].status, CorpusFileStatus::Error); - assert_eq!(report.records[0].variant, "nres"); - assert!(report.records[0] - .message - .as_deref() - .is_some_and(|message| message.contains("NRes"))); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn report_uses_production_nres_parser_for_entry_metrics() { - let root = temp_dir("report-nres"); - let archive = build_nres(&[ - TestNresEntry { - name: "mesh.msh", - type_id: 0, - payload: b"mesh", - }, - TestNresEntry { - name: "mat.bin", - type_id: 0x3054_414D, - payload: b"mat0", - }, - TestNresEntry { - name: "texture.bin", - type_id: 0x6D78_6554, - payload: b"texm", - }, - ]); - fs::write(root.join("archive.lib"), &archive).expect("archive"); - let manifest = CorpusManifest { - kind: CorpusKind::Unknown, - files: vec![manifest_entry( - "archive.lib", - u64::try_from(archive.len()).expect("archive size"), - sha256(&archive), - )], - casefold_collisions: Vec::new(), - }; - - let report = report(&root, &manifest).expect("report"); - - assert_eq!(report.failures, 1); - assert_eq!(report.records.len(), 1); - assert_eq!(report.records[0].status, CorpusFileStatus::Error); - assert_eq!(report.records[0].variant, "nres"); - assert_eq!(report.metrics["nres_files"], 1); - assert_eq!(report.metrics["nres_entries"], 3); - assert_eq!(report.metrics["msh_entries"], 1); - assert_eq!(report.metrics["mat0_entries"], 0); - assert_eq!(report.metrics["texm_entries"], 0); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn report_land_map_paths_use_production_land_parser() { - let root = temp_dir("report-land-map"); - fs::create_dir_all(root.join("WORLD/MAP")).expect("land map dir"); - fs::write(root.join("WORLD/MAP/land.map"), build_nres(&[])).expect("land map"); - let manifest = CorpusManifest { - kind: CorpusKind::Unknown, - files: vec![manifest_entry( - "WORLD/MAP/land.map", - 16, - sha256(b"land.map"), - )], - casefold_collisions: Vec::new(), - }; - - let report = report(&root, &manifest).expect("report"); - - assert_eq!(report.failures, 1); - assert_eq!(report.records[0].status, CorpusFileStatus::Error); - assert_eq!(report.records[0].variant, "land_map"); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn report_land_msh_paths_use_production_land_parser() { - let root = temp_dir("report-land-msh"); - fs::create_dir_all(root.join("WORLD/MAP")).expect("land msh dir"); - fs::write(root.join("WORLD/MAP/land.msh"), build_nres(&[])).expect("land msh"); - let manifest = CorpusManifest { - kind: CorpusKind::Unknown, - files: vec![manifest_entry( - "WORLD/MAP/land.msh", - 16, - sha256(b"land.msh"), - )], - casefold_collisions: Vec::new(), - }; - - let report = report(&root, &manifest).expect("report"); - - assert_eq!(report.failures, 1); - assert_eq!(report.records[0].status, CorpusFileStatus::Error); - assert_eq!(report.records[0].variant, "land_msh"); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn report_tma_paths_use_production_tma_parser() { - let root = temp_dir("report-tma"); - fs::create_dir_all(root.join("MISSIONS/test")).expect("tma dir"); - fs::write(root.join("MISSIONS/test/data.tma"), b"malformed tma").expect("tma"); - let manifest = CorpusManifest { - kind: CorpusKind::Unknown, - files: vec![manifest_entry( - "MISSIONS/test/data.tma", - 12, - sha256(b"malformed tma"), - )], - casefold_collisions: Vec::new(), - }; - - let report = report(&root, &manifest).expect("report"); - - assert_eq!(report.failures, 1); - assert_eq!(report.records[0].status, CorpusFileStatus::Error); - assert_eq!(report.records[0].variant, "tma"); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn report_unit_dat_paths_use_production_unit_parser() { - let root = temp_dir("report-unit"); - fs::create_dir_all(root.join("units")).expect("unit dir"); - fs::write(root.join("units/unit.dat"), vec![0u8; 120]).expect("unit"); - let manifest = CorpusManifest { - kind: CorpusKind::Unknown, - files: vec![manifest_entry("units/unit.dat", 120, sha256(&[0u8; 120]))], - casefold_collisions: Vec::new(), - }; - - let report = report(&root, &manifest).expect("report"); - - assert_eq!(report.failures, 0); - assert_eq!(report.records[0].status, CorpusFileStatus::Ok); - assert_eq!(report.records[0].variant, "unit_dat"); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn report_rsli_paths_use_production_rsli_parser() { - let root = temp_dir("report-rsli"); - fs::write(root.join("patch.nl"), b"NL malformed").expect("rsli"); - let manifest = CorpusManifest { - kind: CorpusKind::Unknown, - files: vec![manifest_entry("patch.nl", 12, sha256(b"NL malformed"))], - casefold_collisions: Vec::new(), - }; - - let report = report(&root, &manifest).expect("report"); - - assert_eq!(report.failures, 1); - assert_eq!(report.records[0].status, CorpusFileStatus::Error); - assert_eq!(report.records[0].variant, "rsli"); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn deterministic_traversal_is_creation_order_independent() { - let first = temp_dir("order-first"); - let second = temp_dir("order-second"); - fs::create_dir_all(first.join("nested")).expect("first nested"); - fs::create_dir_all(second.join("nested")).expect("second nested"); - - fs::write(first.join("b.bin"), b"b").expect("first b"); - fs::write(first.join("nested").join("a.bin"), b"a").expect("first a"); - fs::write(second.join("nested").join("a.bin"), b"a").expect("second a"); - fs::write(second.join("b.bin"), b"b").expect("second b"); - - let first_manifest = discover(&first, DiscoverOptions::default()).expect("first manifest"); - let second_manifest = - discover(&second, DiscoverOptions::default()).expect("second manifest"); - - assert_eq!(first_manifest.files, second_manifest.files); - let _ = fs::remove_dir_all(first); - let _ = fs::remove_dir_all(second); - } - - #[cfg(unix)] - #[test] - fn unreadable_directory_produces_error() { - use std::os::unix::fs::PermissionsExt; - - let root = temp_dir("unreadable"); - let child = root.join("locked"); - fs::create_dir_all(&child).expect("locked dir"); - fs::set_permissions(&child, fs::Permissions::from_mode(0o000)).expect("lock dir"); - - let result = discover(&root, DiscoverOptions::default()); - - fs::set_permissions(&child, fs::Permissions::from_mode(0o700)).expect("unlock dir"); - let _ = fs::remove_dir_all(root); - assert!(matches!(result, Err(CorpusError::Io { path, .. }) if path.ends_with("locked"))); - } - - #[cfg(unix)] - #[test] - fn symlink_loop_is_not_traversed_by_default() { - use std::os::unix::fs::symlink; - - let root = temp_dir("symlink-loop"); - fs::write(root.join("real.bin"), b"real").expect("real file"); - symlink(&root, root.join("loop")).expect("loop symlink"); - - let manifest = discover(&root, DiscoverOptions::default()).expect("manifest"); - - assert_eq!(manifest.files.len(), 1); - assert_eq!(manifest.files[0].path, "real.bin"); - let _ = fs::remove_dir_all(root); - } - - #[test] - fn casefold_collisions_are_registered() { - let manifest = CorpusManifest { - kind: CorpusKind::Unknown, - files: vec![ - manifest_entry("Textures/Foo.TEX", 1, sha256(b"first")), - manifest_entry("textures/foo.tex", 1, sha256(b"second")), - ], - casefold_collisions: Vec::new(), - }; - - let collisions = detect_casefold_collisions(&manifest.files); - - assert_eq!( - collisions, - vec![vec![ - "Textures/Foo.TEX".to_string(), - "textures/foo.tex".to_string() - ]] - ); - } - - #[test] - fn fingerprint_changes() { - let mut manifest = CorpusManifest { - kind: CorpusKind::Unknown, - files: vec![manifest_entry("a", 1, sha256(b"before"))], - casefold_collisions: Vec::new(), - }; - let a = fingerprint(&manifest); - manifest.files[0].hash = sha256(b"after"); - assert_ne!(a, fingerprint(&manifest)); - } - - #[test] - fn atomic_report_write() { - let tmp = std::env::temp_dir().join(format!( - "fparkan-report-{}.json", - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos() - )); - let report = CorpusReport { - schema: 1, - kind: CorpusKind::Unknown, - files: 0, - bytes: 0, - metrics: BTreeMap::new(), - casefold_collisions: 0, - fingerprint: sha256(b"empty-report"), - records: Vec::new(), - failures: 0, - }; - write_report_atomic(&tmp, &report).expect("write"); - assert!(tmp.is_file()); - let _ = fs::remove_file(tmp); - } - - #[cfg(unix)] - #[test] - fn discover_supports_non_utf8_host_paths() { - use std::ffi::OsString; - use std::os::unix::ffi::OsStringExt; - - let root = temp_dir("non-utf8"); - let file_name = OsString::from_vec(vec![0xFF, b'.', b'b', b'i', b'n']); - let file_path = root.join(&file_name); - if let Err(err) = fs::write(&file_path, b"raw") { - assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied); - let _ = fs::remove_dir_all(root); - return; - } - - let manifest = discover(&root, DiscoverOptions::default()).expect("manifest"); - - assert_eq!(manifest.files.len(), 1); - assert_eq!(manifest.files[0].path, "\u{FFFD}.bin"); - assert_eq!(manifest.files[0].host_rel_path, PathBuf::from(&file_name)); - let _ = fs::remove_dir_all(root); - } - - struct TestNresEntry<'a> { - name: &'a str, - type_id: u32, - payload: &'a [u8], - } - - fn build_nres(entries: &[TestNresEntry<'_>]) -> Vec<u8> { - let mut out = vec![0; 16]; - let mut offsets = Vec::with_capacity(entries.len()); - for entry in entries { - offsets.push(u32::try_from(out.len()).expect("offset")); - out.extend_from_slice(entry.payload); - let padding = (8 - (out.len() % 8)) % 8; - out.resize(out.len() + padding, 0); - } - let mut order: Vec<usize> = (0..entries.len()).collect(); - order.sort_by(|left, right| { - entries[*left] - .name - .as_bytes() - .cmp(entries[*right].name.as_bytes()) - }); - for (index, entry) in entries.iter().enumerate() { - push_u32(&mut out, entry.type_id); - push_u32(&mut out, 0); - push_u32(&mut out, 0); - push_u32( - &mut out, - u32::try_from(entry.payload.len()).expect("payload size"), - ); - push_u32(&mut out, 0); - let mut name = [0; 36]; - let name_bytes = entry.name.as_bytes(); - name[..name_bytes.len()].copy_from_slice(name_bytes); - out.extend_from_slice(&name); - push_u32(&mut out, offsets[index]); - push_u32(&mut out, u32::try_from(order[index]).expect("sort index")); - } - out[0..4].copy_from_slice(b"NRes"); - out[4..8].copy_from_slice(&0x100_u32.to_le_bytes()); - out[8..12].copy_from_slice(&u32::try_from(entries.len()).expect("count").to_le_bytes()); - let total_size = u32::try_from(out.len()).expect("total size"); - out[12..16].copy_from_slice(&total_size.to_le_bytes()); - out - } - - fn manifest_entry(path: &str, size: u64, hash: Sha256Digest) -> ManifestEntry { - ManifestEntry { - path: path.to_string(), - host_rel_path: PathBuf::from(path), - size, - hash, - } - } - - fn push_u32(out: &mut Vec<u8>, value: u32) { - out.extend_from_slice(&value.to_le_bytes()); - } - - fn temp_dir(name: &str) -> PathBuf { - let path = std::env::temp_dir().join(format!( - "fparkan-corpus-{name}-{}", - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos() - )); - fs::create_dir_all(&path).expect("temp dir"); - path - } - - fn testdata_root(part: &str) -> PathBuf { - licensed_root(part) - } - - fn licensed_root(part: &str) -> PathBuf { - let variable = match part { - "IS" => "FPARKAN_CORPUS_PART1_ROOT", - "IS2" => "FPARKAN_CORPUS_PART2_ROOT", - _ => panic!("unknown licensed corpus part: {part}"), - }; - let root = std::env::var_os(variable) - .map(PathBuf::from) - .unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests")); - assert!( - root.is_dir(), - "licensed corpus root is missing: {}", - root.display() - ); - root - } - - fn assert_discovered_paths_stay_under_root(part: &str) { - let root = testdata_root(part); - let manifest = discover(&root, DiscoverOptions::default()).expect("licensed manifest"); - - for entry in &manifest.files { - let normalized = normalize_relative(entry.path.as_bytes(), PathPolicy::HostCompatible) - .expect("discovered path should re-normalize"); - let joined = join_under(&root, &normalized).expect("discovered path should join"); - assert!( - joined.starts_with(&root), - "discovered path escaped root: {}", - entry.path - ); - } - } -} diff --git a/crates/fparkan-diagnostics/Cargo.toml b/crates/fparkan-diagnostics/Cargo.toml deleted file mode 100644 index 59b8273..0000000 --- a/crates/fparkan-diagnostics/Cargo.toml +++ /dev/null @@ -1,13 +0,0 @@ -[package] -name = "fparkan-diagnostics" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" - -[lints] -workspace = true diff --git a/crates/fparkan-diagnostics/src/lib.rs b/crates/fparkan-diagnostics/src/lib.rs deleted file mode 100644 index f0228ec..0000000 --- a/crates/fparkan-diagnostics/src/lib.rs +++ /dev/null @@ -1,249 +0,0 @@ -#![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] -//! Structured diagnostics shared by `FParkan` crates. - -use serde::Serialize; - -/// Diagnostic severity. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum Severity { - /// Informational note. - Info, - /// Recoverable warning. - Warning, - /// Error for the current operation. - Error, - /// Fatal error for the current run. - Fatal, -} - -/// Evidence level for a contract or interpretation. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum EvidenceStatus { - /// Described by project documentation. - Documented, - /// Verified by synthetic fixtures. - SyntheticVerified, - /// Verified against the licensed corpus. - CorpusVerified, - /// Verified by runtime capture. - RuntimeCaptured, - /// Working hypothesis; not a runtime contract. - Hypothesis, -} - -/// Operation phase where a diagnostic was produced. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "lowercase")] -pub enum Phase { - /// Discovery. - Discover, - /// Read. - Read, - /// Parse. - Parse, - /// Validate. - Validate, - /// Resolve. - Resolve, - /// Prepare. - Prepare, - /// Construct. - Construct, - /// Register. - Register, - /// Simulate. - Simulate, - /// Render. - Render, -} - -/// Byte span in an input source. -#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] -pub struct SourceSpan { - /// Start offset. - pub offset: u64, - /// Length in bytes. - pub length: u64, -} - -/// Stable diagnostic code. -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)] -pub struct DiagnosticCode(pub &'static str); - -/// Context attached to a diagnostic. -#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)] -pub struct DiagnosticContext { - /// Phase. - #[serde(skip_serializing_if = "Option::is_none")] - pub phase: Option<Phase>, - /// Redacted or logical path. - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option<String>, - /// Archive entry name. - #[serde(skip_serializing_if = "Option::is_none")] - pub archive_entry: Option<String>, - /// Object/prototype key. - #[serde(skip_serializing_if = "Option::is_none")] - pub object_key: Option<String>, - /// Input span. - #[serde(skip_serializing_if = "Option::is_none")] - pub span: Option<SourceSpan>, -} - -/// Structured diagnostic with cause chain. -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -pub struct Diagnostic { - /// Stable code. - pub code: DiagnosticCode, - /// Severity. - pub severity: Severity, - /// Human message. - pub message: String, - /// Context. - pub context: DiagnosticContext, - /// Causes. - pub causes: Vec<Diagnostic>, -} - -/// Creates a diagnostic with default error severity. -#[must_use] -pub fn diagnostic(code: DiagnosticCode, message: impl Into<String>) -> Diagnostic { - Diagnostic { - code, - severity: Severity::Error, - message: message.into(), - context: DiagnosticContext::default(), - causes: Vec::new(), - } -} - -impl Diagnostic { - /// Returns a copy with severity changed. - #[must_use] - pub fn with_severity(mut self, severity: Severity) -> Self { - self.severity = severity; - self - } - - /// Returns a copy with context changed. - #[must_use] - pub fn with_context(mut self, context: DiagnosticContext) -> Self { - self.context = context; - self - } - - /// Adds a cause. - pub fn push_cause(&mut self, cause: Diagnostic) { - self.causes.push(cause); - } -} - -/// Renders a compact human-readable diagnostic. -#[must_use] -pub fn render_human(diagnostic: &Diagnostic) -> String { - let mut out = format!( - "{:?} {}: {}", - diagnostic.severity, diagnostic.code.0, diagnostic.message - ); - if let Some(path) = &diagnostic.context.path { - out.push_str(" ["); - out.push_str(path); - out.push(']'); - } - out -} - -/// Renders deterministic JSON using the typed diagnostic schema. -#[must_use] -pub fn render_json(diagnostic: &Diagnostic) -> String { - match serde_json::to_string(diagnostic) { - Ok(json) => json, - Err(err) => format!("{{\"error\":\"diagnostic serialization failed: {err}\"}}"), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn json_is_stable() { - let d = diagnostic(DiagnosticCode("S0-DIAG-001"), "keeps context").with_context( - DiagnosticContext { - phase: Some(Phase::Parse), - ..DiagnosticContext::default() - }, - ); - assert_eq!( - render_json(&d), - "{\"code\":\"S0-DIAG-001\",\"severity\":\"error\",\"message\":\"keeps context\",\"context\":{\"phase\":\"parse\"},\"causes\":[]}" - ); - } - - #[test] - fn diagnostic_chain_preserves_context() { - let mut root = diagnostic(DiagnosticCode("ROOT"), "root").with_context(DiagnosticContext { - phase: Some(Phase::Resolve), - path: Some("archives/material.lib".to_string()), - archive_entry: Some("MATERIAL.MAT0".to_string()), - object_key: Some("unit/tank".to_string()), - span: Some(SourceSpan { - offset: 12, - length: 4, - }), - }); - root.push_cause(diagnostic(DiagnosticCode("CAUSE"), "cause").with_context( - DiagnosticContext { - phase: Some(Phase::Parse), - path: Some("archives/material.lib".to_string()), - span: Some(SourceSpan { - offset: 16, - length: 8, - }), - ..DiagnosticContext::default() - }, - )); - - let json = render_json(&root); - - assert!(json.contains("\"code\":\"ROOT\"")); - assert!(json.contains("\"phase\":\"resolve\"")); - assert!(json.contains("\"path\":\"archives/material.lib\"")); - assert!(json.contains("\"archive_entry\":\"MATERIAL.MAT0\"")); - assert!(json.contains("\"object_key\":\"unit/tank\"")); - assert!(json.contains("\"span\":{\"offset\":12,\"length\":4}")); - assert!(json.contains("\"code\":\"CAUSE\"")); - assert!(json.contains("\"span\":{\"offset\":16,\"length\":8}")); - } - - #[test] - fn json_escapes_all_control_characters() { - let value = diagnostic(DiagnosticCode("S1-H01"), "quote\"\u{0000}tab\tline\r\n"); - let json = render_json(&value); - assert!(json.contains("\\u0000")); - assert!(json.contains("\\t")); - assert!(!json.contains('\t')); - assert!(!json.contains('\r')); - } -} diff --git a/crates/fparkan-fx/Cargo.toml b/crates/fparkan-fx/Cargo.toml index a8cf963..89672ec 100644 --- a/crates/fparkan-fx/Cargo.toml +++ b/crates/fparkan-fx/Cargo.toml @@ -6,10 +6,7 @@ license.workspace = true repository.workspace = true [dependencies] -fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" } +fparkan-binary = { path = "../fparkan-binary" } [dev-dependencies] -fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" } - -[lints] -workspace = true +fparkan-nres = { path = "../fparkan-nres" } diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs index 44b1a59..3fa4aae 100644 --- a/crates/fparkan-fx/src/lib.rs +++ b/crates/fparkan-fx/src/lib.rs @@ -1,23 +1,4 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] //! FXID effect contracts. //! //! FXID decoding and command framing are implemented as compatibility diff --git a/crates/fparkan-inspection/Cargo.toml b/crates/fparkan-inspection/Cargo.toml deleted file mode 100644 index 240f34d..0000000 --- a/crates/fparkan-inspection/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -name = "fparkan-inspection" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] -fparkan-diagnostics = { path = "../fparkan-diagnostics", version = "0.1.0" } -fparkan-msh = { path = "../fparkan-msh", version = "0.1.0" } -fparkan-material = { path = "../fparkan-material", version = "0.1.0" } -fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" } -fparkan-path = { path = "../fparkan-path", version = "0.1.0" } -fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" } -fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" } -fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" } -fparkan-texm = { path = "../fparkan-texm", version = "0.1.0" } -fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" } - -[lints] -workspace = true diff --git a/crates/fparkan-inspection/src/lib.rs b/crates/fparkan-inspection/src/lib.rs deleted file mode 100644 index be1f098..0000000 --- a/crates/fparkan-inspection/src/lib.rs +++ /dev/null @@ -1,1009 +0,0 @@ -#![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] -//! Shared inspection helpers for format-backed tooling. - -use fparkan_diagnostics::{ - diagnostic, render_human, Diagnostic, DiagnosticCode, DiagnosticContext, Phase, SourceSpan, -}; -use fparkan_material::{decode_wear, resolve_material, MaterialFallback}; -use fparkan_msh::{ - decode_msh, node38_metadata, selected_slot, validate_msh, Group, Lod, ModelAsset, NodeId, -}; -use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile}; -use fparkan_path::{normalize_relative, PathPolicy}; -use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository}; -use fparkan_rsli::decode as decode_rsli; -use fparkan_terrain_format::{decode_land_map, decode_land_msh, LandMeshDocument}; -use fparkan_texm::decode_texm; -use fparkan_vfs::{DirectoryVfs, Vfs}; -use std::fs; -#[cfg(unix)] -use std::os::unix::ffi::OsStrExt; -use std::path::Path; -use std::sync::Arc; - -/// Archive inspection variants. -#[derive(Clone, Debug, Eq, PartialEq)] -pub enum ArchiveInspection { - /// `NRes` inspection summary. - Nres { - /// Archive entry count. - entries: usize, - /// Lookup order validity. - lookup_order_valid: bool, - /// Entry samples (subject to request limit). - sample: Vec<NresEntrySummary>, - }, - /// `RsLi` inspection summary. - Rsli { - /// Archive entry count. - entries: usize, - }, - /// Unknown/unsupported archive magic. - Unsupported, -} - -/// `NRes` entry summary. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct NresEntrySummary { - /// ASCII/legacy resource name. - pub name: String, - /// Entry type identifier. - pub type_id: u32, - /// Declared entry payload size. - pub data_size: u32, -} - -/// Model inspection payload. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct ModelInspection { - /// Terrain stream/document stream count. - pub streams: usize, - /// Node count. - pub nodes: usize, - /// Slot count. - pub slots: usize, - /// Position count. - pub positions: usize, - /// Index count. - pub indices: usize, - /// Batch count. - pub batches: usize, - /// Original node record stride. - pub node_stride: usize, - /// Standard-node metadata in source order, when the model uses `Node38`. - pub node38: Vec<Node38Inspection>, - /// Number of decoded type-8 animation keys, when available. - pub animation_keys: Option<usize>, - /// Declared type-19 animation frame count, when available. - pub animation_frame_count: Option<u32>, -} - -/// Inspection view of a standard 38-byte model node. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct Node38Inspection { - /// Source node index. - pub index: usize, - /// Opaque source field at byte offset two. - pub parent_or_link_raw: u16, - /// Type-19 frame-map offset, or `0xFFFF`. - pub anim_map_start: u16, - /// Type-8 fallback key index. - pub fallback_key: u16, - /// Whether LOD zero/group zero selects a geometry slot. - pub has_lod0_group0: bool, -} - -/// Texture inspection payload. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct TextureInspection { - /// Width. - pub width: u32, - /// Height. - pub height: u32, - /// Texture format debug text. - pub format: String, - /// Mip level count. - pub mips: usize, - /// Total page rectangles. - pub pages: usize, -} - -/// Diffuse TEXM selected through an original WEAR and MAT0 material chain. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct WearMaterialTexture { - /// Source WEAR archive. - pub wear_archive: String, - /// Source WEAR resource. - pub wear_resource: String, - /// Positional WEAR selector used by an MSH batch. - pub material_index: u16, - /// Resolved MAT0 resource name after the original fallback chain. - pub material_name: String, - /// Fallback route used while resolving the MAT0 resource. - pub material_fallback: MaterialFallback, - /// Texture name selected from phase zero of the resolved MAT0 document. - pub texture_name: String, - /// Decoded RGBA8 mip zero suitable for the Vulkan upload boundary. - pub image: fparkan_texm::RgbaImage, -} - -/// Compact inspection summary of a WEAR resource stored in an archive. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct WearInspection { - /// Number of material rows. - pub materials: usize, - /// Number of lightmap rows. - pub lightmaps: usize, - /// First material resource name, when present. - pub first_material: Option<String>, - /// Last material resource name, when present. - pub last_material: Option<String>, -} - -/// Land map/msh inspection payload. -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct MapInspection { - /// Mapped mesh stream count. - pub streams: usize, - /// Slot count. - pub slots: usize, - /// Position count. - pub positions: usize, - /// Face count. - pub faces: usize, - /// Terrain areals. - pub areals: usize, - /// Declared areal count from map metadata. - pub declared_areals: u32, - /// Map grid width. - pub grid_width: u32, - /// Map grid height. - pub grid_height: u32, -} - -/// Axis-aligned position bounds of a decoded `Land.msh`. -#[derive(Clone, Debug, PartialEq)] -pub struct LandMeshBoundsInspection { - /// Number of source positions covered by the bounds. - pub positions: usize, - /// Per-axis inclusive minimum position. - pub min: [f32; 3], - /// Per-axis inclusive maximum position. - pub max: [f32; 3], -} - -/// Supported land file kinds. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum LandFileKind { - /// `land.msh` payload. - LandMsh, - /// `land.map` payload. - LandMap, -} - -/// Inspects a format archive. -/// -/// # Errors -/// -/// Returns a string error when the archive cannot be read or decoded. -pub fn inspect_archive_file(path: &Path, sample_limit: usize) -> Result<ArchiveInspection, String> { - inspect_archive_file_diagnostic(path, sample_limit) - .map_err(|diagnostic| render_human(&diagnostic)) -} - -/// Inspects a format archive and returns a structured diagnostic on failure. -/// -/// # Errors -/// -/// Returns a [`Diagnostic`] when the archive cannot be read or decoded. -// Diagnostic is deliberately returned by value as the public structured-error contract. -#[allow(clippy::result_large_err)] -pub fn inspect_archive_file_diagnostic( - path: &Path, - sample_limit: usize, -) -> Result<ArchiveInspection, Diagnostic> { - let parent = path.parent().unwrap_or_else(|| Path::new(".")); - let file_name = path.file_name().ok_or_else(|| { - diagnostic( - DiagnosticCode("S1.VFS.PATH"), - format!("{}: archive path has no file name", path.display()), - ) - .with_context(DiagnosticContext { - phase: Some(Phase::Read), - path: Some(path.display().to_string()), - ..DiagnosticContext::default() - }) - })?; - #[cfg(unix)] - let raw_name = file_name.as_bytes(); - #[cfg(not(unix))] - let raw_name = file_name - .to_str() - .ok_or_else(|| { - diagnostic( - DiagnosticCode("S1.VFS.PATH"), - format!("{}: archive file name is not valid text", path.display()), - ) - .with_context(DiagnosticContext { - phase: Some(Phase::Read), - path: Some(path.display().to_string()), - ..DiagnosticContext::default() - }) - })? - .as_bytes(); - let normalized = normalize_relative(raw_name, PathPolicy::HostCompatible).map_err(|err| { - diagnostic( - DiagnosticCode("S1.VFS.PATH"), - format!("{}: {err}", path.display()), - ) - .with_context(DiagnosticContext { - phase: Some(Phase::Read), - path: Some(path.display().to_string()), - ..DiagnosticContext::default() - }) - })?; - let vfs = DirectoryVfs::new(parent); - let bytes = vfs.read(&normalized).map_err(|err| { - diagnostic( - DiagnosticCode("S1.VFS.READ"), - format!("{}: {err}", path.display()), - ) - .with_context(DiagnosticContext { - phase: Some(Phase::Read), - path: Some(path.display().to_string()), - ..DiagnosticContext::default() - }) - })?; - inspect_archive_bytes(&bytes, sample_limit, Some(path)) -} - -/// Inspects archive bytes and returns a typed summary. -// Keeps the internal diagnostic flow aligned with the public structured-error contract. -#[allow(clippy::result_large_err)] -fn inspect_archive_bytes( - bytes: &[u8], - sample_limit: usize, - source: Option<&Path>, -) -> Result<ArchiveInspection, Diagnostic> { - if bytes.starts_with(b"NRes") { - let document = decode_nres( - Arc::from(bytes.to_vec().into_boxed_slice()), - ReadProfile::Compatible, - ) - .map_err(|err| { - archive_parse_diagnostic("S1.NRES.DECODE", source, bytes, err.to_string()) - })?; - let mut sample = Vec::new(); - for entry in document.entries().iter().take(sample_limit) { - sample.push(NresEntrySummary { - name: String::from_utf8_lossy(entry.name_bytes()).to_string(), - type_id: entry.meta().type_id, - data_size: entry.meta().data_size, - }); - } - Ok(ArchiveInspection::Nres { - entries: document.entries().len(), - lookup_order_valid: document.lookup_order_valid(), - sample, - }) - } else if bytes.get(0..4) == Some(b"NL\0\x01") { - let document = decode_rsli( - Arc::from(bytes.to_vec().into_boxed_slice()), - fparkan_rsli::ReadProfile::Compatible, - ) - .map_err(|err| { - archive_parse_diagnostic("S1.RSLI.DECODE", source, bytes, err.to_string()) - })?; - Ok(ArchiveInspection::Rsli { - entries: document.entries().len(), - }) - } else { - Err(archive_parse_diagnostic( - "S1.RESOURCE.UNSUPPORTED_ARCHIVE", - source, - bytes, - "unsupported archive magic".to_string(), - )) - } -} - -/// Inspects a model through repository-backed resource lookup. -/// -/// # Errors -/// -/// Returns a string error when the resource cannot be resolved or parsed as a -/// valid model payload. -pub fn inspect_model_from_root( - root: &Path, - archive: &str, - resource: &str, -) -> Result<ModelInspection, String> { - let bytes = read_resource_bytes_diagnostic(root, archive, resource) - .map_err(|err| render_human(&err))?; - let document = decode_nres(bytes.clone(), ReadProfile::Compatible).map_err(|err| { - render_human(&resource_parse_diagnostic( - "S1.NRES.DECODE", - archive, - resource, - &bytes, - err.to_string(), - )) - })?; - let msh = decode_msh(&document).map_err(|err| err.to_string())?; - let validated = validate_msh(&msh).map_err(|err| err.to_string())?; - let node38 = if validated.node_stride == 38 { - (0..validated.node_count) - .filter_map(|index| { - let node = NodeId(u32::try_from(index).ok()?); - let metadata = node38_metadata(&validated, node)?; - Some(Node38Inspection { - index, - parent_or_link_raw: metadata.parent_or_link_raw, - anim_map_start: metadata.anim_map_start, - fallback_key: metadata.fallback_key, - has_lod0_group0: selected_slot(&validated, node, Lod(0), Group(0)).is_some(), - }) - }) - .collect() - } else { - Vec::new() - }; - Ok(ModelInspection { - streams: msh.streams().len(), - nodes: validated.node_count, - slots: validated.slots.len(), - positions: validated.positions.len(), - indices: validated.indices.len(), - batches: validated.batches.len(), - node_stride: validated.node_stride, - node38, - animation_keys: validated - .animation - .as_ref() - .map(|animation| animation.keys.len()), - animation_frame_count: validated - .animation - .as_ref() - .map(|animation| animation.frame_count), - }) -} - -/// Inspects a WEAR resource through repository-backed lookup. -/// -/// # Errors -/// -/// Returns a string error when the resource cannot be resolved or parsed as a -/// valid WEAR payload. -pub fn inspect_wear_from_root( - root: &Path, - archive: &str, - resource: &str, -) -> Result<WearInspection, String> { - let bytes = read_resource_bytes_diagnostic(root, archive, resource) - .map_err(|err| render_human(&err))?; - let wear = decode_wear(&bytes).map_err(|err| err.to_string())?; - Ok(WearInspection { - materials: wear.entries.len(), - lightmaps: wear.lightmaps.len(), - first_material: wear - .entries - .first() - .map(|entry| String::from_utf8_lossy(&entry.material.0).into_owned()), - last_material: wear - .entries - .last() - .map(|entry| String::from_utf8_lossy(&entry.material.0).into_owned()), - }) -} - -/// Loads and validates a model resource through repository-backed lookup. -/// -/// # Errors -/// -/// Returns a string error when the resource cannot be resolved or parsed as a -/// valid model payload. -pub fn load_model_from_root( - root: &Path, - archive: &str, - resource: &str, -) -> Result<ModelAsset, String> { - let document = load_model_document_from_root_diagnostic(root, archive, resource) - .map_err(|err| render_human(&err))?; - let msh = decode_msh(&document).map_err(|err| err.to_string())?; - validate_msh(&msh).map_err(|err| err.to_string()) -} - -/// Inspects a texture through repository-backed resource lookup. -/// -/// # Errors -/// -/// Returns a string error when the resource cannot be resolved or parsed as a -/// valid texture payload. -pub fn inspect_texture_from_root( - root: &Path, - archive: &str, - resource: &str, -) -> Result<TextureInspection, String> { - let bytes = read_resource_bytes_diagnostic(root, archive, resource) - .map_err(|err| render_human(&err))?; - let document = decode_texm(bytes).map_err(|err| err.to_string())?; - Ok(TextureInspection { - width: document.width(), - height: document.height(), - format: format!("{:?}", document.format()), - mips: document.mip_count(), - pages: document.page_rects().len(), - }) -} - -/// Loads a decoded TEXM document through repository-backed lookup. -/// -/// # Errors -/// -/// Returns a string error when the resource cannot be resolved or parsed as a -/// valid TEXM payload. -pub fn load_texture_from_root( - root: &Path, - archive: &str, - resource: &str, -) -> Result<fparkan_texm::TexmDocument, String> { - let bytes = read_resource_bytes_diagnostic(root, archive, resource) - .map_err(|err| render_human(&err))?; - decode_texm(bytes).map_err(|err| err.to_string()) -} - -/// Loads and decodes TEXM mip 0 as RGBA8 through repository-backed lookup. -/// -/// # Errors -/// -/// Returns a string error when the resource cannot be resolved, decoded, or -/// converted to the shared RGBA8 upload representation. -pub fn load_texture_mip0_rgba8_from_root( - root: &Path, - archive: &str, - resource: &str, -) -> Result<fparkan_texm::RgbaImage, String> { - let document = load_texture_from_root(root, archive, resource)?; - fparkan_texm::decode_mip_rgba8(&document, 0).map_err(|err| err.to_string()) -} - -/// Resolves phase-zero diffuse TEXM through `WEAR → MAT0 → Textures.lib`. -/// -/// The selector is the positional `Batch20.material_index`, not WEAR's legacy -/// text id. MAT0 fallback remains owned by `fparkan-material`: exact requested -/// entry, then `DEFAULT`, then the first material entry. -/// -/// # Errors -/// -/// Returns a string error if WEAR/MAT0 resolution, phase selection, or TEXM -/// decoding fails. An empty phase-zero texture name is an intentional -/// untextured material and is reported rather than substituted with a texture. -pub fn load_wear_material_texture_mip0_rgba8_from_root( - root: &Path, - wear_archive: &str, - wear_resource: &str, - material_index: u16, -) -> Result<WearMaterialTexture, String> { - let wear_bytes = read_resource_bytes_diagnostic(root, wear_archive, wear_resource) - .map_err(|err| render_human(&err))?; - let wear = decode_wear(&wear_bytes).map_err(|err| err.to_string())?; - let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root))); - let material = - resolve_material(&repository, &wear, material_index).map_err(|err| err.to_string())?; - let texture = material.document.primary_texture().ok_or_else(|| { - "MAT0 phase zero declares an intentionally untextured material".to_string() - })?; - let texture_name = String::from_utf8_lossy(&texture.0).into_owned(); - let image = load_texture_mip0_rgba8_from_root(root, "Textures.lib", &texture_name)?; - Ok(WearMaterialTexture { - wear_archive: wear_archive.to_string(), - wear_resource: wear_resource.to_string(), - material_index, - material_name: String::from_utf8_lossy(&material.name.0).into_owned(), - material_fallback: material.fallback, - texture_name, - image, - }) -} - -/// Resolves phase-zero diffuse TEXM through a standalone map-local WEAR file. -/// -/// Map terrain keeps its two WEAR tables adjacent to `Land.msh`, while their -/// MAT0 and TEXM resources remain in the game root's normal archives. This -/// preserves that split instead of treating the sidecar as an archive entry. -/// -/// # Errors -/// -/// Returns a string error if the standalone WEAR file, global material -/// resources, or selected diffuse TEXM cannot be decoded. -pub fn load_standalone_wear_material_texture_mip0_rgba8_from_root( - root: &Path, - wear_path: &Path, - material_index: u16, -) -> Result<WearMaterialTexture, String> { - let wear_bytes = - fs::read(wear_path).map_err(|err| format!("{}: {err}", wear_path.display()))?; - let wear = decode_wear(&wear_bytes).map_err(|err| err.to_string())?; - let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root))); - let material = - resolve_material(&repository, &wear, material_index).map_err(|err| err.to_string())?; - let texture = material.document.primary_texture().ok_or_else(|| { - "MAT0 phase zero declares an intentionally untextured material".to_string() - })?; - let texture_name = String::from_utf8_lossy(&texture.0).into_owned(); - let image = load_texture_mip0_rgba8_from_root(root, "Textures.lib", &texture_name)?; - Ok(WearMaterialTexture { - wear_archive: "<standalone>".to_string(), - wear_resource: wear_path.display().to_string(), - material_index, - material_name: String::from_utf8_lossy(&material.name.0).into_owned(), - material_fallback: material.fallback, - texture_name, - image, - }) -} - -/// Inspects a terrain land file by path. -/// -/// # Errors -/// -/// Returns a string error when the file cannot be read or parsed as the -/// requested terrain payload kind. -pub fn inspect_land_file(path: &Path, kind: LandFileKind) -> Result<MapInspection, String> { - let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?; - let document = decode_nres(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible) - .map_err(|err| err.to_string())?; - match kind { - LandFileKind::LandMsh => inspect_land_msh(&document), - LandFileKind::LandMap => inspect_land_map(&document), - } -} - -/// Loads and validates a standalone `Land.msh` file. -/// -/// # Errors -/// -/// Returns a human-readable error if the file cannot be read, decoded as `NRes`, -/// or decoded as the specialized terrain mesh format. -pub fn load_land_msh_from_path(path: &Path) -> Result<LandMeshDocument, String> { - let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?; - let document = decode_nres(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible) - .map_err(|err| err.to_string())?; - decode_land_msh(&document).map_err(|err| err.to_string()) -} - -/// Inspects the source-coordinate bounds of a standalone `Land.msh` file. -/// -/// # Errors -/// -/// Returns a string error when the file cannot be read, decoded, or contains -/// no finite source positions. -pub fn inspect_land_msh_bounds_file(path: &Path) -> Result<LandMeshBoundsInspection, String> { - let mesh = load_land_msh_from_path(path)?; - inspect_land_msh_bounds(&mesh) -} - -/// Computes source-coordinate bounds for an already validated `Land.msh`. -/// -/// # Errors -/// -/// Returns a string error when the mesh has no finite source positions. -pub fn inspect_land_msh_bounds( - mesh: &LandMeshDocument, -) -> Result<LandMeshBoundsInspection, String> { - let mut min = [f32::INFINITY; 3]; - let mut max = [f32::NEG_INFINITY; 3]; - for position in &mesh.positions { - if !position.iter().all(|value| value.is_finite()) { - return Err("Land.msh contains a non-finite source position".to_string()); - } - for axis in 0..3 { - min[axis] = min[axis].min(position[axis]); - max[axis] = max[axis].max(position[axis]); - } - } - if mesh.positions.is_empty() { - return Err("Land.msh contains no source positions".to_string()); - } - Ok(LandMeshBoundsInspection { - positions: mesh.positions.len(), - min, - max, - }) -} - -fn inspect_land_msh(document: &NresDocument) -> Result<MapInspection, String> { - let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?; - Ok(MapInspection { - streams: land_msh.streams.len(), - slots: land_msh.slots.slots_raw.len(), - positions: land_msh.positions.len(), - faces: land_msh.faces.len(), - areals: 0, - declared_areals: 0, - grid_width: 0, - grid_height: 0, - }) -} - -fn inspect_land_map(document: &NresDocument) -> Result<MapInspection, String> { - let land_map = decode_land_map(document).map_err(|err| err.to_string())?; - Ok(MapInspection { - streams: 0, - slots: 0, - positions: 0, - faces: 0, - areals: land_map.areals.len(), - declared_areals: land_map.areal_count, - grid_width: land_map.grid.cells_x, - grid_height: land_map.grid.cells_y, - }) -} - -// Preserves the shared structured-error type without changing callers to boxed errors. -#[allow(clippy::result_large_err)] -fn read_resource_bytes_diagnostic( - root: &Path, - archive: &str, - name: &str, -) -> Result<Arc<[u8]>, Diagnostic> { - let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root))); - let archive_path = archive_path(archive.as_bytes()).map_err(|err| { - diagnostic(DiagnosticCode("S1.PATH.ARCHIVE"), err.to_string()).with_context( - DiagnosticContext { - phase: Some(Phase::Resolve), - path: Some(archive.to_string()), - archive_entry: Some(name.to_string()), - ..DiagnosticContext::default() - }, - ) - })?; - let resource_name = resource_name(name.as_bytes()); - let archive_handle = repository.open_archive(&archive_path).map_err(|err| { - diagnostic(DiagnosticCode("S1.RESOURCE.OPEN_ARCHIVE"), err.to_string()).with_context( - DiagnosticContext { - phase: Some(Phase::Read), - path: Some(archive.to_string()), - archive_entry: Some(name.to_string()), - ..DiagnosticContext::default() - }, - ) - })?; - let Some(handle) = repository - .find(archive_handle, &resource_name) - .map_err(|err| { - diagnostic(DiagnosticCode("S1.RESOURCE.FIND"), err.to_string()).with_context( - DiagnosticContext { - phase: Some(Phase::Resolve), - path: Some(archive.to_string()), - archive_entry: Some(name.to_string()), - ..DiagnosticContext::default() - }, - ) - })? - else { - return Err(diagnostic( - DiagnosticCode("S1.RESOURCE.MISSING_ENTRY"), - format!( - "resource not found: {archive}/{}", - String::from_utf8_lossy(name.as_bytes()) - ), - ) - .with_context(DiagnosticContext { - phase: Some(Phase::Resolve), - path: Some(archive.to_string()), - archive_entry: Some(name.to_string()), - ..DiagnosticContext::default() - })); - }; - let bytes = repository.read(handle).map_err(|err| { - diagnostic(DiagnosticCode("S1.RESOURCE.READ"), err.to_string()).with_context( - DiagnosticContext { - phase: Some(Phase::Read), - path: Some(archive.to_string()), - archive_entry: Some(name.to_string()), - ..DiagnosticContext::default() - }, - ) - })?; - Ok(Arc::from(bytes.into_owned())) -} - -// Preserves the shared structured-error type without changing callers to boxed errors. -#[allow(clippy::result_large_err)] -fn load_model_document_from_root_diagnostic( - root: &Path, - archive: &str, - resource: &str, -) -> Result<NresDocument, Diagnostic> { - let bytes = read_resource_bytes_diagnostic(root, archive, resource)?; - decode_nres(bytes.clone(), ReadProfile::Compatible).map_err(|err| { - resource_parse_diagnostic("S1.NRES.DECODE", archive, resource, &bytes, err.to_string()) - }) -} - -fn archive_parse_diagnostic( - code: &'static str, - source: Option<&Path>, - bytes: &[u8], - message: String, -) -> Diagnostic { - diagnostic(DiagnosticCode(code), message).with_context(DiagnosticContext { - phase: Some(Phase::Parse), - path: source.map(|path| path.display().to_string()), - span: Some(SourceSpan { - offset: 0, - length: u64::try_from(bytes.len().min(4)).unwrap_or(4), - }), - ..DiagnosticContext::default() - }) -} - -fn resource_parse_diagnostic( - code: &'static str, - archive: &str, - resource: &str, - bytes: &[u8], - message: String, -) -> Diagnostic { - diagnostic(DiagnosticCode(code), message).with_context(DiagnosticContext { - phase: Some(Phase::Parse), - path: Some(archive.to_string()), - archive_entry: Some(resource.to_string()), - span: Some(SourceSpan { - offset: 0, - length: u64::try_from(bytes.len().min(4)).unwrap_or(4), - }), - ..DiagnosticContext::default() - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use fparkan_terrain_format::TerrainSlotTable; - use std::io::Write as _; - use std::path::PathBuf; - - const TEST_NRES_HEADER_LEN: usize = 16; - const TEST_NRES_NAME_LEN: usize = 36; - const TEST_NRES_VERSION_0100: u32 = 0x100; - - #[test] - fn inspect_rsli_rejects_malformed_archive() { - let dir = temp_dir("inspect"); - let path = dir.join("test.rsli"); - let mut file = fs::File::create(&path).expect("file"); - file.write_all(b"NL\0\x01").expect("magic"); - drop(file); - - let error = inspect_archive_file(&path, 0).expect_err("malformed archive"); - assert!(error.contains("entry table out of bounds")); - } - - #[test] - fn archive_diagnostic_preserves_source_path_phase_and_span() { - let dir = temp_dir("inspect-diagnostic"); - let path = dir.join("broken.nres"); - fs::write(&path, b"NRes").expect("broken nres"); - - let diagnostic = inspect_archive_file_diagnostic(&path, 0).expect_err("diagnostic failure"); - - assert_eq!(diagnostic.code.0, "S1.NRES.DECODE"); - let expected_path = path.display().to_string(); - assert_eq!( - diagnostic.context.path.as_deref(), - Some(expected_path.as_str()) - ); - assert_eq!(diagnostic.context.phase, Some(Phase::Parse)); - assert_eq!( - diagnostic.context.span, - Some(SourceSpan { - offset: 0, - length: 4 - }) - ); - } - - #[test] - fn nres_entry_summary_fields_are_readable() { - let dir = temp_dir("inspect-nres"); - let archive = dir.join("test.nres"); - let payload = Vec::from("NRes\x00\x00\x00\x00"); - fs::write(&archive, &payload).expect("nres"); - - let _ = inspect_archive_file(&archive, 2); - } - - #[test] - fn model_archive_diagnostic_preserves_archive_entry_context() { - let dir = temp_dir("inspect-model-diagnostic"); - let archive = dir.join("models.rlb"); - fs::write(&archive, build_single_entry_nres(b"BROKEN.MSH", b"NRes")).expect("archive"); - - let diagnostic = load_model_document_from_root_diagnostic(&dir, "models.rlb", "BROKEN.MSH") - .expect_err("nested diagnostic failure"); - - assert_eq!(diagnostic.code.0, "S1.NRES.DECODE"); - assert_eq!(diagnostic.context.phase, Some(Phase::Parse)); - assert_eq!(diagnostic.context.path.as_deref(), Some("models.rlb")); - assert_eq!( - diagnostic.context.archive_entry.as_deref(), - Some("BROKEN.MSH") - ); - assert_eq!( - diagnostic.context.span, - Some(SourceSpan { - offset: 0, - length: 4 - }) - ); - } - - #[test] - fn wear_material_texture_loader_preserves_original_selection_provenance() { - let dir = temp_dir("wear-material-texture"); - fs::write( - dir.join("wear.rlb"), - build_single_entry_nres(b"MODEL.WEA", b"1\n0 MAT\n"), - ) - .expect("wear archive"); - let mut mat0 = vec![0; 4 + 34]; - mat0[0..2].copy_from_slice(&1_u16.to_le_bytes()); - mat0[22..25].copy_from_slice(b"TEX"); - fs::write( - dir.join("material.lib"), - build_single_entry_nres_with_meta(b"MAT", fparkan_material::MAT0_KIND, 0, &mat0), - ) - .expect("material archive"); - fs::write( - dir.join("Textures.lib"), - build_single_entry_nres(b"TEX", &texm_argb8888_pixel([0x40, 0x11, 0x22, 0x33])), - ) - .expect("texture archive"); - - let selected = - load_wear_material_texture_mip0_rgba8_from_root(&dir, "wear.rlb", "MODEL.WEA", 0) - .expect("resolved material texture"); - - assert_eq!(selected.material_name, "MAT"); - assert_eq!(selected.material_fallback, MaterialFallback::Exact); - assert_eq!(selected.texture_name, "TEX"); - assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]); - } - - #[test] - fn standalone_wear_material_texture_loader_keeps_map_sidecar_separate() { - let dir = temp_dir("standalone-wear-material-texture"); - let wear_path = dir.join("Land2.wea"); - fs::write(&wear_path, b"1\n0 MAT\n").expect("standalone wear"); - let mut mat0 = vec![0; 4 + 34]; - mat0[0..2].copy_from_slice(&1_u16.to_le_bytes()); - mat0[22..25].copy_from_slice(b"TEX"); - fs::write( - dir.join("material.lib"), - build_single_entry_nres_with_meta(b"MAT", fparkan_material::MAT0_KIND, 0, &mat0), - ) - .expect("material archive"); - fs::write( - dir.join("Textures.lib"), - build_single_entry_nres(b"TEX", &texm_argb8888_pixel([0x40, 0x11, 0x22, 0x33])), - ) - .expect("texture archive"); - - let selected = - load_standalone_wear_material_texture_mip0_rgba8_from_root(&dir, &wear_path, 0) - .expect("resolved material texture"); - - assert_eq!(selected.wear_archive, "<standalone>"); - assert_eq!(selected.wear_resource, wear_path.display().to_string()); - assert_eq!(selected.texture_name, "TEX"); - assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]); - } - - #[test] - fn land_mesh_bounds_preserve_each_source_axis() { - let mesh = LandMeshDocument { - streams: Vec::new(), - nodes_raw: Vec::new(), - slots: TerrainSlotTable { - header_raw: Vec::new(), - slots_raw: Vec::new(), - }, - positions: vec![[4.0, -2.0, 8.0], [-3.0, 6.0, 1.0]], - normals: Vec::new(), - uv0: Vec::new(), - accelerator: Vec::new(), - aux14: Vec::new(), - aux18: Vec::new(), - faces: Vec::new(), - }; - - let bounds = inspect_land_msh_bounds(&mesh).expect("bounds"); - - assert_eq!(bounds.positions, 2); - assert_eq!(bounds.min, [-3.0, -2.0, 1.0]); - assert_eq!(bounds.max, [4.0, 6.0, 8.0]); - } - - fn temp_dir(name: &str) -> PathBuf { - let base = PathBuf::from("/tmp") - .join("fparkan-inspection-tests") - .join(name); - let _ = fs::remove_dir_all(&base); - fs::create_dir_all(&base).expect("tmp dir"); - base - } - - fn build_single_entry_nres(name: &[u8], payload: &[u8]) -> Vec<u8> { - build_single_entry_nres_with_meta(name, 1, 0, payload) - } - - fn build_single_entry_nres_with_meta( - name: &[u8], - type_id: u32, - attr2: u32, - payload: &[u8], - ) -> Vec<u8> { - let mut out = vec![0; TEST_NRES_HEADER_LEN]; - let payload_offset = u32::try_from(out.len()).expect("payload offset"); - out.extend_from_slice(payload); - let padding = (8 - (out.len() % 8)) % 8; - out.resize(out.len() + padding, 0); - - push_u32(&mut out, type_id); - push_u32(&mut out, 0); - push_u32(&mut out, attr2); - push_u32(&mut out, u32::try_from(payload.len()).expect("payload len")); - push_u32(&mut out, 0); - let mut raw_name = [0; TEST_NRES_NAME_LEN]; - raw_name[..name.len()].copy_from_slice(name); - out.extend_from_slice(&raw_name); - push_u32(&mut out, payload_offset); - push_u32(&mut out, 0); - - out[0..4].copy_from_slice(b"NRes"); - out[4..8].copy_from_slice(&TEST_NRES_VERSION_0100.to_le_bytes()); - out[8..12].copy_from_slice(&1_u32.to_le_bytes()); - let total_size = u32::try_from(out.len()).expect("total size"); - out[12..16].copy_from_slice(&total_size.to_le_bytes()); - out - } - - fn push_u32(out: &mut Vec<u8>, value: u32) { - out.extend_from_slice(&value.to_le_bytes()); - } - - fn texm_argb8888_pixel(pixel: [u8; 4]) -> Vec<u8> { - let mut out = Vec::new(); - out.extend_from_slice(&0x6D78_6554_u32.to_le_bytes()); - out.extend_from_slice(&1_u32.to_le_bytes()); - out.extend_from_slice(&1_u32.to_le_bytes()); - out.extend_from_slice(&1_u32.to_le_bytes()); - out.extend_from_slice(&0_u32.to_le_bytes()); - out.extend_from_slice(&0_u32.to_le_bytes()); - out.extend_from_slice(&0_u32.to_le_bytes()); - out.extend_from_slice(&8888_u32.to_le_bytes()); - out.extend_from_slice(&pixel); - out - } -} diff --git a/crates/fparkan-material/Cargo.toml b/crates/fparkan-material/Cargo.toml index 4a00a73..63174d5 100644 --- a/crates/fparkan-material/Cargo.toml +++ b/crates/fparkan-material/Cargo.toml @@ -7,12 +7,9 @@ repository.workspace = true [dependencies] encoding_rs = "0.8" -fparkan-path = { path = "../fparkan-path", version = "0.1.0" } -fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" } +fparkan-path = { path = "../fparkan-path" } +fparkan-resource = { path = "../fparkan-resource" } [dev-dependencies] -fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" } -fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" } - -[lints] -workspace = true +fparkan-nres = { path = "../fparkan-nres" } +fparkan-vfs = { path = "../fparkan-vfs" } diff --git a/crates/fparkan-material/src/lib.rs b/crates/fparkan-material/src/lib.rs index 7da9952..32d48a1 100644 --- a/crates/fparkan-material/src/lib.rs +++ b/crates/fparkan-material/src/lib.rs @@ -1,23 +1,4 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] //! WEAR/MAT0 material contracts. use encoding_rs::WINDOWS_1251; diff --git a/crates/fparkan-mission-format/Cargo.toml b/crates/fparkan-mission-format/Cargo.toml index 1bfc362..3802966 100644 --- a/crates/fparkan-mission-format/Cargo.toml +++ b/crates/fparkan-mission-format/Cargo.toml @@ -7,7 +7,4 @@ repository.workspace = true [dependencies] encoding_rs = "0.8" -fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" } - -[lints] -workspace = true +fparkan-binary = { path = "../fparkan-binary" } diff --git a/crates/fparkan-mission-format/src/lib.rs b/crates/fparkan-mission-format/src/lib.rs index 1562256..e796d61 100644 --- a/crates/fparkan-mission-format/src/lib.rs +++ b/crates/fparkan-mission-format/src/lib.rs @@ -1,23 +1,4 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] //! Count-driven mission format primitives. use encoding_rs::WINDOWS_1251; diff --git a/crates/fparkan-msh/Cargo.toml b/crates/fparkan-msh/Cargo.toml index 7df80f9..61ebc2b 100644 --- a/crates/fparkan-msh/Cargo.toml +++ b/crates/fparkan-msh/Cargo.toml @@ -7,8 +7,5 @@ repository.workspace = true [dependencies] encoding_rs = "0.8" -fparkan-animation = { path = "../fparkan-animation", version = "0.1.0" } -fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" } - -[lints] -workspace = true +fparkan-animation = { path = "../fparkan-animation" } +fparkan-nres = { path = "../fparkan-nres" } diff --git a/crates/fparkan-msh/src/lib.rs b/crates/fparkan-msh/src/lib.rs index fd222c6..2e38082 100644 --- a/crates/fparkan-msh/src/lib.rs +++ b/crates/fparkan-msh/src/lib.rs @@ -1,23 +1,4 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] //! Stage-3 MSH asset contract. use encoding_rs::WINDOWS_1251; @@ -2065,7 +2046,6 @@ mod tests { out } - #[allow(clippy::similar_names, clippy::too_many_arguments)] fn batch_record( batch_flags: u16, material_index: u16, diff --git a/crates/fparkan-nres/Cargo.toml b/crates/fparkan-nres/Cargo.toml index 539bf16..ae507af 100644 --- a/crates/fparkan-nres/Cargo.toml +++ b/crates/fparkan-nres/Cargo.toml @@ -6,8 +6,5 @@ license.workspace = true repository.workspace = true [dependencies] -fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" } -fparkan-path = { path = "../fparkan-path", version = "0.1.0" } - -[lints] -workspace = true +fparkan-binary = { path = "../fparkan-binary" } +fparkan-path = { path = "../fparkan-path" } diff --git a/crates/fparkan-nres/src/lib.rs b/crates/fparkan-nres/src/lib.rs index e82ca08..56c4370 100644 --- a/crates/fparkan-nres/src/lib.rs +++ b/crates/fparkan-nres/src/lib.rs @@ -1,23 +1,4 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] //! Strict and lossless `NRes` archive support. use fparkan_binary::{checked_allocation_len, Cursor, DecodeError}; diff --git a/crates/fparkan-path/Cargo.toml b/crates/fparkan-path/Cargo.toml index 57664b7..66749f2 100644 --- a/crates/fparkan-path/Cargo.toml +++ b/crates/fparkan-path/Cargo.toml @@ -6,6 +6,3 @@ license.workspace = true repository.workspace = true [dependencies] - -[lints] -workspace = true diff --git a/crates/fparkan-path/src/lib.rs b/crates/fparkan-path/src/lib.rs index 8051b0f..efde47d 100644 --- a/crates/fparkan-path/src/lib.rs +++ b/crates/fparkan-path/src/lib.rs @@ -1,23 +1,4 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] //! Legacy path normalization and ASCII lookup semantics. use std::cmp::Ordering; diff --git a/crates/fparkan-platform/Cargo.toml b/crates/fparkan-platform/Cargo.toml index 3f9d593..143ae1c 100644 --- a/crates/fparkan-platform/Cargo.toml +++ b/crates/fparkan-platform/Cargo.toml @@ -7,6 +7,3 @@ repository.workspace = true [dependencies] raw-window-handle = "0.6" - -[lints] -workspace = true diff --git a/crates/fparkan-platform/src/lib.rs b/crates/fparkan-platform/src/lib.rs index c2a3a0f..04ea32d 100644 --- a/crates/fparkan-platform/src/lib.rs +++ b/crates/fparkan-platform/src/lib.rs @@ -1,23 +1,4 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] //! Platform ports for clocks, event sources and window descriptors. use raw_window_handle::{RawDisplayHandle, RawWindowHandle}; diff --git a/crates/fparkan-prototype/Cargo.toml b/crates/fparkan-prototype/Cargo.toml index 0426c23..fec907c 100644 --- a/crates/fparkan-prototype/Cargo.toml +++ b/crates/fparkan-prototype/Cargo.toml @@ -7,13 +7,10 @@ repository.workspace = true [dependencies] encoding_rs = "0.8" -fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" } -fparkan-path = { path = "../fparkan-path", version = "0.1.0" } -fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" } -fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" } - -[lints] -workspace = true +fparkan-binary = { path = "../fparkan-binary" } +fparkan-path = { path = "../fparkan-path" } +fparkan-resource = { path = "../fparkan-resource" } +fparkan-vfs = { path = "../fparkan-vfs" } [dev-dependencies] -fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" } +fparkan-nres = { path = "../fparkan-nres" } diff --git a/crates/fparkan-prototype/src/lib.rs b/crates/fparkan-prototype/src/lib.rs index f4540d4..5023df4 100644 --- a/crates/fparkan-prototype/src/lib.rs +++ b/crates/fparkan-prototype/src/lib.rs @@ -1,23 +1,4 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] //! Prototype registry and unit DAT primitives. use encoding_rs::WINDOWS_1251; @@ -824,7 +805,7 @@ pub fn build_prototype_graph( /// /// This function reports per-root failures in [`PrototypeGraphReport`] instead /// of returning early. -#[allow(clippy::too_many_lines)] + pub fn build_prototype_graph_report( repository: &dyn ResourceRepository, vfs: &dyn Vfs, diff --git a/crates/fparkan-render/Cargo.toml b/crates/fparkan-render/Cargo.toml index f28496a..9485140 100644 --- a/crates/fparkan-render/Cargo.toml +++ b/crates/fparkan-render/Cargo.toml @@ -6,7 +6,3 @@ license.workspace = true repository.workspace = true [dependencies] -fparkan-world = { path = "../fparkan-world", version = "0.1.0" } - -[lints] -workspace = true diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs index 96ccc2a..7e3c1f8 100644 --- a/crates/fparkan-render/src/lib.rs +++ b/crates/fparkan-render/src/lib.rs @@ -1,26 +1,5 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] -//! Backend-neutral render commands and deterministic captures. - -use fparkan_world::OriginalObjectId; +//! Camera mathematics and graphics pipeline state. /// A 64-byte transform block returned by the original Terrain camera ABI. /// @@ -217,7 +196,7 @@ impl LegacyD3d7Projection { // Ngi32 converts these signed viewport dimensions into single-precision // arithmetic before building the legacy matrix. - #[allow(clippy::cast_precision_loss)] + let aspect = (width as f32) / (height as f32); let half_fov = self.field_of_view_radians * 0.5; let cosine = half_fov.cos(); @@ -338,112 +317,6 @@ impl LegacyIron3dEulerTransform { } } -/// Raw camera state observed through the original Terrain camera interface. -/// -/// Selector 0 supplies the currently active transform and selector 2 supplies -/// its paired transform. Keeping both lets a later compatibility layer derive -/// a view/projection convention without re-reading the legacy process. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct RawCameraPose { - /// Transform returned by selector 0. - pub selector0: RawCameraTransform, - /// Transform returned by selector 2. - pub selector2: RawCameraTransform, -} - -/// Raw projection state observed through the original `CBufferingCamera` ABI. -/// -/// The five-float context block is intentionally represented as original words: -/// its indices are observed, but their semantic labels have not yet all been -/// recovered. The field-of-view value is the type-0 input to `tan(fov / 2)`. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct RawCameraProjection { - /// Viewport rectangle as `(left, top, right, bottom)`. - pub viewport: [i32; 4], - /// Original projection selector. - pub projection_type: u32, - /// Original IEEE-754 FOV value in radians. - pub field_of_view_radians_bits: u32, - /// Exact five-float context block returned by the primary renderer. - pub context_words: [u32; 5], -} - -impl RawCameraProjection { - /// Returns the type-0 FOV input in radians. - #[must_use] - pub fn field_of_view_radians(self) -> f32 { - f32::from_bits(self.field_of_view_radians_bits) - } - - /// Returns the five context values without assigning semantic labels. - #[must_use] - pub fn context_values(self) -> [f32; 5] { - self.context_words.map(f32::from_bits) - } -} - -/// Immutable camera data visible to command generation. -#[derive(Clone, Debug, PartialEq)] -pub struct CameraSnapshot { - /// View matrix, row-major. - pub view: [f32; 16], - /// Projection matrix, row-major. - pub projection: [f32; 16], - /// Optional unconverted source-camera state. - /// - /// This does not alter rendering until the original matrix and projection - /// conventions have been recovered; it preserves the ABI boundary for the - /// runtime adapter and deterministic captures. - pub raw_pose: Option<RawCameraPose>, - /// Optional unconverted source-projection state. - /// - /// This is retained independently of `projection`, because its legacy - /// context words have not yet been mapped to a Vulkan clip convention. - pub raw_projection: Option<RawCameraProjection>, -} - -impl Default for CameraSnapshot { - fn default() -> Self { - Self { - view: identity_transform(), - projection: identity_transform(), - raw_pose: None, - raw_projection: None, - } - } -} - -/// Draw id. -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] -pub struct DrawId(pub u64); - -/// GPU mesh id. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct GpuMeshId(pub u64); - -/// GPU material id. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct GpuMaterialId(pub u64); - -/// Render phase. -#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] -pub enum RenderPhase { - /// Terrain. - Terrain, - /// Opaque. - Opaque, - /// Alpha test. - AlphaTest, - /// Transparent. - Transparent, - /// Effects. - Effects, - /// Debug. - Debug, - /// UI. - Ui, -} - /// Fixed-function blend behaviour represented without a graphics API type. /// /// This is a compatibility contract, not yet a decoded MAT0 mapping. @@ -531,546 +404,6 @@ impl From<LegacyPipelineState> for PipelineKey { } } -/// Index range. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub struct IndexRange { - /// Start. - pub start: u32, - /// Count. - pub count: u32, -} - -/// A draw candidate in an immutable render snapshot. -#[derive(Clone, Debug, PartialEq)] -pub struct RenderSnapshotDraw { - /// Draw id. - pub id: DrawId, - /// Phase. - pub phase: RenderPhase, - /// Object id. - pub object_id: Option<OriginalObjectId>, - /// Mesh. - pub mesh: GpuMeshId, - /// Material table after WEAR/MAT0 fallback resolution. - pub material_slots: Vec<GpuMaterialId>, - /// Batch material index into [`Self::material_slots`]. - pub material_index: u16, - /// Fixed-function state resolved for this draw. - pub pipeline_state: LegacyPipelineState, - /// Node transform matrix, row-major. - pub transform: [f32; 16], - /// Index range. - pub range: IndexRange, - /// Stable sort order. - pub stable_order: u64, -} - -/// Immutable backend-neutral render snapshot. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct RenderSnapshot { - /// Camera data for the frame. - pub camera: CameraSnapshot, - /// Draw candidates gathered from world/assets. - pub draws: Vec<RenderSnapshotDraw>, -} - -/// Command generation profile. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub struct RenderProfile { - /// Include UI phase commands when present. - pub include_ui: bool, -} - -/// Draw command. -#[derive(Clone, Debug, PartialEq)] -pub struct DrawCommand { - /// Draw id. - pub id: DrawId, - /// Phase. - pub phase: RenderPhase, - /// Object id. - pub object_id: Option<OriginalObjectId>, - /// Mesh. - pub mesh: GpuMeshId, - /// Material. - pub material: GpuMaterialId, - /// Canonical graphics-pipeline variant. - pub pipeline_key: PipelineKey, - /// Transform matrix, row-major. - pub transform: [f32; 16], - /// Index range. - pub range: IndexRange, - /// Stable sort order. - pub stable_order: u64, -} - -/// Render command. -#[derive(Clone, Debug, PartialEq)] -pub enum RenderCommand { - /// Begin frame. - BeginFrame, - /// Draw. - Draw(DrawCommand), - /// End frame. - EndFrame, -} - -/// Render command list. -#[derive(Clone, Debug, Default, PartialEq)] -pub struct RenderCommandList { - /// Commands. - pub commands: Vec<RenderCommand>, -} - -/// Optional render command validation limits. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub struct RenderValidationLimits { - /// Exclusive upper bound for GPU mesh ids. - pub mesh_count: Option<u64>, - /// Exclusive upper bound for index ranges. - pub index_count: Option<u32>, -} - -/// Frame output. -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct FrameOutput; - -/// Render error. -#[derive(Debug)] -pub enum RenderError { - /// Invalid range. - InvalidRange, - /// Invalid command stream framing or ordering. - InvalidCommandStream { - /// Command index. - index: usize, - /// Contextual error message. - message: &'static str, - }, - /// Invalid draw range with command-generation context. - InvalidDrawRange { - /// Draw id. - draw_id: DrawId, - /// Stable sort order. - stable_order: u64, - /// Range start. - start: u32, - /// Range count. - count: u32, - }, - /// Index range arithmetic overflow. - IndexRangeOverflow { - /// Draw id. - draw_id: DrawId, - /// Range start. - start: u32, - /// Range count. - count: u32, - }, - /// Index range exceeds validation limits. - IndexRangeOutOfBounds { - /// Draw id. - draw_id: DrawId, - /// Exclusive index limit. - index_count: u32, - /// Range end. - end: u32, - }, - /// Mesh id exceeds validation limits. - MeshOutOfBounds { - /// Draw id. - draw_id: DrawId, - /// Mesh id. - mesh: GpuMeshId, - /// Exclusive mesh limit. - mesh_count: u64, - }, - /// Draw transform contains a non-finite value. - NonFiniteTransform { - /// Draw id. - draw_id: DrawId, - /// Matrix element index. - element: usize, - }, - /// Draw commands are not ordered by phase, stable order and draw id. - PhaseOrderViolation { - /// Draw id. - draw_id: DrawId, - /// Previous phase. - previous: RenderPhase, - /// Current phase. - current: RenderPhase, - }, - /// A batch material index did not resolve through the material table. - MaterialIndexOutOfBounds { - /// Draw id. - draw_id: DrawId, - /// Requested material index. - material_index: u16, - /// Available material slots. - material_count: usize, - }, -} - -impl std::fmt::Display for RenderError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::InvalidRange => write!(f, "render command contains an empty index range"), - Self::InvalidCommandStream { index, message } => { - write!( - f, - "render command stream is invalid at command {index}: {message}" - ) - } - Self::InvalidDrawRange { - draw_id, - stable_order, - start, - count, - } => write!( - f, - "draw {} has invalid index range start={} count={} at stable order {}", - draw_id.0, start, count, stable_order - ), - Self::IndexRangeOverflow { - draw_id, - start, - count, - } => write!( - f, - "draw {} index range overflows start={} count={}", - draw_id.0, start, count - ), - Self::IndexRangeOutOfBounds { - draw_id, - index_count, - end, - } => write!( - f, - "draw {} index range ends at {} but mesh has {} indices", - draw_id.0, end, index_count - ), - Self::MeshOutOfBounds { - draw_id, - mesh, - mesh_count, - } => write!( - f, - "draw {} references mesh {} but only {} meshes are available", - draw_id.0, mesh.0, mesh_count - ), - Self::NonFiniteTransform { draw_id, element } => write!( - f, - "draw {} has non-finite transform element {}", - draw_id.0, element - ), - Self::PhaseOrderViolation { - draw_id, - previous, - current, - } => write!( - f, - "draw {} phase order regressed from {:?} to {:?}", - draw_id.0, previous, current - ), - Self::MaterialIndexOutOfBounds { - draw_id, - material_index, - material_count, - } => write!( - f, - "draw {} references material index {} but only {} material slots are available", - draw_id.0, material_index, material_count - ), - } - } -} - -impl std::error::Error for RenderError {} - -/// Builds a deterministic command list from an immutable render snapshot. -/// -/// # Errors -/// -/// Returns [`RenderError`] when a draw has an invalid index range or a material -/// index that cannot be resolved through its material slot table. -pub fn build_commands( - snapshot: &RenderSnapshot, - profile: RenderProfile, -) -> Result<RenderCommandList, RenderError> { - let mut draws = snapshot - .draws - .iter() - .filter(|draw| profile.include_ui || draw.phase != RenderPhase::Ui) - .collect::<Vec<_>>(); - draws.sort_by_key(|draw| (draw.phase, draw.stable_order, draw.id)); - - let mut commands = Vec::with_capacity(draws.len() + 2); - commands.push(RenderCommand::BeginFrame); - for draw in draws { - if draw.range.count == 0 { - return Err(RenderError::InvalidDrawRange { - draw_id: draw.id, - stable_order: draw.stable_order, - start: draw.range.start, - count: draw.range.count, - }); - } - validate_index_range(draw.id, draw.range)?; - validate_transform(draw.id, &draw.transform)?; - let material = draw - .material_slots - .get(usize::from(draw.material_index)) - .copied() - .ok_or(RenderError::MaterialIndexOutOfBounds { - draw_id: draw.id, - material_index: draw.material_index, - material_count: draw.material_slots.len(), - })?; - commands.push(RenderCommand::Draw(DrawCommand { - id: draw.id, - phase: draw.phase, - object_id: draw.object_id, - mesh: draw.mesh, - material, - pipeline_key: draw.pipeline_state.into(), - transform: draw.transform, - range: draw.range, - stable_order: draw.stable_order, - })); - } - commands.push(RenderCommand::EndFrame); - Ok(RenderCommandList { commands }) -} - -/// Backend port. -pub trait RenderBackend { - /// Executes commands. - /// - /// # Errors - /// - /// Returns [`RenderError`] when the command stream is malformed for the - /// backend. - fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError>; -} - -/// Marker trait for backends that execute draws against a live GPU. -/// -/// Planning and capture-only backends must not implement this trait. -pub trait GpuRenderBackend: RenderBackend {} - -/// Backend that validates commands and intentionally produces no pixels. -#[derive(Clone, Debug, Default)] -pub struct NullBackend; - -impl RenderBackend for NullBackend { - fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> { - validate_command_list(commands)?; - Ok(FrameOutput) - } -} - -/// Backend that stores deterministic command captures for verification. -#[derive(Clone, Debug, Default)] -pub struct RecordingBackend { - captures: Vec<Vec<u8>>, -} - -impl RecordingBackend { - /// Returns all captures in submission order. - #[must_use] - pub fn captures(&self) -> &[Vec<u8>] { - &self.captures - } - - /// Returns the most recent capture. - #[must_use] - pub fn last_capture(&self) -> Option<&[u8]> { - self.captures.last().map(Vec::as_slice) - } - - /// Clears stored captures without changing backend behavior. - pub fn clear(&mut self) { - self.captures.clear(); - } -} - -impl RenderBackend for RecordingBackend { - fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> { - let capture = canonical_capture(commands)?; - self.captures.push(capture); - Ok(FrameOutput) - } -} - -/// Builds a canonical capture. -/// -/// # Errors -/// -/// Returns [`RenderError`] when a draw command contains an invalid index range. -pub fn canonical_capture(commands: &RenderCommandList) -> Result<Vec<u8>, RenderError> { - validate_command_list(commands)?; - let mut out = Vec::new(); - for command in &commands.commands { - match command { - RenderCommand::BeginFrame => out.extend_from_slice(b"B\n"), - RenderCommand::EndFrame => out.extend_from_slice(b"E\n"), - RenderCommand::Draw(draw) => { - out.extend_from_slice( - format!( - "D,{:?},{},{},{},{},{}\n", - draw.phase, - draw.id.0, - draw.mesh.0, - draw.material.0, - draw.pipeline_key.packed(), - draw.stable_order - ) - .as_bytes(), - ); - } - } - } - Ok(out) -} - -/// Validates a render command list without backend-specific resource limits. -/// -/// # Errors -/// -/// Returns [`RenderError`] when framing, ordering or draw data is invalid. -pub fn validate_command_list(commands: &RenderCommandList) -> Result<(), RenderError> { - validate_command_list_with_limits(commands, RenderValidationLimits::default()) -} - -/// Validates a render command list with optional backend resource limits. -/// -/// # Errors -/// -/// Returns [`RenderError`] when framing, ordering, draw data or resource bounds -/// are invalid. -pub fn validate_command_list_with_limits( - commands: &RenderCommandList, - limits: RenderValidationLimits, -) -> Result<(), RenderError> { - let Some(first) = commands.commands.first() else { - return Err(RenderError::InvalidCommandStream { - index: 0, - message: "empty command list", - }); - }; - if !matches!(first, RenderCommand::BeginFrame) { - return Err(RenderError::InvalidCommandStream { - index: 0, - message: "first command must be BeginFrame", - }); - } - if commands.commands.len() < 2 { - return Err(RenderError::InvalidCommandStream { - index: 0, - message: "frame must end with EndFrame", - }); - } - let end_index = commands.commands.len() - 1; - if !matches!(commands.commands[end_index], RenderCommand::EndFrame) { - return Err(RenderError::InvalidCommandStream { - index: end_index, - message: "last command must be EndFrame", - }); - } - - let mut previous_key: Option<(RenderPhase, u64, DrawId)> = None; - for (index, command) in commands.commands.iter().enumerate() { - match command { - RenderCommand::BeginFrame if index == 0 => {} - RenderCommand::BeginFrame => { - return Err(RenderError::InvalidCommandStream { - index, - message: "nested BeginFrame is not allowed", - }); - } - RenderCommand::EndFrame if index == end_index => {} - RenderCommand::EndFrame => { - return Err(RenderError::InvalidCommandStream { - index, - message: "EndFrame before final command is not allowed", - }); - } - RenderCommand::Draw(draw) => { - validate_draw_command(draw, limits)?; - let key = (draw.phase, draw.stable_order, draw.id); - if let Some(previous) = previous_key { - if key < previous { - return Err(RenderError::PhaseOrderViolation { - draw_id: draw.id, - previous: previous.0, - current: draw.phase, - }); - } - } - previous_key = Some(key); - } - } - } - Ok(()) -} - -fn validate_draw_command( - draw: &DrawCommand, - limits: RenderValidationLimits, -) -> Result<(), RenderError> { - if draw.range.count == 0 { - return Err(RenderError::InvalidRange); - } - let end = validate_index_range(draw.id, draw.range)?; - validate_transform(draw.id, &draw.transform)?; - if let Some(mesh_count) = limits.mesh_count { - if draw.mesh.0 >= mesh_count { - return Err(RenderError::MeshOutOfBounds { - draw_id: draw.id, - mesh: draw.mesh, - mesh_count, - }); - } - } - if let Some(index_count) = limits.index_count { - if end > index_count { - return Err(RenderError::IndexRangeOutOfBounds { - draw_id: draw.id, - index_count, - end, - }); - } - } - Ok(()) -} - -fn validate_index_range(draw_id: DrawId, range: IndexRange) -> Result<u32, RenderError> { - range - .start - .checked_add(range.count) - .ok_or(RenderError::IndexRangeOverflow { - draw_id, - start: range.start, - count: range.count, - }) -} - -fn validate_transform(draw_id: DrawId, transform: &[f32; 16]) -> Result<(), RenderError> { - for (element, value) in transform.iter().enumerate() { - if !value.is_finite() { - return Err(RenderError::NonFiniteTransform { draw_id, element }); - } - } - Ok(()) -} - -fn identity_transform() -> [f32; 16] { - [ - 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, - ] -} - #[cfg(test)] mod tests { use super::*; @@ -1098,49 +431,6 @@ mod tests { } #[test] - fn raw_camera_pose_preserves_words_and_extracts_confirmed_translation() { - let mut active = [0_u32; 16]; - active[0] = 0x7FC0_0001; - active[3] = 491.562_5_f32.to_bits(); - active[7] = 761.550_8_f32.to_bits(); - active[11] = 7.361_0_f32.to_bits(); - let paired = [0xA5A5_5A5A; 16]; - - let pose = RawCameraPose { - selector0: RawCameraTransform { words: active }, - selector2: RawCameraTransform { words: paired }, - }; - - assert_eq!(pose.selector0.words, active); - assert_eq!(pose.selector2.words, paired); - assert_eq!( - pose.selector0.translation(), - [491.562_5, 761.550_8, 7.361_0] - ); - assert_eq!(CameraSnapshot::default().raw_pose, None); - } - - #[test] - fn raw_camera_projection_preserves_live_context_words_without_labeling_them() { - let projection = RawCameraProjection { - viewport: [0, 0, 1024, 768], - projection_type: 0, - field_of_view_radians_bits: 1.04_f32.to_bits(), - context_words: [ - 0.0_f32.to_bits(), - 0.5_f32.to_bits(), - 700.0_f32.to_bits(), - 0.1_f32.to_bits(), - 0.99_f32.to_bits(), - ], - }; - - assert_eq!(projection.field_of_view_radians(), 1.04); - assert_eq!(projection.context_values(), [0.0, 0.5, 700.0, 0.1, 0.99]); - assert_eq!(CameraSnapshot::default().raw_projection, None); - } - - #[test] fn raw_camera_transform_inverts_only_non_singular_affine_blocks() { let source = [ 0.0, -1.0, 0.0, 433.544_7, 0.948_985, 0.0, 0.315_322, 652.292_5, -0.315_322, 0.0, @@ -1259,455 +549,4 @@ mod tests { None ); } - - fn snapshot_draw( - id: u64, - phase: RenderPhase, - material_index: u16, - stable_order: u64, - ) -> RenderSnapshotDraw { - RenderSnapshotDraw { - id: DrawId(id), - phase, - object_id: Some(OriginalObjectId(u32::try_from(id).expect("id fits"))), - mesh: GpuMeshId(10 + id), - material_slots: vec![GpuMaterialId(31), GpuMaterialId(37)], - material_index, - pipeline_state: LegacyPipelineState::default(), - transform: identity_transform(), - range: IndexRange { start: 0, count: 3 }, - stable_order, - } - } - - #[test] - fn capture_is_stable() { - let list = RenderCommandList { - commands: vec![ - RenderCommand::BeginFrame, - RenderCommand::Draw(DrawCommand { - id: DrawId(1), - phase: RenderPhase::Opaque, - object_id: None, - mesh: GpuMeshId(2), - material: GpuMaterialId(3), - pipeline_key: LegacyPipelineState::default().into(), - transform: [0.0; 16], - range: IndexRange { start: 0, count: 3 }, - stable_order: 4, - }), - RenderCommand::EndFrame, - ], - }; - assert_eq!( - canonical_capture(&list).expect("capture"), - b"B\nD,Opaque,1,2,3,0,4\nE\n" - ); - } - - #[test] - fn pipeline_key_is_explicit_stable_and_sensitive_to_pipeline_structure() { - let base = LegacyPipelineState::default(); - assert_eq!(PipelineKey::from(base).packed(), 0); - - let variant = LegacyPipelineState { - blend: LegacyBlendMode::SourceAlpha, - depth: LegacyDepthMode::TestReadOnly, - cull: LegacyCullMode::BackFace, - alpha_test: true, - }; - assert_eq!(PipelineKey::from(variant).packed(), 0b00_101_101); - assert_ne!(PipelineKey::from(base), PipelineKey::from(variant)); - } - - #[test] - fn alpha_test_flag_changes_key_without_encoding_material_threshold() { - let opaque = LegacyPipelineState::default(); - let alpha_test = LegacyPipelineState { - alpha_test: true, - ..opaque - }; - assert_eq!(PipelineKey::from(alpha_test).packed(), 0b10_0000); - } - - #[test] - fn null_backend_validates_without_capture() { - let mut backend = NullBackend; - let invalid = RenderCommandList { - commands: vec![ - RenderCommand::BeginFrame, - RenderCommand::Draw(DrawCommand { - id: DrawId(1), - phase: RenderPhase::Opaque, - object_id: None, - mesh: GpuMeshId(2), - material: GpuMaterialId(3), - pipeline_key: LegacyPipelineState::default().into(), - transform: [0.0; 16], - range: IndexRange { start: 0, count: 0 }, - stable_order: 4, - }), - RenderCommand::EndFrame, - ], - }; - - assert!(matches!( - backend.execute(&invalid), - Err(RenderError::InvalidRange) - )); - } - - #[test] - fn recording_backend_stores_captures() { - let mut backend = RecordingBackend::default(); - let list = RenderCommandList { - commands: vec![RenderCommand::BeginFrame, RenderCommand::EndFrame], - }; - - backend.execute(&list).expect("execute"); - backend.execute(&list).expect("execute"); - - assert_eq!(backend.captures().len(), 2); - assert_eq!(backend.last_capture(), Some(&b"B\nE\n"[..])); - backend.clear(); - assert!(backend.captures().is_empty()); - } - - #[test] - fn one_snapshot_draw_produces_one_draw_command() -> Result<(), RenderError> { - let snapshot = RenderSnapshot { - camera: CameraSnapshot::default(), - draws: vec![snapshot_draw(1, RenderPhase::Opaque, 0, 10)], - }; - - let commands = build_commands(&snapshot, RenderProfile::default())?; - - assert!(matches!(commands.commands[0], RenderCommand::BeginFrame)); - assert!(matches!(commands.commands[2], RenderCommand::EndFrame)); - let RenderCommand::Draw(draw) = &commands.commands[1] else { - panic!("expected draw"); - }; - assert_eq!(draw.id, DrawId(1)); - assert_eq!(draw.mesh, GpuMeshId(11)); - assert_eq!(draw.range, IndexRange { start: 0, count: 3 }); - Ok(()) - } - - #[test] - fn material_index_maps_through_resolved_material_slots() -> Result<(), RenderError> { - let snapshot = RenderSnapshot { - camera: CameraSnapshot::default(), - draws: vec![snapshot_draw(2, RenderPhase::Opaque, 1, 10)], - }; - - let commands = build_commands(&snapshot, RenderProfile::default())?; - - let RenderCommand::Draw(draw) = &commands.commands[1] else { - panic!("expected draw"); - }; - assert_eq!(draw.material, GpuMaterialId(37)); - Ok(()) - } - - #[test] - fn node_transform_is_retained() -> Result<(), RenderError> { - let mut draw = snapshot_draw(3, RenderPhase::Opaque, 0, 10); - draw.transform[3] = 12.5; - draw.transform[7] = -4.0; - let snapshot = RenderSnapshot { - camera: CameraSnapshot::default(), - draws: vec![draw], - }; - - let commands = build_commands(&snapshot, RenderProfile::default())?; - - let RenderCommand::Draw(draw) = &commands.commands[1] else { - panic!("expected draw"); - }; - assert_eq!(draw.transform[3], 12.5); - assert_eq!(draw.transform[7], -4.0); - Ok(()) - } - - #[test] - fn command_order_uses_phase_then_stable_key() -> Result<(), RenderError> { - let snapshot = RenderSnapshot { - camera: CameraSnapshot::default(), - draws: vec![ - snapshot_draw(3, RenderPhase::Transparent, 0, 0), - snapshot_draw(2, RenderPhase::Opaque, 0, 20), - snapshot_draw(1, RenderPhase::Opaque, 0, 10), - ], - }; - - let commands = build_commands(&snapshot, RenderProfile::default())?; - let capture = canonical_capture(&commands)?; - - assert_eq!( - capture, - b"B\nD,Opaque,1,11,31,0,10\nD,Opaque,2,12,31,0,20\nD,Transparent,3,13,31,0,0\nE\n" - ); - Ok(()) - } - - #[test] - fn command_capture_independent_of_snapshot_construction_order() -> Result<(), RenderError> { - let forward = RenderSnapshot { - camera: CameraSnapshot::default(), - draws: vec![ - snapshot_draw(1, RenderPhase::Opaque, 0, 10), - snapshot_draw(2, RenderPhase::Opaque, 1, 20), - ], - }; - let reverse = RenderSnapshot { - camera: CameraSnapshot::default(), - draws: vec![ - snapshot_draw(2, RenderPhase::Opaque, 1, 20), - snapshot_draw(1, RenderPhase::Opaque, 0, 10), - ], - }; - - assert_eq!( - canonical_capture(&build_commands(&forward, RenderProfile::default())?)?, - canonical_capture(&build_commands(&reverse, RenderProfile::default())?)? - ); - Ok(()) - } - - #[test] - fn invalid_range_returns_contextual_error() { - let mut draw = snapshot_draw(9, RenderPhase::Opaque, 0, 10); - draw.range = IndexRange { start: 4, count: 0 }; - let snapshot = RenderSnapshot { - camera: CameraSnapshot::default(), - draws: vec![draw], - }; - - assert!(matches!( - build_commands(&snapshot, RenderProfile::default()), - Err(RenderError::InvalidDrawRange { - draw_id: DrawId(9), - stable_order: 10, - start: 4, - count: 0 - }) - )); - } - - #[test] - fn command_validation_rejects_bad_frame_framing() { - let missing_begin = RenderCommandList { - commands: vec![RenderCommand::EndFrame], - }; - assert!(matches!( - validate_command_list(&missing_begin), - Err(RenderError::InvalidCommandStream { - index: 0, - message: "first command must be BeginFrame" - }) - )); - - let nested = RenderCommandList { - commands: vec![ - RenderCommand::BeginFrame, - RenderCommand::BeginFrame, - RenderCommand::EndFrame, - ], - }; - assert!(matches!( - validate_command_list(&nested), - Err(RenderError::InvalidCommandStream { - index: 1, - message: "nested BeginFrame is not allowed" - }) - )); - } - - #[test] - fn command_validation_rejects_nonfinite_transform_and_range_overflow() { - let mut draw = snapshot_draw(10, RenderPhase::Opaque, 0, 10); - draw.transform[5] = f32::NAN; - let nonfinite = build_commands( - &RenderSnapshot { - camera: CameraSnapshot::default(), - draws: vec![draw], - }, - RenderProfile::default(), - ); - assert!(matches!( - nonfinite, - Err(RenderError::NonFiniteTransform { - draw_id: DrawId(10), - element: 5 - }) - )); - - let list = RenderCommandList { - commands: vec![ - RenderCommand::BeginFrame, - RenderCommand::Draw(DrawCommand { - id: DrawId(11), - phase: RenderPhase::Opaque, - object_id: None, - mesh: GpuMeshId(2), - material: GpuMaterialId(3), - pipeline_key: LegacyPipelineState::default().into(), - transform: identity_transform(), - range: IndexRange { - start: u32::MAX, - count: 1, - }, - stable_order: 4, - }), - RenderCommand::EndFrame, - ], - }; - assert!(matches!( - validate_command_list(&list), - Err(RenderError::IndexRangeOverflow { - draw_id: DrawId(11), - start: u32::MAX, - count: 1 - }) - )); - } - - #[test] - fn command_validation_checks_order_and_resource_bounds() { - let ordered = build_commands( - &RenderSnapshot { - camera: CameraSnapshot::default(), - draws: vec![snapshot_draw(1, RenderPhase::Opaque, 0, 10)], - }, - RenderProfile::default(), - ) - .expect("commands"); - assert!(matches!( - validate_command_list_with_limits( - &ordered, - RenderValidationLimits { - mesh_count: Some(5), - index_count: Some(16) - } - ), - Err(RenderError::MeshOutOfBounds { - draw_id: DrawId(1), - mesh: GpuMeshId(11), - mesh_count: 5 - }) - )); - - let out_of_bounds = RenderCommandList { - commands: vec![ - RenderCommand::BeginFrame, - RenderCommand::Draw(DrawCommand { - id: DrawId(12), - phase: RenderPhase::Opaque, - object_id: None, - mesh: GpuMeshId(2), - material: GpuMaterialId(3), - pipeline_key: LegacyPipelineState::default().into(), - transform: identity_transform(), - range: IndexRange { - start: 14, - count: 3, - }, - stable_order: 4, - }), - RenderCommand::EndFrame, - ], - }; - assert!(matches!( - validate_command_list_with_limits( - &out_of_bounds, - RenderValidationLimits { - mesh_count: Some(5), - index_count: Some(16) - } - ), - Err(RenderError::IndexRangeOutOfBounds { - draw_id: DrawId(12), - index_count: 16, - end: 17 - }) - )); - - let unordered = RenderCommandList { - commands: vec![ - RenderCommand::BeginFrame, - RenderCommand::Draw(DrawCommand { - id: DrawId(1), - phase: RenderPhase::Transparent, - object_id: None, - mesh: GpuMeshId(1), - material: GpuMaterialId(1), - pipeline_key: LegacyPipelineState::default().into(), - transform: identity_transform(), - range: IndexRange { start: 0, count: 3 }, - stable_order: 0, - }), - RenderCommand::Draw(DrawCommand { - id: DrawId(2), - phase: RenderPhase::Opaque, - object_id: None, - mesh: GpuMeshId(1), - material: GpuMaterialId(1), - pipeline_key: LegacyPipelineState::default().into(), - transform: identity_transform(), - range: IndexRange { start: 0, count: 3 }, - stable_order: 0, - }), - RenderCommand::EndFrame, - ], - }; - assert!(matches!( - validate_command_list(&unordered), - Err(RenderError::PhaseOrderViolation { - draw_id: DrawId(2), - previous: RenderPhase::Transparent, - current: RenderPhase::Opaque - }) - )); - } - - #[test] - fn render_error_display_is_actionable() { - assert_eq!( - RenderError::InvalidDrawRange { - draw_id: DrawId(9), - stable_order: 10, - start: 4, - count: 0 - } - .to_string(), - "draw 9 has invalid index range start=4 count=0 at stable order 10" - ); - assert_eq!( - RenderError::MaterialIndexOutOfBounds { - draw_id: DrawId(7), - material_index: 3, - material_count: 2 - } - .to_string(), - "draw 7 references material index 3 but only 2 material slots are available" - ); - } - - #[test] - fn ui_phase_is_excluded_until_requested() -> Result<(), RenderError> { - let snapshot = RenderSnapshot { - camera: CameraSnapshot::default(), - draws: vec![ - snapshot_draw(1, RenderPhase::Opaque, 0, 10), - snapshot_draw(2, RenderPhase::Ui, 0, 20), - ], - }; - - let default_commands = build_commands(&snapshot, RenderProfile::default())?; - let ui_commands = build_commands(&snapshot, RenderProfile { include_ui: true })?; - - assert_eq!(default_commands.commands.len(), 3); - assert_eq!(ui_commands.commands.len(), 4); - Ok(()) - } } diff --git a/crates/fparkan-resource/Cargo.toml b/crates/fparkan-resource/Cargo.toml index 011583e..815bef9 100644 --- a/crates/fparkan-resource/Cargo.toml +++ b/crates/fparkan-resource/Cargo.toml @@ -6,11 +6,8 @@ license.workspace = true repository.workspace = true [dependencies] -fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" } -fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" } -fparkan-path = { path = "../fparkan-path", version = "0.1.0" } -fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" } -fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" } - -[lints] -workspace = true +fparkan-binary = { path = "../fparkan-binary" } +fparkan-nres = { path = "../fparkan-nres" } +fparkan-path = { path = "../fparkan-path" } +fparkan-rsli = { path = "../fparkan-rsli" } +fparkan-vfs = { path = "../fparkan-vfs" } diff --git a/crates/fparkan-resource/src/lib.rs b/crates/fparkan-resource/src/lib.rs index f996a71..c470501 100644 --- a/crates/fparkan-resource/src/lib.rs +++ b/crates/fparkan-resource/src/lib.rs @@ -1,23 +1,4 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] //! Resource identity and repository ports. use fparkan_binary::{sha256, Sha256Digest}; diff --git a/crates/fparkan-rsli/Cargo.toml b/crates/fparkan-rsli/Cargo.toml index 0b72705..2230dc2 100644 --- a/crates/fparkan-rsli/Cargo.toml +++ b/crates/fparkan-rsli/Cargo.toml @@ -6,8 +6,5 @@ license.workspace = true repository.workspace = true [dependencies] -fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" } +fparkan-binary = { path = "../fparkan-binary" } flate2 = { version = "1", default-features = false, features = ["rust_backend"] } - -[lints] -workspace = true diff --git a/crates/fparkan-rsli/src/lib.rs b/crates/fparkan-rsli/src/lib.rs index 0bb1498..23b1be2 100644 --- a/crates/fparkan-rsli/src/lib.rs +++ b/crates/fparkan-rsli/src/lib.rs @@ -1,23 +1,4 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] //! Stage-1 `RsLi` archive contract. use fparkan_binary::DecodeError; @@ -954,7 +935,6 @@ struct EntryRecord { effective_offset: usize, } -#[allow(clippy::too_many_lines)] fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliError> { enforce_limit( u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?, diff --git a/crates/fparkan-runtime/Cargo.toml b/crates/fparkan-runtime/Cargo.toml index 858e5af..ddf09a1 100644 --- a/crates/fparkan-runtime/Cargo.toml +++ b/crates/fparkan-runtime/Cargo.toml @@ -6,16 +6,13 @@ license.workspace = true repository.workspace = true [dependencies] -fparkan-assets = { path = "../fparkan-assets", version = "0.1.0" } -fparkan-path = { path = "../fparkan-path", version = "0.1.0" } -fparkan-platform = { path = "../fparkan-platform", version = "0.1.0" } -fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" } -fparkan-render = { path = "../fparkan-render", version = "0.1.0" } -fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" } -fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" } -fparkan-script = { path = "../fparkan-script", version = "0.1.0" } -fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" } -fparkan-world = { path = "../fparkan-world", version = "0.1.0" } - -[lints] -workspace = true +fparkan-assets = { path = "../fparkan-assets" } +fparkan-path = { path = "../fparkan-path" } +fparkan-platform = { path = "../fparkan-platform" } +fparkan-prototype = { path = "../fparkan-prototype" } +fparkan-render = { path = "../fparkan-render" } +fparkan-terrain = { path = "../fparkan-terrain" } +fparkan-resource = { path = "../fparkan-resource" } +fparkan-script = { path = "../fparkan-script" } +fparkan-vfs = { path = "../fparkan-vfs" } +fparkan-world = { path = "../fparkan-world" } diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 2bb1be1..2fead30 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -1,32 +1,13 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] -//! Runtime orchestration for headless and rendered modes. +//! Runtime orchestration for mission loading and world stepping. use fparkan_assets::{ decode_mission_land_path, decode_mission_payload, decode_nres_payload, - derive_mission_land_paths, extend_graph_report_with_visual_dependencies_with_progress, + derive_mission_land_paths, extend_graph_report_with_visual_dependencies, mission_script_bundle_bases, prepare_terrain_world, AssetError as AssetPreparationError, - AssetId, AssetManager, AssetPreparationPhase, BuildCategory, MissionAssetPlan, MissionDocument, - MissionError, MissionTerrainPaths, NresError, PreparedVisual, TerrainFormatError, - TerrainPreparationError, TerrainWorld, TmaProfile, VisualDependencyPhase, + AssetId, AssetManager, BuildCategory, MissionAssetPlan, MissionDocument, MissionError, + MissionTerrainPaths, NresError, PreparedVisual, TerrainFormatError, TerrainPreparationError, + TerrainWorld, TmaProfile, }; use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy}; use fparkan_prototype::{ @@ -53,47 +34,6 @@ pub use fparkan_assets::MissionAssets; const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256; const FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var"; -/// 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 { @@ -116,97 +56,6 @@ pub struct MissionRequest { 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, - /// Expand model-backed visual dependencies of the prototype graph. - GraphVisuals, - /// Resolve WEAR tables while expanding visual dependencies. - GraphVisualWears, - /// Resolve MAT0 documents while expanding visual dependencies. - GraphVisualMaterials, - /// Progress marker emitted after each 64 MAT0 validation requests. - GraphVisualMaterialRequests { - /// Number of MAT0 validation requests started. - request_count: usize, - /// Graph nodes materialized before the request. - graph_node_count: usize, - /// Graph edges materialized before the request. - graph_edge_count: usize, - /// Distinct WEAR validation keys retained before the request. - wear_cache_entries: usize, - /// Distinct MAT0 validation keys retained before the request. - material_cache_entries: usize, - /// Distinct TEXM validation keys retained before the request. - texture_cache_entries: usize, - }, - /// Validate TEXM documents while expanding visual dependencies. - GraphVisualTextures, - /// Progress marker emitted after each 64 TEXM validation requests. - GraphVisualTextureRequests { - /// Number of TEXM validation requests started. - request_count: usize, - /// Graph nodes materialized before the request. - graph_node_count: usize, - /// Graph edges materialized before the request. - graph_edge_count: usize, - /// Distinct WEAR validation keys retained before the request. - wear_cache_entries: usize, - /// Distinct MAT0 validation keys retained before the request. - material_cache_entries: usize, - /// Distinct TEXM validation keys retained before the request. - texture_cache_entries: usize, - }, - /// Prepare all reachable visual/resource dependencies. - Assets, - /// Decode and validate MSH model meshes. - AssetModelMeshes, - /// Decode WEAR material tables. - AssetWearTables, - /// Resolve MAT0 material documents. - AssetMaterials, - /// Decode diffuse textures and baked lightmaps. - AssetTextures, - /// 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>, -} - /// Ordered mission property preserved for later runtime stages. #[derive(Clone, Debug, Eq, PartialEq)] pub struct MissionObjectProperty { @@ -435,20 +284,10 @@ pub struct LoadedMission { pub struct FrameResult { /// Snapshot. pub snapshot: WorldSnapshot, - /// Scheduler phases executed for this frame. - pub trace: FrameTrace, -} - -/// Scheduler trace for a completed frame. -#[derive(Clone, Debug, Default, Eq, PartialEq)] -pub struct FrameTrace { - /// Frame phases in execution order. - pub phases: Vec<SchedulerPhase>, } /// Engine. pub struct Engine { - config: EngineConfig, services: EngineServices, world: World, loaded: Option<LoadedMissionState>, @@ -559,13 +398,6 @@ pub enum EngineError { World(fparkan_world::WorldError), /// Reference movement input or terrain query failed. Movement(String), - /// Scheduler phase order was violated. - SchedulerPhaseOrder { - /// Previous phase. - previous: SchedulerPhase, - /// Current phase. - current: SchedulerPhase, - }, /// Staged mission world was torn down after a registration-phase failure. RegistrationTeardown { /// Registered objects before the forced failure. @@ -676,10 +508,6 @@ impl std::fmt::Display for EngineError { } Self::World(source) => write!(f, "{source}"), Self::Movement(message) => write!(f, "reference movement: {message}"), - Self::SchedulerPhaseOrder { previous, current } => write!( - f, - "scheduler phase order regressed from {previous:?} to {current:?}" - ), Self::RegistrationTeardown { registered_objects, released_objects, @@ -710,22 +538,14 @@ impl std::error::Error for EngineError { | Self::ScriptRuntime { .. } | Self::Movement(_) | Self::PrototypeGraph { .. } - | Self::SchedulerPhaseOrder { .. } | 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> { +/// Creates an engine with the supplied services. +pub fn create(services: EngineServices) -> Result<Engine, EngineError> { Ok(Engine { - config, services, world: new_world(WorldConfig), loaded: None, @@ -744,7 +564,7 @@ pub fn load_mission( engine: &mut Engine, request: MissionRequest, ) -> Result<LoadedMission, EngineError> { - load_mission_with_trace(engine, request).map(|(loaded, _trace)| loaded) + load_mission_with_options(engine, request, MissionLoadOptions::default()) } /// Loads a mission using the bounded static-preview asset scope. @@ -763,123 +583,48 @@ pub fn load_mission_static_preview( engine: &mut Engine, request: MissionRequest, ) -> Result<LoadedMission, EngineError> { - load_mission_static_preview_with_progress(engine, request, |_| {}) -} - -/// Loads a static preview for the first requested non-zero count of mission roots. -/// -/// Normal gameplay still uses [`load_mission`] and always resolves every root. -/// -/// # Errors -/// -/// Returns [`EngineError`] under the same conditions as -/// [`load_mission_static_preview`]. -pub fn load_mission_static_preview_roots( - engine: &mut Engine, - request: MissionRequest, - root_count: NonZeroUsize, -) -> Result<LoadedMission, EngineError> { - load_mission_static_preview_roots_with_progress(engine, request, root_count, |_| {}) -} - -/// Loads selected static-preview roots while synchronously reporting phases. -/// -/// # Errors -/// -/// Returns [`EngineError`] under the same conditions as -/// [`load_mission_static_preview_roots`]. -pub fn load_mission_static_preview_roots_with_progress( - engine: &mut Engine, - request: MissionRequest, - root_count: NonZeroUsize, - mut on_phase: impl FnMut(MissionLoadPhase), -) -> Result<LoadedMission, EngineError> { - load_mission_with_options_and_progress( + load_mission_with_options( engine, request, MissionLoadOptions { - asset_scope: MissionAssetScope::PreviewRoots(root_count), + asset_scope: MissionAssetScope::FirstMeshPreview, ..MissionLoadOptions::default() }, - Some(&mut on_phase), ) - .map(|(loaded, _trace)| loaded) } -/// Loads a static preview while synchronously reporting entered loading phases. +/// Loads a static preview for the first requested non-zero count of mission roots. /// -/// This has the same bounded asset scope as [`load_mission_static_preview`]. +/// Normal gameplay still uses [`load_mission`] and always resolves every root. /// /// # Errors /// /// Returns [`EngineError`] under the same conditions as /// [`load_mission_static_preview`]. -pub fn load_mission_static_preview_with_progress( +pub fn load_mission_static_preview_roots( engine: &mut Engine, request: MissionRequest, - mut on_phase: impl FnMut(MissionLoadPhase), + root_count: NonZeroUsize, ) -> Result<LoadedMission, EngineError> { - load_mission_with_options_and_progress( + load_mission_with_options( engine, request, MissionLoadOptions { - asset_scope: MissionAssetScope::FirstMeshPreview, + asset_scope: MissionAssetScope::PreviewRoots(root_count), ..MissionLoadOptions::default() }, - Some(&mut on_phase), ) - .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_and_progress(engine, request, MissionLoadOptions::default(), None) } -/// Loads a mission while synchronously reporting each entered loading phase. -/// -/// The observer runs immediately after the phase is recorded in the returned -/// trace. It is intended for diagnostic progress reporting; it does not alter -/// loader ordering, validation, or transaction behavior. -/// -/// # Errors -/// -/// Returns [`EngineError`] under the same conditions as [`load_mission`]. -pub fn load_mission_with_progress( - engine: &mut Engine, - request: MissionRequest, - mut on_phase: impl FnMut(MissionLoadPhase), -) -> Result<LoadedMission, EngineError> { - load_mission_with_options_and_progress( - engine, - request, - MissionLoadOptions::default(), - Some(&mut on_phase), - ) - .map(|(loaded, _trace)| loaded) -} - -#[allow(clippy::too_many_lines)] -fn load_mission_with_options_and_progress( +fn load_mission_with_options( engine: &mut Engine, request: MissionRequest, options: MissionLoadOptions, - mut on_phase: Option<&mut dyn FnMut(MissionLoadPhase)>, -) -> Result<(LoadedMission, MissionLoadTrace), EngineError> { - let mut trace = MissionLoadTrace::default(); - record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Context); +) -> Result<LoadedMission, EngineError> { 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)?; - record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Map); let land_path = decode_mission_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| { EngineError::Mission { @@ -919,7 +664,6 @@ fn load_mission_with_options_and_progress( TerrainPreparationError::Runtime(source) => EngineError::Terrain(source), } })?; - record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Tma); let mission = decode_mission_payload(mission_bytes, TmaProfile::Strict).map_err(|source| { EngineError::Mission { path: mission_path.as_str().to_string(), @@ -927,18 +671,6 @@ fn load_mission_with_options_and_progress( } })?; let loaded_scripts = load_mission_scripts(&vfs, &mission)?; - trace.transforms = mission - .objects - .iter() - .enumerate() - .map(|(object_index, object)| PlacedTransformProfile { - object_index, - position: object.position, - orientation_raw: object.orientation, - scale: object.scale, - }) - .collect(); - record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Graph); let repository = CachedResourceRepository::with_payload_cache_budget( vfs.clone(), MISSION_DECODED_PAYLOAD_CACHE_ENTRIES, @@ -957,93 +689,34 @@ fn load_mission_with_options_and_progress( }; let (mut prototype_graph, resolved_prototypes, mut prototype_report) = build_prototype_graph_report(&repository, vfs.as_ref(), scoped_graph_roots); - record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::GraphVisuals); - let mut last_graph_visual_phase = None; - extend_graph_report_with_visual_dependencies_with_progress( + extend_graph_report_with_visual_dependencies( &repository, &mut prototype_report, &mut prototype_graph, &resolved_prototypes, - |progress| { - let runtime_phase = match progress.phase { - VisualDependencyPhase::Wear => MissionLoadPhase::GraphVisualWears, - VisualDependencyPhase::Material => MissionLoadPhase::GraphVisualMaterials, - VisualDependencyPhase::Texture => MissionLoadPhase::GraphVisualTextures, - }; - if last_graph_visual_phase != Some(runtime_phase) { - record_load_phase(&mut trace, &mut on_phase, runtime_phase); - last_graph_visual_phase = Some(runtime_phase); - } - if progress.request_count > 1 { - let request_phase = match progress.phase { - VisualDependencyPhase::Wear => None, - VisualDependencyPhase::Material => { - Some(MissionLoadPhase::GraphVisualMaterialRequests { - request_count: progress.request_count, - graph_node_count: progress.graph_node_count, - graph_edge_count: progress.graph_edge_count, - wear_cache_entries: progress.cache_entries.wear_entries, - material_cache_entries: progress.cache_entries.material_entries, - texture_cache_entries: progress.cache_entries.texture_entries, - }) - } - VisualDependencyPhase::Texture => { - Some(MissionLoadPhase::GraphVisualTextureRequests { - request_count: progress.request_count, - graph_node_count: progress.graph_node_count, - graph_edge_count: progress.graph_edge_count, - wear_cache_entries: progress.cache_entries.wear_entries, - material_cache_entries: progress.cache_entries.material_entries, - texture_cache_entries: progress.cache_entries.texture_entries, - }) - } - }; - if let Some(request_phase) = request_phase { - record_load_phase(&mut trace, &mut on_phase, request_phase); - } - } - }, ); if !prototype_report.is_success() { return Err(EngineError::PrototypeGraph { failures: prototype_report.failures.clone(), }); } - record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Assets); let asset_manager = AssetManager::new(repository); - let mut last_asset_phase = None; - let mut observe_asset_phase = |phase| { - let runtime_phase = match phase { - AssetPreparationPhase::ModelMesh => MissionLoadPhase::AssetModelMeshes, - AssetPreparationPhase::WearTable => MissionLoadPhase::AssetWearTables, - AssetPreparationPhase::Materials => MissionLoadPhase::AssetMaterials, - AssetPreparationPhase::Textures => MissionLoadPhase::AssetTextures, - }; - if last_asset_phase != Some(runtime_phase) { - record_load_phase(&mut trace, &mut on_phase, runtime_phase); - last_asset_phase = Some(runtime_phase); - } - }; let mission_assets = match options.asset_scope { - MissionAssetScope::Full => asset_manager.prepare_mission_assets_with_progress( + MissionAssetScope::Full => asset_manager.prepare_mission_assets( &prototype_graph.root_prototype_request_spans, &resolved_prototypes, - &mut observe_asset_phase, ), MissionAssetScope::FirstMeshPreview => prepare_first_preview_assets( &asset_manager, &prototype_graph.root_prototype_request_spans, &resolved_prototypes, - &mut observe_asset_phase, ), - MissionAssetScope::PreviewRoots(root_count) => asset_manager - .prepare_mission_assets_with_progress( - &prototype_graph.root_prototype_request_spans[..root_count - .get() - .min(prototype_graph.root_prototype_request_spans.len())], - &resolved_prototypes, - &mut observe_asset_phase, - ), + MissionAssetScope::PreviewRoots(root_count) => asset_manager.prepare_mission_assets( + &prototype_graph.root_prototype_request_spans[..root_count + .get() + .min(prototype_graph.root_prototype_request_spans.len())], + &resolved_prototypes, + ), } .map_err(|source| EngineError::AssetPreparation { mission: request.key.clone(), @@ -1079,7 +752,6 @@ fn load_mission_with_options_and_progress( .collect(); let mut new_runtime_world = new_world(WorldConfig); let mut handles = Vec::with_capacity(mission.objects.len()); - record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Construct); for (index, object) in mission.objects.iter().enumerate() { let original_id = u32::try_from(index).ok().map(OriginalObjectId); let handle = construct_object(&mut new_runtime_world, ObjectDraft { original_id })?; @@ -1094,19 +766,18 @@ fn load_mission_with_options_and_progress( )?; handles.push(handle); } - trace.drafts_before_registration = handles.len(); - record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Register); + let mut registered_objects = 0; for handle in &handles { - if options.fail_after_registered_objects == Some(trace.registered_objects) { + if options.fail_after_registered_objects == Some(registered_objects) { let report = fparkan_world::shutdown(new_runtime_world); return Err(EngineError::RegistrationTeardown { - registered_objects: trace.registered_objects, + registered_objects, released_objects: report.released_objects.len(), managers_released: report.managers_released, }); } register_object(&mut new_runtime_world, *handle)?; - trace.registered_objects += 1; + registered_objects += 1; } let summary = LoadedMission { @@ -1174,32 +845,17 @@ fn load_mission_with_options_and_progress( script_varset_states: loaded_scripts.varset_states, script_outcome: MissionScriptOutcome::Active, }); - Ok((summary, trace)) -} - -fn record_load_phase( - trace: &mut MissionLoadTrace, - on_phase: &mut Option<&mut dyn FnMut(MissionLoadPhase)>, - phase: MissionLoadPhase, -) { - trace.phases.push(phase); - if let Some(observer) = on_phase.as_deref_mut() { - observer(phase); - } + Ok(summary) } fn prepare_first_preview_assets<R: ResourceRepository>( asset_manager: &AssetManager<R>, root_spans: &[std::ops::Range<usize>], prototypes: &[fparkan_prototype::EffectivePrototype], - on_phase: &mut dyn FnMut(AssetPreparationPhase), ) -> Result<MissionAssets, AssetPreparationError> { for span in root_spans { - let assets = asset_manager.prepare_mission_assets_with_progress( - std::slice::from_ref(span), - prototypes, - &mut *on_phase, - )?; + let assets = + asset_manager.prepare_mission_assets(std::slice::from_ref(span), prototypes)?; if !assets.models.is_empty() { return Ok(assets); } @@ -1216,7 +872,9 @@ pub fn step_headless( engine: &mut Engine, input: InputSnapshot, ) -> Result<FrameResult, EngineError> { - run_frame(engine, input, SchedulerPresentation::Headless) + Ok(FrameResult { + snapshot: step(&mut engine.world, &input)?, + }) } /// Steps rendered mode. @@ -1225,20 +883,7 @@ pub fn step_headless( /// /// Returns [`EngineError`] when the world step fails. pub fn frame(engine: &mut Engine) -> Result<FrameResult, EngineError> { - match engine.config.mode { - EngineMode::Headless => step_headless(engine, InputSnapshot), - EngineMode::Rendered => run_frame(engine, InputSnapshot, SchedulerPresentation::Rendered), - } -} - -/// 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(()) + step_headless(engine, InputSnapshot) } /// Returns the loaded mission summary. @@ -1451,78 +1096,6 @@ pub fn loaded_mission_object_drafts(engine: &Engine) -> Option<&[MissionObjectDr .map(|state| state.object_drafts.as_slice()) } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SchedulerPresentation { - Headless, - Rendered, -} - -#[derive(Clone, Debug, Default)] -struct Scheduler { - phase: Option<SchedulerPhase>, - trace: FrameTrace, -} - -impl Scheduler { - fn enter(&mut self, phase: SchedulerPhase) -> Result<(), EngineError> { - if let Some(previous) = self.phase { - if scheduler_phase_index(phase) <= scheduler_phase_index(previous) { - return Err(EngineError::SchedulerPhaseOrder { - previous, - current: phase, - }); - } - } - self.phase = Some(phase); - self.trace.phases.push(phase); - Ok(()) - } - - fn finish(self) -> FrameTrace { - self.trace - } -} - -fn run_frame( - engine: &mut Engine, - input: InputSnapshot, - presentation: SchedulerPresentation, -) -> Result<FrameResult, EngineError> { - let mut scheduler = Scheduler::default(); - scheduler.enter(SchedulerPhase::CollectPlatformEvents)?; - scheduler.enter(SchedulerPhase::BuildInputSnapshot)?; - scheduler.enter(SchedulerPhase::AdvanceGameClock)?; - scheduler.enter(SchedulerPhase::CalculateWorldQueue)?; - let snapshot = step(&mut engine.world, &input)?; - scheduler.enter(SchedulerPhase::ApplyDeferredOperations)?; - scheduler.enter(SchedulerPhase::UpdateAnimationAndEffects)?; - if presentation == SchedulerPresentation::Rendered { - scheduler.enter(SchedulerPhase::PublishRenderSnapshot)?; - scheduler.enter(SchedulerPhase::RenderWorld)?; - } - scheduler.enter(SchedulerPhase::EndFrameCallbacks)?; - scheduler.enter(SchedulerPhase::Maintenance)?; - Ok(FrameResult { - snapshot, - trace: scheduler.finish(), - }) -} - -fn scheduler_phase_index(phase: SchedulerPhase) -> u8 { - match phase { - SchedulerPhase::CollectPlatformEvents => 0, - SchedulerPhase::BuildInputSnapshot => 1, - SchedulerPhase::AdvanceGameClock => 2, - SchedulerPhase::CalculateWorldQueue => 3, - SchedulerPhase::ApplyDeferredOperations => 4, - SchedulerPhase::UpdateAnimationAndEffects => 5, - SchedulerPhase::PublishRenderSnapshot => 6, - SchedulerPhase::RenderWorld => 7, - SchedulerPhase::EndFrameCallbacks => 8, - SchedulerPhase::Maintenance => 9, - } -} - fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPath, EngineError> { normalize_relative(value.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| { EngineError::Path { @@ -1702,7 +1275,7 @@ fn handler19_x87_truncated_u32( } // ai.dll's __ftol saves the x87 control word, sets rounding-control bits // to truncate, performs `fistp qword`, and restores the control word. - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let word = value.trunc() as u32; Ok(word) } @@ -1758,13 +1331,7 @@ mod tests { #[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 mut engine = create(EngineServices::default()).expect("engine"); let before = step_headless(&mut engine, InputSnapshot).expect("step"); let err = load_mission( &mut engine, @@ -1779,30 +1346,6 @@ mod tests { } #[test] - fn load_progress_reports_context_before_missing_vfs_error() { - let mut engine = create( - EngineConfig { - mode: EngineMode::Headless, - }, - EngineServices::default(), - ) - .expect("engine"); - let mut phases = Vec::new(); - - let err = load_mission_with_progress( - &mut engine, - MissionRequest { - key: "MISSIONS/Autodemo.00/data.tma".to_string(), - }, - |phase| phases.push(phase), - ) - .expect_err("missing VFS"); - - assert!(matches!(err, EngineError::MissingVfs)); - assert_eq!(phases, vec![MissionLoadPhase::Context]); - } - - #[test] fn script_varset_prefers_bundle_adjacent_file_then_uses_shared_fallback() { let mut personal_files = MemoryVfs::default(); personal_files.insert( @@ -1997,87 +1540,8 @@ mod tests { } #[test] - fn headless_scheduler_trace_skips_presentation_phases() { - let mut engine = create( - EngineConfig { - mode: EngineMode::Headless, - }, - EngineServices::default(), - ) - .expect("engine"); - - let result = frame(&mut engine).expect("frame"); - - assert_eq!(result.snapshot.tick.0, 1); - assert_eq!( - result.trace.phases, - vec![ - SchedulerPhase::CollectPlatformEvents, - SchedulerPhase::BuildInputSnapshot, - SchedulerPhase::AdvanceGameClock, - SchedulerPhase::CalculateWorldQueue, - SchedulerPhase::ApplyDeferredOperations, - SchedulerPhase::UpdateAnimationAndEffects, - SchedulerPhase::EndFrameCallbacks, - SchedulerPhase::Maintenance, - ] - ); - } - - #[test] - fn rendered_scheduler_trace_includes_presentation_after_simulation() { - let mut engine = create( - EngineConfig { - mode: EngineMode::Rendered, - }, - EngineServices::default(), - ) - .expect("engine"); - - let result = frame(&mut engine).expect("frame"); - - assert_eq!( - result.trace.phases, - vec![ - SchedulerPhase::CollectPlatformEvents, - SchedulerPhase::BuildInputSnapshot, - SchedulerPhase::AdvanceGameClock, - SchedulerPhase::CalculateWorldQueue, - SchedulerPhase::ApplyDeferredOperations, - SchedulerPhase::UpdateAnimationAndEffects, - SchedulerPhase::PublishRenderSnapshot, - SchedulerPhase::RenderWorld, - SchedulerPhase::EndFrameCallbacks, - SchedulerPhase::Maintenance, - ] - ); - } - - #[test] - fn scheduler_rejects_phase_regressions() { - let mut scheduler = Scheduler::default(); - scheduler - .enter(SchedulerPhase::BuildInputSnapshot) - .expect("enter build input"); - - assert!(matches!( - scheduler.enter(SchedulerPhase::CollectPlatformEvents), - Err(EngineError::SchedulerPhaseOrder { - previous: SchedulerPhase::BuildInputSnapshot, - current: SchedulerPhase::CollectPlatformEvents, - }) - )); - } - - #[test] fn reference_movement_rejects_non_finite_or_non_positive_input() { - let mut engine = create( - EngineConfig { - mode: EngineMode::Headless, - }, - EngineServices::default(), - ) - .expect("engine"); + let mut engine = create(EngineServices::default()).expect("engine"); let err = advance_reference_movement(&mut engine, OriginalObjectId(0), [f32::NAN, 0.0], 1.0) @@ -2095,59 +1559,10 @@ mod tests { #[test] #[ignore = "requires licensed corpus"] - fn load_trace_records_preparation_before_registration_and_raw_transforms() { - let root = licensed_root("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::GraphVisuals, - 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 reference_movement_snaps_a_live_mission_object_to_terrain() { let root = licensed_root("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 mut engine = create(EngineServices::new(vfs)).expect("engine"); load_mission( &mut engine, MissionRequest { @@ -2221,13 +1636,7 @@ mod tests { inner: DirectoryVfs::new(&root), denied, }); - let mut engine = create( - EngineConfig { - mode: EngineMode::Headless, - }, - EngineServices::new(vfs), - ) - .expect("engine"); + let mut engine = create(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 { @@ -2248,16 +1657,10 @@ mod tests { fn registration_phase_failure_uses_normal_teardown_and_keeps_engine_world() { let root = licensed_root("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 mut engine = create(EngineServices::new(vfs)).expect("engine"); let before = step_headless(&mut engine, InputSnapshot).expect("before"); - let err = load_mission_with_options_and_progress( + let err = load_mission_with_options( &mut engine, MissionRequest { key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(), @@ -2266,7 +1669,6 @@ mod tests { fail_after_registered_objects: Some(1), ..MissionLoadOptions::default() }, - None, ) .expect_err("forced registration failure"); @@ -2326,13 +1728,7 @@ mod tests { ] { let root = local_testdata_root(case.0); let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); - let mut engine = create( - EngineConfig { - mode: EngineMode::Headless, - }, - EngineServices::new(vfs), - ) - .expect("engine"); + let mut engine = create(EngineServices::new(vfs)).expect("engine"); let loaded = load_mission( &mut engine, MissionRequest { @@ -2440,13 +1836,7 @@ mod tests { fn run_headless_case(case: HeadlessCase) -> WorldSnapshot { let root = licensed_root(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 mut engine = create(EngineServices::new(vfs)).expect("engine"); let loaded = load_mission( &mut engine, MissionRequest { @@ -2474,13 +1864,7 @@ mod tests { 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 mut engine = create(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); diff --git a/crates/fparkan-script/Cargo.toml b/crates/fparkan-script/Cargo.toml index 0280be7..47ee000 100644 --- a/crates/fparkan-script/Cargo.toml +++ b/crates/fparkan-script/Cargo.toml @@ -6,7 +6,4 @@ license.workspace = true repository.workspace = true [dependencies] -fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" } - -[lints] -workspace = true +fparkan-binary = { path = "../fparkan-binary" } diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs index 6887a03..88c2d0a 100644 --- a/crates/fparkan-script/src/lib.rs +++ b/crates/fparkan-script/src/lib.rs @@ -1,5 +1,4 @@ #![forbid(unsafe_code)] -#![cfg_attr(test, allow(clippy::expect_used, clippy::panic, clippy::unwrap_used))] //! Lossless, bounded reader for compiled AI `.scr` packages. //! //! This module preserves the layout proven by the GOG `ai.dll` reader. It @@ -1061,9 +1060,7 @@ impl ScriptInstruction { #[must_use] pub fn dispatch_selector(&self) -> ScriptDispatchSelector { match self.header_words[0] { - value if value < GOG_HANDLER_COUNT => - { - #[allow(clippy::cast_possible_truncation)] + value if value < GOG_HANDLER_COUNT => { ScriptDispatchSelector::Handler(self.header_words[0] as u8) } u32::MAX => ScriptDispatchSelector::Sentinel, diff --git a/crates/fparkan-terrain-format/Cargo.toml b/crates/fparkan-terrain-format/Cargo.toml index 28a7a9c..dcd4e9e 100644 --- a/crates/fparkan-terrain-format/Cargo.toml +++ b/crates/fparkan-terrain-format/Cargo.toml @@ -6,8 +6,5 @@ license.workspace = true repository.workspace = true [dependencies] -fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" } -fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" } - -[lints] -workspace = true +fparkan-binary = { path = "../fparkan-binary" } +fparkan-nres = { path = "../fparkan-nres" } diff --git a/crates/fparkan-terrain-format/src/lib.rs b/crates/fparkan-terrain-format/src/lib.rs index 54bd3f4..3c05cfe 100644 --- a/crates/fparkan-terrain-format/src/lib.rs +++ b/crates/fparkan-terrain-format/src/lib.rs @@ -1,23 +1,4 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] //! Terrain disk format primitives. use fparkan_binary::{checked_count_bytes, Cursor, DecodeError}; diff --git a/crates/fparkan-terrain/Cargo.toml b/crates/fparkan-terrain/Cargo.toml index cf10191..8273f3f 100644 --- a/crates/fparkan-terrain/Cargo.toml +++ b/crates/fparkan-terrain/Cargo.toml @@ -6,10 +6,7 @@ license.workspace = true repository.workspace = true [dependencies] -fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" } +fparkan-terrain-format = { path = "../fparkan-terrain-format" } [dev-dependencies] -fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" } - -[lints] -workspace = true +fparkan-nres = { path = "../fparkan-nres" } diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs index de67fd0..7dced19 100644 --- a/crates/fparkan-terrain/src/lib.rs +++ b/crates/fparkan-terrain/src/lib.rs @@ -1,23 +1,4 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] //! Validated terrain runtime queries using XY ground coordinates and Z height. use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument}; diff --git a/crates/fparkan-test-support/Cargo.toml b/crates/fparkan-test-support/Cargo.toml deleted file mode 100644 index 3649ded..0000000 --- a/crates/fparkan-test-support/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "fparkan-test-support" -version.workspace = true -edition.workspace = true -license.workspace = true -repository.workspace = true - -[dependencies] -fparkan-render = { path = "../fparkan-render", version = "0.1.0" } - -[lints] -workspace = true diff --git a/crates/fparkan-test-support/src/lib.rs b/crates/fparkan-test-support/src/lib.rs deleted file mode 100644 index cc2e9e8..0000000 --- a/crates/fparkan-test-support/src/lib.rs +++ /dev/null @@ -1,44 +0,0 @@ -#![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] -//! Dev-only synthetic builders and fake ports. - -use fparkan_render::{FrameOutput, RenderBackend, RenderCommandList, RenderError}; - -/// Fake clock. -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -pub struct FakeClock { - /// Current tick. - pub tick: u64, -} - -/// Recording backend. -#[derive(Clone, Debug, Default)] -pub struct RecordingRenderBackend { - /// Recorded command lists. - pub captures: Vec<RenderCommandList>, -} - -impl RenderBackend for RecordingRenderBackend { - fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> { - self.captures.push(commands.clone()); - Ok(FrameOutput) - } -} diff --git a/crates/fparkan-texm/Cargo.toml b/crates/fparkan-texm/Cargo.toml index 5845efb..b6f9424 100644 --- a/crates/fparkan-texm/Cargo.toml +++ b/crates/fparkan-texm/Cargo.toml @@ -8,7 +8,4 @@ repository.workspace = true [dependencies] [dev-dependencies] -fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" } - -[lints] -workspace = true +fparkan-nres = { path = "../fparkan-nres" } diff --git a/crates/fparkan-texm/src/lib.rs b/crates/fparkan-texm/src/lib.rs index c73bb95..8747737 100644 --- a/crates/fparkan-texm/src/lib.rs +++ b/crates/fparkan-texm/src/lib.rs @@ -1,23 +1,4 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] //! Stage-3 Texm texture contract. use std::sync::Arc; diff --git a/crates/fparkan-vfs/Cargo.toml b/crates/fparkan-vfs/Cargo.toml index e5fab96..2a20dda 100644 --- a/crates/fparkan-vfs/Cargo.toml +++ b/crates/fparkan-vfs/Cargo.toml @@ -6,8 +6,5 @@ license.workspace = true repository.workspace = true [dependencies] -fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" } -fparkan-path = { path = "../fparkan-path", version = "0.1.0" } - -[lints] -workspace = true +fparkan-binary = { path = "../fparkan-binary" } +fparkan-path = { path = "../fparkan-path" } diff --git a/crates/fparkan-vfs/src/lib.rs b/crates/fparkan-vfs/src/lib.rs index 0b25056..3c92fa1 100644 --- a/crates/fparkan-vfs/src/lib.rs +++ b/crates/fparkan-vfs/src/lib.rs @@ -1,23 +1,4 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] //! Virtual filesystem ports for resource loading. use fparkan_binary::{sha256, Sha256Digest}; @@ -312,7 +293,7 @@ pub struct MemoryVfs { impl MemoryVfs { /// Inserts a file. - #[allow(clippy::needless_pass_by_value)] + pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) { let path = path.as_bytes().to_vec(); self.files.insert(path, bytes); @@ -347,13 +328,13 @@ impl MemoryVfs { } #[cfg(unix)] -#[allow(clippy::unnecessary_wraps)] + fn file_identity(metadata: &fs::Metadata) -> Option<u64> { Some(metadata.dev().rotate_left(32) ^ metadata.ino()) } #[cfg(windows)] -#[allow(clippy::unnecessary_wraps)] + fn file_identity(metadata: &fs::Metadata) -> Option<u64> { // Stable Rust does not expose the Windows file index. Keep a best-effort // replacement detector from metadata that is available through the diff --git a/crates/fparkan-world/Cargo.toml b/crates/fparkan-world/Cargo.toml index 0b0d900..a78fcd1 100644 --- a/crates/fparkan-world/Cargo.toml +++ b/crates/fparkan-world/Cargo.toml @@ -6,7 +6,4 @@ license.workspace = true repository.workspace = true [dependencies] -fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" } - -[lints] -workspace = true +fparkan-binary = { path = "../fparkan-binary" } diff --git a/crates/fparkan-world/src/lib.rs b/crates/fparkan-world/src/lib.rs index e14ac97..41c2038 100644 --- a/crates/fparkan-world/src/lib.rs +++ b/crates/fparkan-world/src/lib.rs @@ -1,23 +1,4 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] //! Deterministic world identity, queue, lifecycle, and snapshots. use fparkan_binary::sha256; |
