From d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Mon, 22 Jun 2026 13:12:27 +0400 Subject: feat: implement FParkan architecture foundation Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation. --- crates/fparkan-prototype/Cargo.toml | 20 + crates/fparkan-prototype/src/lib.rs | 2114 +++++++++++++++++++++++++++++++++++ 2 files changed, 2134 insertions(+) create mode 100644 crates/fparkan-prototype/Cargo.toml create mode 100644 crates/fparkan-prototype/src/lib.rs (limited to 'crates/fparkan-prototype') diff --git a/crates/fparkan-prototype/Cargo.toml b/crates/fparkan-prototype/Cargo.toml new file mode 100644 index 0000000..4825faf --- /dev/null +++ b/crates/fparkan-prototype/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "fparkan-prototype" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +encoding_rs = "0.8" +fparkan-binary = { path = "../fparkan-binary" } +fparkan-material = { path = "../fparkan-material" } +fparkan-msh = { path = "../fparkan-msh" } +fparkan-nres = { path = "../fparkan-nres" } +fparkan-path = { path = "../fparkan-path" } +fparkan-resource = { path = "../fparkan-resource" } +fparkan-texm = { path = "../fparkan-texm" } +fparkan-vfs = { path = "../fparkan-vfs" } + +[lints] +workspace = true diff --git a/crates/fparkan-prototype/src/lib.rs b/crates/fparkan-prototype/src/lib.rs new file mode 100644 index 0000000..4efafa1 --- /dev/null +++ b/crates/fparkan-prototype/src/lib.rs @@ -0,0 +1,2114 @@ +#![forbid(unsafe_code)] +//! Prototype registry and unit DAT primitives. + +use encoding_rs::WINDOWS_1251; +use fparkan_binary::{checked_count_bytes, Cursor, DecodeError}; +use fparkan_material::{decode_wear, resolve_material, WEAR_KIND}; +use fparkan_msh::{decode_msh, validate_msh, MshError}; +use fparkan_nres::ReadProfile; +use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName}; +use fparkan_resource::{ + archive_path, resource_name, ResourceError, ResourceKey, ResourceRepository, +}; +use fparkan_texm::decode_texm; +use fparkan_vfs::{Vfs, VfsError}; +use std::sync::Arc; + +const MESH_KIND: u32 = 0x4853_454D; +const UNIT_DAT_MIN_SIZE: usize = 0x48; +const UNIT_DAT_MAGIC: u32 = 0x0000_F0F1; +const PROTOTYPE_INHERITANCE_DEPTH_LIMIT: usize = 32; + +/// Prototype key. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct PrototypeKey(pub ResourceName); + +/// 64-byte object reference record. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ObjectRefRecord { + /// Archive raw bytes. + pub archive_raw: [u8; 32], + /// Resource raw bytes. + pub resource_raw: [u8; 32], +} + +/// Unit DAT document. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UnitDat { + /// Opaque eight-byte header before component records. + pub header_opaque: [u8; 8], + /// Component records. + pub records: Vec, +} + +/// Unit DAT binding used by mission object references. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UnitDatBinding { + /// Flags. + pub flags: u32, + /// Archive raw bytes. + pub archive_raw: [u8; 32], + /// Model key raw bytes. + pub model_raw: [u8; 32], +} + +/// Unit DAT component. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UnitComponentRecord { + /// Archive raw bytes. + pub archive_raw: [u8; 32], + /// Resource raw bytes. + pub resource_raw: [u8; 32], + /// Component kind. + pub kind: u32, + /// Parent or link. + pub parent_or_link: i32, + /// Description raw bytes. + pub description_raw: [u8; 32], + /// Opaque tail. + pub tail0: u32, + /// Opaque tail. + pub tail1: u32, +} + +/// Prototype geometry. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum PrototypeGeometry { + /// Mesh resource. + Mesh(ResourceKey), + /// Valid non-geometric prototype. + NonGeometric, +} + +/// Effective prototype. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EffectivePrototype { + /// Key. + pub key: PrototypeKey, + /// Geometry. + pub geometry: PrototypeGeometry, + /// Resolution source. + pub source: PrototypeSource, + /// Resource dependencies discovered while resolving this prototype. + pub dependencies: Vec, +} + +/// Prototype resolution source. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PrototypeSource { + /// Direct archive/key lookup. + DirectArchive, + /// `objects.rlb` registry lookup. + ObjectsRegistry, + /// Unit DAT binding. + UnitDat, +} + +/// Prototype graph. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PrototypeGraph { + /// Requested keys. + pub roots: Vec, + /// Effective prototype requests after unit DAT expansion. + pub prototype_requests: Vec, +} + +/// Mission prototype dependency graph report. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct PrototypeGraphReport { + /// Requested mission roots. + pub root_count: usize, + /// Roots that point at unit DAT files. + pub unit_reference_count: usize, + /// Roots that point directly at prototype keys. + pub direct_reference_count: usize, + /// Component records reached from unit DAT files. + pub unit_component_count: usize, + /// Prototype requests that resolved to an effective prototype. + pub resolved_count: usize, + /// Mesh dependencies reached by resolved prototypes. + pub mesh_dependency_count: usize, + /// WEAR requests derived from reached mesh dependencies. + pub wear_request_count: usize, + /// WEAR entries successfully decoded. + pub wear_resolved_count: usize, + /// Material slots requested by decoded WEAR tables. + pub material_slot_count: usize, + /// MAT0 material entries successfully decoded. + pub material_resolved_count: usize, + /// Texture requests derived from MAT0 texture phases. + pub texture_request_count: usize, + /// Texm texture entries successfully decoded. + pub texture_resolved_count: usize, + /// Lightmap requests declared by decoded WEAR tables. + pub lightmap_request_count: usize, + /// Lightmap Texm entries successfully decoded. + pub lightmap_resolved_count: usize, + /// Graph failures tied to mission root edges. + pub failures: Vec, +} + +impl PrototypeGraphReport { + /// Returns true when all reachable mission roots resolved. + #[must_use] + pub fn is_success(&self) -> bool { + self.failures.is_empty() + && self.resolved_count == self.direct_reference_count + self.unit_component_count + } +} + +/// Prototype graph failure tied to a root edge. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PrototypeGraphFailure { + /// Root index in the requested mission order. + pub root_index: usize, + /// Raw mission resource bytes. + pub resource_raw: Vec, + /// Edge that failed. + pub edge: PrototypeGraphEdge, + /// Failure detail. + pub message: String, +} + +/// Prototype graph edge. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PrototypeGraphEdge { + /// Mission object to unit DAT binding. + MissionToUnitDat, + /// Mission object to `objects.rlb` registry. + MissionToObjectsRegistry, + /// Unit DAT component to prototype key. + UnitDatToComponent, + /// Resolved prototype to mesh archive/resource. + PrototypeToMesh, + /// Mesh resource to matching WEAR table. + MeshToWear, + /// WEAR material slot to MAT0. + WearToMaterial, + /// MAT0 phase to Texm. + MaterialToTexture, + /// WEAR lightmap slot to lightmap Texm. + WearToLightmap, +} + +/// Prototype error. +#[derive(Debug)] +pub enum PrototypeError { + /// Decode error. + Decode(DecodeError), + /// Invalid size. + InvalidSize, + /// Invalid unit DAT magic. + InvalidUnitDatMagic(u32), + /// Invalid path. + InvalidPath(String), + /// VFS error. + Vfs(String), + /// Resource repository error. + Resource(String), + /// Referenced mesh is present but invalid. + InvalidMesh(String), +} + +impl From for PrototypeError { + fn from(value: DecodeError) -> Self { + Self::Decode(value) + } +} + +impl From for PrototypeError { + fn from(value: ResourceError) -> Self { + Self::Resource(value.to_string()) + } +} + +impl From for PrototypeError { + fn from(value: MshError) -> Self { + Self::InvalidMesh(value.to_string()) + } +} + +impl From for PrototypeError { + fn from(value: VfsError) -> Self { + Self::Vfs(value.to_string()) + } +} + +impl std::fmt::Display for PrototypeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} + +impl std::error::Error for PrototypeError {} + +/// Decodes an `objects.rlb` registry entry as 64-byte records. +/// +/// # Errors +/// +/// Returns [`PrototypeError::InvalidSize`] when the payload is not composed of +/// whole 64-byte records. +pub fn decode_registry_entry(payload: &[u8]) -> Result, PrototypeError> { + if !payload.len().is_multiple_of(64) { + return Err(PrototypeError::InvalidSize); + } + let mut out = Vec::with_capacity(payload.len() / 64); + for chunk in payload.chunks_exact(64) { + let mut archive_raw = [0; 32]; + let mut resource_raw = [0; 32]; + archive_raw.copy_from_slice(&chunk[..32]); + resource_raw.copy_from_slice(&chunk[32..64]); + out.push(ObjectRefRecord { + archive_raw, + resource_raw, + }); + } + Ok(out) +} + +/// Decodes unit DAT as an eight-byte header followed by `N * 112` bytes. +/// +/// # Errors +/// +/// Returns [`PrototypeError`] when the payload is too small or contains a +/// partial component record. +pub fn decode_unit_dat(payload: &[u8]) -> Result { + if payload.len() < 8 { + return Err(PrototypeError::InvalidSize); + } + let mut header_opaque = [0; 8]; + header_opaque.copy_from_slice(&payload[..8]); + let remaining = payload.len().saturating_sub(8) as u64; + if !remaining.is_multiple_of(112) { + return Err(PrototypeError::InvalidSize); + } + let record_count = remaining / 112; + let bytes = checked_count_bytes(record_count, 112, remaining)?; + if bytes as u64 != remaining { + return Err(PrototypeError::InvalidSize); + } + let mut cursor = Cursor::new(&payload[8..]); + let mut records = Vec::with_capacity( + usize::try_from(record_count).map_err(|_| DecodeError::IntegerOverflow)?, + ); + for _ in 0..record_count { + let mut archive_raw = [0; 32]; + let mut resource_raw = [0; 32]; + let mut description_raw = [0; 32]; + archive_raw.copy_from_slice(cursor.read_exact(32)?); + resource_raw.copy_from_slice(cursor.read_exact(32)?); + let kind = cursor.read_u32_le()?; + let parent_or_link = cursor.read_i32_le()?; + description_raw.copy_from_slice(cursor.read_exact(32)?); + let tail0 = cursor.read_u32_le()?; + let tail1 = cursor.read_u32_le()?; + records.push(UnitComponentRecord { + archive_raw, + resource_raw, + kind, + parent_or_link, + description_raw, + tail0, + tail1, + }); + } + cursor.require_eof()?; + Ok(UnitDat { + header_opaque, + records, + }) +} + +/// Decodes a mission unit DAT binding. +/// +/// # Errors +/// +/// Returns [`PrototypeError`] when the DAT file is too small, has the wrong +/// magic, or does not contain both archive and model keys. +pub fn decode_unit_dat_binding(payload: &[u8]) -> Result { + if payload.len() < UNIT_DAT_MIN_SIZE { + return Err(PrototypeError::InvalidSize); + } + let magic = u32::from_le_bytes( + payload[0..4] + .try_into() + .map_err(|_| PrototypeError::InvalidSize)?, + ); + if magic != UNIT_DAT_MAGIC { + return Err(PrototypeError::InvalidUnitDatMagic(magic)); + } + let flags = u32::from_le_bytes( + payload[4..8] + .try_into() + .map_err(|_| PrototypeError::InvalidSize)?, + ); + let mut archive_raw = [0; 32]; + let mut model_raw = [0; 32]; + archive_raw.copy_from_slice(&payload[0x08..0x28]); + model_raw.copy_from_slice(&payload[0x28..0x48]); + if cstr_bytes(&archive_raw).is_empty() || cstr_bytes(&model_raw).is_empty() { + return Err(PrototypeError::InvalidSize); + } + Ok(UnitDatBinding { + flags, + archive_raw, + model_raw, + }) +} + +/// Resolves one prototype request through unit DAT, `objects.rlb`, and direct mesh lookup. +/// +/// # Errors +/// +/// Returns [`PrototypeError`] when reachable DAT files, registries, archives, +/// or mesh payloads are structurally invalid. +pub fn resolve_prototype( + repository: &dyn ResourceRepository, + vfs: &dyn Vfs, + resource: &ResourceName, +) -> Result, PrototypeError> { + if has_extension_bytes(&resource.0, b"dat") { + return resolve_unit_dat_first_component(repository, vfs, resource); + } + + resolve_direct_prototype(repository, resource) +} + +fn resolve_direct_prototype( + repository: &dyn ResourceRepository, + resource: &ResourceName, +) -> Result, PrototypeError> { + let objects = + archive_path(b"objects.rlb").map_err(|err| PrototypeError::InvalidPath(err.to_string()))?; + resolve_archive_model( + repository, + &objects, + resource, + PrototypeSource::ObjectsRegistry, + ) +} + +struct ResolvedPrototypeRequests { + expected_count: usize, + prototypes: Vec, +} + +fn resolve_prototype_requests( + repository: &dyn ResourceRepository, + vfs: &dyn Vfs, + resource: &ResourceName, +) -> Result { + if has_extension_bytes(&resource.0, b"dat") { + return resolve_unit_dat_prototype_requests(repository, vfs, resource); + } + + let prototype = resolve_direct_prototype(repository, resource)?; + Ok(ResolvedPrototypeRequests { + expected_count: 1, + prototypes: prototype.into_iter().collect(), + }) +} + +fn resolve_unit_dat_first_component( + repository: &dyn ResourceRepository, + vfs: &dyn Vfs, + resource: &ResourceName, +) -> Result, PrototypeError> { + let expansion = resolve_unit_dat_prototype_requests(repository, vfs, resource)?; + Ok(expansion.prototypes.into_iter().next()) +} + +fn resolve_unit_dat_prototype_requests( + repository: &dyn ResourceRepository, + vfs: &dyn Vfs, + resource: &ResourceName, +) -> Result { + let dat_path = normalized_path_from_name(resource)?; + let bytes = match vfs.read(&dat_path) { + Ok(bytes) => bytes, + Err(VfsError::NotFound(_)) => { + return Ok(ResolvedPrototypeRequests { + expected_count: 0, + prototypes: Vec::new(), + }); + } + Err(err) => return Err(err.into()), + }; + + if let Ok(unit) = decode_unit_dat(&bytes) { + if !unit.records.is_empty() { + let mut prototypes = Vec::with_capacity(unit.records.len()); + for record in &unit.records { + let prototype = resolve_unit_component(repository, record)?.ok_or_else(|| { + PrototypeError::Resource(format!( + "unit component {} did not resolve", + String::from_utf8_lossy(cstr_bytes(&record.resource_raw)) + )) + })?; + prototypes.push(prototype); + } + return Ok(ResolvedPrototypeRequests { + expected_count: unit.records.len(), + prototypes, + }); + } + } + + let binding = decode_unit_dat_binding(&bytes)?; + let archive = + normalized_path_from_name(&ResourceName(cstr_bytes(&binding.archive_raw).to_vec()))?; + let model = ResourceName(cstr_bytes(&binding.model_raw).to_vec()); + let prototype = resolve_archive_model(repository, &archive, &model, PrototypeSource::UnitDat)?; + Ok(ResolvedPrototypeRequests { + expected_count: 1, + prototypes: prototype.into_iter().collect(), + }) +} + +fn resolve_unit_component( + repository: &dyn ResourceRepository, + record: &UnitComponentRecord, +) -> Result, PrototypeError> { + let archive = + normalized_path_from_name(&ResourceName(cstr_bytes(&record.archive_raw).to_vec()))?; + let resource = ResourceName(cstr_bytes(&record.resource_raw).to_vec()); + if resource.0.is_empty() { + return Ok(None); + } + resolve_archive_model(repository, &archive, &resource, PrototypeSource::UnitDat) +} + +/// Resolves many roots and records every resolved root in a graph. +/// +/// # Errors +/// +/// Returns [`PrototypeError`] when any reachable root fails with a structural +/// error. +pub fn build_prototype_graph( + repository: &dyn ResourceRepository, + vfs: &dyn Vfs, + roots: &[ResourceName], +) -> Result<(PrototypeGraph, Vec), PrototypeError> { + let mut graph = PrototypeGraph::default(); + let mut resolved = Vec::new(); + for root in roots { + let key = PrototypeKey(root.clone()); + graph.roots.push(key); + let expansion = resolve_prototype_requests(repository, vfs, root)?; + for prototype in expansion.prototypes { + graph.prototype_requests.push(prototype.key.clone()); + resolved.push(prototype); + } + } + Ok((graph, resolved)) +} + +/// Resolves many mission roots and records edge-specific graph failures. +/// +/// This function reports per-root failures in [`PrototypeGraphReport`] instead +/// of returning early. +pub fn build_prototype_graph_report( + repository: &dyn ResourceRepository, + vfs: &dyn Vfs, + roots: &[ResourceName], +) -> ( + PrototypeGraph, + Vec, + PrototypeGraphReport, +) { + let mut graph = PrototypeGraph::default(); + let mut resolved = Vec::new(); + let mut report = PrototypeGraphReport { + root_count: roots.len(), + ..PrototypeGraphReport::default() + }; + + for (root_index, root) in roots.iter().enumerate() { + graph.roots.push(PrototypeKey(root.clone())); + let edge = if has_extension_bytes(&root.0, b"dat") { + report.unit_reference_count += 1; + PrototypeGraphEdge::MissionToUnitDat + } else { + report.direct_reference_count += 1; + PrototypeGraphEdge::MissionToObjectsRegistry + }; + + match resolve_prototype_requests(repository, vfs, root) { + Ok(expansion) => { + let expected = expansion.expected_count; + if edge == PrototypeGraphEdge::MissionToUnitDat { + report.unit_component_count += expected; + } + let actual = expansion.prototypes.len(); + for prototype in expansion.prototypes { + graph.prototype_requests.push(prototype.key.clone()); + report.resolved_count += 1; + report.mesh_dependency_count += prototype.dependencies.len(); + resolved.push(prototype); + } + if actual < expected { + report.failures.push(PrototypeGraphFailure { + root_index, + resource_raw: root.0.clone(), + edge, + message: "resource did not resolve to an effective prototype".to_string(), + }); + } + } + Err(err) => report.failures.push(PrototypeGraphFailure { + root_index, + resource_raw: root.0.clone(), + edge: graph_error_edge(edge, &err), + message: err.to_string(), + }), + } + } + + (graph, resolved, report) +} + +/// Extends a graph report by validating visual dependencies for each resolved +/// prototype. +pub fn extend_graph_report_with_visual_dependencies( + repository: &dyn ResourceRepository, + report: &mut PrototypeGraphReport, + prototypes: &[EffectivePrototype], +) { + let texture_archive = archive_path(b"textures.lib").ok(); + let lightmap_archive = archive_path(b"lightmap.lib").ok(); + for (prototype_index, prototype) in prototypes.iter().enumerate() { + let PrototypeGeometry::Mesh(mesh) = &prototype.geometry else { + continue; + }; + 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, + prototype_index, + mesh.name.0.clone(), + PrototypeGraphEdge::WearToMaterial, + "material index does not fit WEAR selector", + ); + 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, + prototype_index, + texture.0, + PrototypeGraphEdge::MaterialToTexture, + &message, + ), + } + } + } + Err(err) => push_visual_failure( + report, + prototype_index, + mesh.name.0.clone(), + PrototypeGraphEdge::WearToMaterial, + &err.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, + prototype_index, + lightmap.lightmap.0.clone(), + PrototypeGraphEdge::WearToLightmap, + &message, + ), + } + } + } + Err(message) => push_visual_failure( + report, + prototype_index, + mesh.name.0.clone(), + PrototypeGraphEdge::MeshToWear, + &message, + ), + } + } +} + +fn resolve_wear_table( + repository: &dyn ResourceRepository, + mesh: &ResourceKey, +) -> Result { + let archive = repository + .open_archive(&mesh.archive) + .map_err(|err| err.to_string())?; + let wear_name = derive_wear_name(&mesh.name) + .ok_or_else(|| "cannot derive WEAR name from mesh resource".to_string())?; + let handle = repository + .find(archive, &wear_name) + .map_err(|err| err.to_string())? + .ok_or_else(|| { + format!( + "missing WEAR entry {}", + String::from_utf8_lossy(&wear_name.0) + ) + })?; + let info = repository + .entry_info(handle) + .map_err(|err| err.to_string())?; + if info.key.type_id != Some(WEAR_KIND) { + return Err(format!( + "entry {} is not WEAR", + String::from_utf8_lossy(&wear_name.0) + )); + } + let bytes = repository + .read(handle) + .map_err(|err| err.to_string())? + .into_owned(); + decode_wear(&bytes).map_err(|err| err.to_string()) +} + +fn resolve_texm_from_candidates<'a>( + repository: &dyn ResourceRepository, + texture: &ResourceName, + candidates: impl IntoIterator>, +) -> Result<(), String> { + let mut missing_archive = false; + for path in candidates.into_iter().flatten() { + let archive = match repository.open_archive(path) { + Ok(archive) => archive, + Err(ResourceError::MissingArchive) => { + missing_archive = true; + continue; + } + Err(err) => return Err(err.to_string()), + }; + let Some(handle) = repository + .find(archive, texture) + .map_err(|err| err.to_string())? + else { + continue; + }; + let bytes = repository + .read(handle) + .map_err(|err| err.to_string())? + .into_owned(); + decode_texm(Arc::from(bytes.into_boxed_slice())).map_err(|err| err.to_string())?; + return Ok(()); + } + if missing_archive { + Err(format!( + "texture archive missing for {}", + String::from_utf8_lossy(&texture.0) + )) + } else { + Err(format!( + "missing texture {}", + String::from_utf8_lossy(&texture.0) + )) + } +} + +fn push_visual_failure( + report: &mut PrototypeGraphReport, + prototype_index: usize, + resource_raw: Vec, + edge: PrototypeGraphEdge, + message: &str, +) { + report.failures.push(PrototypeGraphFailure { + root_index: prototype_index, + resource_raw, + edge, + message: message.to_string(), + }); +} + +fn derive_wear_name(model_name: &ResourceName) -> Option { + let stem = file_stem_bytes(&model_name.0); + if stem.is_empty() { + return None; + } + let mut out = stem.to_vec(); + out.extend_from_slice(b".wea"); + Some(ResourceName(out)) +} + +fn graph_error_edge(edge: PrototypeGraphEdge, err: &PrototypeError) -> PrototypeGraphEdge { + match err { + PrototypeError::InvalidMesh(_) => PrototypeGraphEdge::PrototypeToMesh, + PrototypeError::Decode(_) + | PrototypeError::InvalidSize + | PrototypeError::InvalidUnitDatMagic(_) + | PrototypeError::InvalidPath(_) + | PrototypeError::Vfs(_) + | PrototypeError::Resource(_) => edge, + } +} + +fn resolve_archive_model( + repository: &dyn ResourceRepository, + archive: &NormalizedPath, + model_key: &ResourceName, + source: PrototypeSource, +) -> Result, PrototypeError> { + if archive.as_str().eq_ignore_ascii_case("objects.rlb") { + if let Some(prototype) = resolve_objects_registry_model(repository, archive, model_key)? { + return Ok(Some(prototype)); + } + } + + let Some(mesh) = find_mesh_resource(repository, archive, model_key)? else { + return Ok(None); + }; + Ok(Some(effective(model_key.clone(), mesh, source))) +} + +fn resolve_objects_registry_model( + repository: &dyn ResourceRepository, + registry_archive: &NormalizedPath, + object_key: &ResourceName, +) -> Result, PrototypeError> { + let Some(refs) = + collect_registry_refs(repository, registry_archive, object_key, &mut Vec::new(), 0)? + else { + return Ok(None); + }; + + let mut missing_mesh_refs = Vec::new(); + for item in refs.iter().filter(|item| is_explicit_mesh_ref(item)) { + if let Some(prototype) = + resolve_object_ref_model(repository, object_key, item, cstr_bytes(&item.resource_raw))? + { + return Ok(Some(prototype)); + } + missing_mesh_refs.push(describe_object_ref(item)); + } + if !missing_mesh_refs.is_empty() { + return Err(PrototypeError::Resource(format!( + "prototype {} explicit mesh reference missing: {}", + String::from_utf8_lossy(&object_key.0), + missing_mesh_refs.join(" -> ") + ))); + } + + Ok(Some(EffectivePrototype { + key: PrototypeKey(object_key.clone()), + geometry: PrototypeGeometry::NonGeometric, + source: PrototypeSource::ObjectsRegistry, + dependencies: Vec::new(), + })) +} + +fn collect_registry_refs( + repository: &dyn ResourceRepository, + registry_archive: &NormalizedPath, + object_key: &ResourceName, + stack: &mut Vec, + depth: usize, +) -> Result>, PrototypeError> { + if depth > PROTOTYPE_INHERITANCE_DEPTH_LIMIT { + return Err(PrototypeError::Resource(format!( + "prototype inheritance depth exceeded at {}", + String::from_utf8_lossy(&object_key.0) + ))); + } + if stack + .iter() + .any(|item| eq_ignore_ascii_case(&item.0, &object_key.0)) + { + return Err(PrototypeError::Resource(format!( + "prototype inheritance cycle at {}", + String::from_utf8_lossy(&object_key.0) + ))); + } + let archive_id = match repository.open_archive(registry_archive) { + Ok(id) => id, + Err(ResourceError::MissingArchive) => return Ok(None), + Err(err) => return Err(err.into()), + }; + let Some((registry_entry, _matched_name)) = + find_any_candidate(repository, archive_id, &mesh_name_candidates(&object_key.0))? + else { + return Ok(None); + }; + let payload = repository.read(registry_entry)?.into_owned(); + let refs = decode_registry_entry(&payload)?; + let mut effective_refs = Vec::new(); + stack.push(object_key.clone()); + for item in refs { + if archive_name_is(&item.archive_raw, b"objects.rlb") { + let parent_key = ResourceName(cstr_bytes(&item.resource_raw).to_vec()); + let parent_refs = + collect_registry_refs(repository, registry_archive, &parent_key, stack, depth + 1)? + .ok_or_else(|| { + PrototypeError::Resource(format!( + "missing parent prototype {}", + String::from_utf8_lossy(&parent_key.0) + )) + })?; + effective_refs.extend(parent_refs); + } else { + effective_refs.push(item); + } + } + stack.pop(); + + Ok(Some(effective_refs)) +} + +fn resolve_object_ref_model( + repository: &dyn ResourceRepository, + requested: &ResourceName, + item: &ObjectRefRecord, + model_name: &[u8], +) -> Result, PrototypeError> { + let archive = normalized_path_from_name(&ResourceName(cstr_bytes(&item.archive_raw).to_vec()))?; + let Some(mesh) = find_mesh_resource(repository, &archive, &ResourceName(model_name.to_vec()))? + else { + return Ok(None); + }; + Ok(Some(effective( + requested.clone(), + mesh, + PrototypeSource::ObjectsRegistry, + ))) +} + +fn is_explicit_mesh_ref(item: &ObjectRefRecord) -> bool { + has_extension_bytes(cstr_bytes(&item.resource_raw), b"msh") +} + +fn describe_object_ref(item: &ObjectRefRecord) -> String { + format!( + "{}:{}", + String::from_utf8_lossy(cstr_bytes(&item.archive_raw)), + String::from_utf8_lossy(cstr_bytes(&item.resource_raw)) + ) +} + +fn find_mesh_resource( + repository: &dyn ResourceRepository, + archive: &NormalizedPath, + model_key: &ResourceName, +) -> Result, PrototypeError> { + let archive_id = match repository.open_archive(archive) { + Ok(id) => id, + Err(ResourceError::MissingArchive) => return Ok(None), + Err(err) => return Err(err.into()), + }; + let candidates = mesh_name_candidates(&model_key.0); + let Some((handle, matched_name)) = find_any_candidate(repository, archive_id, &candidates)? + else { + return Ok(None); + }; + validate_mesh_payload(repository.read(handle)?.into_owned())?; + Ok(Some(ResourceKey { + archive: archive.clone(), + name: resource_name(matched_name), + type_id: Some(MESH_KIND), + })) +} + +fn validate_mesh_payload(payload: Vec) -> Result<(), PrototypeError> { + let nested = fparkan_nres::decode( + Arc::from(payload.into_boxed_slice()), + ReadProfile::Compatible, + ) + .map_err(|err| PrototypeError::InvalidMesh(err.to_string()))?; + let document = decode_msh(&nested)?; + validate_msh(&document)?; + Ok(()) +} + +fn find_any_candidate( + repository: &dyn ResourceRepository, + archive_id: fparkan_resource::ArchiveId, + candidates: &[Vec], +) -> Result)>, PrototypeError> { + for candidate in candidates { + if let Some(handle) = repository.find(archive_id, &resource_name(candidate))? { + return Ok(Some((handle, candidate.clone()))); + } + } + Ok(None) +} + +fn effective( + requested: ResourceName, + mesh: ResourceKey, + source: PrototypeSource, +) -> EffectivePrototype { + EffectivePrototype { + key: PrototypeKey(requested), + geometry: PrototypeGeometry::Mesh(mesh.clone()), + source, + dependencies: vec![mesh], + } +} + +fn mesh_name_candidates(name: &[u8]) -> Vec> { + let trimmed = trim_ascii(name); + if trimmed.is_empty() { + return Vec::new(); + } + let mut out = Vec::new(); + push_unique_bytes(&mut out, trimmed.to_vec()); + if has_extension_bytes(trimmed, b"msh") { + let stem = file_stem_bytes(trimmed); + if !stem.is_empty() { + push_unique_bytes(&mut out, stem.to_vec()); + } + } else { + let mut with_suffix = trimmed.to_vec(); + with_suffix.extend_from_slice(b".msh"); + push_unique_bytes(&mut out, with_suffix); + } + out +} + +fn push_unique_bytes(items: &mut Vec>, value: Vec) { + if !items.iter().any(|item| eq_ignore_ascii_case(item, &value)) { + items.push(value); + } +} + +fn normalized_path_from_name(name: &ResourceName) -> Result { + let text = legacy_path_text(cstr_bytes(&name.0)); + normalize_relative(text.as_bytes(), PathPolicy::StrictLegacy) + .map_err(|err| PrototypeError::InvalidPath(err.to_string())) +} + +fn legacy_path_text(raw: &[u8]) -> String { + if let Ok(text) = std::str::from_utf8(raw) { + text.to_string() + } else { + let (decoded, _, _) = WINDOWS_1251.decode(raw); + decoded.into_owned() + } +} + +fn cstr_bytes(raw: &[u8]) -> &[u8] { + let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len()); + trim_ascii(&raw[..len]) +} + +fn archive_name_is(raw: &[u8], expected: &[u8]) -> bool { + cstr_bytes(raw).eq_ignore_ascii_case(expected) +} + +fn trim_ascii(bytes: &[u8]) -> &[u8] { + let mut start = 0usize; + let mut end = bytes.len(); + while start < end && bytes[start].is_ascii_whitespace() { + start += 1; + } + while end > start && bytes[end - 1].is_ascii_whitespace() { + end -= 1; + } + &bytes[start..end] +} + +fn has_extension_bytes(name: &[u8], ext: &[u8]) -> bool { + let Some(pos) = name.iter().rposition(|byte| *byte == b'.') else { + return false; + }; + eq_ignore_ascii_case(&name[pos + 1..], ext) +} + +fn file_stem_bytes(name: &[u8]) -> &[u8] { + let file_name = name + .iter() + .rposition(|byte| *byte == b'/' || *byte == b'\\') + .map_or(name, |pos| &name[pos + 1..]); + let Some(dot) = file_name.iter().rposition(|byte| *byte == b'.') else { + return file_name; + }; + &file_name[..dot] +} + +fn eq_ignore_ascii_case(left: &[u8], right: &[u8]) -> bool { + left.eq_ignore_ascii_case(right) +} + +/// Decodes FX/prototype bytes by preserving them for future typed support. +#[must_use] +pub fn preserve_payload(payload: &[u8]) -> Arc<[u8]> { + Arc::from(payload.to_vec().into_boxed_slice()) +} + +#[cfg(test)] +mod tests { + use super::*; + use fparkan_resource::{archive_path as resource_archive_path, CachedResourceRepository}; + use fparkan_vfs::{DirectoryVfs, MemoryVfs}; + use std::path::Path; + + #[test] + fn registry_requires_record_multiple() { + assert!(decode_registry_entry(&[0; 63]).is_err()); + assert_eq!(decode_registry_entry(&[0; 64]).expect("record").len(), 1); + } + + #[test] + fn registry_zero_records_payload_is_empty() { + let records = decode_registry_entry(&[]).expect("empty registry"); + + assert!(records.is_empty()); + } + + #[test] + fn registry_preserves_bounded_name_tails_and_order() { + let mut bytes = Vec::new(); + let mut first = [0u8; 64]; + first[..9].copy_from_slice(b"arch\0tail"); + first[32..40].copy_from_slice(b"res\0tail"); + bytes.extend_from_slice(&first); + let mut second = [0u8; 64]; + second[..10].copy_from_slice(b"other.rlb\0"); + second[32..43].copy_from_slice(b"second.msh\0"); + bytes.extend_from_slice(&second); + + let records = decode_registry_entry(&bytes).expect("registry records"); + + assert_eq!(records.len(), 2); + assert_eq!(&records[0].archive_raw[..9], b"arch\0tail"); + assert_eq!(&records[0].resource_raw[..8], b"res\0tail"); + assert_eq!(cstr_bytes(&records[0].archive_raw), b"arch"); + assert_eq!(cstr_bytes(&records[1].resource_raw), b"second.msh"); + } + + #[test] + fn unit_zero_records_uses_exact_size() { + let bytes = [0_u8; 8]; + let unit = decode_unit_dat(&bytes).expect("unit"); + assert!(unit.records.is_empty()); + } + + #[test] + fn unit_dat_one_record_uses_exact_size_formula() { + let bytes = build_unit_dat(&[(b"objects.rlb".as_slice(), b"component".as_slice())]); + let unit = decode_unit_dat(&bytes).expect("unit"); + + assert_eq!(bytes.len(), 8 + 112); + assert_eq!(unit.records.len(), 1); + assert_eq!(cstr_bytes(&unit.records[0].archive_raw), b"objects.rlb"); + assert_eq!(cstr_bytes(&unit.records[0].resource_raw), b"component"); + } + + #[test] + fn unit_dat_rejects_truncated_record() { + let mut bytes = build_unit_dat(&[(b"objects.rlb".as_slice(), b"component".as_slice())]); + bytes.pop(); + + assert!(matches!( + decode_unit_dat(&bytes), + Err(PrototypeError::InvalidSize) + )); + } + + #[test] + fn unit_dat_preserves_header_description_tail_and_parent_link() { + let mut bytes = build_unit_dat(&[(b"objects.rlb".as_slice(), b"component".as_slice())]); + bytes[0..8].copy_from_slice(&[0xF1, 0xF0, 1, 2, 3, 4, 5, 6]); + bytes[8 + 68..8 + 72].copy_from_slice(&(-7_i32).to_le_bytes()); + let description = b"desc\0tail"; + bytes[8 + 72..8 + 72 + description.len()].copy_from_slice(description); + bytes[8 + 104..8 + 108].copy_from_slice(&0x1122_3344_u32.to_le_bytes()); + bytes[8 + 108..8 + 112].copy_from_slice(&0x5566_7788_u32.to_le_bytes()); + + let unit = decode_unit_dat(&bytes).expect("unit"); + let record = &unit.records[0]; + assert_eq!(unit.header_opaque, [0xF1, 0xF0, 1, 2, 3, 4, 5, 6]); + assert_eq!(record.parent_or_link, -7); + assert_eq!(&record.description_raw[..description.len()], description); + assert_eq!(record.tail0, 0x1122_3344); + assert_eq!(record.tail1, 0x5566_7788); + } + + #[test] + fn unit_dat_accepts_full_description_without_nul() { + let mut bytes = build_unit_dat(&[(b"objects.rlb".as_slice(), b"component".as_slice())]); + bytes[8 + 72..8 + 104].copy_from_slice(b"12345678901234567890123456789012"); + + let unit = decode_unit_dat(&bytes).expect("unit"); + + assert_eq!( + &unit.records[0].description_raw, + b"12345678901234567890123456789012" + ); + } + + #[test] + fn unit_dat_preserves_positive_parent_link() { + let mut bytes = build_unit_dat(&[(b"objects.rlb".as_slice(), b"component".as_slice())]); + bytes[8 + 68..8 + 72].copy_from_slice(&12_i32.to_le_bytes()); + + let unit = decode_unit_dat(&bytes).expect("unit"); + + assert_eq!(unit.records[0].parent_or_link, 12); + } + + #[test] + fn resolves_synthetic_objects_registry_model() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + let mesh = minimal_msh_payload(); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[( + b"s_tree_04".as_slice(), + build_object_refs(&[(b"static.rlb".as_slice(), b"s_tree_0_04.msh".as_slice())]) + .as_slice(), + )]) + .into_boxed_slice(), + ), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[(b"s_tree_0_04.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"s_tree_04")) + .expect("resolve") + .expect("prototype"); + + assert_eq!(resolved.source, PrototypeSource::ObjectsRegistry); + let PrototypeGeometry::Mesh(mesh) = resolved.geometry else { + panic!("expected mesh"); + }; + assert_eq!(mesh.archive.as_str(), "static.rlb"); + assert!(mesh.name.0.eq_ignore_ascii_case(b"s_tree_0_04.msh")); + } + + #[test] + fn graph_report_records_resolved_roots_and_failures() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + let mesh = minimal_msh_payload(); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[( + b"s_tree_04".as_slice(), + build_object_refs(&[(b"static.rlb".as_slice(), b"s_tree_0_04.msh".as_slice())]) + .as_slice(), + )]) + .into_boxed_slice(), + ), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[(b"s_tree_0_04.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let roots = [resource_name(b"s_tree_04"), resource_name(b"missing_key")]; + let (graph, resolved, report) = build_prototype_graph_report(&repo, vfs.as_ref(), &roots); + + assert_eq!(graph.roots.len(), 2); + assert_eq!(resolved.len(), 1); + assert_eq!(report.root_count, 2); + assert_eq!(report.direct_reference_count, 2); + assert_eq!(report.unit_reference_count, 0); + assert_eq!(report.resolved_count, 1); + assert_eq!(report.failures.len(), 1); + assert_eq!(report.failures[0].root_index, 1); + assert_eq!( + report.failures[0].edge, + PrototypeGraphEdge::MissionToObjectsRegistry + ); + assert!(!report.is_success()); + } + + #[test] + fn resolves_synthetic_unit_dat_binding() { + let mut vfs = MemoryVfs::default(); + let dat_path = resource_archive_path(b"UNITS/AUTO/unit.dat").expect("dat path"); + let archive_path = resource_archive_path(b"units.rlb").expect("archive path"); + let mesh = minimal_msh_payload(); + vfs.insert( + dat_path, + Arc::from(build_unit_dat_binding(b"units.rlb", b"unit_model").into_boxed_slice()), + ); + vfs.insert( + archive_path, + Arc::from( + build_nres(&[(b"unit_model.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let resolved = + resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"UNITS/AUTO/unit.dat")) + .expect("resolve") + .expect("prototype"); + + assert_eq!(resolved.source, PrototypeSource::UnitDat); + let PrototypeGeometry::Mesh(mesh) = resolved.geometry else { + panic!("expected mesh"); + }; + assert_eq!(mesh.archive.as_str(), "units.rlb"); + assert!(mesh.name.0.eq_ignore_ascii_case(b"unit_model.msh")); + } + + #[test] + fn unit_dat_expands_components_in_order() { + let mut vfs = MemoryVfs::default(); + let dat_path = resource_archive_path(b"UNITS/AUTO/compound.dat").expect("dat path"); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + let mesh = minimal_msh_payload(); + vfs.insert( + dat_path, + Arc::from( + build_unit_dat(&[ + (b"objects.rlb".as_slice(), b"component_a".as_slice()), + (b"objects.rlb".as_slice(), b"component_b".as_slice()), + ]) + .into_boxed_slice(), + ), + ); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[ + ( + b"component_a".as_slice(), + build_object_refs(&[( + b"static.rlb".as_slice(), + b"component_a.msh".as_slice(), + )]) + .as_slice(), + ), + ( + b"component_b".as_slice(), + build_object_refs(&[( + b"static.rlb".as_slice(), + b"component_b.msh".as_slice(), + )]) + .as_slice(), + ), + ]) + .into_boxed_slice(), + ), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[ + (b"component_a.msh".as_slice(), mesh.as_slice()), + (b"component_b.msh".as_slice(), mesh.as_slice()), + ]) + .into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let roots = [resource_name(b"UNITS/AUTO/compound.dat")]; + let (graph, resolved, report) = build_prototype_graph_report(&repo, vfs.as_ref(), &roots); + + assert_eq!(graph.roots.len(), 1); + assert_eq!(graph.prototype_requests.len(), 2); + assert_eq!(graph.prototype_requests[0].0 .0, b"component_a"); + assert_eq!(graph.prototype_requests[1].0 .0, b"component_b"); + assert_eq!(resolved.len(), 2); + assert_eq!(report.unit_reference_count, 1); + assert_eq!(report.unit_component_count, 2); + assert_eq!(report.resolved_count, 2); + assert!(report.is_success()); + } + + #[test] + fn objects_registry_inheritance_merges_parent_then_local_refs() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + let fortif_path = resource_archive_path(b"fortif.rlb").expect("fortif path"); + let mesh = minimal_msh_payload(); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[ + ( + b"parent_proto".as_slice(), + build_object_refs(&[( + b"static.rlb".as_slice(), + b"parent_proto.msh".as_slice(), + )]) + .as_slice(), + ), + ( + b"child_proto".as_slice(), + build_object_refs(&[ + (b"objects.rlb".as_slice(), b"parent_proto".as_slice()), + (b"fortif.rlb".as_slice(), b"child_proto.bas".as_slice()), + ]) + .as_slice(), + ), + ]) + .into_boxed_slice(), + ), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[(b"parent_proto.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(), + ), + ); + vfs.insert( + fortif_path, + Arc::from(build_nres(&[(b"child_proto.bas".as_slice(), b"base")]).into_boxed_slice()), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"child_proto")) + .expect("resolve") + .expect("prototype"); + + let PrototypeGeometry::Mesh(mesh) = resolved.geometry else { + panic!("expected inherited mesh"); + }; + assert_eq!(mesh.archive.as_str(), "static.rlb"); + assert!(mesh.name.0.eq_ignore_ascii_case(b"parent_proto.msh")); + } + + #[test] + fn objects_registry_inheritance_resolves_multiple_levels() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + let mesh = minimal_msh_payload(); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[ + ( + b"grandparent".as_slice(), + build_object_refs(&[( + b"static.rlb".as_slice(), + b"grandparent.msh".as_slice(), + )]) + .as_slice(), + ), + ( + b"parent".as_slice(), + build_object_refs(&[( + b"objects.rlb".as_slice(), + b"grandparent".as_slice(), + )]) + .as_slice(), + ), + ( + b"child".as_slice(), + build_object_refs(&[(b"objects.rlb".as_slice(), b"parent".as_slice())]) + .as_slice(), + ), + ]) + .into_boxed_slice(), + ), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[(b"grandparent.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"child")) + .expect("resolve") + .expect("prototype"); + + let PrototypeGeometry::Mesh(mesh) = resolved.geometry else { + panic!("expected inherited mesh"); + }; + assert!(mesh.name.0.eq_ignore_ascii_case(b"grandparent.msh")); + } + + #[test] + fn base_only_registry_entry_is_nongeometric() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let fortif_path = resource_archive_path(b"fortif.rlb").expect("fortif path"); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[( + b"base_only".as_slice(), + build_object_refs(&[(b"fortif.rlb".as_slice(), b"base_only.bas".as_slice())]) + .as_slice(), + )]) + .into_boxed_slice(), + ), + ); + vfs.insert( + fortif_path, + Arc::from(build_nres(&[(b"base_only.bas".as_slice(), b"base")]).into_boxed_slice()), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"base_only")) + .expect("resolve") + .expect("prototype"); + + assert_eq!(resolved.geometry, PrototypeGeometry::NonGeometric); + assert!(resolved.dependencies.is_empty()); + } + + #[test] + fn objects_registry_inheritance_rejects_direct_cycle() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[( + b"self_cycle".as_slice(), + build_object_refs(&[(b"objects.rlb".as_slice(), b"self_cycle".as_slice())]) + .as_slice(), + )]) + .into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let err = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"self_cycle")) + .expect_err("cycle"); + + assert!(err.to_string().contains("cycle")); + } + + #[test] + fn objects_registry_inheritance_rejects_indirect_cycle() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[ + ( + b"cycle_a".as_slice(), + build_object_refs(&[(b"objects.rlb".as_slice(), b"cycle_b".as_slice())]) + .as_slice(), + ), + ( + b"cycle_b".as_slice(), + build_object_refs(&[(b"objects.rlb".as_slice(), b"cycle_a".as_slice())]) + .as_slice(), + ), + ]) + .into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + let err = + resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"cycle_a")).expect_err("cycle"); + + assert!(err.to_string().contains("cycle")); + } + + #[test] + fn invalid_referenced_msh_is_error() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[( + b"bad_tree".as_slice(), + build_object_refs(&[(b"static.rlb".as_slice(), b"bad_tree.msh".as_slice())]) + .as_slice(), + )]) + .into_boxed_slice(), + ), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[(b"bad_tree.msh".as_slice(), b"not an nres".as_slice())]) + .into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + + let err = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"bad_tree")) + .expect_err("invalid mesh"); + + assert!(matches!(err, PrototypeError::InvalidMesh(_))); + } + + #[test] + fn missing_referenced_archive_reports_root_chain() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[( + b"broken".as_slice(), + build_object_refs(&[(b"missing.rlb".as_slice(), b"broken.msh".as_slice())]) + .as_slice(), + )]) + .into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + + let (_graph, _resolved, report) = + build_prototype_graph_report(&repo, vfs.as_ref(), &[resource_name(b"broken")]); + + assert_eq!(report.failures.len(), 1); + assert_eq!(report.failures[0].resource_raw, b"broken"); + assert_eq!( + report.failures[0].edge, + PrototypeGraphEdge::MissionToObjectsRegistry + ); + assert!(report.failures[0].message.contains("broken")); + assert!(report.failures[0] + .message + .contains("missing.rlb:broken.msh")); + } + + #[test] + fn missing_referenced_resource_reports_root_chain() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[( + b"broken".as_slice(), + build_object_refs(&[(b"static.rlb".as_slice(), b"missing.msh".as_slice())]) + .as_slice(), + )]) + .into_boxed_slice(), + ), + ); + vfs.insert(static_path, Arc::from(build_nres(&[]).into_boxed_slice())); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + + let (_graph, _resolved, report) = + build_prototype_graph_report(&repo, vfs.as_ref(), &[resource_name(b"broken")]); + + assert_eq!(report.failures.len(), 1); + assert_eq!(report.failures[0].resource_raw, b"broken"); + assert!(report.failures[0] + .message + .contains("static.rlb:missing.msh")); + } + + #[test] + fn first_existing_explicit_msh_is_selected_in_order() { + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + let mesh = minimal_msh_payload(); + vfs.insert( + objects_path, + Arc::from( + build_nres(&[( + b"ordered".as_slice(), + build_object_refs(&[ + (b"static.rlb".as_slice(), b"missing.msh".as_slice()), + (b"static.rlb".as_slice(), b"ordered.msh".as_slice()), + ]) + .as_slice(), + )]) + .into_boxed_slice(), + ), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[(b"ordered.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + + let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"ordered")) + .expect("ordered resolve") + .expect("prototype"); + + let PrototypeGeometry::Mesh(mesh) = resolved.geometry else { + panic!("expected mesh"); + }; + assert!(mesh.name.0.eq_ignore_ascii_case(b"ordered.msh")); + } + + #[test] + fn objects_registry_inheritance_rejects_depth_limit() { + let mut names = Vec::new(); + let mut payloads = Vec::new(); + for index in 0..34usize { + names.push(format!("proto_{index}").into_bytes()); + payloads.push(build_object_refs(&[( + b"objects.rlb".as_slice(), + format!("proto_{}", index + 1).as_bytes(), + )])); + } + let entries = names + .iter() + .zip(payloads.iter()) + .map(|(name, payload)| (name.as_slice(), payload.as_slice())) + .collect::>(); + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + vfs.insert( + objects_path, + Arc::from(build_nres(&entries).into_boxed_slice()), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + + let err = + resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"proto_0")).expect_err("depth"); + + assert!(err.to_string().contains("depth exceeded")); + } + + #[test] + fn generated_acyclic_prototype_graph_resolves_deterministically() { + let first = generated_acyclic_graph(&[0, 1, 2, 3, 4, 5]); + let second = generated_acyclic_graph(&[5, 4, 3, 2, 1, 0]); + + assert_eq!(first.0, second.0); + assert_eq!(first.1, second.1); + assert_eq!(first.2, second.2); + } + + #[test] + fn arbitrary_unit_and_registry_bytes_are_bounded_and_panic_free() { + for len in 0..256usize { + let bytes = vec![0xA5; len]; + let unit = std::panic::catch_unwind(|| decode_unit_dat(&bytes)); + let registry = std::panic::catch_unwind(|| decode_registry_entry(&bytes)); + + assert!(unit.is_ok()); + assert!(registry.is_ok()); + } + } + + #[test] + fn resolver_cache_invalidates_when_archive_fingerprint_changes() { + let root = temp_dir("resolver-cache"); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + std::fs::write( + root.join(objects_path.as_str()), + build_nres(&[( + b"dynamic".as_slice(), + build_object_refs(&[(b"static.rlb".as_slice(), b"dynamic.msh".as_slice())]) + .as_slice(), + )]), + ) + .expect("objects.rlb"); + std::fs::write( + root.join(static_path.as_str()), + build_nres(&[(b"dynamic.msh".as_slice(), b"not an nres".as_slice())]), + ) + .expect("initial static.rlb"); + let vfs = Arc::new(DirectoryVfs::new(&root)); + let repo = CachedResourceRepository::new(vfs.clone()); + + let err = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"dynamic")) + .expect_err("invalid initial mesh"); + assert!(matches!(err, PrototypeError::InvalidMesh(_))); + + std::fs::write( + root.join(static_path.as_str()), + build_nres(&[(b"dynamic.msh".as_slice(), minimal_msh_payload().as_slice())]), + ) + .expect("updated static.rlb"); + let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"dynamic")) + .expect("updated resolve") + .expect("prototype"); + + let PrototypeGeometry::Mesh(mesh) = resolved.geometry else { + panic!("expected mesh"); + }; + assert!(mesh.name.0.eq_ignore_ascii_case(b"dynamic.msh")); + let _ = std::fs::remove_dir_all(root); + } + + #[test] + fn resolves_known_part1_registry_cases() { + let root = corpus_root("IS").expect("part 1 root"); + let vfs = Arc::new(DirectoryVfs::new(&root)); + let repo = CachedResourceRepository::new(vfs.clone()); + let cases = [ + (b"r_h_01".as_slice(), "bases.rlb", b"r_h_01.msh".as_slice()), + ( + b"s_tree_04".as_slice(), + "static.rlb", + b"s_tree_0_04.msh".as_slice(), + ), + ( + b"fr_m_brige".as_slice(), + "fortif.rlb", + b"fr_m_brige.msh".as_slice(), + ), + ]; + + for (key, archive, model) in cases { + let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(key)) + .unwrap_or_else(|err| panic!("failed to resolve {:?}: {err}", key)) + .unwrap_or_else(|| panic!("missing prototype for {:?}", key)); + let PrototypeGeometry::Mesh(mesh) = resolved.geometry else { + panic!("expected mesh"); + }; + assert_eq!(mesh.archive.as_str().to_ascii_lowercase(), archive); + assert!(mesh.name.0.eq_ignore_ascii_case(model)); + } + } + + #[test] + fn resolves_some_registry_entries_in_both_corpora() { + for corpus in ["IS", "IS2"] { + let root = corpus_root(corpus).expect("corpus root"); + let objects = std::fs::read(root.join("objects.rlb")).expect("objects.rlb"); + let document = fparkan_nres::decode( + Arc::from(objects.into_boxed_slice()), + fparkan_nres::ReadProfile::Compatible, + ) + .expect("objects.rlb document"); + let vfs = Arc::new(DirectoryVfs::new(&root)); + let repo = CachedResourceRepository::new(vfs.clone()); + let mut resolved = 0usize; + + for entry in document.entries().iter().take(64) { + if resolve_prototype(&repo, vfs.as_ref(), &resource_name(entry.name_bytes())) + .unwrap_or_else(|err| panic!("{corpus} {:?}: {err}", entry.name_bytes())) + .is_some() + { + resolved += 1; + } + } + + assert!(resolved > 0, "{corpus}: no registry entries resolved"); + } + } + + #[test] + fn licensed_corpora_unit_dat_parse_counts() { + let cases = [("IS", 425, 5_219), ("IS2", 676, 8_145)]; + for (corpus, expected_files, expected_records) in cases { + let root = corpus_root(corpus).expect("corpus root"); + let mut dat_paths = Vec::new(); + collect_unit_dat_files(&root, &mut dat_paths); + dat_paths.sort(); + let mut records = 0usize; + for path in &dat_paths { + let bytes = std::fs::read(path).expect("unit DAT"); + let unit = decode_unit_dat(&bytes).expect("unit DAT decode"); + for record in &unit.records { + assert!( + archive_name_is(&record.archive_raw, b"objects.rlb"), + "{}: unexpected component archive {:?}", + path.display(), + cstr_bytes(&record.archive_raw) + ); + assert_eq!( + record.kind, + 1, + "{}: unexpected component kind", + path.display() + ); + } + records += unit.records.len(); + } + assert_eq!(dat_paths.len(), expected_files, "{corpus} unit DAT files"); + assert_eq!(records, expected_records, "{corpus} unit DAT records"); + } + } + + #[test] + fn licensed_corpora_registry_payloads_are_record_aligned() { + for corpus in ["IS", "IS2"] { + let root = corpus_root(corpus).expect("corpus root"); + let objects = std::fs::read(root.join("objects.rlb")).expect("objects.rlb"); + let document = fparkan_nres::decode( + Arc::from(objects.into_boxed_slice()), + fparkan_nres::ReadProfile::Compatible, + ) + .expect("objects.rlb document"); + + assert!(document.entry_count() > 0, "{corpus}: empty objects.rlb"); + for entry in document.entries() { + let payload = document.payload(entry.id()).expect("registry payload"); + assert!( + payload.len().is_multiple_of(64), + "{corpus}: registry payload for {:?} is not 64-byte aligned", + entry.name_bytes() + ); + decode_registry_entry(payload).expect("registry payload decode"); + } + } + } + + fn collect_unit_dat_files(dir: &Path, out: &mut Vec) { + let mut children: Vec<_> = std::fs::read_dir(dir) + .expect("read dir") + .map(|entry| entry.expect("entry").path()) + .collect(); + children.sort(); + for child in children { + if child.is_dir() { + collect_unit_dat_files(&child, out); + } else if child + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("dat")) + && child.components().any(|component| { + component + .as_os_str() + .to_str() + .is_some_and(|text| text.eq_ignore_ascii_case("UNITS")) + }) + { + out.push(child); + } + } + } + + fn corpus_root(name: &str) -> Option { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("testdata") + .join(name); + root.is_dir().then_some(root) + } + + fn generated_acyclic_graph( + order: &[usize], + ) -> ( + PrototypeGraph, + Vec, + PrototypeGraphReport, + ) { + let names = (0..6usize) + .map(|index| format!("node_{index}").into_bytes()) + .collect::>(); + let payloads = (0..6usize) + .map(|index| { + if index == 0 { + build_object_refs(&[(b"static.rlb".as_slice(), b"node_0.msh".as_slice())]) + } else { + build_object_refs(&[( + b"objects.rlb".as_slice(), + format!("node_{}", index - 1).as_bytes(), + )]) + } + }) + .collect::>(); + let entries = order + .iter() + .map(|index| (names[*index].as_slice(), payloads[*index].as_slice())) + .collect::>(); + let mut vfs = MemoryVfs::default(); + let objects_path = resource_archive_path(b"objects.rlb").expect("objects path"); + let static_path = resource_archive_path(b"static.rlb").expect("static path"); + vfs.insert( + objects_path, + Arc::from(build_nres(&entries).into_boxed_slice()), + ); + vfs.insert( + static_path, + Arc::from( + build_nres(&[(b"node_0.msh".as_slice(), minimal_msh_payload().as_slice())]) + .into_boxed_slice(), + ), + ); + let vfs = Arc::new(vfs); + let repo = CachedResourceRepository::new(vfs.clone()); + build_prototype_graph_report( + &repo, + vfs.as_ref(), + &[resource_name(b"node_5"), resource_name(b"node_3")], + ) + } + + fn temp_dir(name: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!( + "fparkan-prototype-{name}-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::create_dir_all(&path).expect("temp dir"); + path + } + + fn build_unit_dat_binding(archive: &[u8], model: &[u8]) -> Vec { + let mut out = vec![0; UNIT_DAT_MIN_SIZE]; + out[0..4].copy_from_slice(&UNIT_DAT_MAGIC.to_le_bytes()); + copy_cstr(&mut out[0x08..0x28], archive); + copy_cstr(&mut out[0x28..0x48], model); + out + } + + fn build_unit_dat(components: &[(&[u8], &[u8])]) -> Vec { + let mut out = vec![0; 8]; + out[0..4].copy_from_slice(&UNIT_DAT_MAGIC.to_le_bytes()); + for (index, (archive, resource)) in components.iter().enumerate() { + let mut record = [0; 112]; + copy_cstr(&mut record[0..32], archive); + copy_cstr(&mut record[32..64], resource); + record[64..68].copy_from_slice(&1_u32.to_le_bytes()); + record[68..72].copy_from_slice( + &i32::try_from(index) + .map_or(-1, |value| value.saturating_sub(1)) + .to_le_bytes(), + ); + copy_cstr(&mut record[72..104], b"component"); + out.extend_from_slice(&record); + } + out + } + + fn build_object_refs(items: &[(&[u8], &[u8])]) -> Vec { + let mut out = Vec::with_capacity(items.len() * 64); + for (archive, resource) in items { + let mut chunk = [0; 64]; + copy_cstr(&mut chunk[..32], archive); + copy_cstr(&mut chunk[32..], resource); + out.extend_from_slice(&chunk); + } + out + } + + fn build_nres(entries: &[(&[u8], &[u8])]) -> Vec { + let entries = entries + .iter() + .map(|(name, payload)| TestEntry { + type_id: 0, + attr3: 0, + name, + payload, + }) + .collect::>(); + build_nres_typed(&entries) + } + + fn minimal_msh_payload() -> Vec { + build_nres_typed(&[ + TestEntry { + type_id: 1, + attr3: 38, + name: b"Res1", + payload: &[], + }, + TestEntry { + type_id: 2, + attr3: 0, + name: b"Res2", + payload: &[0; 0x8c], + }, + TestEntry { + type_id: 3, + attr3: 0, + name: b"Res3", + payload: &[], + }, + TestEntry { + type_id: 6, + attr3: 0, + name: b"Res6", + payload: &[], + }, + TestEntry { + type_id: 13, + attr3: 0, + name: b"Res13", + payload: &[], + }, + ]) + } + + struct TestEntry<'a> { + type_id: u32, + attr3: u32, + name: &'a [u8], + payload: &'a [u8], + } + + fn build_nres_typed(entries: &[TestEntry<'_>]) -> Vec { + 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 = (0..entries.len()).collect(); + order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name)); + for (idx, 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"), + ); + push_u32(&mut out, entry.attr3); + let mut name_raw = [0; 36]; + copy_cstr(&mut name_raw, entry.name); + out.extend_from_slice(&name_raw); + push_u32(&mut out, offsets[idx]); + push_u32(&mut out, u32::try_from(order[idx]).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 copy_cstr(dst: &mut [u8], src: &[u8]) { + let len = dst.len().saturating_sub(1).min(src.len()); + dst[..len].copy_from_slice(&src[..len]); + } + + fn push_u32(out: &mut Vec, value: u32) { + out.extend_from_slice(&value.to_le_bytes()); + } +} -- cgit v1.2.3