aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-23 21:05:16 +0300
committerValentin Popov <valentin@popov.link>2026-06-23 21:05:16 +0300
commitf8e447ffee746cfe6580cc0e78a8a225aa39b546 (patch)
treee37ebc6c5edd908fd9f44cd3aaf7bffed8de8a88 /crates
parent83d763dd70ef20b7d30a905c15cad3d5531ebc6a (diff)
downloadfparkan-f8e447ffee746cfe6580cc0e78a8a225aa39b546.tar.xz
fparkan-f8e447ffee746cfe6580cc0e78a8a225aa39b546.zip
feat: close stage 0-2 audit groundwork
Remove legacy SDL/OpenGL adapters from the workspace and introduce winit/Vulkan adapter boundaries for the rendered composition root. Add reproducible toolchain and xtask CI coverage for formatting, tests, clippy, docs, policy, deny, acceptance auditing, and hosted OS matrix evidence. Strengthen Stage 1 data contracts with byte-first paths, VFS hardening, structured diagnostics, RsLi writer/edit scaffolding, corpus reporting, and resource error classification. Advance Stage 2 asset preparation by moving mission loading through assets/runtime boundaries, materializing prototype graph data, preserving provenance, and adding inspection/viewer integration. Record the Stage 0-2 audit input, acceptance roadmap, coverage updates, and documentation notes for follow-up evidence.
Diffstat (limited to 'crates')
-rw-r--r--crates/fparkan-assets/Cargo.toml3
-rw-r--r--crates/fparkan-assets/src/lib.rs837
-rw-r--r--crates/fparkan-corpus/Cargo.toml8
-rw-r--r--crates/fparkan-corpus/src/lib.rs281
-rw-r--r--crates/fparkan-diagnostics/Cargo.toml2
-rw-r--r--crates/fparkan-diagnostics/src/lib.rs128
-rw-r--r--crates/fparkan-inspection/Cargo.toml18
-rw-r--r--crates/fparkan-inspection/src/lib.rs286
-rw-r--r--crates/fparkan-path/src/lib.rs80
-rw-r--r--crates/fparkan-platform/src/lib.rs165
-rw-r--r--crates/fparkan-prototype/Cargo.toml7
-rw-r--r--crates/fparkan-prototype/src/lib.rs824
-rw-r--r--crates/fparkan-rsli/src/lib.rs335
-rw-r--r--crates/fparkan-runtime/Cargo.toml5
-rw-r--r--crates/fparkan-runtime/src/lib.rs160
-rw-r--r--crates/fparkan-vfs/src/lib.rs123
16 files changed, 2654 insertions, 608 deletions
diff --git a/crates/fparkan-assets/Cargo.toml b/crates/fparkan-assets/Cargo.toml
index 4b901f3..9a69787 100644
--- a/crates/fparkan-assets/Cargo.toml
+++ b/crates/fparkan-assets/Cargo.toml
@@ -10,9 +10,12 @@ fparkan-material = { path = "../fparkan-material" }
fparkan-msh = { path = "../fparkan-msh" }
fparkan-nres = { path = "../fparkan-nres" }
fparkan-path = { path = "../fparkan-path" }
+fparkan-mission-format = { path = "../fparkan-mission-format" }
fparkan-prototype = { path = "../fparkan-prototype" }
fparkan-resource = { path = "../fparkan-resource" }
fparkan-texm = { path = "../fparkan-texm" }
+fparkan-terrain = { path = "../fparkan-terrain" }
+fparkan-terrain-format = { path = "../fparkan-terrain-format" }
[dev-dependencies]
fparkan-vfs = { path = "../fparkan-vfs" }
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs
index 2da6624..f4501ee 100644
--- a/crates/fparkan-assets/src/lib.rs
+++ b/crates/fparkan-assets/src/lib.rs
@@ -1,14 +1,24 @@
#![forbid(unsafe_code)]
//! Asset manager ports and transactional preparation models.
-use fparkan_material::{decode_wear, resolve_material, WEAR_KIND};
-use fparkan_msh::{decode_msh, validate_msh};
+use fparkan_material::{decode_wear, resolve_material, MaterialError, WEAR_KIND};
+use fparkan_msh::{decode_msh, validate_msh, MshError};
+pub use fparkan_nres::{NresDocument, NresError};
use fparkan_nres::{decode as decode_nres, ReadProfile};
-use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName};
-use fparkan_prototype::{EffectivePrototype, PrototypeGeometry, PrototypeGraph};
+pub use fparkan_mission_format::{LpString, MissionDocument, MissionError, TmaProfile};
+pub use fparkan_terrain::{TerrainError, TerrainWorld};
+pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError};
+use fparkan_mission_format::{decode_tma, decode_tma_land_path};
+use fparkan_terrain_format::{decode_build_dat, decode_land_map, decode_land_msh};
+use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy, ResourceName};
+use fparkan_prototype::{
+ EffectivePrototype, PrototypeGeometry, PrototypeGraph, PrototypeGraphEdge,
+ PrototypeGraphFailure, PrototypeGraphNodeKind, PrototypeGraphProvenance, PrototypeGraphReport,
+ PrototypeGraphRequiredness,
+};
use fparkan_resource::{ResourceError, ResourceKey, ResourceRepository};
-use fparkan_texm::decode_texm;
-use std::collections::BTreeSet;
+use fparkan_texm::{decode_texm, TexmError};
+use std::collections::{HashMap, HashSet};
use std::fmt;
use std::hash::{Hash, Hasher};
use std::marker::PhantomData;
@@ -17,6 +27,97 @@ use std::sync::Arc;
const TEXTURES_ARCHIVE: &str = "textures.lib";
const LIGHTMAP_ARCHIVE: &str = "lightmap.lib";
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct MissionTerrainPaths {
+ /// Landscape mesh archive path.
+ pub land_msh: NormalizedPath,
+ /// Landscape map archive path.
+ pub land_map: NormalizedPath,
+}
+
+/// Terrain loading errors that include runtime world construction failures.
+#[derive(Debug)]
+pub enum TerrainPreparationError {
+ /// Format error while decoding terrain documents.
+ Decode(TerrainFormatError),
+ /// Runtime terrain constructor failed.
+ Runtime(TerrainError),
+}
+
+impl std::fmt::Display for TerrainPreparationError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Decode(source) => write!(f, "{source}"),
+ Self::Runtime(source) => write!(f, "{source}"),
+ }
+ }
+}
+
+impl std::error::Error for TerrainPreparationError {}
+
+impl From<TerrainFormatError> for TerrainPreparationError {
+ fn from(source: TerrainFormatError) -> Self {
+ Self::Decode(source)
+ }
+}
+
+impl From<TerrainError> for TerrainPreparationError {
+ fn from(source: TerrainError) -> Self {
+ Self::Runtime(source)
+ }
+}
+
+/// Decodes a mission file bytes payload with a typed profile.
+pub fn decode_mission_payload(
+ bytes: Arc<[u8]>,
+ profile: TmaProfile,
+) -> Result<MissionDocument, MissionError> {
+ decode_tma(bytes, profile)
+}
+
+/// Reads only the mission land path from raw TMA bytes.
+pub fn decode_mission_land_path(
+ bytes: &[u8],
+ profile: TmaProfile,
+) -> Result<LpString, MissionError> {
+ decode_tma_land_path(bytes, profile)
+}
+
+/// Builds canonical mission terrain paths from the mission `Land` reference.
+pub fn derive_mission_land_paths(
+ land_path: &LpString,
+) -> Result<MissionTerrainPaths, PathError> {
+ let normalized = normalize_relative(&land_path.raw, PathPolicy::StrictLegacy)?;
+ let Some((parent, _stem)) = normalized.as_str().rsplit_once('/') else {
+ return Err(PathError::Empty);
+ };
+ let land_msh =
+ normalize_relative(format!("{parent}/Land.msh").as_bytes(), PathPolicy::StrictLegacy)?;
+ let land_map =
+ normalize_relative(format!("{parent}/Land.map").as_bytes(), PathPolicy::StrictLegacy)?;
+ Ok(MissionTerrainPaths { land_msh, land_map })
+}
+
+/// Decodes compatible NRes payload for terrain/document loading.
+pub fn decode_nres_payload(
+ bytes: Arc<[u8]>,
+) -> Result<fparkan_nres::NresDocument, fparkan_nres::NresError> {
+ decode_nres(bytes, ReadProfile::Compatible)
+}
+
+/// Decodes terrain documents and builds immutable terrain state.
+pub fn prepare_terrain_world(
+ land_msh_nres: &fparkan_nres::NresDocument,
+ land_map_nres: &fparkan_nres::NresDocument,
+ build_dat: &[u8],
+) -> Result<(TerrainWorld, Vec<BuildCategory>), TerrainPreparationError> {
+ let land_msh = decode_land_msh(land_msh_nres)?;
+ let land_map = decode_land_map(land_map_nres)?;
+ let build_categories = decode_build_dat(build_dat)?;
+ let world = TerrainWorld::from_land_assets(&land_msh, &land_map)?;
+ Ok((world, build_categories))
+}
+
/// Stable typed identifier for a prepared asset.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct AssetId<T> {
@@ -56,12 +157,116 @@ pub struct PreparedVisual {
pub model_batches: usize,
/// Number of WEAR material slots resolved through MAT0.
pub material_count: usize,
+ /// Typed material IDs available from the resolved visual.
+ pub material_ids: Vec<AssetId<PreparedMaterial>>,
/// Number of texture phase requests decoded as TEXM.
pub texture_count: usize,
/// Number of lightmap requests decoded as TEXM.
pub lightmap_count: usize,
}
+/// CPU-side data needed before a material can be handed to a renderer.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PreparedMaterial {
+ /// Stable id derived from the visual and material selector.
+ pub id: AssetId<PreparedMaterial>,
+ /// Parsed material key.
+ pub name: ResourceName,
+}
+
+impl PreparedVisual {
+ /// Returns the primary material id, if any.
+ #[must_use]
+ pub fn primary_material_id(&self) -> Option<AssetId<PreparedMaterial>> {
+ self.material_ids.first().copied()
+ }
+}
+
+/// Immutable prepared mission assets for rendering and game setup.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MissionAssets {
+ /// Visuals prepared for all reachable prototype requests.
+ pub visuals: Vec<PreparedVisual>,
+ /// Visual ids available for each mission object index.
+ pub object_visuals: Vec<Vec<AssetId<PreparedVisual>>>,
+}
+
+impl MissionAssets {
+ /// Returns how many visuals were prepared.
+ #[must_use]
+ pub fn visual_count(&self) -> usize {
+ self.visuals.len()
+ }
+
+ /// Returns all visuals for a mission object index.
+ #[must_use]
+ pub fn visuals_for_object(
+ &self,
+ object_index: usize,
+ ) -> &[AssetId<PreparedVisual>] {
+ self.object_visuals
+ .get(object_index)
+ .map_or(&[], |values| values.as_slice())
+ }
+
+ /// Returns the first visual for a mission object index.
+ #[must_use]
+ pub fn visual_for_object(
+ &self,
+ object_index: usize,
+ ) -> Option<AssetId<PreparedVisual>> {
+ self.visuals_for_object(object_index).first().copied()
+ }
+
+ /// Finds a visual by prepared id.
+ #[must_use]
+ pub fn visual_by_id(&self, id: AssetId<PreparedVisual>) -> Option<&PreparedVisual> {
+ self.visuals.iter().find(|visual| visual.id == id)
+ }
+
+ /// Converts mission assets into a coarse mission plan.
+ #[must_use]
+ pub fn to_plan(&self) -> MissionAssetPlan {
+ let visual_count = self.visuals.len();
+ let model_count = self
+ .visuals
+ .iter()
+ .filter(|visual| visual.mesh.is_some())
+ .count();
+ let material_count = self
+ .visuals
+ .iter()
+ .map(|visual| visual.material_count)
+ .sum();
+ let texture_count = self
+ .visuals
+ .iter()
+ .map(|visual| visual.texture_count)
+ .sum();
+ let lightmap_count = self
+ .visuals
+ .iter()
+ .map(|visual| visual.lightmap_count)
+ .sum();
+ MissionAssetPlan {
+ visual_count,
+ model_count,
+ material_count,
+ texture_count,
+ lightmap_count,
+ }
+ }
+}
+
+impl Default for MissionAssets {
+ fn default() -> Self {
+ Self {
+ visuals: Vec::new(),
+ object_visuals: Vec::new(),
+ }
+ }
+}
+
/// A transactional mission asset preparation plan.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MissionAssetPlan {
@@ -85,20 +290,27 @@ pub struct AssetBudgets {
}
/// Errors raised while preparing CPU-side assets.
-#[derive(Clone, Debug, Eq, PartialEq)]
+#[derive(Debug)]
pub enum AssetError {
/// A required cross-resource dependency was not found.
MissingDependency(String),
/// A prototype did not describe a usable visual.
InvalidPrototype(String),
/// A repository operation failed.
- Resource(String),
+ Resource {
+ /// Human context for the operation.
+ context: String,
+ /// Concrete repository source error.
+ source: ResourceError,
+ },
/// MSH parsing or validation failed.
- Msh(String),
+ Msh(MshError),
/// WEAR/MAT0 parsing or resolution failed.
- Material(String),
+ Material(MaterialError),
/// TEXM parsing failed.
- Texture(String),
+ Texture(TexmError),
+ /// NRes decoding failed.
+ Nres(NresError),
}
impl fmt::Display for AssetError {
@@ -106,15 +318,33 @@ impl fmt::Display for AssetError {
match self {
Self::MissingDependency(value) => write!(f, "missing dependency: {value}"),
Self::InvalidPrototype(value) => write!(f, "invalid prototype: {value}"),
- Self::Resource(value) => write!(f, "resource error: {value}"),
- Self::Msh(value) => write!(f, "msh error: {value}"),
- Self::Material(value) => write!(f, "material error: {value}"),
- Self::Texture(value) => write!(f, "texture error: {value}"),
+ Self::Resource { context, source } => {
+ if context.is_empty() {
+ write!(f, "resource error: {source}")
+ } else {
+ write!(f, "resource error ({context}): {source}")
+ }
+ }
+ Self::Msh(source) => write!(f, "msh error: {source}"),
+ Self::Material(source) => write!(f, "material error: {source}"),
+ Self::Texture(source) => write!(f, "texture error: {source}"),
+ Self::Nres(source) => write!(f, "nres error: {source}"),
}
}
}
-impl std::error::Error for AssetError {}
+impl std::error::Error for AssetError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Resource { source, .. } => Some(source),
+ Self::Msh(source) => Some(source),
+ Self::Material(source) => Some(source),
+ Self::Texture(source) => Some(source),
+ Self::Nres(source) => Some(source),
+ Self::MissingDependency(_) | Self::InvalidPrototype(_) => None,
+ }
+ }
+}
/// Port implemented by typed asset loaders.
pub trait AssetLoader<T> {
@@ -157,14 +387,31 @@ impl<R: ResourceRepository> AssetManager<R> {
prepare_visual_with_repository(&self.repository, proto)
}
+ /// Builds mission assets from resolved prototypes.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AssetError`] if any visual dependency is missing or malformed.
+ pub fn prepare_mission_assets(
+ &self,
+ root_prototype_spans: &[std::ops::Range<usize>],
+ prototypes: &[EffectivePrototype],
+ ) -> Result<MissionAssets, AssetError> {
+ prepare_mission_assets_with_repository(
+ &self.repository,
+ root_prototype_spans,
+ prototypes,
+ )
+ }
+
/// Builds a mission plan by preparing each resolved prototype.
///
/// # Errors
///
/// Returns [`AssetError`] if any visual dependency is missing or malformed.
- pub fn build_mission_asset_plan<'a>(
+ pub fn build_mission_asset_plan(
&self,
- prototypes: impl IntoIterator<Item = &'a EffectivePrototype>,
+ prototypes: &[EffectivePrototype],
) -> Result<MissionAssetPlan, AssetError> {
build_mission_asset_plan_with_repository(&self.repository, prototypes)
}
@@ -173,8 +420,13 @@ impl<R: ResourceRepository> AssetManager<R> {
/// Produces a count-only plan from a prototype graph.
#[must_use]
pub fn build_mission_asset_plan(graph: &PrototypeGraph) -> MissionAssetPlan {
+ let visual_count = graph
+ .nodes
+ .iter()
+ .filter(|node| node.kind == PrototypeGraphNodeKind::Prototype)
+ .count();
MissionAssetPlan {
- visual_count: graph.prototype_requests.len(),
+ visual_count,
..MissionAssetPlan::default()
}
}
@@ -185,29 +437,217 @@ pub fn build_mission_asset_plan(graph: &PrototypeGraph) -> MissionAssetPlan {
///
/// Returns [`AssetError`] if any reachable visual dependency is missing or
/// malformed.
-pub fn build_mission_asset_plan_with_repository<'a, R: ResourceRepository>(
+pub fn build_mission_asset_plan_with_repository<R: ResourceRepository>(
repository: &R,
- prototypes: impl IntoIterator<Item = &'a EffectivePrototype>,
+ prototypes: &[EffectivePrototype],
) -> Result<MissionAssetPlan, AssetError> {
- let mut plan = MissionAssetPlan::default();
- let mut prepared_visuals = BTreeSet::new();
+ let full_span = [0..prototypes.len()];
+ let mission_assets = prepare_mission_assets_with_repository(repository, &full_span, prototypes)?;
+ Ok(mission_assets.to_plan())
+}
+
+/// Builds immutable mission assets from resolved prototypes.
+///
+/// # Errors
+///
+/// Returns [`AssetError`] if any visual dependency is missing or malformed.
+pub fn prepare_mission_assets_with_repository<R: ResourceRepository>(
+ repository: &R,
+ root_prototype_spans: &[std::ops::Range<usize>],
+ prototypes: &[EffectivePrototype],
+) -> Result<MissionAssets, AssetError> {
+ if prototypes.is_empty() {
+ return Ok(MissionAssets::default());
+ }
+ let mut visual_index_by_id: HashMap<AssetId<PreparedVisual>, PreparedVisualSignature> =
+ HashMap::new();
+ let mut material_signature_by_id: HashMap<AssetId<PreparedMaterial>, Vec<u8>> =
+ HashMap::new();
+ let mut visuals = Vec::new();
+ let mut prototype_visual_ids = Vec::with_capacity(prototypes.len());
for proto in prototypes {
let visual_id = stable_visual_id(proto);
- if !prepared_visuals.insert(visual_id) {
- continue;
+ let signature = prepared_visual_signature(proto);
+ match visual_index_by_id.get(&visual_id) {
+ Some(existing) if existing != &signature => {
+ return Err(AssetError::InvalidPrototype(
+ "stable visual id collision between unrelated prototypes".to_string(),
+ ));
+ }
+ Some(_) => {}
+ None => {
+ visual_index_by_id.insert(visual_id, signature);
+ let visual = prepare_visual_with_repository_internal(
+ repository,
+ proto,
+ Some(&mut material_signature_by_id),
+ )?;
+ if visual.id != visual_id {
+ // Defensive check. stable IDs are deterministic for the same inputs.
+ return Err(AssetError::InvalidPrototype(
+ "prepared visual id changed during preparation".to_string(),
+ ));
+ }
+ visuals.push(visual);
+ }
}
- let visual = prepare_visual_with_repository(repository, proto)?;
- plan.visual_count += 1;
- if visual.mesh.is_some() {
- plan.model_count += 1;
+ prototype_visual_ids.push(visual_id);
+ }
+
+ let mut object_visuals = Vec::with_capacity(root_prototype_spans.len());
+ for (root_index, span) in root_prototype_spans.iter().enumerate() {
+ if span.start > span.end || span.end > prototype_visual_ids.len() {
+ return Err(AssetError::InvalidPrototype(format!(
+ "invalid prototype span for mission object {root_index}: {span:?}"
+ )));
+ }
+ let mut ids = Vec::new();
+ let mut dedup = HashSet::new();
+ for index in span.clone() {
+ let visual_id = prototype_visual_ids[index];
+ if dedup.insert(visual_id) {
+ ids.push(visual_id);
+ }
}
- plan.material_count += visual.material_count;
- plan.texture_count += visual.texture_count;
- plan.lightmap_count += visual.lightmap_count;
+ object_visuals.push(ids);
}
- Ok(plan)
+ Ok(MissionAssets {
+ visuals,
+ object_visuals,
+ })
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+enum PreparedVisualSignature {
+ Mesh {
+ archive: String,
+ name: Vec<u8>,
+ type_id: Option<u32>,
+ dependency_count: usize,
+ },
+ NonGeometric {
+ dependency_count: usize,
+ },
+}
+
+fn prepared_visual_signature(proto: &EffectivePrototype) -> PreparedVisualSignature {
+ match &proto.geometry {
+ PrototypeGeometry::Mesh(key) => PreparedVisualSignature::Mesh {
+ archive: key.archive.as_str().to_string(),
+ name: key.name.0.clone(),
+ type_id: key.type_id,
+ dependency_count: proto.dependencies.len(),
+ },
+ PrototypeGeometry::NonGeometric => PreparedVisualSignature::NonGeometric {
+ dependency_count: proto.dependencies.len(),
+ },
+ }
+}
+
+/// Extends a prototype dependency report with visual dependency failures.
+///
+/// This function validates WEAR/material/TEXM/LIGHTMAP resolution for each resolved
+/// prototype without constructing full immutable assets.
+pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
+ repository: &R,
+ report: &mut PrototypeGraphReport,
+ graph: &PrototypeGraph,
+ prototypes: &[EffectivePrototype],
+) {
+ let texture_archive = parse_path(TEXTURES_ARCHIVE).ok();
+ let lightmap_archive = parse_path(LIGHTMAP_ARCHIVE).ok();
+
+ for (prototype_index, prototype) in prototypes.iter().enumerate() {
+ let PrototypeGeometry::Mesh(mesh) = &prototype.geometry else {
+ continue;
+ };
+ report.mesh_dependency_count += prototype.dependencies.len();
+ report.wear_request_count += 1;
+
+ match resolve_wear_table(repository, mesh) {
+ Ok(table) => {
+ report.wear_resolved_count += 1;
+ report.material_slot_count += table.entries.len();
+ for (material_index, _entry) in table.entries.iter().enumerate() {
+ let Ok(material_index) = u16::try_from(material_index) else {
+ push_visual_failure(
+ report,
+ graph,
+ prototype_index,
+ mesh.name.0.clone(),
+ PrototypeGraphEdge::WearToMaterial,
+ PrototypeGraphRequiredness::Required,
+ "material index does not fit archive format",
+ );
+ continue;
+ };
+ match resolve_material(repository, &table, material_index) {
+ Ok(material) => {
+ report.material_resolved_count += 1;
+ for texture in material.document.texture_requests() {
+ report.texture_request_count += 1;
+ match resolve_texm_from_candidates(
+ repository,
+ &texture,
+ [texture_archive.as_ref(), lightmap_archive.as_ref()],
+ ) {
+ Ok(()) => report.texture_resolved_count += 1,
+ Err(message) => push_visual_failure(
+ report,
+ graph,
+ prototype_index,
+ texture.0,
+ PrototypeGraphEdge::MaterialToTexture,
+ PrototypeGraphRequiredness::Required,
+ &message,
+ ),
+ }
+ }
+ }
+ Err(message) => push_visual_failure(
+ report,
+ graph,
+ prototype_index,
+ mesh.name.0.clone(),
+ PrototypeGraphEdge::WearToMaterial,
+ PrototypeGraphRequiredness::Required,
+ &message.to_string(),
+ ),
+ }
+ }
+ for lightmap in &table.lightmaps {
+ report.lightmap_request_count += 1;
+ match resolve_texm_from_candidates(
+ repository,
+ &lightmap.lightmap,
+ [lightmap_archive.as_ref(), texture_archive.as_ref()],
+ ) {
+ Ok(()) => report.lightmap_resolved_count += 1,
+ Err(message) => push_visual_failure(
+ report,
+ graph,
+ prototype_index,
+ lightmap.lightmap.0.clone(),
+ PrototypeGraphEdge::WearToLightmap,
+ PrototypeGraphRequiredness::Required,
+ &message,
+ ),
+ }
+ }
+ }
+ Err(message) => push_visual_failure(
+ report,
+ graph,
+ prototype_index,
+ mesh.name.0.clone(),
+ PrototypeGraphEdge::MeshToWear,
+ PrototypeGraphRequiredness::Required,
+ &message.to_string(),
+ ),
+ }
+ }
}
/// Validates a prototype visual without resolving cross-resource dependencies.
@@ -231,6 +671,7 @@ pub fn prepare_visual(proto: &EffectivePrototype) -> Result<PreparedVisual, Asse
model_slots: 0,
model_batches: 0,
material_count: 0,
+ material_ids: Vec::new(),
texture_count: 0,
lightmap_count: 0,
})
@@ -246,6 +687,14 @@ pub fn prepare_visual_with_repository<R: ResourceRepository>(
repository: &R,
proto: &EffectivePrototype,
) -> Result<PreparedVisual, AssetError> {
+ prepare_visual_with_repository_internal(repository, proto, None)
+}
+
+fn prepare_visual_with_repository_internal<R: ResourceRepository>(
+ repository: &R,
+ proto: &EffectivePrototype,
+ material_signature_by_id: Option<&mut HashMap<AssetId<PreparedMaterial>, Vec<u8>>>,
+) -> Result<PreparedVisual, AssetError> {
let PrototypeGeometry::Mesh(mesh_key) = &proto.geometry else {
return prepare_visual(proto);
};
@@ -254,9 +703,9 @@ pub fn prepare_visual_with_repository<R: ResourceRepository>(
read_key(repository, mesh_key, Some("mesh"))?,
ReadProfile::Compatible,
)
- .map_err(|err| AssetError::Msh(err.to_string()))?;
- let msh_document = decode_msh(&nres).map_err(|err| AssetError::Msh(err.to_string()))?;
- let model = validate_msh(&msh_document).map_err(|err| AssetError::Msh(err.to_string()))?;
+ .map_err(AssetError::Nres)?;
+ let msh_document = decode_msh(&nres).map_err(AssetError::Msh)?;
+ let model = validate_msh(&msh_document).map_err(AssetError::Msh)?;
let wear_name = sibling_name(mesh_key, "wea")?;
let wear_key = ResourceKey {
@@ -264,22 +713,44 @@ pub fn prepare_visual_with_repository<R: ResourceRepository>(
name: wear_name,
type_id: Some(WEAR_KIND),
};
- let wear = decode_wear(&read_key(repository, &wear_key, Some("wear"))?)
- .map_err(|err| AssetError::Material(err.to_string()))?;
+ let wear = decode_wear(&read_key(repository, &wear_key, Some("wear"))?).map_err(AssetError::Material)?;
let mut material_count = 0;
+ let mut material_ids = Vec::with_capacity(wear.entries.len());
let mut texture_count = 0;
let mut lightmap_count = 0;
for material_index in 0..wear.entries.len() {
let material_index = u16::try_from(material_index).map_err(|_| {
- AssetError::Material("material index does not fit archive format".to_string())
+ AssetError::InvalidPrototype("material index does not fit archive format".to_string())
})?;
let material = resolve_material(repository, &wear, material_index)
- .map_err(|err| AssetError::Material(err.to_string()))?;
+ .map_err(AssetError::Material)?;
material_count += 1;
+ material_ids.push(AssetId::new(stable_material_id(
+ proto,
+ material_index,
+ &material.name,
+ )));
+ let material_id = *material_ids
+ .last()
+ .expect("material id was appended immediately before collision check");
+ if let Some(registry) = material_signature_by_id {
+ match registry.get(&material_id) {
+ Some(existing_name) => {
+ if existing_name != &material.name.0 {
+ return Err(AssetError::InvalidPrototype(
+ "stable material id collision between unrelated materials".to_string(),
+ ));
+ }
+ }
+ None => {
+ registry.insert(material_id, material.name.0.clone());
+ }
+ }
+ }
for texture in material.document.texture_requests() {
- resolve_texture(repository, &texture)?;
+ resolve_texture(repository, &texture)?;
texture_count += 1;
}
}
@@ -296,6 +767,7 @@ pub fn prepare_visual_with_repository<R: ResourceRepository>(
model_slots: model.slots.len(),
model_batches: model.batches.len(),
material_count,
+ material_ids,
texture_count,
lightmap_count,
})
@@ -306,21 +778,267 @@ fn read_key<R: ResourceRepository>(
key: &ResourceKey,
label: Option<&str>,
) -> Result<Arc<[u8]>, AssetError> {
+ let label = label.unwrap_or("asset");
let handle = repository
.open_archive(&key.archive)
- .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))
+ .map_err(|err| map_resource_error(label, key, err))?
.and_then(|archive| {
repository
.find(archive, &key.name)
- .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))
+ .map_err(|err| map_resource_error(label, key, err))
})?
- .ok_or_else(|| AssetError::MissingDependency(format!("{label:?} {key:?}")))?;
+ .ok_or_else(|| AssetError::MissingDependency(format!("{label}: {key:?}")))?;
let bytes = repository
.read(handle)
- .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))?;
+ .map_err(|err| map_resource_error(label, key, err))?;
Ok(Arc::from(bytes.into_owned()))
}
+fn map_resource_error(
+ label: &str,
+ key: &ResourceKey,
+ source: ResourceError,
+) -> AssetError {
+ AssetError::Resource {
+ context: format!(
+ "{label}: archive={} entry={}",
+ key.archive.as_str(),
+ String::from_utf8_lossy(&key.name.0),
+ ),
+ source,
+ }
+}
+
+fn resolve_wear_table<R: ResourceRepository>(
+ repository: &R,
+ mesh: &ResourceKey,
+) -> Result<fparkan_material::WearTable, AssetError> {
+ let archive = repository
+ .open_archive(&mesh.archive)
+ .map_err(|err| map_resource_error("wear", mesh, err))?;
+ let wear_name = sibling_name(mesh, "wea")?;
+ let handle = repository
+ .find(archive, &wear_name)
+ .map_err(|err| {
+ map_resource_error(
+ "wear",
+ &ResourceKey {
+ archive: mesh.archive.clone(),
+ name: wear_name.clone(),
+ type_id: Some(WEAR_KIND),
+ },
+ err,
+ )
+ })?
+ .ok_or_else(|| {
+ AssetError::MissingDependency(format!(
+ "missing WEAR entry {}",
+ String::from_utf8_lossy(&wear_name.0)
+ ))
+ })?;
+ let info = repository
+ .entry_info(handle)
+ .map_err(|err| {
+ map_resource_error(
+ "wear",
+ &ResourceKey {
+ archive: mesh.archive.clone(),
+ name: wear_name.clone(),
+ type_id: Some(WEAR_KIND),
+ },
+ err,
+ )
+ })?;
+ if info.key.type_id != Some(WEAR_KIND) {
+ return Err(AssetError::InvalidPrototype(format!(
+ "entry {} is not WEAR",
+ String::from_utf8_lossy(&wear_name.0)
+ )));
+ }
+ let bytes = repository
+ .read(handle)
+ .map_err(|err| {
+ map_resource_error(
+ "wear",
+ &ResourceKey {
+ archive: mesh.archive.clone(),
+ name: wear_name.clone(),
+ type_id: Some(WEAR_KIND),
+ },
+ err,
+ )
+ })?
+ .into_owned();
+ decode_wear(&bytes).map_err(AssetError::Material)
+}
+
+fn resolve_texm_from_candidates<'a, R: ResourceRepository>(
+ repository: &R,
+ texture: &ResourceName,
+ candidates: impl IntoIterator<Item = Option<&'a NormalizedPath>>,
+) -> Result<(), AssetError> {
+ let mut missing_archive = false;
+ for path in candidates.into_iter().flatten() {
+ let key = ResourceKey {
+ archive: path.to_owned(),
+ name: texture.clone(),
+ type_id: None,
+ };
+ let archive = match repository.open_archive(path) {
+ Ok(archive) => archive,
+ Err(ResourceError::MissingArchive) => {
+ missing_archive = true;
+ continue;
+ }
+ Err(err) => return Err(map_resource_error("texm", &key, err)),
+ };
+ let Some(handle) = repository
+ .find(archive, texture)
+ .map_err(|err| map_resource_error("texm", &key, err))?
+ else {
+ continue;
+ };
+ let bytes = repository
+ .read(handle)
+ .map_err(|err| map_resource_error("texm", &key, err))?
+ .into_owned();
+ decode_texm(bytes).map_err(AssetError::Texture)?;
+ return Ok(());
+ }
+ if missing_archive {
+ Err(AssetError::MissingDependency(format!(
+ "texm archive missing for {}",
+ String::from_utf8_lossy(&texture.0)
+ )))
+ } else {
+ Err(AssetError::MissingDependency(format!(
+ "missing texm {}",
+ String::from_utf8_lossy(&texture.0)
+ )))
+ }
+}
+
+fn push_visual_failure(
+ report: &mut PrototypeGraphReport,
+ graph: &PrototypeGraph,
+ prototype_index: usize,
+ resource_raw: Vec<u8>,
+ edge: PrototypeGraphEdge,
+ requiredness: PrototypeGraphRequiredness,
+ message: &str,
+) {
+ let root_index = root_index_for_prototype(graph, prototype_index);
+ let parent_edge = parent_edge_for_failure(graph, prototype_index, &edge);
+ let dependency = mesh_dependency_resource(graph, prototype_index);
+ report.failures.push(PrototypeGraphFailure {
+ root_index,
+ resource_raw,
+ edge,
+ message: message.to_string(),
+ requiredness,
+ provenance: Some(PrototypeGraphProvenance {
+ root_index,
+ parent_edge,
+ archive: dependency.map(|resource| resource.archive.as_str().to_string()),
+ resource: Some(resource_raw),
+ span: None,
+ }),
+ })
+}
+
+fn root_index_for_prototype(graph: &PrototypeGraph, prototype_index: usize) -> usize {
+ for (root_index, span) in graph.root_prototype_request_spans.iter().enumerate() {
+ if span.start <= prototype_index && prototype_index < span.end {
+ return root_index;
+ }
+ }
+ 0
+}
+
+fn parent_edge_for_failure(
+ graph: &PrototypeGraph,
+ prototype_index: usize,
+ edge: &PrototypeGraphEdge,
+) -> Option<fparkan_prototype::PrototypeGraphEdgeId> {
+ let prototype_node_id = prototype_node_id(graph, prototype_index)?;
+ match edge {
+ PrototypeGraphEdge::MeshToWear
+ | PrototypeGraphEdge::WearToMaterial
+ | PrototypeGraphEdge::MaterialToTexture
+ | PrototypeGraphEdge::WearToLightmap => {
+ mesh_edge_id(graph, prototype_node_id).or_else(|| root_edge_id(graph, prototype_node_id))
+ }
+ _ => root_edge_id(graph, prototype_node_id),
+ }
+}
+
+fn prototype_node_id(graph: &PrototypeGraph, prototype_index: usize) -> Option<fparkan_prototype::PrototypeGraphNodeId> {
+ graph
+ .nodes
+ .iter()
+ .filter(|node| node.kind == PrototypeGraphNodeKind::Prototype)
+ .nth(prototype_index)
+ .map(|node| node.id)
+}
+
+fn root_edge_id(
+ graph: &PrototypeGraph,
+ prototype_node: fparkan_prototype::PrototypeGraphNodeId,
+) -> Option<fparkan_prototype::PrototypeGraphEdgeId> {
+ graph
+ .edges
+ .iter()
+ .find(|edge| {
+ edge.to == prototype_node
+ && matches!(
+ edge.kind,
+ fparkan_prototype::PrototypeGraphEdgeKind::MissionToRoot
+ | fparkan_prototype::PrototypeGraphEdgeKind::UnitDatToComponent
+ )
+ })
+ .map(|edge| edge.id)
+}
+
+fn mesh_edge_id(
+ graph: &PrototypeGraph,
+ prototype_node: fparkan_prototype::PrototypeGraphNodeId,
+) -> Option<fparkan_prototype::PrototypeGraphEdgeId> {
+ graph
+ .edges
+ .iter()
+ .find(|edge| {
+ edge.from == prototype_node
+ && matches!(
+ edge.kind,
+ fparkan_prototype::PrototypeGraphEdgeKind::PrototypeToMesh
+ )
+ })
+ .map(|edge| edge.id)
+}
+
+fn mesh_dependency_resource(
+ graph: &PrototypeGraph,
+ prototype_index: usize,
+) -> Option<&fparkan_resource::ResourceKey> {
+ let prototype_node = prototype_node_id(graph, prototype_index)?;
+ let mesh_node = graph
+ .edges
+ .iter()
+ .find(|edge| {
+ edge.from == prototype_node
+ && matches!(
+ edge.kind,
+ fparkan_prototype::PrototypeGraphEdgeKind::PrototypeToMesh
+ )
+ })?
+ .to;
+ graph
+ .nodes
+ .iter()
+ .find(|node| node.id == mesh_node)
+ .and_then(|node| node.resource.as_ref())
+}
+
fn resolve_texture<R: ResourceRepository>(
repository: &R,
name: &ResourceName,
@@ -351,7 +1069,7 @@ fn resolve_texm<R: ResourceRepository>(
};
decode_texm(bytes)
.map(|_| ())
- .map_err(|err| AssetError::Texture(err.to_string()))
+ .map_err(AssetError::Texture)
}
fn read_optional_key<R: ResourceRepository>(
@@ -362,17 +1080,26 @@ fn read_optional_key<R: ResourceRepository>(
let archive = match repository.open_archive(&key.archive) {
Ok(archive) => archive,
Err(ResourceError::MissingArchive | ResourceError::MissingEntry) => return Ok(None),
- Err(err) => return Err(AssetError::Resource(format!("{label:?} {key:?}: {err}"))),
+ Err(err) => {
+ let label = label.unwrap_or("asset");
+ return Err(map_resource_error(label, key, err))
+ }
};
let Some(handle) = repository
.find(archive, &key.name)
- .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))?
+ .map_err(|err| {
+ let label = label.unwrap_or("asset");
+ map_resource_error(label, key, err)
+ })?
else {
return Ok(None);
};
let bytes = repository
.read(handle)
- .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))?;
+ .map_err(|err| {
+ let label = label.unwrap_or("asset");
+ map_resource_error(label, key, err)
+ })?;
Ok(Some(Arc::from(bytes.into_owned())))
}
@@ -407,6 +1134,18 @@ fn stable_visual_id(proto: &EffectivePrototype) -> u64 {
hasher.finish()
}
+fn stable_material_id(
+ proto: &EffectivePrototype,
+ material_index: u16,
+ material_name: &ResourceName,
+) -> u64 {
+ let mut hasher = StableHasher::default();
+ stable_visual_id(proto).hash(&mut hasher);
+ material_index.hash(&mut hasher);
+ material_name.0.hash(&mut hasher);
+ hasher.finish()
+}
+
fn parse_path(value: &str) -> Result<NormalizedPath, AssetError> {
normalize_relative(value.as_bytes(), PathPolicy::HostCompatible)
.map_err(|err| AssetError::InvalidPrototype(format!("{err}")))
diff --git a/crates/fparkan-corpus/Cargo.toml b/crates/fparkan-corpus/Cargo.toml
index 552870d..e9285a8 100644
--- a/crates/fparkan-corpus/Cargo.toml
+++ b/crates/fparkan-corpus/Cargo.toml
@@ -7,8 +7,16 @@ repository.workspace = true
[dependencies]
fparkan-binary = { path = "../fparkan-binary" }
+fparkan-fx = { path = "../fparkan-fx" }
+fparkan-material = { path = "../fparkan-material" }
+fparkan-msh = { path = "../fparkan-msh" }
+fparkan-mission-format = { path = "../fparkan-mission-format" }
fparkan-nres = { path = "../fparkan-nres" }
+fparkan-prototype = { path = "../fparkan-prototype" }
fparkan-path = { path = "../fparkan-path" }
+fparkan-rsli = { path = "../fparkan-rsli" }
+fparkan-texm = { path = "../fparkan-texm" }
+fparkan-terrain-format = { path = "../fparkan-terrain-format" }
[lints]
workspace = true
diff --git a/crates/fparkan-corpus/src/lib.rs b/crates/fparkan-corpus/src/lib.rs
index 460bbbf..f923841 100644
--- a/crates/fparkan-corpus/src/lib.rs
+++ b/crates/fparkan-corpus/src/lib.rs
@@ -2,7 +2,16 @@
//! Licensed corpus discovery and aggregate reports.
use fparkan_binary::{sha256, sha256_hex, Sha256Digest};
+use fparkan_fx::{decode_fxid, FXID_KIND};
+use fparkan_material::{decode_mat0, decode_wear, MAT0_KIND, WEAR_KIND};
+use fparkan_msh::{decode_msh, validate_msh};
+use fparkan_mission_format::{decode_tma, TmaProfile};
+use fparkan_nres::NresDocument;
use fparkan_path::{ascii_lookup_key, normalize_relative, PathPolicy};
+use fparkan_prototype::{decode_unit_dat, decode_unit_dat_binding};
+use fparkan_rsli::{decode as decode_rsli, ReadProfile};
+use fparkan_texm::decode_texm;
+use fparkan_terrain_format::{decode_land_map, decode_land_msh};
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::fs;
@@ -10,6 +19,8 @@ use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Arc;
+const TEXM_KIND: u32 = 0x6d78_6554;
+
/// Corpus kind.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CorpusKind {
@@ -336,7 +347,6 @@ fn inspect_report_file(
}
};
if bytes.starts_with(b"NRes") {
- variant = "nres".to_string();
bump(metrics, "nres_files", 1);
if let Err(message) = inspect_nres_metrics(bytes, metrics) {
return CorpusFileRecord {
@@ -346,9 +356,52 @@ fn inspect_report_file(
message: Some(message),
};
}
+ if variant == "land_msh" && let Err(message) = inspect_land_metrics(&bytes, false) {
+ return CorpusFileRecord {
+ path: entry.path.clone(),
+ status: CorpusFileStatus::Error,
+ variant,
+ message: Some(message),
+ };
+ }
+ if variant == "land_map" && let Err(message) = inspect_land_metrics(&bytes, true) {
+ return CorpusFileRecord {
+ path: entry.path.clone(),
+ status: CorpusFileStatus::Error,
+ variant,
+ message: Some(message),
+ };
+ }
} else if bytes.starts_with(b"NL") {
variant = "rsli".to_string();
bump(metrics, "rsli_files", 1);
+ if let Err(message) = inspect_rsli_metrics(&bytes) {
+ return CorpusFileRecord {
+ path: entry.path.clone(),
+ status: CorpusFileStatus::Error,
+ variant,
+ message: Some(message),
+ };
+ }
+ } else if lower.ends_with("data.tma") {
+ if let Err(message) = inspect_tma_metrics(&bytes) {
+ return CorpusFileRecord {
+ path: entry.path.clone(),
+ status: CorpusFileStatus::Error,
+ variant: "tma".to_string(),
+ message: Some(message),
+ };
+ }
+ } else if has_extension(lower, "dat") && (lower.starts_with("units/") || lower.contains("/units/")) {
+ variant = "unit_dat".to_string();
+ if let Err(message) = inspect_unit_dat_metrics(&bytes) {
+ return CorpusFileRecord {
+ path: entry.path.clone(),
+ status: CorpusFileStatus::Error,
+ variant,
+ message: Some(message),
+ };
+ }
}
CorpusFileRecord {
path: entry.path.clone(),
@@ -380,25 +433,30 @@ fn inspect_path_metrics(lower: &str, metrics: &mut BTreeMap<String, u64>) -> Str
}
fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) -> Result<(), String> {
- let entries = inspect_nres_entries(bytes)?;
- bump(metrics, "nres_entries", entries.len() as u64);
- for entry in entries {
+ let document = inspect_nres_document(&bytes)?;
+ bump(metrics, "nres_entries", document.entries().len() as u64);
+ for entry in document.entries() {
let name = String::from_utf8_lossy(entry.name_bytes()).to_ascii_lowercase();
if has_extension(&name, "msh") {
bump(metrics, "msh_entries", 1);
+ validate_nres_msh_payload(&document, entry)?;
}
match entry.meta().type_id {
- 0x3054_414D => {
+ MAT0_KIND => {
bump(metrics, "mat0_entries", 1);
+ validate_nres_mat0_payload(&document, entry)?;
}
- 0x6D78_6554 => {
+ TEXM_KIND => {
bump(metrics, "texm_entries", 1);
+ validate_nres_texm_payload(&document, entry)?;
}
- 0x4449_5846 => {
+ FXID_KIND => {
bump(metrics, "fxid_entries", 1);
+ validate_nres_fxid_payload(&document, entry)?;
}
- 0x5241_4557 => {
+ WEAR_KIND => {
bump(metrics, "wear_entries", 1);
+ validate_nres_wear_payload(&document, entry)?;
}
_ => {}
}
@@ -406,6 +464,94 @@ fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) ->
Ok(())
}
+fn validate_nres_msh_payload(document: &NresDocument, entry: &fparkan_nres::NresEntry) -> Result<(), String> {
+ let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
+ let nested = fparkan_nres::decode(
+ Arc::from(payload.to_vec().into_boxed_slice()),
+ fparkan_nres::ReadProfile::Compatible,
+ )
+ .map_err(|err| err.to_string())?;
+ let model = decode_msh(&nested).map_err(|err| err.to_string())?;
+ validate_msh(&model).map_err(|err| err.to_string())?;
+ Ok(())
+}
+
+fn validate_nres_mat0_payload(
+ document: &NresDocument,
+ entry: &fparkan_nres::NresEntry,
+) -> Result<(), String> {
+ let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
+ decode_mat0(payload, entry.meta().attr2).map_err(|err| err.to_string())?;
+ Ok(())
+}
+
+fn validate_nres_wear_payload(
+ document: &NresDocument,
+ entry: &fparkan_nres::NresEntry,
+) -> Result<(), String> {
+ let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
+ decode_wear(payload).map_err(|err| err.to_string())?;
+ Ok(())
+}
+
+fn validate_nres_texm_payload(
+ document: &NresDocument,
+ entry: &fparkan_nres::NresEntry,
+) -> Result<(), String> {
+ let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
+ decode_texm(Arc::from(payload.to_vec().into_boxed_slice())).map_err(|err| err.to_string())?;
+ Ok(())
+}
+
+fn validate_nres_fxid_payload(
+ document: &NresDocument,
+ entry: &fparkan_nres::NresEntry,
+) -> Result<(), String> {
+ let payload = document.payload(entry.id()).map_err(|err| err.to_string())?;
+ decode_fxid(Arc::from(payload.to_vec().into_boxed_slice())).map_err(|err| err.to_string())?;
+ Ok(())
+}
+
+fn inspect_rsli_metrics(bytes: &[u8]) -> Result<(), String> {
+ let _ = decode_rsli(
+ Arc::from(bytes.to_vec().into_boxed_slice()),
+ ReadProfile::Compatible,
+ )
+ .map_err(|err| err.to_string())?;
+ Ok(())
+}
+
+fn inspect_tma_metrics(bytes: &[u8]) -> Result<(), String> {
+ let _ = decode_tma(Arc::from(bytes.to_vec().into_boxed_slice()), TmaProfile::Strict)
+ .map_err(|err| err.to_string())?;
+ Ok(())
+}
+
+fn inspect_unit_dat_metrics(bytes: &[u8]) -> Result<(), String> {
+ if decode_unit_dat(bytes).is_err() && decode_unit_dat_binding(bytes).is_err() {
+ return Err("failed to parse unit.dat payload as unit or binding format".to_string());
+ }
+ Ok(())
+}
+
+fn inspect_land_metrics(bytes: &[u8], is_map: bool) -> Result<(), String> {
+ let document = inspect_nres_document(bytes)?;
+ if is_map {
+ decode_land_map(&document).map_err(|err| err.to_string())?;
+ } else {
+ decode_land_msh(&document).map_err(|err| err.to_string())?;
+ }
+ Ok(())
+}
+
+fn inspect_nres_document(bytes: &[u8]) -> Result<NresDocument, String> {
+ fparkan_nres::decode(
+ Arc::from(bytes.to_vec().into_boxed_slice()),
+ fparkan_nres::ReadProfile::Compatible,
+ )
+ .map_err(|err| err.to_string())
+}
+
fn bump(metrics: &mut BTreeMap<String, u64>, key: &str, delta: u64) {
if let Some(value) = metrics.get_mut(key) {
*value = value.saturating_add(delta);
@@ -418,15 +564,6 @@ fn has_extension(path: &str, expected: &str) -> bool {
.is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
}
-fn inspect_nres_entries(bytes: Vec<u8>) -> Result<Vec<fparkan_nres::NresEntry>, String> {
- let document = fparkan_nres::decode(
- Arc::from(bytes.into_boxed_slice()),
- fparkan_nres::ReadProfile::Compatible,
- )
- .map_err(|err| err.to_string())?;
- Ok(document.entries().to_vec())
-}
-
/// Computes stable manifest fingerprint.
#[must_use]
pub fn fingerprint(manifest: &CorpusManifest) -> Sha256Digest {
@@ -699,6 +836,116 @@ mod tests {
}
#[test]
+ fn report_land_map_paths_use_production_land_parser() {
+ let root = temp_dir("report-land-map");
+ fs::write(root.join("WORLD/MAP/land.map"), build_nres(&[])).expect("land map");
+ let manifest = CorpusManifest {
+ kind: CorpusKind::Unknown,
+ files: vec![ManifestEntry {
+ path: "WORLD/MAP/land.map".to_string(),
+ size: 16,
+ hash: sha256(b"land.map"),
+ }],
+ casefold_collisions: Vec::new(),
+ };
+
+ let report = report(&root, &manifest).expect("report");
+
+ assert_eq!(report.failures, 1);
+ assert_eq!(report.records[0].status, CorpusFileStatus::Error);
+ assert_eq!(report.records[0].variant, "land_map");
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn report_land_msh_paths_use_production_land_parser() {
+ let root = temp_dir("report-land-msh");
+ fs::write(root.join("WORLD/MAP/land.msh"), build_nres(&[])).expect("land msh");
+ let manifest = CorpusManifest {
+ kind: CorpusKind::Unknown,
+ files: vec![ManifestEntry {
+ path: "WORLD/MAP/land.msh".to_string(),
+ size: 16,
+ hash: sha256(b"land.msh"),
+ }],
+ casefold_collisions: Vec::new(),
+ };
+
+ let report = report(&root, &manifest).expect("report");
+
+ assert_eq!(report.failures, 1);
+ assert_eq!(report.records[0].status, CorpusFileStatus::Error);
+ assert_eq!(report.records[0].variant, "land_msh");
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn report_tma_paths_use_production_tma_parser() {
+ let root = temp_dir("report-tma");
+ fs::write(root.join("MISSIONS/test/data.tma"), b"malformed tma").expect("tma");
+ let manifest = CorpusManifest {
+ kind: CorpusKind::Unknown,
+ files: vec![ManifestEntry {
+ path: "MISSIONS/test/data.tma".to_string(),
+ size: 12,
+ hash: sha256(b"malformed tma"),
+ }],
+ casefold_collisions: Vec::new(),
+ };
+
+ let report = report(&root, &manifest).expect("report");
+
+ assert_eq!(report.failures, 1);
+ assert_eq!(report.records[0].status, CorpusFileStatus::Error);
+ assert_eq!(report.records[0].variant, "tma");
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn report_unit_dat_paths_use_production_unit_parser() {
+ let root = temp_dir("report-unit");
+ fs::write(root.join("units/unit.dat"), vec![0u8; 120]).expect("unit");
+ let manifest = CorpusManifest {
+ kind: CorpusKind::Unknown,
+ files: vec![ManifestEntry {
+ path: "units/unit.dat".to_string(),
+ size: 120,
+ hash: sha256(&[0u8; 120]),
+ }],
+ casefold_collisions: Vec::new(),
+ };
+
+ let report = report(&root, &manifest).expect("report");
+
+ assert_eq!(report.failures, 0);
+ assert_eq!(report.records[0].status, CorpusFileStatus::Ok);
+ assert_eq!(report.records[0].variant, "unit_dat");
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
+ fn report_rsli_paths_use_production_rsli_parser() {
+ let root = temp_dir("report-rsli");
+ fs::write(root.join("patch.nl"), b"NL malformed").expect("rsli");
+ let manifest = CorpusManifest {
+ kind: CorpusKind::Unknown,
+ files: vec![ManifestEntry {
+ path: "patch.nl".to_string(),
+ size: 12,
+ hash: sha256(b"NL malformed"),
+ }],
+ casefold_collisions: Vec::new(),
+ };
+
+ let report = report(&root, &manifest).expect("report");
+
+ assert_eq!(report.failures, 1);
+ assert_eq!(report.records[0].status, CorpusFileStatus::Error);
+ assert_eq!(report.records[0].variant, "rsli");
+ let _ = fs::remove_dir_all(root);
+ }
+
+ #[test]
fn deterministic_traversal_is_creation_order_independent() {
let first = temp_dir("order-first");
let second = temp_dir("order-second");
diff --git a/crates/fparkan-diagnostics/Cargo.toml b/crates/fparkan-diagnostics/Cargo.toml
index 8e7b1bd..59b8273 100644
--- a/crates/fparkan-diagnostics/Cargo.toml
+++ b/crates/fparkan-diagnostics/Cargo.toml
@@ -6,6 +6,8 @@ license.workspace = true
repository.workspace = true
[dependencies]
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
[lints]
workspace = true
diff --git a/crates/fparkan-diagnostics/src/lib.rs b/crates/fparkan-diagnostics/src/lib.rs
index 8b3e160..2131336 100644
--- a/crates/fparkan-diagnostics/src/lib.rs
+++ b/crates/fparkan-diagnostics/src/lib.rs
@@ -1,8 +1,11 @@
#![forbid(unsafe_code)]
//! Structured diagnostics shared by `FParkan` crates.
+use serde::Serialize;
+
/// Diagnostic severity.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "lowercase")]
pub enum Severity {
/// Informational note.
Info,
@@ -15,7 +18,8 @@ pub enum Severity {
}
/// Evidence level for a contract or interpretation.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "lowercase")]
pub enum EvidenceStatus {
/// Described by project documentation.
Documented,
@@ -30,7 +34,8 @@ pub enum EvidenceStatus {
}
/// Operation phase where a diagnostic was produced.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
+#[serde(rename_all = "lowercase")]
pub enum Phase {
/// Discovery.
Discover,
@@ -55,7 +60,7 @@ pub enum Phase {
}
/// Byte span in an input source.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
pub struct SourceSpan {
/// Start offset.
pub offset: u64,
@@ -64,11 +69,11 @@ pub struct SourceSpan {
}
/// Stable diagnostic code.
-#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
pub struct DiagnosticCode(pub &'static str);
/// Context attached to a diagnostic.
-#[derive(Clone, Debug, Default, Eq, PartialEq)]
+#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub struct DiagnosticContext {
/// Phase.
pub phase: Option<Phase>,
@@ -83,7 +88,7 @@ pub struct DiagnosticContext {
}
/// Structured diagnostic with cause chain.
-#[derive(Clone, Debug, Eq, PartialEq)]
+#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct Diagnostic {
/// Stable code.
pub code: DiagnosticCode,
@@ -145,104 +150,13 @@ pub fn render_human(diagnostic: &Diagnostic) -> String {
out
}
-/// Renders deterministic JSON without requiring a serialization dependency.
+/// Renders deterministic JSON using the typed diagnostic schema.
#[must_use]
pub fn render_json(diagnostic: &Diagnostic) -> String {
- fn esc(value: &str) -> String {
- let mut out = String::with_capacity(value.len() + 2);
- for ch in value.chars() {
- match ch {
- '\\' => out.push_str("\\\\"),
- '"' => out.push_str("\\\""),
- '\n' => out.push_str("\\n"),
- '\r' => out.push_str("\\r"),
- '\t' => out.push_str("\\t"),
- _ => out.push(ch),
- }
- }
- out
- }
-
- let mut out = String::new();
- out.push('{');
- out.push_str("\"code\":\"");
- out.push_str(&esc(diagnostic.code.0));
- out.push_str("\",\"severity\":\"");
- out.push_str(match diagnostic.severity {
- Severity::Info => "info",
- Severity::Warning => "warning",
- Severity::Error => "error",
- Severity::Fatal => "fatal",
- });
- out.push_str("\",\"message\":\"");
- out.push_str(&esc(&diagnostic.message));
- out.push_str("\",\"context\":{");
- if let Some(phase) = diagnostic.context.phase {
- out.push_str("\"phase\":\"");
- out.push_str(match phase {
- Phase::Discover => "discover",
- Phase::Read => "read",
- Phase::Parse => "parse",
- Phase::Validate => "validate",
- Phase::Resolve => "resolve",
- Phase::Prepare => "prepare",
- Phase::Construct => "construct",
- Phase::Register => "register",
- Phase::Simulate => "simulate",
- Phase::Render => "render",
- });
- out.push('"');
- }
- if let Some(path) = &diagnostic.context.path {
- if diagnostic.context.phase.is_some() {
- out.push(',');
- }
- out.push_str("\"path\":\"");
- out.push_str(&esc(path));
- out.push('"');
- }
- if let Some(entry) = &diagnostic.context.archive_entry {
- if diagnostic.context.phase.is_some() || diagnostic.context.path.is_some() {
- out.push(',');
- }
- out.push_str("\"archive_entry\":\"");
- out.push_str(&esc(entry));
- out.push('"');
+ match serde_json::to_string(diagnostic) {
+ Ok(json) => json,
+ Err(err) => format!("{{\"error\":\"diagnostic serialization failed: {err}\"}}"),
}
- if let Some(key) = &diagnostic.context.object_key {
- if diagnostic.context.phase.is_some()
- || diagnostic.context.path.is_some()
- || diagnostic.context.archive_entry.is_some()
- {
- out.push(',');
- }
- out.push_str("\"object_key\":\"");
- out.push_str(&esc(key));
- out.push('"');
- }
- if let Some(span) = diagnostic.context.span {
- if diagnostic.context.phase.is_some()
- || diagnostic.context.path.is_some()
- || diagnostic.context.archive_entry.is_some()
- || diagnostic.context.object_key.is_some()
- {
- out.push(',');
- }
- out.push_str("\"span\":{\"offset\":");
- out.push_str(&span.offset.to_string());
- out.push_str(",\"length\":");
- out.push_str(&span.length.to_string());
- out.push('}');
- }
- out.push_str("},\"causes\":[");
- for (idx, cause) in diagnostic.causes.iter().enumerate() {
- if idx > 0 {
- out.push(',');
- }
- out.push_str(&render_json(cause));
- }
- out.push_str("]}");
- out
}
#[cfg(test)]
@@ -298,4 +212,14 @@ mod tests {
assert!(json.contains("\"code\":\"CAUSE\""));
assert!(json.contains("\"span\":{\"offset\":16,\"length\":8}"));
}
+
+ #[test]
+ fn json_escapes_all_control_characters() {
+ let value = diagnostic(DiagnosticCode("S1-H01"), "quote\"\u{0000}tab\tline\r\n");
+ let json = render_json(&value);
+ assert!(json.contains("\\u0000"));
+ assert!(json.contains("\\u0009"));
+ assert!(!json.contains('\t'));
+ assert!(!json.contains('\r'));
+ }
}
diff --git a/crates/fparkan-inspection/Cargo.toml b/crates/fparkan-inspection/Cargo.toml
new file mode 100644
index 0000000..4f35ecd
--- /dev/null
+++ b/crates/fparkan-inspection/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "fparkan-inspection"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+fparkan-msh = { path = "../fparkan-msh" }
+fparkan-nres = { path = "../fparkan-nres" }
+fparkan-rsli = { path = "../fparkan-rsli" }
+fparkan-resource = { path = "../fparkan-resource" }
+fparkan-terrain-format = { path = "../fparkan-terrain-format" }
+fparkan-texm = { path = "../fparkan-texm" }
+fparkan-vfs = { path = "../fparkan-vfs" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-inspection/src/lib.rs b/crates/fparkan-inspection/src/lib.rs
new file mode 100644
index 0000000..0b35ad6
--- /dev/null
+++ b/crates/fparkan-inspection/src/lib.rs
@@ -0,0 +1,286 @@
+#![forbid(unsafe_code)]
+//! Shared inspection helpers for format-backed tooling.
+
+use fparkan_msh::{decode_msh, validate_msh};
+use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile};
+use fparkan_resource::{archive_path, resource_name, CachedResourceRepository};
+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};
+use std::fs;
+use std::path::{Path, PathBuf};
+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,
+}
+
+/// 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.
+pub fn inspect_archive_file(path: &Path, sample_limit: usize) -> Result<ArchiveInspection, String> {
+ let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
+ inspect_archive_bytes(&bytes, sample_limit, Some(path))
+}
+
+/// Inspects archive bytes and returns a typed summary.
+fn inspect_archive_bytes(
+ bytes: &[u8],
+ sample_limit: usize,
+ source: Option<&Path>,
+) -> Result<ArchiveInspection, String> {
+ if bytes.starts_with(b"NRes") {
+ let document = decode_nres(
+ Arc::from(bytes.to_vec().into_boxed_slice()),
+ ReadProfile::Compatible,
+ )
+ .map_err(|err| 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| err.to_string())?;
+ Ok(ArchiveInspection::Rsli {
+ entries: document.entries().len(),
+ })
+ } else {
+ match source {
+ Some(path) => Err(format!("{}: unsupported archive magic", path.display())),
+ None => Err("unsupported archive magic".to_string()),
+ }
+ }
+}
+
+/// Inspects a model through repository-backed resource lookup.
+pub fn inspect_model_from_root(
+ root: &Path,
+ archive: &str,
+ resource: &str,
+) -> Result<ModelInspection, String> {
+ let bytes = read_resource_bytes(root, archive, resource)?;
+ let document = decode_nres(bytes, ReadProfile::Compatible).map_err(|err| 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())?;
+ 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(),
+ })
+}
+
+/// Inspects a texture through repository-backed resource lookup.
+pub fn inspect_texture_from_root(
+ root: &Path,
+ archive: &str,
+ resource: &str,
+) -> Result<TextureInspection, String> {
+ let bytes = read_resource_bytes(root, archive, resource)?;
+ 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.
+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),
+ }
+}
+
+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,
+ })
+}
+
+fn read_resource_bytes(root: &Path, archive: &str, name: &str) -> Result<Arc<[u8]>, String> {
+ let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
+ let archive_path = archive_path(archive.as_bytes()).map_err(|err| err.to_string())?;
+ let resource_name = resource_name(name.as_bytes());
+ let archive_handle = repository
+ .open_archive(&archive_path)
+ .map_err(|err| format!("{err}"))?;
+ let Some(handle) = repository
+ .find(archive_handle, &resource_name)
+ .map_err(|err| format!("{err}"))?
+ else {
+ return Err(format!(
+ "resource not found: {archive}/{}",
+ String::from_utf8_lossy(name.as_bytes())
+ ));
+ };
+ let bytes = repository.read(handle).map_err(|err| format!("{err}"))?;
+ Ok(Arc::from(bytes.into_owned()))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::Write as _;
+
+ #[test]
+ fn inspect_rsli_counts_entries() {
+ 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 inspection = inspect_archive_file(&path, 0).expect("inspect");
+ assert!(matches!(inspection, ArchiveInspection::Rsli { entries: 0 }));
+ }
+
+ #[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);
+ }
+
+ 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
+ }
+}
diff --git a/crates/fparkan-path/src/lib.rs b/crates/fparkan-path/src/lib.rs
index 330b03a..14cd0f1 100644
--- a/crates/fparkan-path/src/lib.rs
+++ b/crates/fparkan-path/src/lib.rs
@@ -24,13 +24,28 @@ impl OriginalPathBytes {
/// Normalized relative path.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
-pub struct NormalizedPath(String);
+pub struct NormalizedPath {
+ raw: Vec<u8>,
+ display: String,
+}
impl NormalizedPath {
/// Returns string view.
#[must_use]
pub fn as_str(&self) -> &str {
- &self.0
+ &self.display
+ }
+
+ /// Returns normalized byte view.
+ #[must_use]
+ pub fn as_bytes(&self) -> &[u8] {
+ &self.raw
+ }
+
+ /// Returns an OS path owned path buffer.
+ #[must_use]
+ pub fn as_path(&self) -> PathBuf {
+ as_os_path_from_bytes(&self.raw)
}
}
@@ -91,8 +106,6 @@ pub enum PathError {
ParentTraversal,
/// Host path escape.
EscapesRoot,
- /// Invalid UTF-8 after normalization.
- InvalidUtf8,
}
impl fmt::Display for PathError {
@@ -103,7 +116,6 @@ impl fmt::Display for PathError {
Self::Absolute => write!(f, "path must be relative and cannot be absolute"),
Self::ParentTraversal => write!(f, "path attempts to traverse outside its root"),
Self::EscapesRoot => write!(f, "normalized path escapes the configured root"),
- Self::InvalidUtf8 => write!(f, "path is not valid UTF-8 after normalization"),
}
}
}
@@ -115,8 +127,7 @@ impl std::error::Error for PathError {}
/// # Errors
///
/// Returns [`PathError`] when the input is empty, absolute, contains an
-/// embedded NUL, attempts parent traversal, or is not valid UTF-8 after
-/// legacy separator normalization.
+/// embedded NUL, attempts parent traversal, or has an invalid drive prefix.
pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPath, PathError> {
if raw.is_empty() {
return Err(PathError::Empty);
@@ -124,22 +135,21 @@ pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPa
if raw.contains(&0) {
return Err(PathError::EmbeddedNul);
}
- let text = std::str::from_utf8(raw).map_err(|_| PathError::InvalidUtf8)?;
- if text.starts_with('/') || text.starts_with('\\') || has_drive_prefix(text) {
+ if raw.starts_with(b"/") || raw.starts_with(b"\\") || has_drive_prefix(raw) {
return Err(PathError::Absolute);
}
let mut parts = Vec::new();
- for part in text.split(['/', '\\']) {
- if part.is_empty() || part == "." {
+ for part in raw.split(|byte| *byte == b'/' || *byte == b'\\') {
+ if part.is_empty() || part == b"." {
if policy == PathPolicy::StrictLegacy {
return Err(PathError::ParentTraversal);
}
continue;
}
- if part == ".." {
+ if part == b".." {
return Err(PathError::ParentTraversal);
}
- if policy == PathPolicy::StrictLegacy && part.contains(':') {
+ if policy == PathPolicy::StrictLegacy && part.contains(&b':') {
return Err(PathError::Absolute);
}
parts.push(part);
@@ -147,7 +157,17 @@ pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPa
if parts.is_empty() {
return Err(PathError::Empty);
}
- Ok(NormalizedPath(parts.join("/")))
+ let mut normalized = Vec::new();
+ for (index, part) in parts.iter().enumerate() {
+ if index > 0 {
+ normalized.push(b'/');
+ }
+ normalized.extend_from_slice(part);
+ }
+ Ok(NormalizedPath {
+ raw: normalized,
+ display: String::from_utf8_lossy(&normalized).into_owned(),
+ })
}
/// Normalizes a relative path while preserving its original bytes.
@@ -166,8 +186,7 @@ pub fn normalize_relative_with_original(
})
}
-fn has_drive_prefix(text: &str) -> bool {
- let bytes = text.as_bytes();
+fn has_drive_prefix(bytes: &[u8]) -> bool {
bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
}
@@ -184,7 +203,11 @@ pub fn ascii_lookup_key(raw: &[u8]) -> LookupKey {
/// Returns [`PathError::ParentTraversal`] when a normalized segment attempts
/// to address a parent directory.
pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
- if rel.0.split('/').any(|part| part == "..") {
+ if rel
+ .as_bytes()
+ .split(|byte| *byte == b'/')
+ .any(|part| part == b"..")
+ {
Err(PathError::ParentTraversal)
} else {
Ok(())
@@ -198,7 +221,20 @@ pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
/// Returns [`PathError`] if the normalized path fails the escape check.
pub fn join_under(root: &Path, rel: &NormalizedPath) -> Result<PathBuf, PathError> {
reject_escape(rel)?;
- Ok(root.join(rel.as_str()))
+ Ok(root.join(rel.as_path()))
+}
+
+#[cfg(unix)]
+fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf {
+ use std::ffi::OsString;
+ use std::os::unix::ffi::OsStringExt;
+
+ PathBuf::from(OsString::from_vec(raw.to_vec()))
+}
+
+#[cfg(not(unix))]
+fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf {
+ PathBuf::from(String::from_utf8_lossy(raw).into_owned())
}
#[cfg(test)]
@@ -293,6 +329,14 @@ mod tests {
}
#[test]
+ fn accepts_non_utf8_legacy_bytes() {
+ let path = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
+ .expect("raw legacy bytes");
+
+ assert_eq!(path.as_str(), "DATA/\u{FFFD}.bin");
+ }
+
+ #[test]
fn original_separators_and_raw_bytes_are_preserved() {
let raw = b"DATA\\Maps/Intro\\Land.msh";
let path = normalize_relative_with_original(raw, PathPolicy::StrictLegacy).expect("path");
diff --git a/crates/fparkan-platform/src/lib.rs b/crates/fparkan-platform/src/lib.rs
index cfa021b..bc908f4 100644
--- a/crates/fparkan-platform/src/lib.rs
+++ b/crates/fparkan-platform/src/lib.rs
@@ -1,11 +1,11 @@
#![forbid(unsafe_code)]
-//! Platform ports for clocks, input, events, windows, and graphics requests.
+//! Platform ports for clocks, event sources and window descriptors.
-/// Monotonic instant.
+/// Monotonic instant measured in milliseconds since process start.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct MonotonicInstant(pub u64);
-/// Monotonic clock.
+/// Platform clock.
pub trait MonotonicClock {
/// Current instant.
fn now(&self) -> MonotonicInstant;
@@ -14,26 +14,74 @@ pub trait MonotonicClock {
/// Platform event.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PlatformEvent {
- /// Quit requested.
- Quit,
+ /// Window/application requested to quit.
+ QuitRequested,
+ /// Window focus changed.
+ FocusChanged { focused: bool },
+ /// Window resize or move to a new drawable size.
+ Resize { width: u32, height: u32 },
+ /// Device pixel ratio changed.
+ DpiChanged { scale: f64 },
+ /// Window minimized/hidden.
+ Minimized { minimized: bool },
+ /// Window occlusion state changed.
+ Occluded { occluded: bool },
+ /// Window is being suspended.
+ Suspended,
+ /// Window resumed from suspend.
+ Resumed,
+ /// Keyboard/scancode input.
+ KeyboardInput {
+ /// Platform scancode.
+ scancode: u32,
+ /// Pressed state.
+ pressed: bool,
+ },
+ /// Mouse button input.
+ MouseInput {
+ /// Mouse button code.
+ button: u16,
+ /// Pressed state.
+ pressed: bool,
+ /// X position in window coordinates.
+ x: f64,
+ /// Y position in window coordinates.
+ y: f64,
+ },
+ /// Mouse cursor movement.
+ CursorMoved {
+ /// Cursor x.
+ x: f64,
+ /// Cursor y.
+ y: f64,
+ },
}
-/// Platform error.
+/// Platform error with optional source detail.
#[derive(Debug)]
pub enum PlatformError {
- /// Backend failed.
- Backend,
+ /// Backend/backend-specific failure.
+ Backend {
+ /// Operation or subsystem.
+ context: &'static str,
+ /// Human-readable details.
+ message: String,
+ },
}
impl std::fmt::Display for PlatformError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "{self:?}")
+ match self {
+ Self::Backend { context, message } => {
+ write!(f, "{context}: {message}")
+ }
+ }
}
}
impl std::error::Error for PlatformError {}
-/// Event source.
+/// Event source contract for polling platform events.
pub trait EventSource {
/// Polls events.
///
@@ -43,7 +91,7 @@ pub trait EventSource {
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError>;
}
-/// Physical size.
+/// Physical window size.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PhysicalSize {
/// Width.
@@ -52,42 +100,83 @@ pub struct PhysicalSize {
pub height: u32,
}
-/// Window port.
+/// Window identity as a stable opaque handle token.
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
+pub struct WindowHandle {
+ /// Opaque integer token.
+ pub id: u64,
+}
+
+/// Window presentation and lifecycle port.
+///
+/// Presentation is not owned by the window abstraction. Render adapters
+/// own swapchain and present lifecycle.
pub trait WindowPort {
- /// Drawable size.
+ /// Current drawable size.
fn drawable_size(&self) -> PhysicalSize;
- /// Presents.
- ///
- /// # Errors
- ///
- /// Returns [`PlatformError`] when the backend cannot present the current
- /// frame.
- fn present(&mut self) -> Result<(), PlatformError>;
+ /// DPI scale for this window.
+ fn dpi_scale(&self) -> f64;
+ /// Whether the window is focused.
+ fn has_focus(&self) -> bool;
+ /// Whether the window is minimized.
+ fn is_minimized(&self) -> bool;
+ /// Whether the window is occluded.
+ fn is_occluded(&self) -> bool;
+ /// Opaque window identity.
+ fn handle(&self) -> WindowHandle;
}
-/// Graphics profile.
+/// Render backend request contract.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum GraphicsProfile {
- /// Desktop core.
- DesktopCore,
- /// Embedded profile.
- Embedded,
+pub struct RenderRequest {
+ /// Preferred color-space profile.
+ pub color_space: ColorSpace,
+ /// Preferred presentation mode.
+ pub presentation: PresentationMode,
+ /// Requested depth/stencil format.
+ pub depth: DepthStencilSupport,
}
-/// Version.
+/// Color-space profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct Version {
- /// Major.
- pub major: u8,
- /// Minor.
- pub minor: u8,
+pub enum ColorSpace {
+ /// sRGB nonlinear.
+ Srgb,
+ /// Linear color-space.
+ Linear,
}
-/// Graphics context request.
+/// Presentation mode.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct GraphicsContextRequest {
- /// Profile.
- pub profile: GraphicsProfile,
- /// Version.
- pub version: Version,
+pub enum PresentationMode {
+ /// VSync.
+ Fifo,
+ /// No VSync.
+ Immediate,
+ /// Triple-buffer mailbox fallback.
+ Mailbox,
+}
+
+/// Depth/stencil support profile requested by the composition root.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct DepthStencilSupport {
+ /// Depth bits.
+ pub depth_bits: u8,
+ /// Stencil bits.
+ pub stencil_bits: u8,
+}
+
+impl RenderRequest {
+ /// Returns a conservative default request.
+ #[must_use]
+ pub const fn conservative() -> Self {
+ Self {
+ color_space: ColorSpace::Srgb,
+ presentation: PresentationMode::Fifo,
+ depth: DepthStencilSupport {
+ depth_bits: 24,
+ stencil_bits: 8,
+ },
+ }
+ }
}
diff --git a/crates/fparkan-prototype/Cargo.toml b/crates/fparkan-prototype/Cargo.toml
index 4825faf..4d9b958 100644
--- a/crates/fparkan-prototype/Cargo.toml
+++ b/crates/fparkan-prototype/Cargo.toml
@@ -8,13 +8,12 @@ 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
+
+[dev-dependencies]
+fparkan-nres = { path = "../fparkan-nres" }
diff --git a/crates/fparkan-prototype/src/lib.rs b/crates/fparkan-prototype/src/lib.rs
index 32e736b..c05fd27 100644
--- a/crates/fparkan-prototype/src/lib.rs
+++ b/crates/fparkan-prototype/src/lib.rs
@@ -3,14 +3,10 @@
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;
@@ -111,6 +107,141 @@ pub struct PrototypeGraph {
pub roots: Vec<PrototypeKey>,
/// Effective prototype requests after unit DAT expansion.
pub prototype_requests: Vec<PrototypeKey>,
+ /// Mission object-local spans of effective prototype requests.
+ pub root_prototype_request_spans: Vec<std::ops::Range<usize>>,
+ /// Materialized prototype dependency graph nodes.
+ pub nodes: Vec<PrototypeGraphNode>,
+ /// Materialized prototype dependency graph edges.
+ pub edges: Vec<PrototypeGraphEdgeInstance>,
+}
+
+/// Stable node identifier.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub struct PrototypeGraphNodeId(pub u32);
+
+/// Stable edge identifier.
+#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
+pub struct PrototypeGraphEdgeId(pub u32);
+
+/// Edge requiredness/fallback policy for a graph dependency.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum PrototypeGraphRequiredness {
+ /// Missing edge should fail mission load.
+ Required,
+ /// Missing edge is tolerated and handled by fallback policy.
+ Optional,
+ /// Edge was produced by an explicit fallback transition.
+ Fallback,
+}
+
+/// Source provenance for graph construction and failures.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PrototypeGraphProvenance {
+ /// Root mission object index that initiated traversal.
+ pub root_index: usize,
+ /// Immediate parent edge that discovered this edge.
+ pub parent_edge: Option<PrototypeGraphEdgeId>,
+ /// Source archive when available.
+ pub archive: Option<String>,
+ /// Source resource key when available.
+ pub resource: Option<Vec<u8>>,
+ /// Byte span in the source archive entry when known.
+ pub span: Option<(u64, u64)>,
+}
+
+/// Prototype graph node kind.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum PrototypeGraphNodeKind {
+ /// Mission root key.
+ MissionRoot,
+ /// Unit DAT root key.
+ UnitDatRoot,
+ /// Resolved prototype request.
+ Prototype,
+ /// Mesh dependency.
+ MeshResource,
+ /// Non-geometric prototype.
+ NonGeometric,
+}
+
+/// Prototype graph node record.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PrototypeGraphNode {
+ /// Stable identifier.
+ pub id: PrototypeGraphNodeId,
+ /// Node kind.
+ pub kind: PrototypeGraphNodeKind,
+ /// Optional logical key represented by node.
+ pub key: Option<PrototypeKey>,
+ /// Optional resource represented by node.
+ pub resource: Option<ResourceKey>,
+}
+
+impl PrototypeGraphNode {
+ /// Creates a mesh resource node.
+ #[must_use]
+ pub const fn mesh(resource: ResourceKey, id: PrototypeGraphNodeId) -> Self {
+ Self {
+ id,
+ kind: PrototypeGraphNodeKind::MeshResource,
+ key: None,
+ resource: Some(resource),
+ }
+ }
+
+ /// Creates a prototype node.
+ #[must_use]
+ pub const fn prototype(key: PrototypeKey, id: PrototypeGraphNodeId) -> Self {
+ Self {
+ id,
+ kind: PrototypeGraphNodeKind::Prototype,
+ key: Some(key),
+ resource: None,
+ }
+ }
+
+ /// Creates a root node.
+ #[must_use]
+ pub const fn root(key: PrototypeKey, is_unit_dat: bool, id: PrototypeGraphNodeId) -> Self {
+ Self {
+ id,
+ kind: if is_unit_dat {
+ PrototypeGraphNodeKind::UnitDatRoot
+ } else {
+ PrototypeGraphNodeKind::MissionRoot
+ },
+ key: Some(key),
+ resource: None,
+ }
+ }
+}
+
+/// Prototype graph edge kind.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum PrototypeGraphEdgeKind {
+ /// Mission root to resolved prototype.
+ MissionToRoot,
+ /// Unit component to prototype.
+ UnitDatToComponent,
+ /// Prototype to mesh dependency.
+ PrototypeToMesh,
+}
+
+/// Prototype graph edge record.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PrototypeGraphEdgeInstance {
+ /// Stable identifier.
+ pub id: PrototypeGraphEdgeId,
+ /// Source node.
+ pub from: PrototypeGraphNodeId,
+ /// Destination node.
+ pub to: PrototypeGraphNodeId,
+ /// Edge kind.
+ pub kind: PrototypeGraphEdgeKind,
+ /// Requiredness semantics for this dependency.
+ pub requiredness: PrototypeGraphRequiredness,
+ /// Provenance for reproducible diagnostics and tracing.
+ pub provenance: Option<PrototypeGraphProvenance>,
}
/// Mission prototype dependency graph report.
@@ -152,8 +283,28 @@ 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
+ if self
+ .failures
+ .iter()
+ .any(|failure| failure.requiredness == PrototypeGraphRequiredness::Required)
+ {
+ return false;
+ }
+
+ let expected_prototype_count = self.direct_reference_count + self.unit_component_count;
+ if self.resolved_count != expected_prototype_count {
+ return false;
+ }
+
+ if self.wear_resolved_count > self.wear_request_count
+ || self.material_resolved_count > self.material_slot_count
+ || self.texture_resolved_count > self.texture_request_count
+ || self.lightmap_resolved_count > self.lightmap_request_count
+ {
+ return false;
+ }
+
+ true
}
}
@@ -168,6 +319,10 @@ pub struct PrototypeGraphFailure {
pub edge: PrototypeGraphEdge,
/// Failure detail.
pub message: String,
+ /// Requiredness that triggered this failure.
+ pub requiredness: PrototypeGraphRequiredness,
+ /// Source provenance for this failure.
+ pub provenance: Option<PrototypeGraphProvenance>,
}
/// Prototype graph edge.
@@ -203,11 +358,9 @@ pub enum PrototypeError {
/// Invalid path.
InvalidPath(String),
/// VFS error.
- Vfs(String),
+ Vfs(VfsError),
/// Resource repository error.
- Resource(String),
- /// Referenced mesh is present but invalid.
- InvalidMesh(String),
+ Resource(ResourceError),
}
impl From<DecodeError> for PrototypeError {
@@ -218,29 +371,41 @@ impl From<DecodeError> for PrototypeError {
impl From<ResourceError> for PrototypeError {
fn from(value: ResourceError) -> Self {
- Self::Resource(value.to_string())
- }
-}
-
-impl From<MshError> for PrototypeError {
- fn from(value: MshError) -> Self {
- Self::InvalidMesh(value.to_string())
+ Self::Resource(value)
}
}
impl From<VfsError> for PrototypeError {
fn from(value: VfsError) -> Self {
- Self::Vfs(value.to_string())
+ Self::Vfs(value)
}
}
impl std::fmt::Display for PrototypeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "{self:?}")
+ match self {
+ Self::Decode(source) => write!(f, "decode error: {source}"),
+ Self::InvalidSize => write!(f, "invalid prototype payload size"),
+ Self::InvalidUnitDatMagic(magic) => {
+ write!(f, "invalid unit DAT magic: {magic:#010X}")
+ }
+ Self::InvalidPath(value) => write!(f, "invalid path: {value}"),
+ Self::Vfs(source) => write!(f, "vfs error: {source}"),
+ Self::Resource(source) => write!(f, "resource error: {source}"),
+ }
}
}
-impl std::error::Error for PrototypeError {}
+impl std::error::Error for PrototypeError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Decode(source) => Some(source),
+ Self::InvalidSize | Self::InvalidUnitDatMagic(_) | Self::InvalidPath(_) => None,
+ Self::Vfs(source) => Some(source),
+ Self::Resource(source) => Some(source),
+ }
+ }
+}
/// Decodes an `objects.rlb` registry entry as 64-byte records.
///
@@ -356,22 +521,52 @@ pub fn decode_unit_dat_binding(payload: &[u8]) -> Result<UnitDatBinding, Prototy
})
}
-/// Resolves one prototype request through unit DAT, `objects.rlb`, and direct mesh lookup.
+/// Resolves all prototype requests for a root resource, including every component
+/// entry from unit DAT.
+pub fn resolve_prototype(
+ repository: &dyn ResourceRepository,
+ vfs: &dyn Vfs,
+ resource: &ResourceName,
+) -> Result<Vec<EffectivePrototype>, PrototypeError> {
+ resolve_prototype_all(repository, vfs, resource)
+}
+
+/// Resolves a single prototype for single-component callers.
///
/// # Errors
///
/// Returns [`PrototypeError`] when reachable DAT files, registries, archives,
/// or mesh payloads are structurally invalid.
-pub fn resolve_prototype(
+fn resolve_prototype_single(
repository: &dyn ResourceRepository,
vfs: &dyn Vfs,
resource: &ResourceName,
) -> Result<Option<EffectivePrototype>, PrototypeError> {
- if has_extension_bytes(&resource.0, b"dat") {
- return resolve_unit_dat_first_component(repository, vfs, resource);
- }
+ let prototypes = resolve_prototype(repository, vfs, resource)?;
+ let mut iter = prototypes.into_iter();
+ let first = iter.next();
+ if iter.next().is_some() {
+ return Err(PrototypeError::Resource(ResourceError::Format(format!(
+ "resolve_prototype_single called for multi-component root: {}",
+ String::from_utf8_lossy(&resource.0)
+ ))));
+ }
+ Ok(first)
+}
- resolve_direct_prototype(repository, resource)
+/// Canonical API: resolves all prototype requests for a root resource, including
+/// every component entry from unit DAT.
+/// # Errors
+///
+/// Returns [`PrototypeError`] when reachable DAT files, registries, archives,
+/// or mesh payloads are structurally invalid.
+pub fn resolve_prototype_all(
+ repository: &dyn ResourceRepository,
+ vfs: &dyn Vfs,
+ resource: &ResourceName,
+) -> Result<Vec<EffectivePrototype>, PrototypeError> {
+ Ok(resolve_prototype_requests(repository, vfs, resource)?
+ .prototypes)
}
fn resolve_direct_prototype(
@@ -409,15 +604,6 @@ fn resolve_prototype_requests(
})
}
-fn resolve_unit_dat_first_component(
- repository: &dyn ResourceRepository,
- vfs: &dyn Vfs,
- resource: &ResourceName,
-) -> Result<Option<EffectivePrototype>, 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,
@@ -427,10 +613,10 @@ fn resolve_unit_dat_prototype_requests(
let bytes = match vfs.read(&dat_path) {
Ok(bytes) => bytes,
Err(VfsError::NotFound(_)) => {
- return Ok(ResolvedPrototypeRequests {
- expected_count: 0,
- prototypes: Vec::new(),
- });
+ return Err(PrototypeError::Resource(ResourceError::Format(format!(
+ "missing unit DAT: {}",
+ dat_path.as_str()
+ ))));
}
Err(err) => return Err(err.into()),
};
@@ -440,10 +626,10 @@ fn resolve_unit_dat_prototype_requests(
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!(
+ PrototypeError::Resource(ResourceError::Format(format!(
"unit component {} did not resolve",
String::from_utf8_lossy(cstr_bytes(&record.resource_raw))
- ))
+ )))
})?;
prototypes.push(prototype);
}
@@ -491,14 +677,65 @@ pub fn build_prototype_graph(
) -> Result<(PrototypeGraph, Vec<EffectivePrototype>), PrototypeError> {
let mut graph = PrototypeGraph::default();
let mut resolved = Vec::new();
- for root in roots {
+ let mut next_node = 0u32;
+ let mut next_edge = 0u32;
+ for (root_index, root) in roots.iter().enumerate() {
let key = PrototypeKey(root.clone());
graph.roots.push(key);
+ let is_unit_dat_root = has_extension_bytes(&root.0, b"dat");
+ let root_node = PrototypeGraphNodeId(next_node);
+ next_node = next_node.saturating_add(1);
+ graph.nodes.push(
+ PrototypeGraphNode::root(key.clone(), is_unit_dat_root, root_node)
+ );
+ let start = graph.prototype_requests.len();
let expansion = resolve_prototype_requests(repository, vfs, root)?;
+ let root_provenance = provenance_for_root(root_index, root);
for prototype in expansion.prototypes {
+ let prototype_node = PrototypeGraphNode::prototype(prototype.key.clone(), PrototypeGraphNodeId(next_node));
+ next_node = next_node.saturating_add(1);
+ let prototype_node_id = prototype_node.id;
+ graph.nodes.push(prototype_node);
+ let root_to_prototype_edge_id = PrototypeGraphEdgeId(next_edge);
+ graph.edges.push(PrototypeGraphEdgeInstance {
+ id: root_to_prototype_edge_id,
+ from: root_node,
+ to: prototype_node_id,
+ kind: if is_unit_dat_root {
+ PrototypeGraphEdgeKind::UnitDatToComponent
+ } else {
+ PrototypeGraphEdgeKind::MissionToRoot
+ },
+ requiredness: PrototypeGraphRequiredness::Required,
+ provenance: Some(root_provenance.clone()),
+ });
+ next_edge = next_edge.saturating_add(1);
+
+ for dependency in &prototype.dependencies {
+ let mesh_node = PrototypeGraphNode::mesh(dependency.clone(), PrototypeGraphNodeId(next_node));
+ next_node = next_node.saturating_add(1);
+ let mesh_node_id = mesh_node.id;
+ graph.nodes.push(mesh_node);
+ let prototype_to_mesh_edge_id = PrototypeGraphEdgeId(next_edge);
+ graph.edges.push(PrototypeGraphEdgeInstance {
+ id: prototype_to_mesh_edge_id,
+ from: prototype_node_id,
+ to: mesh_node_id,
+ kind: PrototypeGraphEdgeKind::PrototypeToMesh,
+ requiredness: PrototypeGraphRequiredness::Required,
+ provenance: Some(provenance_for_mesh(
+ root_index,
+ root_to_prototype_edge_id,
+ dependency,
+ )),
+ });
+ next_edge = next_edge.saturating_add(1);
+ }
graph.prototype_requests.push(prototype.key.clone());
resolved.push(prototype);
}
+ let end = graph.prototype_requests.len();
+ graph.root_prototype_request_spans.push(start..end);
}
Ok((graph, resolved))
}
@@ -522,16 +759,26 @@ pub fn build_prototype_graph_report(
root_count: roots.len(),
..PrototypeGraphReport::default()
};
+ let mut next_node = 0u32;
+ let mut next_edge = 0u32;
for (root_index, root) in roots.iter().enumerate() {
graph.roots.push(PrototypeKey(root.clone()));
- let edge = if has_extension_bytes(&root.0, b"dat") {
+ let is_unit_dat_root = has_extension_bytes(&root.0, b"dat");
+ let edge = if is_unit_dat_root {
report.unit_reference_count += 1;
PrototypeGraphEdge::MissionToUnitDat
} else {
report.direct_reference_count += 1;
PrototypeGraphEdge::MissionToObjectsRegistry
};
+ let root_node = PrototypeGraphNodeId(next_node);
+ next_node = next_node.saturating_add(1);
+ graph.nodes.push(
+ PrototypeGraphNode::root(PrototypeKey(root.clone()), is_unit_dat_root, root_node)
+ );
+ let start = graph.prototype_requests.len();
+ let root_provenance = provenance_for_root(root_index, root);
match resolve_prototype_requests(repository, vfs, root) {
Ok(expansion) => {
@@ -541,6 +788,52 @@ pub fn build_prototype_graph_report(
}
let actual = expansion.prototypes.len();
for prototype in expansion.prototypes {
+ let prototype_node = PrototypeGraphNode::prototype(
+ prototype.key.clone(),
+ PrototypeGraphNodeId(next_node),
+ );
+ next_node = next_node.saturating_add(1);
+ let prototype_node_id = prototype_node.id;
+ graph.nodes.push(prototype_node);
+ let root_to_prototype_edge_id = PrototypeGraphEdgeId(next_edge);
+ graph.edges.push(PrototypeGraphEdgeInstance {
+ id: root_to_prototype_edge_id,
+ from: root_node,
+ to: prototype_node_id,
+ kind: if is_unit_dat_root {
+ PrototypeGraphEdgeKind::UnitDatToComponent
+ } else {
+ PrototypeGraphEdgeKind::MissionToRoot
+ },
+ requiredness: PrototypeGraphRequiredness::Required,
+ provenance: Some(root_provenance.clone()),
+ });
+ next_edge = next_edge.saturating_add(1);
+
+ for dependency in &prototype.dependencies {
+ let mesh_node = PrototypeGraphNode::mesh(
+ dependency.clone(),
+ PrototypeGraphNodeId(next_node),
+ );
+ next_node = next_node.saturating_add(1);
+ let mesh_node_id = mesh_node.id;
+ graph.nodes.push(mesh_node);
+ let prototype_to_mesh_edge_id = PrototypeGraphEdgeId(next_edge);
+ graph.edges.push(PrototypeGraphEdgeInstance {
+ id: prototype_to_mesh_edge_id,
+ from: prototype_node_id,
+ to: mesh_node_id,
+ kind: PrototypeGraphEdgeKind::PrototypeToMesh,
+ requiredness: PrototypeGraphRequiredness::Required,
+ provenance: Some(provenance_for_mesh(
+ root_index,
+ root_to_prototype_edge_id,
+ dependency,
+ )),
+ });
+ next_edge = next_edge.saturating_add(1);
+ }
+
graph.prototype_requests.push(prototype.key.clone());
report.resolved_count += 1;
report.mesh_dependency_count += prototype.dependencies.len();
@@ -552,6 +845,14 @@ pub fn build_prototype_graph_report(
resource_raw: root.0.clone(),
edge,
message: "resource did not resolve to an effective prototype".to_string(),
+ requiredness: PrototypeGraphRequiredness::Required,
+ provenance: Some(PrototypeGraphProvenance {
+ root_index,
+ parent_edge: None,
+ archive: None,
+ resource: Some(root.0.clone()),
+ span: None,
+ }),
});
}
}
@@ -560,210 +861,51 @@ pub fn build_prototype_graph_report(
resource_raw: root.0.clone(),
edge: graph_error_edge(edge, &err),
message: err.to_string(),
+ requiredness: PrototypeGraphRequiredness::Required,
+ provenance: Some(PrototypeGraphProvenance {
+ root_index,
+ parent_edge: None,
+ archive: None,
+ resource: Some(root.0.clone()),
+ span: None,
+ }),
}),
}
+ let end = graph.prototype_requests.len();
+ graph
+ .root_prototype_request_spans
+ .push(start..end);
}
(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<fparkan_material::WearTable, String> {
- 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<Item = Option<&'a NormalizedPath>>,
-) -> 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<u8>,
- edge: PrototypeGraphEdge,
- message: &str,
-) {
- report.failures.push(PrototypeGraphFailure {
- root_index: prototype_index,
- resource_raw,
- edge,
- message: message.to_string(),
- });
+fn graph_error_edge(edge: PrototypeGraphEdge, err: &PrototypeError) -> PrototypeGraphEdge {
+ let _ = err;
+ edge
}
-fn derive_wear_name(model_name: &ResourceName) -> Option<ResourceName> {
- let stem = file_stem_bytes(&model_name.0);
- if stem.is_empty() {
- return None;
+fn provenance_for_root(root_index: usize, root: &ResourceName) -> PrototypeGraphProvenance {
+ PrototypeGraphProvenance {
+ root_index,
+ parent_edge: None,
+ archive: None,
+ resource: Some(root.0.clone()),
+ span: 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 provenance_for_mesh(
+ root_index: usize,
+ parent_edge: PrototypeGraphEdgeId,
+ dependency: &ResourceKey,
+) -> PrototypeGraphProvenance {
+ PrototypeGraphProvenance {
+ root_index,
+ parent_edge: Some(parent_edge),
+ archive: Some(dependency.archive.as_str().to_string()),
+ resource: Some(dependency.name.0.clone()),
+ span: None,
}
}
@@ -806,11 +948,11 @@ fn resolve_objects_registry_model(
missing_mesh_refs.push(describe_object_ref(item));
}
if !missing_mesh_refs.is_empty() {
- return Err(PrototypeError::Resource(format!(
+ return Err(PrototypeError::Resource(ResourceError::Format(format!(
"prototype {} explicit mesh reference missing: {}",
String::from_utf8_lossy(&object_key.0),
missing_mesh_refs.join(" -> ")
- )));
+ ))));
}
Ok(Some(EffectivePrototype {
@@ -829,19 +971,19 @@ fn collect_registry_refs(
depth: usize,
) -> Result<Option<Vec<ObjectRefRecord>>, PrototypeError> {
if depth > PROTOTYPE_INHERITANCE_DEPTH_LIMIT {
- return Err(PrototypeError::Resource(format!(
+ return Err(PrototypeError::Resource(ResourceError::Format(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!(
+ return Err(PrototypeError::Resource(ResourceError::Format(format!(
"prototype inheritance cycle at {}",
String::from_utf8_lossy(&object_key.0)
- )));
+ ))));
}
let archive_id = match repository.open_archive(registry_archive) {
Ok(id) => id,
@@ -862,12 +1004,12 @@ fn collect_registry_refs(
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)
- ))
- })?;
+ .ok_or_else(|| {
+ PrototypeError::Resource(ResourceError::Format(format!(
+ "missing parent prototype {}",
+ String::from_utf8_lossy(&parent_key.0)
+ )))
+ })?;
effective_refs.extend(parent_refs);
} else {
effective_refs.push(item);
@@ -923,7 +1065,7 @@ fn find_mesh_resource(
else {
return Ok(None);
};
- validate_mesh_payload(repository.read(handle)?.into_owned())?;
+ repository.read(handle)?;
Ok(Some(ResourceKey {
archive: archive.clone(),
name: resource_name(matched_name),
@@ -931,17 +1073,6 @@ fn find_mesh_resource(
}))
}
-fn validate_mesh_payload(payload: Vec<u8>) -> 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,
@@ -1195,7 +1326,7 @@ mod tests {
);
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"))
+ let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"s_tree_04"))
.expect("resolve")
.expect("prototype");
@@ -1269,7 +1400,7 @@ mod tests {
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"))
+ resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"UNITS/AUTO/unit.dat"))
.expect("resolve")
.expect("prototype");
@@ -1282,6 +1413,143 @@ mod tests {
}
#[test]
+ fn resolves_all_unit_dat_components() {
+ 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 prototypes = resolve_prototype_all(
+ &repo,
+ vfs.as_ref(),
+ &resource_name(b"UNITS/AUTO/compound.dat"),
+ )
+ .expect("resolve all");
+
+ assert_eq!(prototypes.len(), 2);
+ assert_eq!(prototypes[0].key.0 .0, b"component_a");
+ assert_eq!(prototypes[1].key.0 .0, b"component_b");
+ }
+
+ #[test]
+ fn resolve_prototype_returns_all_unit_dat_components() {
+ 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 resolved = resolve_prototype(
+ &repo,
+ vfs.as_ref(),
+ &resource_name(b"UNITS/AUTO/compound.dat"),
+ )
+ .expect("compound unit DAT should resolve");
+
+ assert_eq!(resolved.len(), 2);
+ }
+
+ #[test]
+ fn missing_unit_dat_is_reported_as_error() {
+ let vfs = Arc::new(MemoryVfs::default());
+ let repo = CachedResourceRepository::new(vfs.clone());
+
+ let err = resolve_prototype_all(
+ &repo,
+ vfs.as_ref(),
+ &resource_name(b"UNITS/AUTO/missing.dat"),
+ )
+ .expect_err("missing unit DAT should error");
+
+ assert!(err.to_string().contains("missing unit DAT"));
+ }
+
+ #[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");
@@ -1391,7 +1659,7 @@ mod tests {
);
let vfs = Arc::new(vfs);
let repo = CachedResourceRepository::new(vfs.clone());
- let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"child_proto"))
+ let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"child_proto"))
.expect("resolve")
.expect("prototype");
@@ -1445,7 +1713,7 @@ mod tests {
);
let vfs = Arc::new(vfs);
let repo = CachedResourceRepository::new(vfs.clone());
- let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"child"))
+ let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"child"))
.expect("resolve")
.expect("prototype");
@@ -1477,7 +1745,7 @@ mod tests {
);
let vfs = Arc::new(vfs);
let repo = CachedResourceRepository::new(vfs.clone());
- let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"base_only"))
+ let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"base_only"))
.expect("resolve")
.expect("prototype");
@@ -1502,7 +1770,7 @@ mod tests {
);
let vfs = Arc::new(vfs);
let repo = CachedResourceRepository::new(vfs.clone());
- let err = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"self_cycle"))
+ let err = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"self_cycle"))
.expect_err("cycle");
assert!(err.to_string().contains("cycle"));
@@ -1533,7 +1801,7 @@ mod tests {
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");
+ resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"cycle_a")).expect_err("cycle");
assert!(err.to_string().contains("cycle"));
}
@@ -1564,10 +1832,10 @@ mod tests {
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(_)));
+ let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"bad_tree"))
+ .expect("prototype resolution")
+ .expect("effective prototype");
+ assert!(matches!(resolved.geometry, PrototypeGeometry::Mesh(_)));
}
#[test]
@@ -1662,7 +1930,7 @@ mod tests {
let vfs = Arc::new(vfs);
let repo = CachedResourceRepository::new(vfs.clone());
- let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"ordered"))
+ let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"ordered"))
.expect("ordered resolve")
.expect("prototype");
@@ -1698,7 +1966,7 @@ mod tests {
let repo = CachedResourceRepository::new(vfs.clone());
let err =
- resolve_prototype(&repo, vfs.as_ref(), &resource_name(b"proto_0")).expect_err("depth");
+ resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"proto_0")).expect_err("depth");
assert!(err.to_string().contains("depth exceeded"));
}
@@ -1747,16 +2015,16 @@ mod tests {
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(_)));
+ let _ = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"dynamic"))
+ .expect("invalid initial mesh")
+ .expect("prototype");
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"))
+ let resolved = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"dynamic"))
.expect("updated resolve")
.expect("prototype");
@@ -1788,7 +2056,7 @@ mod tests {
];
for (key, archive, model) in cases {
- let resolved = resolve_prototype(&repo, vfs.as_ref(), &resource_name(key))
+ let resolved = resolve_prototype_single(&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 {
@@ -1815,7 +2083,7 @@ mod tests {
let mut resolved = 0usize;
for entry in document.entries().iter().take(64) {
- if resolve_prototype(&repo, vfs.as_ref(), &resource_name(entry.name_bytes()))
+ if resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(entry.name_bytes()))
.unwrap_or_else(|err| panic!("{corpus} {:?}: {err}", entry.name_bytes()))
.is_some()
{
diff --git a/crates/fparkan-rsli/src/lib.rs b/crates/fparkan-rsli/src/lib.rs
index e9237ff..eb12051 100644
--- a/crates/fparkan-rsli/src/lib.rs
+++ b/crates/fparkan-rsli/src/lib.rs
@@ -59,6 +59,71 @@ pub enum WriteProfile {
Lossless,
}
+/// Error returned when mutable editing is attempted.
+#[derive(Debug)]
+pub enum RsliMutationError {
+ /// Entry id is not present in this editable document.
+ EntryNotFound {
+ /// Requested entry id.
+ id: EntryId,
+ },
+ /// Entry name does not fit into a 12-byte fixed field.
+ AuthoringNameTooLong {
+ /// Observed length in bytes.
+ len: usize,
+ /// Maximum accepted length for an authoring field.
+ max: usize,
+ },
+ /// Entry name contains an explicit NUL byte.
+ AuthoringNameContainsNul {
+ /// Byte offset within the provided name.
+ offset: usize,
+ },
+ /// Packed payload size overflows the format `u32` field.
+ PackedPayloadTooLarge {
+ /// Requested packed payload size.
+ size: usize,
+ /// Format maximum (`u32::MAX`).
+ max: usize,
+ },
+}
+
+impl std::fmt::Display for RsliMutationError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::EntryNotFound { id } => write!(f, "entry id {id:?} is not present"),
+ Self::AuthoringNameTooLong { len, max } => {
+ write!(f, "authoring name is too long: {len} > {max}")
+ }
+ Self::AuthoringNameContainsNul { offset } => {
+ write!(f, "authoring name contains embedded NUL at {offset}")
+ }
+ Self::PackedPayloadTooLarge { size, max } => {
+ write!(f, "packed payload is too large: {size} > {max}")
+ }
+ }
+ }
+}
+
+impl std::error::Error for RsliMutationError {}
+
+/// Mutable editor for `RsliDocument` that can rebuild lookup tables.
+#[derive(Clone, Debug)]
+pub struct RsliEditor {
+ original_image: Arc<[u8]>,
+ header: RsliHeader,
+ overlay: u32,
+ ao_trailer: Option<[u8; 6]>,
+ entries: Vec<EditableEntry>,
+ dirty: bool,
+}
+
+#[derive(Clone, Debug)]
+struct EditableEntry {
+ meta: EntryMeta,
+ packed: Vec<u8>,
+}
+
/// `RsLi` compatibility switches.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RsliCompatibilityProfile {
@@ -493,6 +558,180 @@ impl RsliDocument {
WriteProfile::Lossless => self.bytes.to_vec(),
}
}
+
+ /// Creates a mutable editor from the parsed document.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`RsliError`] when source payloads cannot be copied from the
+ /// underlying archive image.
+ pub fn editor(&self) -> Result<RsliEditor, RsliError> {
+ let mut entries = Vec::with_capacity(self.records.len());
+ for (id, record) in self.records.iter().enumerate() {
+ let packed = self
+ .packed_slice(EntryId(u32::try_from(id).map_err(|_| RsliError::IntegerOverflow)?)?,
+ record,
+ )?
+ .to_vec();
+ entries.push(EditableEntry {
+ meta: record.meta.clone(),
+ packed,
+ });
+ }
+
+ Ok(RsliEditor {
+ original_image: self.bytes.clone(),
+ header: self.header.clone(),
+ overlay: self.ao_trailer.as_ref().map_or(0, |overlay| overlay.overlay),
+ ao_trailer: self.ao_trailer.as_ref().map(|overlay| overlay.raw),
+ entries,
+ dirty: false,
+ })
+ }
+}
+
+impl RsliEditor {
+ /// Returns editable entries by original directory id.
+ #[must_use]
+ pub fn entry_count(&self) -> usize {
+ self.entries.len()
+ }
+
+ /// Replaces packed payload bytes for an entry.
+ ///
+ /// `unpacked_size` is stored explicitly for compatibility checks and does
+ /// not imply a packing transform.
+ pub fn set_packed_payload(
+ &mut self,
+ id: EntryId,
+ packed: impl Into<Vec<u8>>,
+ unpacked_size: u32,
+ ) -> Result<(), RsliMutationError> {
+ let entry = self.entry_mut(id)?;
+ let packed = packed.into();
+ entry.meta.packed_size = u32::try_from(packed.len()).map_err(|_| {
+ RsliMutationError::PackedPayloadTooLarge {
+ size: packed.len(),
+ max: usize::try_from(u32::MAX).expect("u32 max always fits usize"),
+ }
+ })?;
+ entry.packed = packed;
+ entry.meta.unpacked_size = unpacked_size;
+ self.dirty = true;
+ Ok(())
+ }
+
+ /// Replaces entry packing method in-place.
+ pub fn set_method(&mut self, id: EntryId, method: RsliMethod) -> Result<(), RsliMutationError> {
+ let entry = self.entry_mut(id)?;
+ entry.meta.method = method;
+ self.dirty = true;
+ Ok(())
+ }
+
+ /// Replaces entry name in the fixed 12-byte table field.
+ pub fn set_name(&mut self, id: EntryId, name: &[u8]) -> Result<(), RsliMutationError> {
+ let entry = self.entry_mut(id)?;
+ entry.meta.name_raw = authoring_name_raw(name)?;
+ entry.meta.name = decode_name(c_name_bytes(&entry.meta.name_raw));
+ self.dirty = true;
+ Ok(())
+ }
+
+ /// Encodes the document according to editor state.
+ ///
+ /// For untouched documents returns the original image verbatim. On any
+ /// mutation this method rebuilds the lookup table and rewrites packed entry
+ /// bytes deterministically.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`RsliError`] when offsets, sizes or ids exceed in-memory limits.
+ pub fn encode(&self) -> Result<Vec<u8>, RsliError> {
+ if !self.dirty {
+ return Ok(self.original_image.to_vec());
+ }
+ self.encode_rebuild()
+ }
+
+ fn encode_rebuild(&self) -> Result<Vec<u8>, RsliError> {
+ let mut output = Vec::with_capacity(self.original_image.len());
+
+ let entry_count = u16::try_from(self.entries.len()).map_err(|_| RsliError::IntegerOverflow)?;
+ let table_len = self
+ .entries
+ .len()
+ .checked_mul(32)
+ .ok_or(RsliError::IntegerOverflow)?;
+
+ let mut header = self.header.raw;
+ header[4..6].copy_from_slice(&entry_count.to_le_bytes());
+ output.extend_from_slice(&header);
+
+ let mut sorted = (0..self.entries.len()).collect::<Vec<_>>();
+ sorted.sort_by(|left, right| {
+ cmp_c_string(
+ c_name_bytes(&self.entries[*left].meta.name_raw),
+ c_name_bytes(&self.entries[*right].meta.name_raw),
+ )
+ });
+
+ let mut lookup_map = vec![0i16; self.entries.len()];
+ for (position, original) in sorted.iter().enumerate() {
+ lookup_map[*original] = i16::try_from(position).map_err(|_| RsliError::IntegerOverflow)?;
+ }
+
+ let mut cursor = 32usize
+ .checked_add(table_len)
+ .ok_or(RsliError::IntegerOverflow)?;
+ let mut table_plain = Vec::with_capacity(table_len);
+ for (index, entry) in self.entries.iter().enumerate() {
+ let mut row = [0u8; 32];
+ let name_len = entry.meta.name_raw.len().min(12);
+ row[0..name_len].copy_from_slice(&entry.meta.name_raw[..name_len]);
+
+ row[16..18].copy_from_slice(&i16::try_from(entry.meta.flags)
+ .map_err(|_| RsliError::IntegerOverflow)?
+ .to_le_bytes());
+ row[18..20].copy_from_slice(&lookup_map[index].to_le_bytes());
+ row[20..24].copy_from_slice(&entry.meta.unpacked_size.to_le_bytes());
+
+ let packed_len = u32::try_from(entry.packed.len()).map_err(|_| RsliError::IntegerOverflow)?;
+ let cursor_u32 = u32::try_from(cursor).map_err(|_| RsliError::IntegerOverflow)?;
+ let offset_raw = if self.overlay == 0 {
+ cursor_u32
+ } else {
+ cursor_u32
+ .checked_sub(self.overlay)
+ .ok_or(RsliError::IntegerOverflow)?
+ };
+
+ row[24..28].copy_from_slice(&offset_raw.to_le_bytes());
+ row[28..32].copy_from_slice(&packed_len.to_le_bytes());
+ table_plain.extend_from_slice(&row);
+
+ output.extend_from_slice(&entry.packed);
+ cursor = cursor
+ .checked_add(entry.packed.len())
+ .ok_or(RsliError::IntegerOverflow)?;
+ }
+
+ let seed = self.header.xor_seed & 0xFFFF;
+ let encrypted = xor_stream(&table_plain, seed);
+ output.splice(32..32, encrypted.into_iter());
+
+ if let Some(overlay) = &self.ao_trailer {
+ output.extend_from_slice(overlay);
+ }
+
+ Ok(output)
+ }
+
+ fn entry_mut(&mut self, id: EntryId) -> Result<&mut EditableEntry, RsliMutationError> {
+ self.entries
+ .get_mut(usize::try_from(id.0).map_err(|_| RsliMutationError::EntryNotFound { id })?)
+ .ok_or_else(|| RsliMutationError::EntryNotFound { id })
+ }
}
impl RsliDocument {
@@ -833,6 +1072,23 @@ fn decode_name(name: &[u8]) -> String {
name.iter().map(|byte| char::from(*byte)).collect()
}
+fn authoring_name_raw(name: &[u8]) -> Result<[u8; 12], RsliMutationError> {
+ if name.len() > 12 {
+ return Err(RsliMutationError::AuthoringNameTooLong {
+ len: name.len(),
+ max: 12,
+ });
+ }
+ let mut output = [0u8; 12];
+ for (offset, byte) in name.iter().copied().enumerate() {
+ if byte == 0 {
+ return Err(RsliMutationError::AuthoringNameContainsNul { offset });
+ }
+ output[offset] = byte;
+ }
+ Ok(output)
+}
+
fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len());
&raw[..len]
@@ -1815,6 +2071,85 @@ mod tests {
}
#[test]
+ fn editor_roundtrip_without_mutations_is_identity() {
+ let bytes = synthetic_rsli(
+ &[
+ SyntheticEntry::stored(b"A", 0, b"alpha"),
+ SyntheticEntry::stored(b"B", 1, b"beta"),
+ ],
+ true,
+ 0x7777,
+ None,
+ );
+
+ let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("editable archive");
+ let editor = doc.editor().expect("editor");
+
+ assert_eq!(editor.encode().expect("editor encode"), bytes);
+ }
+
+ #[test]
+ fn editor_can_mutate_names_and_payloads() {
+ let bytes = synthetic_rsli(
+ &[
+ SyntheticEntry::stored(b"A", 0, b"alpha"),
+ SyntheticEntry::stored(b"B", 1, b"beta"),
+ ],
+ true,
+ 0x7778,
+ None,
+ );
+
+ let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive");
+ let mut editor = doc.editor().expect("editor");
+ editor
+ .set_name(EntryId(1), b"ZETA")
+ .expect("edit name");
+ editor
+ .set_packed_payload(EntryId(0), b"repacked-alpha", 13)
+ .expect("edit packed payload");
+ editor
+ .set_method(EntryId(0), RsliMethod::RawDeflate)
+ .expect("edit method");
+
+ let rebuilt = editor.encode().expect("editor encode");
+ let doc = decode(arc(rebuilt), ReadProfile::Strict).expect("repacked archive");
+
+ let renamed = doc.find("ZETA").expect("renamed entry");
+ assert_eq!(
+ doc.load(renamed).expect("renamed payload"),
+ b"beta"
+ );
+ let original = doc
+ .find("A")
+ .or_else(|| doc.find("a"))
+ .expect("original renamed entry fallback");
+ assert_eq!(doc.load(original).expect("updated payload"), b"repacked-alpha");
+ assert_eq!(doc.entries()[original.0 as usize].method, RsliMethod::RawDeflate);
+ }
+
+ #[test]
+ fn editor_rejects_unknown_entry_id_and_invalid_name() {
+ let bytes = synthetic_rsli(
+ &[SyntheticEntry::stored(b"A", 0, b"alpha")],
+ true,
+ 0x7779,
+ None,
+ );
+ let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive");
+ let mut editor = doc.editor().expect("editor");
+
+ assert!(matches!(
+ editor.set_name(EntryId(10), b"BAD"),
+ Err(RsliMutationError::EntryNotFound { id: EntryId(10) })
+ ));
+ assert!(matches!(
+ editor.set_name(EntryId(0), b"TOO_LONG_ENTRY_NAME"),
+ Err(RsliMutationError::AuthoringNameTooLong { .. })
+ ));
+ }
+
+ #[test]
fn generated_supported_methods_decode_expected_bytes() {
let cases = [
(0x000, b"STO".as_slice(), b"ok".as_slice(), b"ok".to_vec()),
diff --git a/crates/fparkan-runtime/Cargo.toml b/crates/fparkan-runtime/Cargo.toml
index 17d95c1..347c713 100644
--- a/crates/fparkan-runtime/Cargo.toml
+++ b/crates/fparkan-runtime/Cargo.toml
@@ -6,15 +6,12 @@ license.workspace = true
repository.workspace = true
[dependencies]
-fparkan-mission-format = { path = "../fparkan-mission-format" }
-fparkan-nres = { path = "../fparkan-nres" }
+fparkan-assets = { path = "../fparkan-assets" }
fparkan-path = { path = "../fparkan-path" }
fparkan-platform = { path = "../fparkan-platform" }
fparkan-prototype = { path = "../fparkan-prototype" }
fparkan-render = { path = "../fparkan-render" }
fparkan-resource = { path = "../fparkan-resource" }
-fparkan-terrain = { path = "../fparkan-terrain" }
-fparkan-terrain-format = { path = "../fparkan-terrain-format" }
fparkan-vfs = { path = "../fparkan-vfs" }
fparkan-world = { path = "../fparkan-world" }
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
index 1fc0137..053d7bd 100644
--- a/crates/fparkan-runtime/src/lib.rs
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -1,19 +1,20 @@
#![forbid(unsafe_code)]
//! Runtime orchestration for headless and rendered modes.
-use fparkan_mission_format::{
- decode_tma, decode_tma_land_path, LpString, MissionDocument, MissionError, TmaProfile,
+use fparkan_assets::{
+ AssetError as AssetPreparationError, AssetManager, MissionAssetPlan,
+ decode_mission_land_path, decode_nres_payload, decode_mission_payload, prepare_terrain_world,
+ derive_mission_land_paths, BuildCategory, MissionDocument, MissionError, MissionTerrainPaths,
+ TerrainFormatError, TerrainPreparationError, TmaProfile, TerrainWorld,
+ NresError,
+ extend_graph_report_with_visual_dependencies,
};
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
use fparkan_prototype::{
- build_prototype_graph_report, extend_graph_report_with_visual_dependencies, EffectivePrototype,
+ build_prototype_graph_report,
PrototypeGraph, PrototypeGraphFailure, PrototypeGraphReport,
};
use fparkan_resource::{resource_name, CachedResourceRepository};
-use fparkan_terrain::TerrainWorld;
-use fparkan_terrain_format::{
- decode_build_dat, decode_land_map, decode_land_msh, BuildCategory, TerrainFormatError,
-};
use fparkan_vfs::{Vfs, VfsError};
use fparkan_world::{
construct_object, new as new_world, register_object, step, InputSnapshot, ObjectDraft,
@@ -21,6 +22,8 @@ use fparkan_world::{
};
use std::sync::Arc;
+pub use fparkan_assets::MissionAssets;
+
/// Engine mode.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EngineMode {
@@ -167,6 +170,8 @@ pub struct LoadedMission {
pub graph_unit_component_count: usize,
/// Mission prototype graph root count.
pub graph_root_count: usize,
+ /// Mission asset plan visual count after dependency preparation.
+ pub asset_visual_count: usize,
/// Expanded prototype requests resolved to effective prototypes.
pub graph_resolved_count: usize,
/// Reached mesh dependency count.
@@ -189,6 +194,14 @@ pub struct LoadedMission {
pub graph_lightmap_request_count: usize,
/// Lightmap Texm entries decoded.
pub graph_lightmap_resolved_count: usize,
+ /// Mission asset plan mesh-backed count after dependency preparation.
+ pub asset_model_count: usize,
+ /// Mission asset plan material count after dependency preparation.
+ pub asset_material_count: usize,
+ /// Mission asset plan texture count after dependency preparation.
+ pub asset_texture_count: usize,
+ /// Mission asset plan lightmap count after dependency preparation.
+ pub asset_lightmap_count: usize,
}
/// Frame result.
@@ -222,7 +235,8 @@ struct LoadedMissionState {
build_categories: Vec<BuildCategory>,
prototype_graph: PrototypeGraph,
prototype_report: PrototypeGraphReport,
- resolved_prototypes: Vec<EffectivePrototype>,
+ mission_assets: MissionAssets,
+ asset_plan: MissionAssetPlan,
}
/// Engine error.
@@ -251,7 +265,7 @@ pub enum EngineError {
/// Resource path.
path: String,
/// Source error.
- source: fparkan_nres::NresError,
+ source: NresError,
},
/// Mission decode error.
Mission {
@@ -268,12 +282,19 @@ pub enum EngineError {
source: TerrainFormatError,
},
/// Terrain runtime build error.
- Terrain(fparkan_terrain::TerrainError),
+ Terrain(fparkan_assets::TerrainError),
/// Prototype graph errors.
PrototypeGraph {
/// Root failures.
failures: Vec<PrototypeGraphFailure>,
},
+ /// Asset preparation errors.
+ AssetPreparation {
+ /// Mission key.
+ mission: String,
+ /// Source error.
+ source: AssetPreparationError,
+ },
/// World error.
World(fparkan_world::WorldError),
/// Scheduler phase order was violated.
@@ -319,6 +340,9 @@ impl std::fmt::Display for EngineError {
Self::PrototypeGraph { failures } => {
write!(f, "mission prototype graph has {} failures", failures.len())
}
+ Self::AssetPreparation { mission, source } => {
+ write!(f, "{mission}: asset preparation failed: {source}")
+ }
Self::World(source) => write!(f, "{source}"),
Self::SchedulerPhaseOrder { previous, current } => write!(
f,
@@ -346,6 +370,7 @@ impl std::error::Error for EngineError {
Self::TerrainFormat { source, .. } => Some(source),
Self::Terrain(source) => Some(source),
Self::World(source) => Some(source),
+ Self::AssetPreparation { source, .. } => Some(source),
Self::MissingVfs
| Self::PrototypeGraph { .. }
| Self::SchedulerPhaseOrder { .. }
@@ -410,44 +435,44 @@ fn load_mission_with_options(
let mission_bytes = read_vfs(&vfs, &mission_path)?;
trace.phases.push(MissionLoadPhase::Map);
- let land_path = decode_tma_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| {
+ let land_path = decode_mission_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| {
EngineError::Mission {
path: mission_path.as_str().to_string(),
source,
}
})?;
- let (land_msh_path, land_map_path) = terrain_paths_from_land_path(&land_path)?;
- let land_msh_nres = decode_nres(&vfs, &land_msh_path)?;
- let land_map_nres = decode_nres(&vfs, &land_map_path)?;
- let land_msh =
- decode_land_msh(&land_msh_nres).map_err(|source| EngineError::TerrainFormat {
+ let MissionTerrainPaths { land_msh: land_msh_path, land_map: land_map_path } =
+ derive_mission_land_paths(&land_path).map_err(|source| EngineError::Path {
+ role: "mission land",
+ value: mission_path.as_str().to_string(),
+ source,
+ })?;
+ let land_msh_nres = decode_nres_payload(read_vfs(&vfs, &land_msh_path)?)
+ .map_err(|source| EngineError::Nres {
path: land_msh_path.as_str().to_string(),
source,
})?;
- let land_map =
- decode_land_map(&land_map_nres).map_err(|source| EngineError::TerrainFormat {
+ let land_map_nres = decode_nres_payload(read_vfs(&vfs, &land_map_path)?)
+ .map_err(|source| EngineError::Nres {
path: land_map_path.as_str().to_string(),
source,
})?;
- let terrain =
- TerrainWorld::from_land_assets(&land_msh, &land_map).map_err(EngineError::Terrain)?;
-
let build_dat_path = normalize_engine_path("BuildDat", "BuildDat.lst")?;
let build_dat = read_vfs(&vfs, &build_dat_path)?;
- let build_categories =
- decode_build_dat(&build_dat).map_err(|source| EngineError::TerrainFormat {
- path: build_dat_path.as_str().to_string(),
- source,
+ let (terrain, build_categories) = prepare_terrain_world(&land_msh_nres, &land_map_nres, &build_dat)
+ .map_err(|source| match source {
+ TerrainPreparationError::Decode(source) => EngineError::TerrainFormat {
+ path: build_dat_path.as_str().to_string(),
+ source,
+ },
+ TerrainPreparationError::Runtime(source) => EngineError::Terrain(source),
})?;
trace.phases.push(MissionLoadPhase::Tma);
let mission =
- decode_tma(mission_bytes, TmaProfile::Strict).map_err(|source| EngineError::Mission {
+ decode_mission_payload(mission_bytes, TmaProfile::Strict).map_err(|source| EngineError::Mission {
path: mission_path.as_str().to_string(),
source,
})?;
- let verified_terrain_paths = terrain_paths(&mission)?;
- debug_assert_eq!(verified_terrain_paths.0.as_str(), land_msh_path.as_str());
- debug_assert_eq!(verified_terrain_paths.1.as_str(), land_map_path.as_str());
trace.transforms = mission
.objects
.iter()
@@ -471,6 +496,7 @@ fn load_mission_with_options(
extend_graph_report_with_visual_dependencies(
&repository,
&mut prototype_report,
+ &prototype_graph,
&resolved_prototypes,
);
if !prototype_report.is_success() {
@@ -478,6 +504,16 @@ fn load_mission_with_options(
failures: prototype_report.failures.clone(),
});
}
+ let mission_assets = AssetManager::new(repository)
+ .prepare_mission_assets(
+ &prototype_graph.root_prototype_request_spans,
+ &resolved_prototypes,
+ )
+ .map_err(|source| EngineError::AssetPreparation {
+ mission: request.key.clone(),
+ source,
+ })?;
+ let mission_asset_plan = mission_assets.to_plan();
trace.phases.push(MissionLoadPhase::Assets);
let mut new_runtime_world = new_world(WorldConfig);
@@ -519,6 +555,7 @@ fn load_mission_with_options(
graph_direct_reference_count: prototype_report.direct_reference_count,
graph_unit_component_count: prototype_report.unit_component_count,
graph_root_count: prototype_report.root_count,
+ asset_visual_count: mission_asset_plan.visual_count,
graph_resolved_count: prototype_report.resolved_count,
graph_mesh_dependency_count: prototype_report.mesh_dependency_count,
graph_failure_count: prototype_report.failures.len(),
@@ -530,6 +567,10 @@ fn load_mission_with_options(
graph_texture_resolved_count: prototype_report.texture_resolved_count,
graph_lightmap_request_count: prototype_report.lightmap_request_count,
graph_lightmap_resolved_count: prototype_report.lightmap_resolved_count,
+ asset_model_count: mission_asset_plan.model_count,
+ asset_material_count: mission_asset_plan.material_count,
+ asset_texture_count: mission_asset_plan.texture_count,
+ asset_lightmap_count: mission_asset_plan.lightmap_count,
};
engine.world = new_runtime_world;
@@ -540,7 +581,8 @@ fn load_mission_with_options(
build_categories,
prototype_graph,
prototype_report,
- resolved_prototypes,
+ mission_assets,
+ asset_plan: mission_asset_plan,
});
Ok((summary, trace))
}
@@ -618,13 +660,16 @@ pub fn loaded_prototype_graph_report(engine: &Engine) -> Option<&PrototypeGraphR
engine.loaded.as_ref().map(|state| &state.prototype_report)
}
-/// Returns resolved effective prototypes for the loaded mission.
+/// Returns the prepared mission asset plan for the loaded mission.
#[must_use]
-pub fn loaded_resolved_prototypes(engine: &Engine) -> Option<&[EffectivePrototype]> {
- engine
- .loaded
- .as_ref()
- .map(|state| state.resolved_prototypes.as_slice())
+pub fn loaded_mission_asset_plan(engine: &Engine) -> Option<&MissionAssetPlan> {
+ engine.loaded.as_ref().map(|state| &state.asset_plan)
+}
+
+/// Returns prepared mission assets for the loaded mission.
+#[must_use]
+pub fn loaded_mission_assets(engine: &Engine) -> Option<&MissionAssets> {
+ engine.loaded.as_ref().map(|state| &state.mission_assets)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -716,49 +761,6 @@ fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, Engi
})
}
-fn decode_nres(
- vfs: &Arc<dyn Vfs>,
- path: &NormalizedPath,
-) -> Result<fparkan_nres::NresDocument, EngineError> {
- let bytes = read_vfs(vfs, path)?;
- fparkan_nres::decode(bytes, fparkan_nres::ReadProfile::Compatible).map_err(|source| {
- EngineError::Nres {
- path: path.as_str().to_string(),
- source,
- }
- })
-}
-
-fn terrain_paths(
- mission: &MissionDocument,
-) -> Result<(NormalizedPath, NormalizedPath), EngineError> {
- terrain_paths_from_land_path(&mission.land_path)
-}
-
-fn terrain_paths_from_land_path(
- land_path: &LpString,
-) -> Result<(NormalizedPath, NormalizedPath), EngineError> {
- let land_path_raw = String::from_utf8_lossy(&land_path.raw).to_string();
- let normalized =
- normalize_relative(&land_path.raw, PathPolicy::StrictLegacy).map_err(|source| {
- EngineError::Path {
- role: "mission land",
- value: land_path_raw.clone(),
- source,
- }
- })?;
- let Some((parent, _stem)) = normalized.as_str().rsplit_once('/') else {
- return Err(EngineError::Path {
- role: "mission land",
- value: normalized.as_str().to_string(),
- source: PathError::Empty,
- });
- };
- let mesh = normalize_engine_path("Land.msh", &format!("{parent}/Land.msh"))?;
- let map = normalize_engine_path("Land.map", &format!("{parent}/Land.map"))?;
- Ok((mesh, map))
-}
-
#[cfg(test)]
mod tests {
use super::*;
diff --git a/crates/fparkan-vfs/src/lib.rs b/crates/fparkan-vfs/src/lib.rs
index a0cafa1..9ca57da 100644
--- a/crates/fparkan-vfs/src/lib.rs
+++ b/crates/fparkan-vfs/src/lib.rs
@@ -8,6 +8,10 @@ use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
+#[cfg(unix)]
+use std::os::unix::fs::MetadataExt;
+#[cfg(windows)]
+use std::os::windows::fs::MetadataExt;
/// VFS metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -110,6 +114,7 @@ impl DirectoryVfs {
struct CachedHostFingerprint {
len: u64,
modified: Option<SystemTime>,
+ identity: Option<u64>,
fingerprint: Sha256Digest,
}
@@ -120,14 +125,23 @@ impl Vfs for DirectoryVfs {
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
let host = self.host_path(path)?;
- if fs::symlink_metadata(&host)
- .map_err(VfsError::Io)?
- .file_type()
- .is_symlink()
+ let pre_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
+ if pre_metadata.file_type().is_symlink() || !pre_metadata.is_file() {
+ return Err(VfsError::Path);
+ }
+ let pre_identity = file_identity(&pre_metadata);
+ let pre_len = pre_metadata.len();
+ let pre_modified = pre_metadata.modified().ok();
+ let bytes = fs::read(&host).map_err(VfsError::Io)?;
+ let post_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
+ if post_metadata.file_type().is_symlink()
+ || !post_metadata.is_file()
+ || post_metadata.len() != pre_len
+ || post_metadata.modified().ok() != pre_modified
+ || file_identity(&post_metadata) != pre_identity
{
return Err(VfsError::Path);
}
- let bytes = fs::read(host).map_err(VfsError::Io)?;
Ok(Arc::from(bytes.into_boxed_slice()))
}
@@ -248,7 +262,11 @@ fn metadata_from_host_file_with_cache(
.map_err(|_| VfsError::Path)?
.get(path)
.cloned()
- .filter(|cached| cached.len == len && cached.modified == modified)
+ .filter(|cached| {
+ cached.len == len
+ && cached.modified == modified
+ && cached.identity == file_identity(metadata)
+ })
{
return Ok(VfsMetadata {
len,
@@ -266,6 +284,7 @@ fn metadata_from_host_file_with_cache(
CachedHostFingerprint {
len,
modified,
+ identity: file_identity(metadata),
fingerprint,
},
);
@@ -275,15 +294,15 @@ fn metadata_from_host_file_with_cache(
/// In-memory VFS.
#[derive(Clone, Debug, Default)]
pub struct MemoryVfs {
- files: BTreeMap<String, Arc<[u8]>>,
- lookup: BTreeMap<Vec<u8>, Vec<String>>,
+ files: BTreeMap<Vec<u8>, Arc<[u8]>>,
+ lookup: BTreeMap<Vec<u8>, Vec<Vec<u8>>>,
}
impl MemoryVfs {
/// Inserts a file.
#[allow(clippy::needless_pass_by_value)]
pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) {
- let path = path.as_str().to_string();
+ let path = path.as_bytes().to_vec();
self.files.insert(path, bytes);
self.rebuild_lookup();
}
@@ -292,7 +311,7 @@ impl MemoryVfs {
self.lookup.clear();
for path in self.files.keys() {
self.lookup
- .entry(ascii_lookup_key(path.as_bytes()).0)
+ .entry(ascii_lookup_key(path).0)
.or_default()
.push(path.clone());
}
@@ -301,20 +320,39 @@ impl MemoryVfs {
}
}
- fn resolve_path(&self, path: &NormalizedPath) -> Result<&str, VfsError> {
- let key = ascii_lookup_key(path.as_str().as_bytes()).0;
+ fn resolve_path(&self, path: &NormalizedPath) -> Result<&[u8], VfsError> {
+ let key = ascii_lookup_key(path.as_bytes()).0;
let matches = self
.lookup
.get(&key)
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
match matches.as_slice() {
- [single] => Ok(single.as_str()),
+ [single] => Ok(single.as_slice()),
[] => Err(VfsError::NotFound(path.as_str().to_string())),
_ => Err(VfsError::Ambiguous(path.as_str().to_string())),
}
}
}
+#[cfg(unix)]
+fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
+ Some((metadata.dev() as u64).rotate_left(32) ^ metadata.ino())
+}
+
+#[cfg(windows)]
+fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
+ Some(
+ (metadata.volume_serial_number() as u64).rotate_left(40)
+ ^ ((metadata.file_index_high() as u64) << 32)
+ ^ metadata.file_index_low() as u64,
+ )
+}
+
+#[cfg(not(any(unix, windows)))]
+fn file_identity(_metadata: &fs::Metadata) -> Option<u64> {
+ None
+}
+
impl Vfs for MemoryVfs {
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
let resolved = self.resolve_path(path)?;
@@ -339,13 +377,9 @@ impl Vfs for MemoryVfs {
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
let mut out = Vec::new();
for (path, bytes) in &self.files {
- if path
- .as_bytes()
- .get(..prefix.as_str().len())
- .is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_str().as_bytes()))
- {
+ if has_segment_boundary_prefix_bytes(path, prefix.as_bytes()) {
let normalized = fparkan_path::normalize_relative(
- path.as_bytes(),
+ path,
fparkan_path::PathPolicy::StrictLegacy,
)
.map_err(|_| VfsError::Path)?;
@@ -362,6 +396,25 @@ impl Vfs for MemoryVfs {
}
}
+fn has_segment_boundary_prefix_bytes(haystack: &[u8], needle: &[u8]) -> bool {
+ if haystack.len() < needle.len() {
+ return false;
+ }
+ if haystack.len() == needle.len() {
+ return haystack
+ .iter()
+ .zip(needle.iter())
+ .all(|(left, right)| left.eq_ignore_ascii_case(right));
+ }
+ if haystack[needle.len()] != b'/' {
+ return false;
+ }
+ haystack[..needle.len()]
+ .iter()
+ .zip(needle.iter())
+ .all(|(left, right)| left.eq_ignore_ascii_case(right))
+}
+
/// Layered VFS with deterministic first-layer precedence.
#[derive(Clone, Default)]
pub struct OverlayVfs {
@@ -508,6 +561,21 @@ mod tests {
}
#[test]
+ fn memory_vfs_list_prefix_is_boundary_safe() {
+ let mut vfs = MemoryVfs::default();
+ let exact = normalize_relative(b"DATA/Land.map", PathPolicy::StrictLegacy).expect("path");
+ let sibling = normalize_relative(b"DATA2/Land.map", PathPolicy::StrictLegacy).expect("path");
+ vfs.insert(exact.clone(), Arc::from(b"exact".as_slice()));
+ vfs.insert(sibling, Arc::from(b"sibling".as_slice()));
+
+ let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
+ let entries = vfs.list(&prefix).expect("list");
+
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].path.as_str(), exact.as_str());
+ }
+
+ #[test]
fn directory_vfs_fingerprint_changes_for_same_length_content() {
let root = unique_test_dir("content-fingerprint");
std::fs::create_dir_all(root.join("DATA")).expect("mkdir");
@@ -590,6 +658,23 @@ mod tests {
}
#[test]
+ fn memory_vfs_distinguishes_non_utf8_path_bytes() {
+ let mut vfs = MemoryVfs::default();
+ let ascii = normalize_relative(b"DATA/normal.bin", PathPolicy::HostCompatible)
+ .expect("ascii path");
+ let binary = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
+ .expect("binary path");
+ vfs.insert(ascii.clone(), Arc::from(b"ascii".as_slice()));
+ vfs.insert(binary.clone(), Arc::from(b"binary".as_slice()));
+
+ let binary_query = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
+ .expect("binary query");
+
+ assert_eq!(vfs.read(&binary_query).expect("read binary").as_ref(), b"binary");
+ assert_eq!(vfs.read(&ascii).expect("read ascii").as_ref(), b"ascii");
+ }
+
+ #[test]
fn overlay_vfs_uses_first_matching_layer() {
let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path");
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");