From f8e447ffee746cfe6580cc0e78a8a225aa39b546 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Tue, 23 Jun 2026 22:05:16 +0400 Subject: feat: close stage 0-2 audit groundwork Remove legacy SDL/OpenGL adapters from the workspace and introduce winit/Vulkan adapter boundaries for the rendered composition root. Add reproducible toolchain and xtask CI coverage for formatting, tests, clippy, docs, policy, deny, acceptance auditing, and hosted OS matrix evidence. Strengthen Stage 1 data contracts with byte-first paths, VFS hardening, structured diagnostics, RsLi writer/edit scaffolding, corpus reporting, and resource error classification. Advance Stage 2 asset preparation by moving mission loading through assets/runtime boundaries, materializing prototype graph data, preserving provenance, and adding inspection/viewer integration. Record the Stage 0-2 audit input, acceptance roadmap, coverage updates, and documentation notes for follow-up evidence. --- crates/fparkan-assets/Cargo.toml | 3 + crates/fparkan-assets/src/lib.rs | 837 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 791 insertions(+), 49 deletions(-) (limited to 'crates/fparkan-assets') diff --git a/crates/fparkan-assets/Cargo.toml b/crates/fparkan-assets/Cargo.toml index 4b901f3..9a69787 100644 --- a/crates/fparkan-assets/Cargo.toml +++ b/crates/fparkan-assets/Cargo.toml @@ -10,9 +10,12 @@ 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" } [dev-dependencies] fparkan-vfs = { path = "../fparkan-vfs" } diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index 2da6624..f4501ee 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/src/lib.rs @@ -1,14 +1,24 @@ #![forbid(unsafe_code)] //! Asset manager ports and transactional preparation models. -use fparkan_material::{decode_wear, resolve_material, WEAR_KIND}; -use fparkan_msh::{decode_msh, validate_msh}; +use fparkan_material::{decode_wear, resolve_material, MaterialError, WEAR_KIND}; +use fparkan_msh::{decode_msh, validate_msh, MshError}; +pub use fparkan_nres::{NresDocument, NresError}; use fparkan_nres::{decode as decode_nres, ReadProfile}; -use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName}; -use fparkan_prototype::{EffectivePrototype, PrototypeGeometry, PrototypeGraph}; +pub use fparkan_mission_format::{LpString, MissionDocument, MissionError, TmaProfile}; +pub use fparkan_terrain::{TerrainError, TerrainWorld}; +pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError}; +use fparkan_mission_format::{decode_tma, decode_tma_land_path}; +use fparkan_terrain_format::{decode_build_dat, decode_land_map, decode_land_msh}; +use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy, ResourceName}; +use fparkan_prototype::{ + EffectivePrototype, PrototypeGeometry, PrototypeGraph, PrototypeGraphEdge, + PrototypeGraphFailure, PrototypeGraphNodeKind, PrototypeGraphProvenance, PrototypeGraphReport, + PrototypeGraphRequiredness, +}; use fparkan_resource::{ResourceError, ResourceKey, ResourceRepository}; -use fparkan_texm::decode_texm; -use std::collections::BTreeSet; +use fparkan_texm::{decode_texm, TexmError}; +use std::collections::{HashMap, HashSet}; use std::fmt; use std::hash::{Hash, Hasher}; use std::marker::PhantomData; @@ -17,6 +27,97 @@ use std::sync::Arc; const TEXTURES_ARCHIVE: &str = "textures.lib"; const LIGHTMAP_ARCHIVE: &str = "lightmap.lib"; +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MissionTerrainPaths { + /// Landscape mesh archive path. + pub land_msh: NormalizedPath, + /// Landscape map archive path. + pub land_map: NormalizedPath, +} + +/// Terrain loading errors that include runtime world construction failures. +#[derive(Debug)] +pub enum TerrainPreparationError { + /// Format error while decoding terrain documents. + Decode(TerrainFormatError), + /// Runtime terrain constructor failed. + Runtime(TerrainError), +} + +impl std::fmt::Display for TerrainPreparationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Decode(source) => write!(f, "{source}"), + Self::Runtime(source) => write!(f, "{source}"), + } + } +} + +impl std::error::Error for TerrainPreparationError {} + +impl From for TerrainPreparationError { + fn from(source: TerrainFormatError) -> Self { + Self::Decode(source) + } +} + +impl From for TerrainPreparationError { + fn from(source: TerrainError) -> Self { + Self::Runtime(source) + } +} + +/// Decodes a mission file bytes payload with a typed profile. +pub fn decode_mission_payload( + bytes: Arc<[u8]>, + profile: TmaProfile, +) -> Result { + decode_tma(bytes, profile) +} + +/// Reads only the mission land path from raw TMA bytes. +pub fn decode_mission_land_path( + bytes: &[u8], + profile: TmaProfile, +) -> Result { + decode_tma_land_path(bytes, profile) +} + +/// Builds canonical mission terrain paths from the mission `Land` reference. +pub fn derive_mission_land_paths( + land_path: &LpString, +) -> Result { + let normalized = normalize_relative(&land_path.raw, PathPolicy::StrictLegacy)?; + let Some((parent, _stem)) = normalized.as_str().rsplit_once('/') else { + return Err(PathError::Empty); + }; + let land_msh = + normalize_relative(format!("{parent}/Land.msh").as_bytes(), PathPolicy::StrictLegacy)?; + let land_map = + normalize_relative(format!("{parent}/Land.map").as_bytes(), PathPolicy::StrictLegacy)?; + Ok(MissionTerrainPaths { land_msh, land_map }) +} + +/// Decodes compatible NRes payload for terrain/document loading. +pub fn decode_nres_payload( + bytes: Arc<[u8]>, +) -> Result { + decode_nres(bytes, ReadProfile::Compatible) +} + +/// Decodes terrain documents and builds immutable terrain state. +pub fn prepare_terrain_world( + land_msh_nres: &fparkan_nres::NresDocument, + land_map_nres: &fparkan_nres::NresDocument, + build_dat: &[u8], +) -> Result<(TerrainWorld, Vec), TerrainPreparationError> { + let land_msh = decode_land_msh(land_msh_nres)?; + let land_map = decode_land_map(land_map_nres)?; + let build_categories = decode_build_dat(build_dat)?; + let world = TerrainWorld::from_land_assets(&land_msh, &land_map)?; + Ok((world, build_categories)) +} + /// Stable typed identifier for a prepared asset. #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] pub struct AssetId { @@ -56,12 +157,116 @@ pub struct PreparedVisual { pub model_batches: usize, /// Number of WEAR material slots resolved through MAT0. pub material_count: usize, + /// Typed material IDs available from the resolved visual. + pub material_ids: Vec>, /// Number of texture phase requests decoded as TEXM. pub texture_count: usize, /// Number of lightmap requests decoded as TEXM. pub lightmap_count: usize, } +/// CPU-side data needed before a material can be handed to a renderer. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreparedMaterial { + /// Stable id derived from the visual and material selector. + pub id: AssetId, + /// Parsed material key. + pub name: ResourceName, +} + +impl PreparedVisual { + /// Returns the primary material id, if any. + #[must_use] + pub fn primary_material_id(&self) -> Option> { + self.material_ids.first().copied() + } +} + +/// Immutable prepared mission assets for rendering and game setup. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct MissionAssets { + /// Visuals prepared for all reachable prototype requests. + pub visuals: Vec, + /// Visual ids available for each mission object index. + pub object_visuals: Vec>>, +} + +impl MissionAssets { + /// Returns how many visuals were prepared. + #[must_use] + pub fn visual_count(&self) -> usize { + self.visuals.len() + } + + /// Returns all visuals for a mission object index. + #[must_use] + pub fn visuals_for_object( + &self, + object_index: usize, + ) -> &[AssetId] { + self.object_visuals + .get(object_index) + .map_or(&[], |values| values.as_slice()) + } + + /// Returns the first visual for a mission object index. + #[must_use] + pub fn visual_for_object( + &self, + object_index: usize, + ) -> Option> { + self.visuals_for_object(object_index).first().copied() + } + + /// Finds a visual by prepared id. + #[must_use] + pub fn visual_by_id(&self, id: AssetId) -> Option<&PreparedVisual> { + self.visuals.iter().find(|visual| visual.id == id) + } + + /// Converts mission assets into a coarse mission plan. + #[must_use] + pub fn to_plan(&self) -> MissionAssetPlan { + let visual_count = self.visuals.len(); + let model_count = self + .visuals + .iter() + .filter(|visual| visual.mesh.is_some()) + .count(); + let material_count = self + .visuals + .iter() + .map(|visual| visual.material_count) + .sum(); + let texture_count = self + .visuals + .iter() + .map(|visual| visual.texture_count) + .sum(); + let lightmap_count = self + .visuals + .iter() + .map(|visual| visual.lightmap_count) + .sum(); + MissionAssetPlan { + visual_count, + model_count, + material_count, + texture_count, + lightmap_count, + } + } +} + +impl Default for MissionAssets { + fn default() -> Self { + Self { + visuals: Vec::new(), + object_visuals: Vec::new(), + } + } +} + /// A transactional mission asset preparation plan. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct MissionAssetPlan { @@ -85,20 +290,27 @@ pub struct AssetBudgets { } /// Errors raised while preparing CPU-side assets. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Debug)] pub enum AssetError { /// A required cross-resource dependency was not found. MissingDependency(String), /// A prototype did not describe a usable visual. InvalidPrototype(String), /// A repository operation failed. - Resource(String), + Resource { + /// Human context for the operation. + context: String, + /// Concrete repository source error. + source: ResourceError, + }, /// MSH parsing or validation failed. - Msh(String), + Msh(MshError), /// WEAR/MAT0 parsing or resolution failed. - Material(String), + Material(MaterialError), /// TEXM parsing failed. - Texture(String), + Texture(TexmError), + /// NRes decoding failed. + Nres(NresError), } impl fmt::Display for AssetError { @@ -106,15 +318,33 @@ impl fmt::Display for AssetError { match self { Self::MissingDependency(value) => write!(f, "missing dependency: {value}"), Self::InvalidPrototype(value) => write!(f, "invalid prototype: {value}"), - Self::Resource(value) => write!(f, "resource error: {value}"), - Self::Msh(value) => write!(f, "msh error: {value}"), - Self::Material(value) => write!(f, "material error: {value}"), - Self::Texture(value) => write!(f, "texture error: {value}"), + Self::Resource { context, source } => { + if context.is_empty() { + write!(f, "resource error: {source}") + } else { + write!(f, "resource error ({context}): {source}") + } + } + Self::Msh(source) => write!(f, "msh error: {source}"), + Self::Material(source) => write!(f, "material error: {source}"), + Self::Texture(source) => write!(f, "texture error: {source}"), + Self::Nres(source) => write!(f, "nres error: {source}"), } } } -impl std::error::Error for AssetError {} +impl std::error::Error for AssetError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Resource { source, .. } => Some(source), + Self::Msh(source) => Some(source), + Self::Material(source) => Some(source), + Self::Texture(source) => Some(source), + Self::Nres(source) => Some(source), + Self::MissingDependency(_) | Self::InvalidPrototype(_) => None, + } + } +} /// Port implemented by typed asset loaders. pub trait AssetLoader { @@ -157,14 +387,31 @@ impl AssetManager { prepare_visual_with_repository(&self.repository, proto) } + /// Builds mission assets from resolved prototypes. + /// + /// # Errors + /// + /// Returns [`AssetError`] if any visual dependency is missing or malformed. + pub fn prepare_mission_assets( + &self, + root_prototype_spans: &[std::ops::Range], + prototypes: &[EffectivePrototype], + ) -> Result { + prepare_mission_assets_with_repository( + &self.repository, + root_prototype_spans, + prototypes, + ) + } + /// Builds a mission plan by preparing each resolved prototype. /// /// # Errors /// /// Returns [`AssetError`] if any visual dependency is missing or malformed. - pub fn build_mission_asset_plan<'a>( + pub fn build_mission_asset_plan( &self, - prototypes: impl IntoIterator, + prototypes: &[EffectivePrototype], ) -> Result { build_mission_asset_plan_with_repository(&self.repository, prototypes) } @@ -173,8 +420,13 @@ impl AssetManager { /// Produces a count-only plan from a prototype graph. #[must_use] pub fn build_mission_asset_plan(graph: &PrototypeGraph) -> MissionAssetPlan { + let visual_count = graph + .nodes + .iter() + .filter(|node| node.kind == PrototypeGraphNodeKind::Prototype) + .count(); MissionAssetPlan { - visual_count: graph.prototype_requests.len(), + visual_count, ..MissionAssetPlan::default() } } @@ -185,29 +437,217 @@ pub fn build_mission_asset_plan(graph: &PrototypeGraph) -> MissionAssetPlan { /// /// Returns [`AssetError`] if any reachable visual dependency is missing or /// malformed. -pub fn build_mission_asset_plan_with_repository<'a, R: ResourceRepository>( +pub fn build_mission_asset_plan_with_repository( repository: &R, - prototypes: impl IntoIterator, + prototypes: &[EffectivePrototype], ) -> Result { - let mut plan = MissionAssetPlan::default(); - let mut prepared_visuals = BTreeSet::new(); + let full_span = [0..prototypes.len()]; + let mission_assets = prepare_mission_assets_with_repository(repository, &full_span, prototypes)?; + Ok(mission_assets.to_plan()) +} + +/// Builds immutable mission assets from resolved prototypes. +/// +/// # Errors +/// +/// Returns [`AssetError`] if any visual dependency is missing or malformed. +pub fn prepare_mission_assets_with_repository( + repository: &R, + root_prototype_spans: &[std::ops::Range], + prototypes: &[EffectivePrototype], +) -> Result { + if prototypes.is_empty() { + return Ok(MissionAssets::default()); + } + let mut visual_index_by_id: HashMap, PreparedVisualSignature> = + HashMap::new(); + let mut material_signature_by_id: HashMap, Vec> = + HashMap::new(); + let mut visuals = Vec::new(); + let mut prototype_visual_ids = Vec::with_capacity(prototypes.len()); for proto in prototypes { let visual_id = stable_visual_id(proto); - if !prepared_visuals.insert(visual_id) { - continue; + let signature = prepared_visual_signature(proto); + match visual_index_by_id.get(&visual_id) { + Some(existing) if existing != &signature => { + return Err(AssetError::InvalidPrototype( + "stable visual id collision between unrelated prototypes".to_string(), + )); + } + Some(_) => {} + None => { + visual_index_by_id.insert(visual_id, signature); + let visual = prepare_visual_with_repository_internal( + repository, + proto, + Some(&mut material_signature_by_id), + )?; + if visual.id != visual_id { + // Defensive check. stable IDs are deterministic for the same inputs. + return Err(AssetError::InvalidPrototype( + "prepared visual id changed during preparation".to_string(), + )); + } + visuals.push(visual); + } } - let visual = prepare_visual_with_repository(repository, proto)?; - plan.visual_count += 1; - if visual.mesh.is_some() { - plan.model_count += 1; + prototype_visual_ids.push(visual_id); + } + + let mut object_visuals = Vec::with_capacity(root_prototype_spans.len()); + for (root_index, span) in root_prototype_spans.iter().enumerate() { + if span.start > span.end || span.end > prototype_visual_ids.len() { + return Err(AssetError::InvalidPrototype(format!( + "invalid prototype span for mission object {root_index}: {span:?}" + ))); + } + let mut ids = Vec::new(); + let mut dedup = HashSet::new(); + for index in span.clone() { + let visual_id = prototype_visual_ids[index]; + if dedup.insert(visual_id) { + ids.push(visual_id); + } } - plan.material_count += visual.material_count; - plan.texture_count += visual.texture_count; - plan.lightmap_count += visual.lightmap_count; + object_visuals.push(ids); } - Ok(plan) + Ok(MissionAssets { + visuals, + object_visuals, + }) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +enum PreparedVisualSignature { + Mesh { + archive: String, + name: Vec, + type_id: Option, + dependency_count: usize, + }, + NonGeometric { + dependency_count: usize, + }, +} + +fn prepared_visual_signature(proto: &EffectivePrototype) -> PreparedVisualSignature { + match &proto.geometry { + PrototypeGeometry::Mesh(key) => PreparedVisualSignature::Mesh { + archive: key.archive.as_str().to_string(), + name: key.name.0.clone(), + type_id: key.type_id, + dependency_count: proto.dependencies.len(), + }, + PrototypeGeometry::NonGeometric => PreparedVisualSignature::NonGeometric { + dependency_count: proto.dependencies.len(), + }, + } +} + +/// Extends a prototype dependency report with visual dependency failures. +/// +/// This function validates WEAR/material/TEXM/LIGHTMAP resolution for each resolved +/// prototype without constructing full immutable assets. +pub fn extend_graph_report_with_visual_dependencies( + repository: &R, + report: &mut PrototypeGraphReport, + graph: &PrototypeGraph, + prototypes: &[EffectivePrototype], +) { + let texture_archive = parse_path(TEXTURES_ARCHIVE).ok(); + let lightmap_archive = parse_path(LIGHTMAP_ARCHIVE).ok(); + + for (prototype_index, prototype) in prototypes.iter().enumerate() { + let PrototypeGeometry::Mesh(mesh) = &prototype.geometry else { + continue; + }; + report.mesh_dependency_count += prototype.dependencies.len(); + report.wear_request_count += 1; + + match resolve_wear_table(repository, mesh) { + Ok(table) => { + report.wear_resolved_count += 1; + report.material_slot_count += table.entries.len(); + for (material_index, _entry) in table.entries.iter().enumerate() { + let Ok(material_index) = u16::try_from(material_index) else { + push_visual_failure( + report, + graph, + prototype_index, + mesh.name.0.clone(), + PrototypeGraphEdge::WearToMaterial, + PrototypeGraphRequiredness::Required, + "material index does not fit archive format", + ); + continue; + }; + match resolve_material(repository, &table, material_index) { + Ok(material) => { + report.material_resolved_count += 1; + for texture in material.document.texture_requests() { + report.texture_request_count += 1; + match resolve_texm_from_candidates( + repository, + &texture, + [texture_archive.as_ref(), lightmap_archive.as_ref()], + ) { + Ok(()) => report.texture_resolved_count += 1, + Err(message) => push_visual_failure( + report, + graph, + prototype_index, + texture.0, + PrototypeGraphEdge::MaterialToTexture, + PrototypeGraphRequiredness::Required, + &message, + ), + } + } + } + Err(message) => push_visual_failure( + report, + graph, + prototype_index, + mesh.name.0.clone(), + PrototypeGraphEdge::WearToMaterial, + PrototypeGraphRequiredness::Required, + &message.to_string(), + ), + } + } + for lightmap in &table.lightmaps { + report.lightmap_request_count += 1; + match resolve_texm_from_candidates( + repository, + &lightmap.lightmap, + [lightmap_archive.as_ref(), texture_archive.as_ref()], + ) { + Ok(()) => report.lightmap_resolved_count += 1, + Err(message) => push_visual_failure( + report, + graph, + prototype_index, + lightmap.lightmap.0.clone(), + PrototypeGraphEdge::WearToLightmap, + PrototypeGraphRequiredness::Required, + &message, + ), + } + } + } + Err(message) => push_visual_failure( + report, + graph, + prototype_index, + mesh.name.0.clone(), + PrototypeGraphEdge::MeshToWear, + PrototypeGraphRequiredness::Required, + &message.to_string(), + ), + } + } } /// Validates a prototype visual without resolving cross-resource dependencies. @@ -231,6 +671,7 @@ pub fn prepare_visual(proto: &EffectivePrototype) -> Result Result( repository: &R, proto: &EffectivePrototype, +) -> Result { + prepare_visual_with_repository_internal(repository, proto, None) +} + +fn prepare_visual_with_repository_internal( + repository: &R, + proto: &EffectivePrototype, + material_signature_by_id: Option<&mut HashMap, Vec>>, ) -> Result { let PrototypeGeometry::Mesh(mesh_key) = &proto.geometry else { return prepare_visual(proto); @@ -254,9 +703,9 @@ pub fn prepare_visual_with_repository( read_key(repository, mesh_key, Some("mesh"))?, ReadProfile::Compatible, ) - .map_err(|err| AssetError::Msh(err.to_string()))?; - let msh_document = decode_msh(&nres).map_err(|err| AssetError::Msh(err.to_string()))?; - let model = validate_msh(&msh_document).map_err(|err| AssetError::Msh(err.to_string()))?; + .map_err(AssetError::Nres)?; + let msh_document = decode_msh(&nres).map_err(AssetError::Msh)?; + let model = validate_msh(&msh_document).map_err(AssetError::Msh)?; let wear_name = sibling_name(mesh_key, "wea")?; let wear_key = ResourceKey { @@ -264,22 +713,44 @@ pub fn prepare_visual_with_repository( name: wear_name, type_id: Some(WEAR_KIND), }; - let wear = decode_wear(&read_key(repository, &wear_key, Some("wear"))?) - .map_err(|err| AssetError::Material(err.to_string()))?; + let wear = decode_wear(&read_key(repository, &wear_key, Some("wear"))?).map_err(AssetError::Material)?; let mut material_count = 0; + let mut material_ids = Vec::with_capacity(wear.entries.len()); let mut texture_count = 0; let mut lightmap_count = 0; for material_index in 0..wear.entries.len() { let material_index = u16::try_from(material_index).map_err(|_| { - AssetError::Material("material index does not fit archive format".to_string()) + AssetError::InvalidPrototype("material index does not fit archive format".to_string()) })?; let material = resolve_material(repository, &wear, material_index) - .map_err(|err| AssetError::Material(err.to_string()))?; + .map_err(AssetError::Material)?; material_count += 1; + material_ids.push(AssetId::new(stable_material_id( + proto, + material_index, + &material.name, + ))); + let material_id = *material_ids + .last() + .expect("material id was appended immediately before collision check"); + if let Some(registry) = material_signature_by_id { + match registry.get(&material_id) { + Some(existing_name) => { + if existing_name != &material.name.0 { + return Err(AssetError::InvalidPrototype( + "stable material id collision between unrelated materials".to_string(), + )); + } + } + None => { + registry.insert(material_id, material.name.0.clone()); + } + } + } for texture in material.document.texture_requests() { - resolve_texture(repository, &texture)?; + resolve_texture(repository, &texture)?; texture_count += 1; } } @@ -296,6 +767,7 @@ pub fn prepare_visual_with_repository( model_slots: model.slots.len(), model_batches: model.batches.len(), material_count, + material_ids, texture_count, lightmap_count, }) @@ -306,21 +778,267 @@ fn read_key( key: &ResourceKey, label: Option<&str>, ) -> Result, AssetError> { + let label = label.unwrap_or("asset"); let handle = repository .open_archive(&key.archive) - .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}"))) + .map_err(|err| map_resource_error(label, key, err))? .and_then(|archive| { repository .find(archive, &key.name) - .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}"))) + .map_err(|err| map_resource_error(label, key, err)) })? - .ok_or_else(|| AssetError::MissingDependency(format!("{label:?} {key:?}")))?; + .ok_or_else(|| AssetError::MissingDependency(format!("{label}: {key:?}")))?; let bytes = repository .read(handle) - .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))?; + .map_err(|err| map_resource_error(label, key, err))?; Ok(Arc::from(bytes.into_owned())) } +fn map_resource_error( + label: &str, + key: &ResourceKey, + source: ResourceError, +) -> AssetError { + AssetError::Resource { + context: format!( + "{label}: archive={} entry={}", + key.archive.as_str(), + String::from_utf8_lossy(&key.name.0), + ), + source, + } +} + +fn resolve_wear_table( + repository: &R, + mesh: &ResourceKey, +) -> Result { + let archive = repository + .open_archive(&mesh.archive) + .map_err(|err| map_resource_error("wear", mesh, err))?; + let wear_name = sibling_name(mesh, "wea")?; + let handle = repository + .find(archive, &wear_name) + .map_err(|err| { + map_resource_error( + "wear", + &ResourceKey { + archive: mesh.archive.clone(), + name: wear_name.clone(), + type_id: Some(WEAR_KIND), + }, + err, + ) + })? + .ok_or_else(|| { + AssetError::MissingDependency(format!( + "missing WEAR entry {}", + String::from_utf8_lossy(&wear_name.0) + )) + })?; + let info = repository + .entry_info(handle) + .map_err(|err| { + map_resource_error( + "wear", + &ResourceKey { + archive: mesh.archive.clone(), + name: wear_name.clone(), + type_id: Some(WEAR_KIND), + }, + err, + ) + })?; + if info.key.type_id != Some(WEAR_KIND) { + return Err(AssetError::InvalidPrototype(format!( + "entry {} is not WEAR", + String::from_utf8_lossy(&wear_name.0) + ))); + } + let bytes = repository + .read(handle) + .map_err(|err| { + map_resource_error( + "wear", + &ResourceKey { + archive: mesh.archive.clone(), + name: wear_name.clone(), + type_id: Some(WEAR_KIND), + }, + err, + ) + })? + .into_owned(); + decode_wear(&bytes).map_err(AssetError::Material) +} + +fn resolve_texm_from_candidates<'a, R: ResourceRepository>( + repository: &R, + texture: &ResourceName, + candidates: impl IntoIterator>, +) -> Result<(), AssetError> { + let mut missing_archive = false; + for path in candidates.into_iter().flatten() { + let key = ResourceKey { + archive: path.to_owned(), + name: texture.clone(), + type_id: None, + }; + let archive = match repository.open_archive(path) { + Ok(archive) => archive, + Err(ResourceError::MissingArchive) => { + missing_archive = true; + continue; + } + Err(err) => return Err(map_resource_error("texm", &key, err)), + }; + let Some(handle) = repository + .find(archive, texture) + .map_err(|err| map_resource_error("texm", &key, err))? + else { + continue; + }; + let bytes = repository + .read(handle) + .map_err(|err| map_resource_error("texm", &key, err))? + .into_owned(); + decode_texm(bytes).map_err(AssetError::Texture)?; + return Ok(()); + } + if missing_archive { + Err(AssetError::MissingDependency(format!( + "texm archive missing for {}", + String::from_utf8_lossy(&texture.0) + ))) + } else { + Err(AssetError::MissingDependency(format!( + "missing texm {}", + String::from_utf8_lossy(&texture.0) + ))) + } +} + +fn push_visual_failure( + report: &mut PrototypeGraphReport, + graph: &PrototypeGraph, + prototype_index: usize, + resource_raw: Vec, + edge: PrototypeGraphEdge, + requiredness: PrototypeGraphRequiredness, + message: &str, +) { + let root_index = root_index_for_prototype(graph, prototype_index); + let parent_edge = parent_edge_for_failure(graph, prototype_index, &edge); + let dependency = mesh_dependency_resource(graph, prototype_index); + report.failures.push(PrototypeGraphFailure { + root_index, + resource_raw, + edge, + message: message.to_string(), + requiredness, + provenance: Some(PrototypeGraphProvenance { + root_index, + parent_edge, + archive: dependency.map(|resource| resource.archive.as_str().to_string()), + resource: Some(resource_raw), + span: None, + }), + }) +} + +fn root_index_for_prototype(graph: &PrototypeGraph, prototype_index: usize) -> usize { + for (root_index, span) in graph.root_prototype_request_spans.iter().enumerate() { + if span.start <= prototype_index && prototype_index < span.end { + return root_index; + } + } + 0 +} + +fn parent_edge_for_failure( + graph: &PrototypeGraph, + prototype_index: usize, + edge: &PrototypeGraphEdge, +) -> Option { + let prototype_node_id = prototype_node_id(graph, prototype_index)?; + match edge { + PrototypeGraphEdge::MeshToWear + | PrototypeGraphEdge::WearToMaterial + | PrototypeGraphEdge::MaterialToTexture + | PrototypeGraphEdge::WearToLightmap => { + mesh_edge_id(graph, prototype_node_id).or_else(|| root_edge_id(graph, prototype_node_id)) + } + _ => root_edge_id(graph, prototype_node_id), + } +} + +fn prototype_node_id(graph: &PrototypeGraph, prototype_index: usize) -> Option { + graph + .nodes + .iter() + .filter(|node| node.kind == PrototypeGraphNodeKind::Prototype) + .nth(prototype_index) + .map(|node| node.id) +} + +fn root_edge_id( + graph: &PrototypeGraph, + prototype_node: fparkan_prototype::PrototypeGraphNodeId, +) -> Option { + graph + .edges + .iter() + .find(|edge| { + edge.to == prototype_node + && matches!( + edge.kind, + fparkan_prototype::PrototypeGraphEdgeKind::MissionToRoot + | fparkan_prototype::PrototypeGraphEdgeKind::UnitDatToComponent + ) + }) + .map(|edge| edge.id) +} + +fn mesh_edge_id( + graph: &PrototypeGraph, + prototype_node: fparkan_prototype::PrototypeGraphNodeId, +) -> Option { + graph + .edges + .iter() + .find(|edge| { + edge.from == prototype_node + && matches!( + edge.kind, + fparkan_prototype::PrototypeGraphEdgeKind::PrototypeToMesh + ) + }) + .map(|edge| edge.id) +} + +fn mesh_dependency_resource( + graph: &PrototypeGraph, + prototype_index: usize, +) -> Option<&fparkan_resource::ResourceKey> { + let prototype_node = prototype_node_id(graph, prototype_index)?; + let mesh_node = graph + .edges + .iter() + .find(|edge| { + edge.from == prototype_node + && matches!( + edge.kind, + fparkan_prototype::PrototypeGraphEdgeKind::PrototypeToMesh + ) + })? + .to; + graph + .nodes + .iter() + .find(|node| node.id == mesh_node) + .and_then(|node| node.resource.as_ref()) +} + fn resolve_texture( repository: &R, name: &ResourceName, @@ -351,7 +1069,7 @@ fn resolve_texm( }; decode_texm(bytes) .map(|_| ()) - .map_err(|err| AssetError::Texture(err.to_string())) + .map_err(AssetError::Texture) } fn read_optional_key( @@ -362,17 +1080,26 @@ fn read_optional_key( let archive = match repository.open_archive(&key.archive) { Ok(archive) => archive, Err(ResourceError::MissingArchive | ResourceError::MissingEntry) => return Ok(None), - Err(err) => return Err(AssetError::Resource(format!("{label:?} {key:?}: {err}"))), + Err(err) => { + let label = label.unwrap_or("asset"); + return Err(map_resource_error(label, key, err)) + } }; let Some(handle) = repository .find(archive, &key.name) - .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))? + .map_err(|err| { + let label = label.unwrap_or("asset"); + map_resource_error(label, key, err) + })? else { return Ok(None); }; let bytes = repository .read(handle) - .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))?; + .map_err(|err| { + let label = label.unwrap_or("asset"); + map_resource_error(label, key, err) + })?; Ok(Some(Arc::from(bytes.into_owned()))) } @@ -407,6 +1134,18 @@ fn stable_visual_id(proto: &EffectivePrototype) -> u64 { hasher.finish() } +fn stable_material_id( + proto: &EffectivePrototype, + material_index: u16, + material_name: &ResourceName, +) -> u64 { + let mut hasher = StableHasher::default(); + stable_visual_id(proto).hash(&mut hasher); + material_index.hash(&mut hasher); + material_name.0.hash(&mut hasher); + hasher.finish() +} + fn parse_path(value: &str) -> Result { normalize_relative(value.as_bytes(), PathPolicy::HostCompatible) .map_err(|err| AssetError::InvalidPrototype(format!("{err}"))) -- cgit v1.2.3