Files
fparkan/crates/fparkan-inspection/src/lib.rs
T

616 lines
20 KiB
Rust
Raw Normal View History

2026-06-23 22:05:16 +04:00
#![forbid(unsafe_code)]
2026-06-23 22:32:50 +04:00
#![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
)
)]
2026-06-23 22:05:16 +04:00
//! Shared inspection helpers for format-backed tooling.
use fparkan_diagnostics::{
diagnostic, render_human, Diagnostic, DiagnosticCode, DiagnosticContext, Phase, SourceSpan,
};
use fparkan_msh::{decode_msh, validate_msh, ModelAsset};
2026-06-23 22:05:16 +04:00
use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile};
use fparkan_path::{normalize_relative, PathPolicy};
2026-06-23 22:32:50 +04:00
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
2026-06-23 22:05:16 +04:00
use fparkan_rsli::decode as decode_rsli;
use fparkan_terrain_format::{decode_land_map, decode_land_msh};
use fparkan_texm::decode_texm;
use fparkan_vfs::{DirectoryVfs, Vfs};
2026-06-23 22:05:16 +04:00
use std::fs;
#[cfg(unix)]
use std::os::unix::ffi::OsStrExt;
2026-06-23 22:32:50 +04:00
use std::path::Path;
2026-06-23 22:05:16 +04:00
use std::sync::Arc;
/// Archive inspection variants.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ArchiveInspection {
2026-06-23 22:32:50 +04:00
/// `NRes` inspection summary.
2026-06-23 22:05:16 +04:00
Nres {
/// Archive entry count.
entries: usize,
/// Lookup order validity.
lookup_order_valid: bool,
/// Entry samples (subject to request limit).
sample: Vec<NresEntrySummary>,
},
2026-06-23 22:32:50 +04:00
/// `RsLi` inspection summary.
2026-06-23 22:05:16 +04:00
Rsli {
/// Archive entry count.
entries: usize,
},
/// Unknown/unsupported archive magic.
Unsupported,
}
2026-06-23 22:32:50 +04:00
/// `NRes` entry summary.
2026-06-23 22:05:16 +04:00
#[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,
}
/// 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,
}
/// 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,
}
/// 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.
2026-06-23 22:32:50 +04:00
///
/// # Errors
///
/// Returns a string error when the archive cannot be read or decoded.
2026-06-23 22:05:16 +04:00
pub fn inspect_archive_file(path: &Path, sample_limit: usize) -> Result<ArchiveInspection, String> {
2026-07-18 05:28:07 +04:00
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.
2026-07-18 05:31:39 +04:00
// 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()
})
})?;
2026-06-23 22:05:16 +04:00
inspect_archive_bytes(&bytes, sample_limit, Some(path))
}
/// Inspects archive bytes and returns a typed summary.
2026-07-18 05:31:39 +04:00
// Keeps the internal diagnostic flow aligned with the public structured-error contract.
#[allow(clippy::result_large_err)]
2026-06-23 22:05:16 +04:00
fn inspect_archive_bytes(
bytes: &[u8],
sample_limit: usize,
source: Option<&Path>,
) -> Result<ArchiveInspection, Diagnostic> {
2026-06-23 22:05:16 +04:00
if bytes.starts_with(b"NRes") {
let document = decode_nres(
Arc::from(bytes.to_vec().into_boxed_slice()),
ReadProfile::Compatible,
)
2026-07-18 05:28:07 +04:00
.map_err(|err| {
archive_parse_diagnostic("S1.NRES.DECODE", source, bytes, err.to_string())
})?;
2026-06-23 22:05:16 +04:00
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") {
2026-06-23 22:32:50 +04:00
let document = decode_rsli(
Arc::from(bytes.to_vec().into_boxed_slice()),
fparkan_rsli::ReadProfile::Compatible,
)
2026-07-18 05:28:07 +04:00
.map_err(|err| {
archive_parse_diagnostic("S1.RSLI.DECODE", source, bytes, err.to_string())
})?;
2026-06-23 22:05:16 +04:00
Ok(ArchiveInspection::Rsli {
entries: document.entries().len(),
})
} else {
Err(archive_parse_diagnostic(
"S1.RESOURCE.UNSUPPORTED_ARCHIVE",
source,
bytes,
"unsupported archive magic".to_string(),
))
2026-06-23 22:05:16 +04:00
}
}
/// Inspects a model through repository-backed resource lookup.
2026-06-23 22:32:50 +04:00
///
/// # Errors
///
/// Returns a string error when the resource cannot be resolved or parsed as a
/// valid model payload.
2026-06-23 22:05:16 +04:00
pub fn inspect_model_from_root(
root: &Path,
archive: &str,
resource: &str,
) -> Result<ModelInspection, String> {
2026-07-18 05:28:07 +04:00
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(),
))
})?;
2026-06-23 22:05:16 +04:00
let msh = decode_msh(&document).map_err(|err| err.to_string())?;
let validated = validate_msh(&msh).map_err(|err| err.to_string())?;
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(),
})
}
/// 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> {
2026-07-18 05:28:07 +04:00
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())
}
2026-06-23 22:05:16 +04:00
/// Inspects a texture through repository-backed resource lookup.
2026-06-23 22:32:50 +04:00
///
/// # Errors
///
/// Returns a string error when the resource cannot be resolved or parsed as a
/// valid texture payload.
2026-06-23 22:05:16 +04:00
pub fn inspect_texture_from_root(
root: &Path,
archive: &str,
resource: &str,
) -> Result<TextureInspection, String> {
2026-07-18 05:28:07 +04:00
let bytes = read_resource_bytes_diagnostic(root, archive, resource)
.map_err(|err| render_human(&err))?;
2026-06-23 22:05:16 +04:00
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(),
})
}
/// Inspects a terrain land file by path.
2026-06-23 22:32:50 +04:00
///
/// # Errors
///
/// Returns a string error when the file cannot be read or parsed as the
/// requested terrain payload kind.
2026-06-23 22:05:16 +04:00
pub fn inspect_land_file(path: &Path, kind: LandFileKind) -> Result<MapInspection, String> {
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
2026-06-23 22:32:50 +04:00
let document = decode_nres(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
.map_err(|err| err.to_string())?;
2026-06-23 22:05:16 +04:00
match kind {
LandFileKind::LandMsh => inspect_land_msh(&document),
LandFileKind::LandMap => inspect_land_map(&document),
}
}
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,
})
}
2026-07-18 05:31:39 +04:00
// 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> {
2026-06-23 22:05:16 +04:00
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()
},
)
})?;
2026-06-23 22:05:16 +04:00
let resource_name = resource_name(name.as_bytes());
2026-07-18 05:28:07 +04:00
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()
},
)
})?;
2026-06-23 22:05:16 +04:00
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()
},
)
})?
2026-06-23 22:05:16 +04:00
else {
2026-07-18 05:28:07 +04:00
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()
}));
2026-06-23 22:05:16 +04:00
};
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()
},
)
})?;
2026-06-23 22:05:16 +04:00
Ok(Arc::from(bytes.into_owned()))
}
2026-07-18 05:31:39 +04:00
// 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()
})
}
2026-06-23 22:05:16 +04:00
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
2026-06-23 22:32:50 +04:00
use std::path::PathBuf;
2026-06-23 22:05:16 +04:00
const TEST_NRES_HEADER_LEN: usize = 16;
const TEST_NRES_NAME_LEN: usize = 36;
const TEST_NRES_VERSION_0100: u32 = 0x100;
2026-06-23 22:05:16 +04:00
#[test]
2026-06-23 22:32:50 +04:00
fn inspect_rsli_rejects_malformed_archive() {
2026-06-23 22:05:16 +04:00
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);
2026-06-23 22:32:50 +04:00
let error = inspect_archive_file(&path, 0).expect_err("malformed archive");
assert!(error.contains("entry table out of bounds"));
2026-06-23 22:05:16 +04:00
}
#[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");
2026-07-18 05:28:07 +04:00
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
})
);
}
2026-06-23 22:05:16 +04:00
#[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"));
2026-07-18 05:28:07 +04:00
assert_eq!(
diagnostic.context.archive_entry.as_deref(),
Some("BROKEN.MSH")
);
assert_eq!(
diagnostic.context.span,
Some(SourceSpan {
offset: 0,
length: 4
})
);
}
2026-06-23 22:05:16 +04:00
fn temp_dir(name: &str) -> PathBuf {
2026-06-23 22:32:50 +04:00
let base = PathBuf::from("/tmp")
.join("fparkan-inspection-tests")
.join(name);
2026-06-23 22:05:16 +04:00
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> {
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, 1);
push_u32(&mut out, 0);
push_u32(&mut out, 0);
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());
}
2026-06-23 22:05:16 +04:00
}