aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-inspection/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/fparkan-inspection/src')
-rw-r--r--crates/fparkan-inspection/src/lib.rs1009
1 files changed, 0 insertions, 1009 deletions
diff --git a/crates/fparkan-inspection/src/lib.rs b/crates/fparkan-inspection/src/lib.rs
deleted file mode 100644
index be1f098..0000000
--- a/crates/fparkan-inspection/src/lib.rs
+++ /dev/null
@@ -1,1009 +0,0 @@
-#![forbid(unsafe_code)]
-#![cfg_attr(
- test,
- allow(
- clippy::cast_possible_truncation,
- clippy::cast_possible_wrap,
- clippy::cast_precision_loss,
- clippy::expect_used,
- clippy::float_cmp,
- clippy::identity_op,
- clippy::too_many_lines,
- clippy::uninlined_format_args,
- clippy::map_unwrap_or,
- clippy::needless_raw_string_hashes,
- clippy::semicolon_if_nothing_returned,
- clippy::type_complexity,
- clippy::panic,
- clippy::unwrap_used
- )
-)]
-//! Shared inspection helpers for format-backed tooling.
-
-use fparkan_diagnostics::{
- diagnostic, render_human, Diagnostic, DiagnosticCode, DiagnosticContext, Phase, SourceSpan,
-};
-use fparkan_material::{decode_wear, resolve_material, MaterialFallback};
-use fparkan_msh::{
- decode_msh, node38_metadata, selected_slot, validate_msh, Group, Lod, ModelAsset, NodeId,
-};
-use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile};
-use fparkan_path::{normalize_relative, PathPolicy};
-use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
-use fparkan_rsli::decode as decode_rsli;
-use fparkan_terrain_format::{decode_land_map, decode_land_msh, LandMeshDocument};
-use fparkan_texm::decode_texm;
-use fparkan_vfs::{DirectoryVfs, Vfs};
-use std::fs;
-#[cfg(unix)]
-use std::os::unix::ffi::OsStrExt;
-use std::path::Path;
-use std::sync::Arc;
-
-/// Archive inspection variants.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum ArchiveInspection {
- /// `NRes` inspection summary.
- Nres {
- /// Archive entry count.
- entries: usize,
- /// Lookup order validity.
- lookup_order_valid: bool,
- /// Entry samples (subject to request limit).
- sample: Vec<NresEntrySummary>,
- },
- /// `RsLi` inspection summary.
- Rsli {
- /// Archive entry count.
- entries: usize,
- },
- /// Unknown/unsupported archive magic.
- Unsupported,
-}
-
-/// `NRes` entry summary.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct NresEntrySummary {
- /// ASCII/legacy resource name.
- pub name: String,
- /// Entry type identifier.
- pub type_id: u32,
- /// Declared entry payload size.
- pub data_size: u32,
-}
-
-/// Model inspection payload.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct ModelInspection {
- /// Terrain stream/document stream count.
- pub streams: usize,
- /// Node count.
- pub nodes: usize,
- /// Slot count.
- pub slots: usize,
- /// Position count.
- pub positions: usize,
- /// Index count.
- pub indices: usize,
- /// Batch count.
- pub batches: usize,
- /// Original node record stride.
- pub node_stride: usize,
- /// Standard-node metadata in source order, when the model uses `Node38`.
- pub node38: Vec<Node38Inspection>,
- /// Number of decoded type-8 animation keys, when available.
- pub animation_keys: Option<usize>,
- /// Declared type-19 animation frame count, when available.
- pub animation_frame_count: Option<u32>,
-}
-
-/// Inspection view of a standard 38-byte model node.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct Node38Inspection {
- /// Source node index.
- pub index: usize,
- /// Opaque source field at byte offset two.
- pub parent_or_link_raw: u16,
- /// Type-19 frame-map offset, or `0xFFFF`.
- pub anim_map_start: u16,
- /// Type-8 fallback key index.
- pub fallback_key: u16,
- /// Whether LOD zero/group zero selects a geometry slot.
- pub has_lod0_group0: bool,
-}
-
-/// Texture inspection payload.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct TextureInspection {
- /// Width.
- pub width: u32,
- /// Height.
- pub height: u32,
- /// Texture format debug text.
- pub format: String,
- /// Mip level count.
- pub mips: usize,
- /// Total page rectangles.
- pub pages: usize,
-}
-
-/// Diffuse TEXM selected through an original WEAR and MAT0 material chain.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct WearMaterialTexture {
- /// Source WEAR archive.
- pub wear_archive: String,
- /// Source WEAR resource.
- pub wear_resource: String,
- /// Positional WEAR selector used by an MSH batch.
- pub material_index: u16,
- /// Resolved MAT0 resource name after the original fallback chain.
- pub material_name: String,
- /// Fallback route used while resolving the MAT0 resource.
- pub material_fallback: MaterialFallback,
- /// Texture name selected from phase zero of the resolved MAT0 document.
- pub texture_name: String,
- /// Decoded RGBA8 mip zero suitable for the Vulkan upload boundary.
- pub image: fparkan_texm::RgbaImage,
-}
-
-/// Compact inspection summary of a WEAR resource stored in an archive.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct WearInspection {
- /// Number of material rows.
- pub materials: usize,
- /// Number of lightmap rows.
- pub lightmaps: usize,
- /// First material resource name, when present.
- pub first_material: Option<String>,
- /// Last material resource name, when present.
- pub last_material: Option<String>,
-}
-
-/// Land map/msh inspection payload.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct MapInspection {
- /// Mapped mesh stream count.
- pub streams: usize,
- /// Slot count.
- pub slots: usize,
- /// Position count.
- pub positions: usize,
- /// Face count.
- pub faces: usize,
- /// Terrain areals.
- pub areals: usize,
- /// Declared areal count from map metadata.
- pub declared_areals: u32,
- /// Map grid width.
- pub grid_width: u32,
- /// Map grid height.
- pub grid_height: u32,
-}
-
-/// Axis-aligned position bounds of a decoded `Land.msh`.
-#[derive(Clone, Debug, PartialEq)]
-pub struct LandMeshBoundsInspection {
- /// Number of source positions covered by the bounds.
- pub positions: usize,
- /// Per-axis inclusive minimum position.
- pub min: [f32; 3],
- /// Per-axis inclusive maximum position.
- pub max: [f32; 3],
-}
-
-/// Supported land file kinds.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum LandFileKind {
- /// `land.msh` payload.
- LandMsh,
- /// `land.map` payload.
- LandMap,
-}
-
-/// Inspects a format archive.
-///
-/// # Errors
-///
-/// Returns a string error when the archive cannot be read or decoded.
-pub fn inspect_archive_file(path: &Path, sample_limit: usize) -> Result<ArchiveInspection, String> {
- inspect_archive_file_diagnostic(path, sample_limit)
- .map_err(|diagnostic| render_human(&diagnostic))
-}
-
-/// Inspects a format archive and returns a structured diagnostic on failure.
-///
-/// # Errors
-///
-/// Returns a [`Diagnostic`] when the archive cannot be read or decoded.
-// Diagnostic is deliberately returned by value as the public structured-error contract.
-#[allow(clippy::result_large_err)]
-pub fn inspect_archive_file_diagnostic(
- path: &Path,
- sample_limit: usize,
-) -> Result<ArchiveInspection, Diagnostic> {
- let parent = path.parent().unwrap_or_else(|| Path::new("."));
- let file_name = path.file_name().ok_or_else(|| {
- diagnostic(
- DiagnosticCode("S1.VFS.PATH"),
- format!("{}: archive path has no file name", path.display()),
- )
- .with_context(DiagnosticContext {
- phase: Some(Phase::Read),
- path: Some(path.display().to_string()),
- ..DiagnosticContext::default()
- })
- })?;
- #[cfg(unix)]
- let raw_name = file_name.as_bytes();
- #[cfg(not(unix))]
- let raw_name = file_name
- .to_str()
- .ok_or_else(|| {
- diagnostic(
- DiagnosticCode("S1.VFS.PATH"),
- format!("{}: archive file name is not valid text", path.display()),
- )
- .with_context(DiagnosticContext {
- phase: Some(Phase::Read),
- path: Some(path.display().to_string()),
- ..DiagnosticContext::default()
- })
- })?
- .as_bytes();
- let normalized = normalize_relative(raw_name, PathPolicy::HostCompatible).map_err(|err| {
- diagnostic(
- DiagnosticCode("S1.VFS.PATH"),
- format!("{}: {err}", path.display()),
- )
- .with_context(DiagnosticContext {
- phase: Some(Phase::Read),
- path: Some(path.display().to_string()),
- ..DiagnosticContext::default()
- })
- })?;
- let vfs = DirectoryVfs::new(parent);
- let bytes = vfs.read(&normalized).map_err(|err| {
- diagnostic(
- DiagnosticCode("S1.VFS.READ"),
- format!("{}: {err}", path.display()),
- )
- .with_context(DiagnosticContext {
- phase: Some(Phase::Read),
- path: Some(path.display().to_string()),
- ..DiagnosticContext::default()
- })
- })?;
- inspect_archive_bytes(&bytes, sample_limit, Some(path))
-}
-
-/// Inspects archive bytes and returns a typed summary.
-// Keeps the internal diagnostic flow aligned with the public structured-error contract.
-#[allow(clippy::result_large_err)]
-fn inspect_archive_bytes(
- bytes: &[u8],
- sample_limit: usize,
- source: Option<&Path>,
-) -> Result<ArchiveInspection, Diagnostic> {
- if bytes.starts_with(b"NRes") {
- let document = decode_nres(
- Arc::from(bytes.to_vec().into_boxed_slice()),
- ReadProfile::Compatible,
- )
- .map_err(|err| {
- archive_parse_diagnostic("S1.NRES.DECODE", source, bytes, err.to_string())
- })?;
- let mut sample = Vec::new();
- for entry in document.entries().iter().take(sample_limit) {
- sample.push(NresEntrySummary {
- name: String::from_utf8_lossy(entry.name_bytes()).to_string(),
- type_id: entry.meta().type_id,
- data_size: entry.meta().data_size,
- });
- }
- Ok(ArchiveInspection::Nres {
- entries: document.entries().len(),
- lookup_order_valid: document.lookup_order_valid(),
- sample,
- })
- } else if bytes.get(0..4) == Some(b"NL\0\x01") {
- let document = decode_rsli(
- Arc::from(bytes.to_vec().into_boxed_slice()),
- fparkan_rsli::ReadProfile::Compatible,
- )
- .map_err(|err| {
- archive_parse_diagnostic("S1.RSLI.DECODE", source, bytes, err.to_string())
- })?;
- Ok(ArchiveInspection::Rsli {
- entries: document.entries().len(),
- })
- } else {
- Err(archive_parse_diagnostic(
- "S1.RESOURCE.UNSUPPORTED_ARCHIVE",
- source,
- bytes,
- "unsupported archive magic".to_string(),
- ))
- }
-}
-
-/// Inspects a model through repository-backed resource lookup.
-///
-/// # Errors
-///
-/// Returns a string error when the resource cannot be resolved or parsed as a
-/// valid model payload.
-pub fn inspect_model_from_root(
- root: &Path,
- archive: &str,
- resource: &str,
-) -> Result<ModelInspection, String> {
- let bytes = read_resource_bytes_diagnostic(root, archive, resource)
- .map_err(|err| render_human(&err))?;
- let document = decode_nres(bytes.clone(), ReadProfile::Compatible).map_err(|err| {
- render_human(&resource_parse_diagnostic(
- "S1.NRES.DECODE",
- archive,
- resource,
- &bytes,
- err.to_string(),
- ))
- })?;
- let msh = decode_msh(&document).map_err(|err| err.to_string())?;
- let validated = validate_msh(&msh).map_err(|err| err.to_string())?;
- let node38 = if validated.node_stride == 38 {
- (0..validated.node_count)
- .filter_map(|index| {
- let node = NodeId(u32::try_from(index).ok()?);
- let metadata = node38_metadata(&validated, node)?;
- Some(Node38Inspection {
- index,
- parent_or_link_raw: metadata.parent_or_link_raw,
- anim_map_start: metadata.anim_map_start,
- fallback_key: metadata.fallback_key,
- has_lod0_group0: selected_slot(&validated, node, Lod(0), Group(0)).is_some(),
- })
- })
- .collect()
- } else {
- Vec::new()
- };
- Ok(ModelInspection {
- streams: msh.streams().len(),
- nodes: validated.node_count,
- slots: validated.slots.len(),
- positions: validated.positions.len(),
- indices: validated.indices.len(),
- batches: validated.batches.len(),
- node_stride: validated.node_stride,
- node38,
- animation_keys: validated
- .animation
- .as_ref()
- .map(|animation| animation.keys.len()),
- animation_frame_count: validated
- .animation
- .as_ref()
- .map(|animation| animation.frame_count),
- })
-}
-
-/// Inspects a WEAR resource through repository-backed lookup.
-///
-/// # Errors
-///
-/// Returns a string error when the resource cannot be resolved or parsed as a
-/// valid WEAR payload.
-pub fn inspect_wear_from_root(
- root: &Path,
- archive: &str,
- resource: &str,
-) -> Result<WearInspection, String> {
- let bytes = read_resource_bytes_diagnostic(root, archive, resource)
- .map_err(|err| render_human(&err))?;
- let wear = decode_wear(&bytes).map_err(|err| err.to_string())?;
- Ok(WearInspection {
- materials: wear.entries.len(),
- lightmaps: wear.lightmaps.len(),
- first_material: wear
- .entries
- .first()
- .map(|entry| String::from_utf8_lossy(&entry.material.0).into_owned()),
- last_material: wear
- .entries
- .last()
- .map(|entry| String::from_utf8_lossy(&entry.material.0).into_owned()),
- })
-}
-
-/// Loads and validates a model resource through repository-backed lookup.
-///
-/// # Errors
-///
-/// Returns a string error when the resource cannot be resolved or parsed as a
-/// valid model payload.
-pub fn load_model_from_root(
- root: &Path,
- archive: &str,
- resource: &str,
-) -> Result<ModelAsset, String> {
- let document = load_model_document_from_root_diagnostic(root, archive, resource)
- .map_err(|err| render_human(&err))?;
- let msh = decode_msh(&document).map_err(|err| err.to_string())?;
- validate_msh(&msh).map_err(|err| err.to_string())
-}
-
-/// Inspects a texture through repository-backed resource lookup.
-///
-/// # Errors
-///
-/// Returns a string error when the resource cannot be resolved or parsed as a
-/// valid texture payload.
-pub fn inspect_texture_from_root(
- root: &Path,
- archive: &str,
- resource: &str,
-) -> Result<TextureInspection, String> {
- let bytes = read_resource_bytes_diagnostic(root, archive, resource)
- .map_err(|err| render_human(&err))?;
- let document = decode_texm(bytes).map_err(|err| err.to_string())?;
- Ok(TextureInspection {
- width: document.width(),
- height: document.height(),
- format: format!("{:?}", document.format()),
- mips: document.mip_count(),
- pages: document.page_rects().len(),
- })
-}
-
-/// Loads a decoded TEXM document through repository-backed lookup.
-///
-/// # Errors
-///
-/// Returns a string error when the resource cannot be resolved or parsed as a
-/// valid TEXM payload.
-pub fn load_texture_from_root(
- root: &Path,
- archive: &str,
- resource: &str,
-) -> Result<fparkan_texm::TexmDocument, String> {
- let bytes = read_resource_bytes_diagnostic(root, archive, resource)
- .map_err(|err| render_human(&err))?;
- decode_texm(bytes).map_err(|err| err.to_string())
-}
-
-/// Loads and decodes TEXM mip 0 as RGBA8 through repository-backed lookup.
-///
-/// # Errors
-///
-/// Returns a string error when the resource cannot be resolved, decoded, or
-/// converted to the shared RGBA8 upload representation.
-pub fn load_texture_mip0_rgba8_from_root(
- root: &Path,
- archive: &str,
- resource: &str,
-) -> Result<fparkan_texm::RgbaImage, String> {
- let document = load_texture_from_root(root, archive, resource)?;
- fparkan_texm::decode_mip_rgba8(&document, 0).map_err(|err| err.to_string())
-}
-
-/// Resolves phase-zero diffuse TEXM through `WEAR → MAT0 → Textures.lib`.
-///
-/// The selector is the positional `Batch20.material_index`, not WEAR's legacy
-/// text id. MAT0 fallback remains owned by `fparkan-material`: exact requested
-/// entry, then `DEFAULT`, then the first material entry.
-///
-/// # Errors
-///
-/// Returns a string error if WEAR/MAT0 resolution, phase selection, or TEXM
-/// decoding fails. An empty phase-zero texture name is an intentional
-/// untextured material and is reported rather than substituted with a texture.
-pub fn load_wear_material_texture_mip0_rgba8_from_root(
- root: &Path,
- wear_archive: &str,
- wear_resource: &str,
- material_index: u16,
-) -> Result<WearMaterialTexture, String> {
- let wear_bytes = read_resource_bytes_diagnostic(root, wear_archive, wear_resource)
- .map_err(|err| render_human(&err))?;
- let wear = decode_wear(&wear_bytes).map_err(|err| err.to_string())?;
- let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
- let material =
- resolve_material(&repository, &wear, material_index).map_err(|err| err.to_string())?;
- let texture = material.document.primary_texture().ok_or_else(|| {
- "MAT0 phase zero declares an intentionally untextured material".to_string()
- })?;
- let texture_name = String::from_utf8_lossy(&texture.0).into_owned();
- let image = load_texture_mip0_rgba8_from_root(root, "Textures.lib", &texture_name)?;
- Ok(WearMaterialTexture {
- wear_archive: wear_archive.to_string(),
- wear_resource: wear_resource.to_string(),
- material_index,
- material_name: String::from_utf8_lossy(&material.name.0).into_owned(),
- material_fallback: material.fallback,
- texture_name,
- image,
- })
-}
-
-/// Resolves phase-zero diffuse TEXM through a standalone map-local WEAR file.
-///
-/// Map terrain keeps its two WEAR tables adjacent to `Land.msh`, while their
-/// MAT0 and TEXM resources remain in the game root's normal archives. This
-/// preserves that split instead of treating the sidecar as an archive entry.
-///
-/// # Errors
-///
-/// Returns a string error if the standalone WEAR file, global material
-/// resources, or selected diffuse TEXM cannot be decoded.
-pub fn load_standalone_wear_material_texture_mip0_rgba8_from_root(
- root: &Path,
- wear_path: &Path,
- material_index: u16,
-) -> Result<WearMaterialTexture, String> {
- let wear_bytes =
- fs::read(wear_path).map_err(|err| format!("{}: {err}", wear_path.display()))?;
- let wear = decode_wear(&wear_bytes).map_err(|err| err.to_string())?;
- let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
- let material =
- resolve_material(&repository, &wear, material_index).map_err(|err| err.to_string())?;
- let texture = material.document.primary_texture().ok_or_else(|| {
- "MAT0 phase zero declares an intentionally untextured material".to_string()
- })?;
- let texture_name = String::from_utf8_lossy(&texture.0).into_owned();
- let image = load_texture_mip0_rgba8_from_root(root, "Textures.lib", &texture_name)?;
- Ok(WearMaterialTexture {
- wear_archive: "<standalone>".to_string(),
- wear_resource: wear_path.display().to_string(),
- material_index,
- material_name: String::from_utf8_lossy(&material.name.0).into_owned(),
- material_fallback: material.fallback,
- texture_name,
- image,
- })
-}
-
-/// Inspects a terrain land file by path.
-///
-/// # Errors
-///
-/// Returns a string error when the file cannot be read or parsed as the
-/// requested terrain payload kind.
-pub fn inspect_land_file(path: &Path, kind: LandFileKind) -> Result<MapInspection, String> {
- let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
- let document = decode_nres(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
- .map_err(|err| err.to_string())?;
- match kind {
- LandFileKind::LandMsh => inspect_land_msh(&document),
- LandFileKind::LandMap => inspect_land_map(&document),
- }
-}
-
-/// Loads and validates a standalone `Land.msh` file.
-///
-/// # Errors
-///
-/// Returns a human-readable error if the file cannot be read, decoded as `NRes`,
-/// or decoded as the specialized terrain mesh format.
-pub fn load_land_msh_from_path(path: &Path) -> Result<LandMeshDocument, String> {
- let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
- let document = decode_nres(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
- .map_err(|err| err.to_string())?;
- decode_land_msh(&document).map_err(|err| err.to_string())
-}
-
-/// Inspects the source-coordinate bounds of a standalone `Land.msh` file.
-///
-/// # Errors
-///
-/// Returns a string error when the file cannot be read, decoded, or contains
-/// no finite source positions.
-pub fn inspect_land_msh_bounds_file(path: &Path) -> Result<LandMeshBoundsInspection, String> {
- let mesh = load_land_msh_from_path(path)?;
- inspect_land_msh_bounds(&mesh)
-}
-
-/// Computes source-coordinate bounds for an already validated `Land.msh`.
-///
-/// # Errors
-///
-/// Returns a string error when the mesh has no finite source positions.
-pub fn inspect_land_msh_bounds(
- mesh: &LandMeshDocument,
-) -> Result<LandMeshBoundsInspection, String> {
- let mut min = [f32::INFINITY; 3];
- let mut max = [f32::NEG_INFINITY; 3];
- for position in &mesh.positions {
- if !position.iter().all(|value| value.is_finite()) {
- return Err("Land.msh contains a non-finite source position".to_string());
- }
- for axis in 0..3 {
- min[axis] = min[axis].min(position[axis]);
- max[axis] = max[axis].max(position[axis]);
- }
- }
- if mesh.positions.is_empty() {
- return Err("Land.msh contains no source positions".to_string());
- }
- Ok(LandMeshBoundsInspection {
- positions: mesh.positions.len(),
- min,
- max,
- })
-}
-
-fn inspect_land_msh(document: &NresDocument) -> Result<MapInspection, String> {
- let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?;
- Ok(MapInspection {
- streams: land_msh.streams.len(),
- slots: land_msh.slots.slots_raw.len(),
- positions: land_msh.positions.len(),
- faces: land_msh.faces.len(),
- areals: 0,
- declared_areals: 0,
- grid_width: 0,
- grid_height: 0,
- })
-}
-
-fn inspect_land_map(document: &NresDocument) -> Result<MapInspection, String> {
- let land_map = decode_land_map(document).map_err(|err| err.to_string())?;
- Ok(MapInspection {
- streams: 0,
- slots: 0,
- positions: 0,
- faces: 0,
- areals: land_map.areals.len(),
- declared_areals: land_map.areal_count,
- grid_width: land_map.grid.cells_x,
- grid_height: land_map.grid.cells_y,
- })
-}
-
-// Preserves the shared structured-error type without changing callers to boxed errors.
-#[allow(clippy::result_large_err)]
-fn read_resource_bytes_diagnostic(
- root: &Path,
- archive: &str,
- name: &str,
-) -> Result<Arc<[u8]>, Diagnostic> {
- let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
- let archive_path = archive_path(archive.as_bytes()).map_err(|err| {
- diagnostic(DiagnosticCode("S1.PATH.ARCHIVE"), err.to_string()).with_context(
- DiagnosticContext {
- phase: Some(Phase::Resolve),
- path: Some(archive.to_string()),
- archive_entry: Some(name.to_string()),
- ..DiagnosticContext::default()
- },
- )
- })?;
- let resource_name = resource_name(name.as_bytes());
- let archive_handle = repository.open_archive(&archive_path).map_err(|err| {
- diagnostic(DiagnosticCode("S1.RESOURCE.OPEN_ARCHIVE"), err.to_string()).with_context(
- DiagnosticContext {
- phase: Some(Phase::Read),
- path: Some(archive.to_string()),
- archive_entry: Some(name.to_string()),
- ..DiagnosticContext::default()
- },
- )
- })?;
- let Some(handle) = repository
- .find(archive_handle, &resource_name)
- .map_err(|err| {
- diagnostic(DiagnosticCode("S1.RESOURCE.FIND"), err.to_string()).with_context(
- DiagnosticContext {
- phase: Some(Phase::Resolve),
- path: Some(archive.to_string()),
- archive_entry: Some(name.to_string()),
- ..DiagnosticContext::default()
- },
- )
- })?
- else {
- return Err(diagnostic(
- DiagnosticCode("S1.RESOURCE.MISSING_ENTRY"),
- format!(
- "resource not found: {archive}/{}",
- String::from_utf8_lossy(name.as_bytes())
- ),
- )
- .with_context(DiagnosticContext {
- phase: Some(Phase::Resolve),
- path: Some(archive.to_string()),
- archive_entry: Some(name.to_string()),
- ..DiagnosticContext::default()
- }));
- };
- let bytes = repository.read(handle).map_err(|err| {
- diagnostic(DiagnosticCode("S1.RESOURCE.READ"), err.to_string()).with_context(
- DiagnosticContext {
- phase: Some(Phase::Read),
- path: Some(archive.to_string()),
- archive_entry: Some(name.to_string()),
- ..DiagnosticContext::default()
- },
- )
- })?;
- Ok(Arc::from(bytes.into_owned()))
-}
-
-// Preserves the shared structured-error type without changing callers to boxed errors.
-#[allow(clippy::result_large_err)]
-fn load_model_document_from_root_diagnostic(
- root: &Path,
- archive: &str,
- resource: &str,
-) -> Result<NresDocument, Diagnostic> {
- let bytes = read_resource_bytes_diagnostic(root, archive, resource)?;
- decode_nres(bytes.clone(), ReadProfile::Compatible).map_err(|err| {
- resource_parse_diagnostic("S1.NRES.DECODE", archive, resource, &bytes, err.to_string())
- })
-}
-
-fn archive_parse_diagnostic(
- code: &'static str,
- source: Option<&Path>,
- bytes: &[u8],
- message: String,
-) -> Diagnostic {
- diagnostic(DiagnosticCode(code), message).with_context(DiagnosticContext {
- phase: Some(Phase::Parse),
- path: source.map(|path| path.display().to_string()),
- span: Some(SourceSpan {
- offset: 0,
- length: u64::try_from(bytes.len().min(4)).unwrap_or(4),
- }),
- ..DiagnosticContext::default()
- })
-}
-
-fn resource_parse_diagnostic(
- code: &'static str,
- archive: &str,
- resource: &str,
- bytes: &[u8],
- message: String,
-) -> Diagnostic {
- diagnostic(DiagnosticCode(code), message).with_context(DiagnosticContext {
- phase: Some(Phase::Parse),
- path: Some(archive.to_string()),
- archive_entry: Some(resource.to_string()),
- span: Some(SourceSpan {
- offset: 0,
- length: u64::try_from(bytes.len().min(4)).unwrap_or(4),
- }),
- ..DiagnosticContext::default()
- })
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use fparkan_terrain_format::TerrainSlotTable;
- use std::io::Write as _;
- use std::path::PathBuf;
-
- const TEST_NRES_HEADER_LEN: usize = 16;
- const TEST_NRES_NAME_LEN: usize = 36;
- const TEST_NRES_VERSION_0100: u32 = 0x100;
-
- #[test]
- fn inspect_rsli_rejects_malformed_archive() {
- let dir = temp_dir("inspect");
- let path = dir.join("test.rsli");
- let mut file = fs::File::create(&path).expect("file");
- file.write_all(b"NL\0\x01").expect("magic");
- drop(file);
-
- let error = inspect_archive_file(&path, 0).expect_err("malformed archive");
- assert!(error.contains("entry table out of bounds"));
- }
-
- #[test]
- fn archive_diagnostic_preserves_source_path_phase_and_span() {
- let dir = temp_dir("inspect-diagnostic");
- let path = dir.join("broken.nres");
- fs::write(&path, b"NRes").expect("broken nres");
-
- let diagnostic = inspect_archive_file_diagnostic(&path, 0).expect_err("diagnostic failure");
-
- assert_eq!(diagnostic.code.0, "S1.NRES.DECODE");
- let expected_path = path.display().to_string();
- assert_eq!(
- diagnostic.context.path.as_deref(),
- Some(expected_path.as_str())
- );
- assert_eq!(diagnostic.context.phase, Some(Phase::Parse));
- assert_eq!(
- diagnostic.context.span,
- Some(SourceSpan {
- offset: 0,
- length: 4
- })
- );
- }
-
- #[test]
- fn nres_entry_summary_fields_are_readable() {
- let dir = temp_dir("inspect-nres");
- let archive = dir.join("test.nres");
- let payload = Vec::from("NRes\x00\x00\x00\x00");
- fs::write(&archive, &payload).expect("nres");
-
- let _ = inspect_archive_file(&archive, 2);
- }
-
- #[test]
- fn model_archive_diagnostic_preserves_archive_entry_context() {
- let dir = temp_dir("inspect-model-diagnostic");
- let archive = dir.join("models.rlb");
- fs::write(&archive, build_single_entry_nres(b"BROKEN.MSH", b"NRes")).expect("archive");
-
- let diagnostic = load_model_document_from_root_diagnostic(&dir, "models.rlb", "BROKEN.MSH")
- .expect_err("nested diagnostic failure");
-
- assert_eq!(diagnostic.code.0, "S1.NRES.DECODE");
- assert_eq!(diagnostic.context.phase, Some(Phase::Parse));
- assert_eq!(diagnostic.context.path.as_deref(), Some("models.rlb"));
- assert_eq!(
- diagnostic.context.archive_entry.as_deref(),
- Some("BROKEN.MSH")
- );
- assert_eq!(
- diagnostic.context.span,
- Some(SourceSpan {
- offset: 0,
- length: 4
- })
- );
- }
-
- #[test]
- fn wear_material_texture_loader_preserves_original_selection_provenance() {
- let dir = temp_dir("wear-material-texture");
- fs::write(
- dir.join("wear.rlb"),
- build_single_entry_nres(b"MODEL.WEA", b"1\n0 MAT\n"),
- )
- .expect("wear archive");
- let mut mat0 = vec![0; 4 + 34];
- mat0[0..2].copy_from_slice(&1_u16.to_le_bytes());
- mat0[22..25].copy_from_slice(b"TEX");
- fs::write(
- dir.join("material.lib"),
- build_single_entry_nres_with_meta(b"MAT", fparkan_material::MAT0_KIND, 0, &mat0),
- )
- .expect("material archive");
- fs::write(
- dir.join("Textures.lib"),
- build_single_entry_nres(b"TEX", &texm_argb8888_pixel([0x40, 0x11, 0x22, 0x33])),
- )
- .expect("texture archive");
-
- let selected =
- load_wear_material_texture_mip0_rgba8_from_root(&dir, "wear.rlb", "MODEL.WEA", 0)
- .expect("resolved material texture");
-
- assert_eq!(selected.material_name, "MAT");
- assert_eq!(selected.material_fallback, MaterialFallback::Exact);
- assert_eq!(selected.texture_name, "TEX");
- assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
- }
-
- #[test]
- fn standalone_wear_material_texture_loader_keeps_map_sidecar_separate() {
- let dir = temp_dir("standalone-wear-material-texture");
- let wear_path = dir.join("Land2.wea");
- fs::write(&wear_path, b"1\n0 MAT\n").expect("standalone wear");
- let mut mat0 = vec![0; 4 + 34];
- mat0[0..2].copy_from_slice(&1_u16.to_le_bytes());
- mat0[22..25].copy_from_slice(b"TEX");
- fs::write(
- dir.join("material.lib"),
- build_single_entry_nres_with_meta(b"MAT", fparkan_material::MAT0_KIND, 0, &mat0),
- )
- .expect("material archive");
- fs::write(
- dir.join("Textures.lib"),
- build_single_entry_nres(b"TEX", &texm_argb8888_pixel([0x40, 0x11, 0x22, 0x33])),
- )
- .expect("texture archive");
-
- let selected =
- load_standalone_wear_material_texture_mip0_rgba8_from_root(&dir, &wear_path, 0)
- .expect("resolved material texture");
-
- assert_eq!(selected.wear_archive, "<standalone>");
- assert_eq!(selected.wear_resource, wear_path.display().to_string());
- assert_eq!(selected.texture_name, "TEX");
- assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
- }
-
- #[test]
- fn land_mesh_bounds_preserve_each_source_axis() {
- let mesh = LandMeshDocument {
- streams: Vec::new(),
- nodes_raw: Vec::new(),
- slots: TerrainSlotTable {
- header_raw: Vec::new(),
- slots_raw: Vec::new(),
- },
- positions: vec![[4.0, -2.0, 8.0], [-3.0, 6.0, 1.0]],
- normals: Vec::new(),
- uv0: Vec::new(),
- accelerator: Vec::new(),
- aux14: Vec::new(),
- aux18: Vec::new(),
- faces: Vec::new(),
- };
-
- let bounds = inspect_land_msh_bounds(&mesh).expect("bounds");
-
- assert_eq!(bounds.positions, 2);
- assert_eq!(bounds.min, [-3.0, -2.0, 1.0]);
- assert_eq!(bounds.max, [4.0, 6.0, 8.0]);
- }
-
- fn temp_dir(name: &str) -> PathBuf {
- let base = PathBuf::from("/tmp")
- .join("fparkan-inspection-tests")
- .join(name);
- let _ = fs::remove_dir_all(&base);
- fs::create_dir_all(&base).expect("tmp dir");
- base
- }
-
- fn build_single_entry_nres(name: &[u8], payload: &[u8]) -> Vec<u8> {
- build_single_entry_nres_with_meta(name, 1, 0, payload)
- }
-
- fn build_single_entry_nres_with_meta(
- name: &[u8],
- type_id: u32,
- attr2: u32,
- payload: &[u8],
- ) -> Vec<u8> {
- let mut out = vec![0; TEST_NRES_HEADER_LEN];
- let payload_offset = u32::try_from(out.len()).expect("payload offset");
- out.extend_from_slice(payload);
- let padding = (8 - (out.len() % 8)) % 8;
- out.resize(out.len() + padding, 0);
-
- push_u32(&mut out, type_id);
- push_u32(&mut out, 0);
- push_u32(&mut out, attr2);
- push_u32(&mut out, u32::try_from(payload.len()).expect("payload len"));
- push_u32(&mut out, 0);
- let mut raw_name = [0; TEST_NRES_NAME_LEN];
- raw_name[..name.len()].copy_from_slice(name);
- out.extend_from_slice(&raw_name);
- push_u32(&mut out, payload_offset);
- push_u32(&mut out, 0);
-
- out[0..4].copy_from_slice(b"NRes");
- out[4..8].copy_from_slice(&TEST_NRES_VERSION_0100.to_le_bytes());
- out[8..12].copy_from_slice(&1_u32.to_le_bytes());
- let total_size = u32::try_from(out.len()).expect("total size");
- out[12..16].copy_from_slice(&total_size.to_le_bytes());
- out
- }
-
- fn push_u32(out: &mut Vec<u8>, value: u32) {
- out.extend_from_slice(&value.to_le_bytes());
- }
-
- fn texm_argb8888_pixel(pixel: [u8; 4]) -> Vec<u8> {
- let mut out = Vec::new();
- out.extend_from_slice(&0x6D78_6554_u32.to_le_bytes());
- out.extend_from_slice(&1_u32.to_le_bytes());
- out.extend_from_slice(&1_u32.to_le_bytes());
- out.extend_from_slice(&1_u32.to_le_bytes());
- out.extend_from_slice(&0_u32.to_le_bytes());
- out.extend_from_slice(&0_u32.to_le_bytes());
- out.extend_from_slice(&0_u32.to_le_bytes());
- out.extend_from_slice(&8888_u32.to_le_bytes());
- out.extend_from_slice(&pixel);
- out
- }
-}