aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-assets/src
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-22 12:12:27 +0300
committerValentin Popov <valentin@popov.link>2026-06-22 12:13:32 +0300
commitd0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448 (patch)
treea0bd35c3940be62a5b5de1acc2366af377ffd181 /crates/fparkan-assets/src
parent7416fdc7e9a48837fff5056e6dc8d0774e90964b (diff)
downloadfparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.tar.xz
fparkan-d0bdbaa1ed76dfbf3211bb43eee48c49cc4fd448.zip
feat: implement FParkan architecture foundation
Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation.
Diffstat (limited to 'crates/fparkan-assets/src')
-rw-r--r--crates/fparkan-assets/src/lib.rs481
1 files changed, 481 insertions, 0 deletions
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs
new file mode 100644
index 0000000..78ffb0b
--- /dev/null
+++ b/crates/fparkan-assets/src/lib.rs
@@ -0,0 +1,481 @@
+#![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_nres::{decode as decode_nres, ReadProfile};
+use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy, ResourceName};
+use fparkan_prototype::{EffectivePrototype, PrototypeGeometry, PrototypeGraph};
+use fparkan_resource::{ResourceKey, ResourceRepository};
+use fparkan_texm::decode_texm;
+use std::collections::BTreeSet;
+use std::fmt;
+use std::hash::{Hash, Hasher};
+use std::marker::PhantomData;
+use std::sync::Arc;
+
+const TEXTURES_ARCHIVE: &str = "textures.lib";
+const LIGHTMAP_ARCHIVE: &str = "lightmap.lib";
+
+/// Stable typed identifier for a prepared asset.
+#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
+pub struct AssetId<T> {
+ raw: u64,
+ marker: PhantomData<T>,
+}
+
+impl<T> AssetId<T> {
+ /// Creates an asset id from a stable raw value.
+ #[must_use]
+ pub const fn new(raw: u64) -> Self {
+ Self {
+ raw,
+ marker: PhantomData,
+ }
+ }
+
+ /// Returns the stable raw id.
+ #[must_use]
+ pub const fn raw(self) -> u64 {
+ self.raw
+ }
+}
+
+/// CPU-side data needed before a visual can be handed to a renderer.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PreparedVisual {
+ /// Stable id derived from the prototype geometry key.
+ pub id: AssetId<PreparedVisual>,
+ /// Optional mesh resource backing the visual.
+ pub mesh: Option<ResourceKey>,
+ /// Number of validated model nodes.
+ pub model_nodes: usize,
+ /// Number of validated material slots on the model.
+ pub model_slots: usize,
+ /// Number of validated render batches.
+ pub model_batches: usize,
+ /// Number of WEAR material slots resolved through MAT0.
+ pub material_count: usize,
+ /// Number of texture phase requests decoded as TEXM.
+ pub texture_count: usize,
+ /// Number of lightmap requests decoded as TEXM.
+ pub lightmap_count: usize,
+}
+
+/// A transactional mission asset preparation plan.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct MissionAssetPlan {
+ /// Number of visual prototypes in the plan.
+ pub visual_count: usize,
+ /// Number of mesh-backed visuals.
+ pub model_count: usize,
+ /// Number of material slot requests.
+ pub material_count: usize,
+ /// Number of texture phase requests.
+ pub texture_count: usize,
+ /// Number of lightmap requests.
+ pub lightmap_count: usize,
+}
+
+/// Coarse CPU-side asset budgets.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct AssetBudgets {
+ /// Bytes parsed from source resource payloads.
+ pub parsed_bytes: u64,
+}
+
+/// Errors raised while preparing CPU-side assets.
+#[derive(Clone, Debug, Eq, PartialEq)]
+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),
+ /// MSH parsing or validation failed.
+ Msh(String),
+ /// WEAR/MAT0 parsing or resolution failed.
+ Material(String),
+ /// TEXM parsing failed.
+ Texture(String),
+}
+
+impl fmt::Display for AssetError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ 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}"),
+ }
+ }
+}
+
+impl std::error::Error for AssetError {}
+
+/// Port implemented by typed asset loaders.
+pub trait AssetLoader<T> {
+ /// Loads an asset for the given resource key.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AssetError`] when the resource cannot be resolved or decoded.
+ fn load(&self, key: &ResourceKey) -> Result<Arc<T>, AssetError>;
+}
+
+/// Minimal asset manager façade over an immutable resource repository.
+#[derive(Debug)]
+pub struct AssetManager<R> {
+ repository: R,
+}
+
+impl<R> AssetManager<R> {
+ /// Creates a manager backed by the given repository.
+ #[must_use]
+ pub const fn new(repository: R) -> Self {
+ Self { repository }
+ }
+
+ /// Returns the backing repository.
+ #[must_use]
+ pub const fn repository(&self) -> &R {
+ &self.repository
+ }
+}
+
+impl<R: ResourceRepository> AssetManager<R> {
+ /// Prepares one prototype visual using the manager repository.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`AssetError`] if any model, material, texture, or lightmap
+ /// dependency is missing or malformed.
+ pub fn prepare_visual(&self, proto: &EffectivePrototype) -> Result<PreparedVisual, AssetError> {
+ prepare_visual_with_repository(&self.repository, proto)
+ }
+
+ /// 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>(
+ &self,
+ prototypes: impl IntoIterator<Item = &'a EffectivePrototype>,
+ ) -> Result<MissionAssetPlan, AssetError> {
+ build_mission_asset_plan_with_repository(&self.repository, prototypes)
+ }
+}
+
+/// Produces a count-only plan from a prototype graph.
+#[must_use]
+pub fn build_mission_asset_plan(graph: &PrototypeGraph) -> MissionAssetPlan {
+ MissionAssetPlan {
+ visual_count: graph.prototype_requests.len(),
+ ..MissionAssetPlan::default()
+ }
+}
+
+/// Builds a fully validated CPU-side mission asset plan.
+///
+/// # Errors
+///
+/// Returns [`AssetError`] if any reachable visual dependency is missing or
+/// malformed.
+pub fn build_mission_asset_plan_with_repository<'a, R: ResourceRepository>(
+ repository: &R,
+ prototypes: impl IntoIterator<Item = &'a EffectivePrototype>,
+) -> Result<MissionAssetPlan, AssetError> {
+ let mut plan = MissionAssetPlan::default();
+ let mut prepared_visuals = BTreeSet::new();
+
+ for proto in prototypes {
+ let visual_id = stable_visual_id(proto);
+ if !prepared_visuals.insert(visual_id) {
+ continue;
+ }
+ let visual = prepare_visual_with_repository(repository, proto)?;
+ plan.visual_count += 1;
+ if visual.mesh.is_some() {
+ plan.model_count += 1;
+ }
+ plan.material_count += visual.material_count;
+ plan.texture_count += visual.texture_count;
+ plan.lightmap_count += visual.lightmap_count;
+ }
+
+ Ok(plan)
+}
+
+/// Validates a prototype visual without resolving cross-resource dependencies.
+///
+/// This is useful for tests and API callers that only need a stable visual id.
+///
+/// # Errors
+///
+/// Returns [`AssetError`] when the prototype geometry is malformed.
+pub fn prepare_visual(proto: &EffectivePrototype) -> Result<PreparedVisual, AssetError> {
+ let id = stable_visual_id(proto);
+ let mesh = match &proto.geometry {
+ PrototypeGeometry::Mesh(key) => Some(key.clone()),
+ PrototypeGeometry::NonGeometric => None,
+ };
+
+ Ok(PreparedVisual {
+ id: AssetId::new(id),
+ mesh,
+ model_nodes: 0,
+ model_slots: 0,
+ model_batches: 0,
+ material_count: 0,
+ texture_count: 0,
+ lightmap_count: 0,
+ })
+}
+
+/// Prepares one visual and validates all CPU-side resource dependencies.
+///
+/// # Errors
+///
+/// Returns [`AssetError`] if the model, WEAR table, MAT0 materials, texture
+/// phases, or lightmaps cannot be resolved and decoded.
+pub fn prepare_visual_with_repository<R: ResourceRepository>(
+ repository: &R,
+ proto: &EffectivePrototype,
+) -> Result<PreparedVisual, AssetError> {
+ let PrototypeGeometry::Mesh(mesh_key) = &proto.geometry else {
+ return prepare_visual(proto);
+ };
+
+ let nres = decode_nres(
+ 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()))?;
+
+ let wear_name = sibling_name(mesh_key, "wea")?;
+ let wear_key = ResourceKey {
+ archive: mesh_key.archive.clone(),
+ 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 mut material_count = 0;
+ 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())
+ })?;
+ let material = resolve_material(repository, &wear, material_index)
+ .map_err(|err| AssetError::Material(err.to_string()))?;
+ material_count += 1;
+
+ for texture in material.document.texture_requests() {
+ resolve_texm(repository, &texture, &[TEXTURES_ARCHIVE, LIGHTMAP_ARCHIVE])?;
+ texture_count += 1;
+ }
+ }
+
+ for lightmap in &wear.lightmaps {
+ resolve_texm(
+ repository,
+ &lightmap.lightmap,
+ &[LIGHTMAP_ARCHIVE, TEXTURES_ARCHIVE],
+ )?;
+ lightmap_count += 1;
+ }
+
+ Ok(PreparedVisual {
+ id: AssetId::new(stable_visual_id(proto)),
+ mesh: Some(mesh_key.clone()),
+ model_nodes: model.node_count,
+ model_slots: model.slots.len(),
+ model_batches: model.batches.len(),
+ material_count,
+ texture_count,
+ lightmap_count,
+ })
+}
+
+fn read_key<R: ResourceRepository>(
+ repository: &R,
+ key: &ResourceKey,
+ label: Option<&str>,
+) -> Result<Arc<[u8]>, AssetError> {
+ let handle = repository
+ .open_archive(&key.archive)
+ .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))
+ .and_then(|archive| {
+ repository
+ .find(archive, &key.name)
+ .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))
+ })?
+ .ok_or_else(|| AssetError::MissingDependency(format!("{label:?} {key:?}")))?;
+ let bytes = repository
+ .read(handle)
+ .map_err(|err| AssetError::Resource(format!("{label:?} {key:?}: {err}")))?;
+ Ok(Arc::from(bytes.into_owned()))
+}
+
+fn resolve_texm<R: ResourceRepository>(
+ repository: &R,
+ name: &ResourceName,
+ archives: &[&str],
+) -> Result<(), AssetError> {
+ for archive in archives {
+ let key = ResourceKey {
+ archive: parse_path(archive)?,
+ name: name.clone(),
+ type_id: None,
+ };
+ match read_key(repository, &key, Some("texm")) {
+ Ok(bytes) => {
+ decode_texm(bytes).map_err(|err| AssetError::Texture(err.to_string()))?;
+ return Ok(());
+ }
+ Err(AssetError::MissingDependency(_) | AssetError::Resource(_)) => {}
+ Err(err) => return Err(err),
+ }
+ }
+
+ Err(AssetError::MissingDependency(format!("{name:?}")))
+}
+
+fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, AssetError> {
+ let dot = key
+ .name
+ .0
+ .iter()
+ .rposition(|byte| *byte == b'.')
+ .ok_or_else(|| {
+ AssetError::InvalidPrototype(format!("resource name has no extension: {:?}", key.name))
+ })?;
+ let mut name = key.name.0[..dot].to_vec();
+ name.push(b'.');
+ name.extend_from_slice(extension.as_bytes());
+ Ok(ResourceName(name))
+}
+
+fn stable_visual_id(proto: &EffectivePrototype) -> u64 {
+ let mut hasher = StableHasher::default();
+ match &proto.geometry {
+ PrototypeGeometry::Mesh(key) => {
+ 1_u8.hash(&mut hasher);
+ key.archive.as_str().hash(&mut hasher);
+ key.name.0.hash(&mut hasher);
+ key.type_id.hash(&mut hasher);
+ }
+ PrototypeGeometry::NonGeometric => {
+ 0_u8.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}")))
+}
+
+#[derive(Default)]
+struct StableHasher(u64);
+
+impl Hasher for StableHasher {
+ fn finish(&self) -> u64 {
+ self.0
+ }
+
+ fn write(&mut self, bytes: &[u8]) {
+ let mut value = if self.0 == 0 {
+ 0xcbf2_9ce4_8422_2325
+ } else {
+ self.0
+ };
+ for byte in bytes {
+ value ^= u64::from(*byte);
+ value = value.wrapping_mul(0x0000_0100_0000_01b3);
+ }
+ self.0 = value;
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use fparkan_prototype::build_prototype_graph;
+ use fparkan_resource::{resource_name, CachedResourceRepository};
+ use fparkan_vfs::{DirectoryVfs, Vfs};
+ use std::path::PathBuf;
+
+ #[test]
+ fn count_only_plan_uses_graph_requests() {
+ let graph = PrototypeGraph::default();
+
+ let plan = build_mission_asset_plan(&graph);
+
+ assert_eq!(plan.visual_count, 0);
+ assert_eq!(plan.model_count, 0);
+ }
+
+ #[test]
+ fn prepares_real_unit_asset_plan() {
+ let root = fixture_root("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
+ let repository = CachedResourceRepository::new(Arc::clone(&vfs));
+ let roots = [resource_name(b"UNITS/AUTO/swlklas.dat")];
+
+ let (graph, prototypes) =
+ build_prototype_graph(&repository, vfs.as_ref(), &roots).expect("prototype graph");
+ let count_only = build_mission_asset_plan(&graph);
+ let plan = build_mission_asset_plan_with_repository(&repository, &prototypes)
+ .expect("asset preparation");
+
+ assert_eq!(count_only.visual_count, 12);
+ assert_eq!(prototypes.len(), 12);
+ assert_eq!(plan.visual_count, 11);
+ assert_eq!(plan.model_count, 11);
+ assert_eq!(plan.material_count, 62);
+ assert_eq!(plan.texture_count, 77);
+ assert_eq!(plan.lightmap_count, 0);
+ }
+
+ #[test]
+ fn repository_plan_deduplicates_duplicate_visuals_but_graph_preserves_requests() {
+ let root = fixture_root("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
+ let repository = CachedResourceRepository::new(Arc::clone(&vfs));
+ let roots = [
+ resource_name(b"UNITS/AUTO/swlklas.dat"),
+ resource_name(b"UNITS/AUTO/swlklas.dat"),
+ ];
+
+ let (graph, prototypes) =
+ build_prototype_graph(&repository, vfs.as_ref(), &roots).expect("prototype graph");
+ let count_only = build_mission_asset_plan(&graph);
+ let plan = build_mission_asset_plan_with_repository(&repository, &prototypes)
+ .expect("asset preparation");
+
+ assert_eq!(graph.roots.len(), 2);
+ assert_eq!(count_only.visual_count, 24);
+ assert_eq!(prototypes.len(), 24);
+ assert_eq!(plan.visual_count, 11);
+ assert_eq!(plan.model_count, 11);
+ assert_eq!(plan.material_count, 62);
+ assert_eq!(plan.texture_count, 77);
+ }
+
+ fn fixture_root(part: &str) -> PathBuf {
+ PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .join("../..")
+ .join("testdata")
+ .join(part)
+ }
+}