aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-07-03 16:11:49 +0300
committerValentin Popov <valentin@popov.link>2026-07-03 16:11:49 +0300
commitb2e193f23461cfa0e687ed96001edf034d64348f (patch)
treec59139f7484c0bc1d8ae2ce6295d2d8ad199c3bf
parent66804e0bd2821e12ba0ed41de1133cd2b48febfa (diff)
downloadfparkan-b2e193f23461cfa0e687ed96001edf034d64348f.tar.xz
fparkan-b2e193f23461cfa0e687ed96001edf034d64348f.zip
feat(stage2): persist prepared mission assets and reports
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke.rs1
-rw-r--r--apps/fparkan-cli/src/main.rs206
-rw-r--r--crates/fparkan-assets/src/lib.rs351
-rw-r--r--crates/fparkan-runtime/src/lib.rs75
4 files changed, 509 insertions, 124 deletions
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
index e92a7a0..528f55d 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
@@ -17,6 +17,7 @@ use super::{
use crate::policy::KHR_PORTABILITY_SUBSET_EXTENSION;
use crate::shader_manifest::{triangle_shader_manifest, validate_shader_manifest};
+#[cfg(test)]
fn take_runtime_owners_in_dependency_order<Instance, Validation, Surface, Device, Swapchain>(
instance: &mut Option<Instance>,
validation: &mut Option<Validation>,
diff --git a/apps/fparkan-cli/src/main.rs b/apps/fparkan-cli/src/main.rs
index b1d3f36..e96f787 100644
--- a/apps/fparkan-cli/src/main.rs
+++ b/apps/fparkan-cli/src/main.rs
@@ -32,11 +32,12 @@ use fparkan_runtime::{
};
use fparkan_vfs::DirectoryVfs;
use serde::Serialize;
-use std::fmt::Write;
use std::path::PathBuf;
use std::sync::Arc;
const ARCHIVE_INSPECT_SCHEMA: &str = "fparkan-archive-inspect-v1";
+const PROTOTYPE_INSPECT_SCHEMA: &str = "fparkan-prototype-inspect-v1";
+const MISSION_GRAPH_SCHEMA: &str = "fparkan-mission-graph-v1";
#[derive(Serialize)]
struct ArchiveInspectOutput<'a> {
@@ -48,6 +49,63 @@ struct ArchiveInspectOutput<'a> {
lookup_order_valid: Option<bool>,
}
+#[derive(Serialize)]
+struct PrototypeInspectOutput {
+ schema_version: &'static str,
+ key: String,
+ roots: usize,
+ node_count: usize,
+ edge_count: usize,
+ prototype_requests: usize,
+ resolved: usize,
+ unit_references: usize,
+ unit_components: usize,
+ direct_references: usize,
+ wear_requests: usize,
+ wear: usize,
+ materials: usize,
+ textures: usize,
+ lightmaps: usize,
+ is_success: bool,
+ failures: Vec<GraphFailureOutput>,
+}
+
+#[derive(Serialize)]
+struct MissionGraphOutput {
+ schema_version: &'static str,
+ mission: String,
+ objects: usize,
+ paths: usize,
+ clans: usize,
+ extras: usize,
+ roots: usize,
+ node_count: usize,
+ edge_count: usize,
+ direct_references: usize,
+ unit_references: usize,
+ unit_components: usize,
+ prototype_requests: usize,
+ wear_requests: usize,
+ wear: usize,
+ materials: usize,
+ textures: usize,
+ lightmaps: usize,
+ is_success: bool,
+ failures: usize,
+}
+
+#[derive(Serialize)]
+struct GraphFailureOutput {
+ root_index: usize,
+ edge: &'static str,
+ requiredness: &'static str,
+ message: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ archive: Option<String>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ resource: Option<String>,
+}
+
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let result = run(&args);
@@ -168,7 +226,10 @@ fn inspect_prototype(args: &[String]) -> Result<(), String> {
let (mut graph, resolved, mut report) =
build_prototype_graph_report(&repository, vfs.as_ref(), &roots);
extend_graph_report_with_visual_dependencies(&repository, &mut report, &mut graph, &resolved);
- println!("{}", prototype_inspect_json(&key, &graph, &report));
+ println!(
+ "{}",
+ prototype_inspect_json(&key, &graph, &report).map_err(|err| err.to_string())?
+ );
Ok(())
}
@@ -176,22 +237,26 @@ fn prototype_inspect_json(
key: &str,
graph: &fparkan_prototype::PrototypeGraph,
report: &fparkan_prototype::PrototypeGraphReport,
-) -> String {
- format!(
- "{{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":{},\"roots\":{},\"prototype_requests\":{},\"resolved\":{},\"unit_references\":{},\"unit_components\":{},\"direct_references\":{},\"wear\":{},\"materials\":{},\"textures\":{},\"lightmaps\":{},\"failures\":{}}}",
- json_string(key),
- report.root_count,
- graph.prototype_requests.len(),
- report.resolved_count,
- report.unit_reference_count,
- report.unit_component_count,
- report.direct_reference_count,
- report.wear_resolved_count,
- report.material_resolved_count,
- report.texture_resolved_count,
- report.lightmap_resolved_count,
- report.failures.len()
- )
+) -> Result<String, serde_json::Error> {
+ serde_json::to_string(&PrototypeInspectOutput {
+ schema_version: PROTOTYPE_INSPECT_SCHEMA,
+ key: key.to_string(),
+ roots: report.root_count,
+ node_count: graph.nodes.len(),
+ edge_count: graph.edges.len(),
+ prototype_requests: graph.prototype_requests.len(),
+ resolved: report.resolved_count,
+ unit_references: report.unit_reference_count,
+ unit_components: report.unit_component_count,
+ direct_references: report.direct_reference_count,
+ wear_requests: report.wear_request_count,
+ wear: report.wear_resolved_count,
+ materials: report.material_resolved_count,
+ textures: report.texture_resolved_count,
+ lightmaps: report.lightmap_resolved_count,
+ is_success: report.is_success(),
+ failures: report.failures.iter().take(16).map(graph_failure_output).collect(),
+ })
}
fn graph_mission(args: &[String]) -> Result<(), String> {
@@ -213,22 +278,29 @@ fn graph_mission(args: &[String]) -> Result<(), String> {
)
.map_err(|err| err.to_string())?;
println!(
- "{{\"schema_version\":\"fparkan-mission-graph-v1\",\"mission\":{},\"objects\":{},\"paths\":{},\"clans\":{},\"extras\":{},\"roots\":{},\"direct_references\":{},\"unit_references\":{},\"unit_components\":{},\"prototype_requests\":{},\"wear\":{},\"materials\":{},\"textures\":{},\"lightmaps\":{},\"failures\":{}}}",
- json_string(&mission),
- loaded.object_count,
- loaded.path_count,
- loaded.clan_count,
- loaded.extra_count,
- loaded.graph_root_count,
- loaded.graph_direct_reference_count,
- loaded.graph_unit_reference_count,
- loaded.graph_unit_component_count,
- loaded.graph_resolved_count,
- loaded.graph_wear_resolved_count,
- loaded.graph_material_resolved_count,
- loaded.graph_texture_resolved_count,
- loaded.graph_lightmap_resolved_count,
- loaded.graph_failure_count
+ "{}",
+ serialize_json(&MissionGraphOutput {
+ schema_version: MISSION_GRAPH_SCHEMA,
+ mission: mission.clone(),
+ objects: loaded.object_count,
+ paths: loaded.path_count,
+ clans: loaded.clan_count,
+ extras: loaded.extra_count,
+ roots: loaded.graph_root_count,
+ node_count: loaded.graph_node_count,
+ edge_count: loaded.graph_edge_count,
+ direct_references: loaded.graph_direct_reference_count,
+ unit_references: loaded.graph_unit_reference_count,
+ unit_components: loaded.graph_unit_component_count,
+ prototype_requests: loaded.graph_resolved_count,
+ wear_requests: loaded.graph_wear_request_count,
+ wear: loaded.graph_wear_resolved_count,
+ materials: loaded.graph_material_resolved_count,
+ textures: loaded.graph_texture_resolved_count,
+ lightmaps: loaded.graph_lightmap_resolved_count,
+ is_success: loaded.graph_failure_count == 0,
+ failures: loaded.graph_failure_count,
+ })?
);
Ok(())
}
@@ -290,28 +362,54 @@ fn parse_archive_path(args: &[String]) -> Result<PathBuf, String> {
}
}
-fn json_string(value: &str) -> String {
- let mut out = String::with_capacity(value.len() + 2);
- out.push('"');
- 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"),
- c if c.is_control() => {
- let _ = write!(out, "\\u{:04x}", c as u32);
- }
- c => out.push(c),
+fn serialize_json<T: Serialize>(value: &T) -> Result<String, String> {
+ serde_json::to_string(value).map_err(|err| err.to_string())
+}
+
+fn graph_failure_output(
+ failure: &fparkan_prototype::PrototypeGraphFailure,
+) -> GraphFailureOutput {
+ GraphFailureOutput {
+ root_index: failure.root_index,
+ edge: prototype_graph_edge_label(failure.edge),
+ requiredness: prototype_graph_requiredness_label(failure.requiredness),
+ message: failure.message.clone(),
+ archive: failure
+ .provenance
+ .as_ref()
+ .and_then(|provenance| provenance.archive.clone()),
+ resource: failure.provenance.as_ref().and_then(|provenance| {
+ provenance
+ .resource
+ .as_ref()
+ .map(|raw| String::from_utf8_lossy(raw).into_owned())
+ }),
+ }
+}
+
+fn prototype_graph_edge_label(edge: fparkan_prototype::PrototypeGraphEdge) -> &'static str {
+ match edge {
+ fparkan_prototype::PrototypeGraphEdge::MissionToUnitDat => "mission_to_unit_dat",
+ fparkan_prototype::PrototypeGraphEdge::MissionToObjectsRegistry => {
+ "mission_to_objects_registry"
}
+ fparkan_prototype::PrototypeGraphEdge::UnitDatToComponent => "unit_dat_to_component",
+ fparkan_prototype::PrototypeGraphEdge::PrototypeToMesh => "prototype_to_mesh",
+ fparkan_prototype::PrototypeGraphEdge::MeshToWear => "mesh_to_wear",
+ fparkan_prototype::PrototypeGraphEdge::WearToMaterial => "wear_to_material",
+ fparkan_prototype::PrototypeGraphEdge::MaterialToTexture => "material_to_texture",
+ fparkan_prototype::PrototypeGraphEdge::WearToLightmap => "wear_to_lightmap",
}
- out.push('"');
- out
}
-fn serialize_json<T: Serialize>(value: &T) -> Result<String, String> {
- serde_json::to_string(value).map_err(|err| err.to_string())
+fn prototype_graph_requiredness_label(
+ requiredness: fparkan_prototype::PrototypeGraphRequiredness,
+) -> &'static str {
+ match requiredness {
+ fparkan_prototype::PrototypeGraphRequiredness::Required => "required",
+ fparkan_prototype::PrototypeGraphRequiredness::Optional => "optional",
+ fparkan_prototype::PrototypeGraphRequiredness::Fallback => "fallback",
+ }
}
fn usage() -> String {
@@ -367,11 +465,11 @@ mod tests {
..fparkan_prototype::PrototypeGraphReport::default()
};
- let json = prototype_inspect_json("root", &graph, &report);
+ let json = prototype_inspect_json("root", &graph, &report).expect("serialize");
assert_eq!(
json,
- "{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":\"root\",\"roots\":1,\"prototype_requests\":1,\"resolved\":1,\"unit_references\":0,\"unit_components\":0,\"direct_references\":1,\"wear\":0,\"materials\":0,\"textures\":0,\"lightmaps\":0,\"failures\":0}"
+ "{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":\"root\",\"roots\":1,\"node_count\":0,\"edge_count\":0,\"prototype_requests\":1,\"resolved\":1,\"unit_references\":0,\"unit_components\":0,\"direct_references\":1,\"wear_requests\":0,\"wear\":0,\"materials\":0,\"textures\":0,\"lightmaps\":0,\"is_success\":true,\"failures\":[]}"
);
}
}
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs
index ddf5e65..460b3c6 100644
--- a/crates/fparkan-assets/src/lib.rs
+++ b/crates/fparkan-assets/src/lib.rs
@@ -20,10 +20,12 @@
)]
//! Asset manager ports and transactional preparation models.
-use fparkan_material::{decode_wear, resolve_material, MaterialError, MAT0_KIND, WEAR_KIND};
+use fparkan_material::{
+ decode_wear, resolve_material, Mat0Document, MaterialError, WearTable, MAT0_KIND, WEAR_KIND,
+};
use fparkan_mission_format::{decode_tma, decode_tma_land_path};
pub use fparkan_mission_format::{LpString, MissionDocument, MissionError, TmaProfile};
-use fparkan_msh::{decode_msh, validate_msh, MshError};
+use fparkan_msh::{decode_msh, validate_msh, ModelAsset, MshError};
use fparkan_nres::{decode as decode_nres, ReadProfile};
pub use fparkan_nres::{NresDocument, NresError};
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy, ResourceName};
@@ -36,7 +38,7 @@ use fparkan_resource::{ResourceError, ResourceKey, ResourceRepository};
pub use fparkan_terrain::{TerrainError, TerrainWorld};
use fparkan_terrain_format::{decode_build_dat, decode_land_map, decode_land_msh};
pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError};
-use fparkan_texm::{decode_texm, TexmError};
+use fparkan_texm::{decode_texm, TexmDocument, TexmError};
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::hash::{Hash, Hasher};
@@ -217,6 +219,10 @@ pub struct PreparedVisual {
pub id: AssetId<PreparedVisual>,
/// Optional mesh resource backing the visual.
pub mesh: Option<ResourceKey>,
+ /// Prepared model backing the visual, when geometry is present.
+ pub model_id: Option<AssetId<PreparedModel>>,
+ /// Prepared WEAR table backing the visual, when geometry is present.
+ pub wear_id: Option<AssetId<PreparedWear>>,
/// Number of validated model nodes.
pub model_nodes: usize,
/// Number of validated material slots on the model.
@@ -227,19 +233,77 @@ pub struct PreparedVisual {
pub material_count: usize,
/// Typed material IDs available from the resolved visual.
pub material_ids: Vec<AssetId<PreparedMaterial>>,
+ /// Typed texture IDs available from the resolved visual.
+ pub texture_ids: Vec<AssetId<PreparedTexture>>,
+ /// Typed lightmap IDs available from the resolved visual.
+ pub lightmap_ids: Vec<AssetId<PreparedTexture>>,
/// 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 validated model ready for a renderer upload path.
+#[derive(Clone, Debug, PartialEq)]
+pub struct PreparedModel {
+ /// Stable id derived from the visual source.
+ pub id: AssetId<PreparedModel>,
+ /// Source mesh resource.
+ pub source: ResourceKey,
+ /// Fully validated model payload.
+ pub validated: ModelAsset,
+ /// Mesh dependencies that led to this prepared model.
+ pub mesh_dependencies: Vec<ResourceKey>,
+}
+
+/// CPU-side WEAR table resolved for a visual.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PreparedWear {
+ /// Stable id derived from the visual source.
+ pub id: AssetId<PreparedWear>,
+ /// Source WEAR resource.
+ pub source: ResourceKey,
+ /// Decoded WEAR table.
+ pub table: WearTable,
+}
+
/// 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.
+ /// Source MAT0 resource.
+ pub source: ResourceKey,
+ /// Parsed material key retained for compatibility with older callers.
pub name: ResourceName,
+ /// Decoded MAT0 payload.
+ pub mat0: Mat0Document,
+ /// Texture requests declared by MAT0 phases.
+ pub texture_requests: Vec<ResourceName>,
+ /// Lightmap requests associated with the owning WEAR table.
+ pub lightmap_requests: Vec<ResourceName>,
+}
+
+/// Texture usage role inside a prepared visual.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum PreparedTextureUsage {
+ /// Standard diffuse/albedo texture.
+ Diffuse,
+ /// Lightmap texture.
+ Lightmap,
+}
+
+/// CPU-side TEXM texture ready for a renderer upload path.
+#[derive(Clone, Debug)]
+pub struct PreparedTexture {
+ /// Stable id derived from the texture source and usage.
+ pub id: AssetId<PreparedTexture>,
+ /// Source TEXM resource.
+ pub source: ResourceKey,
+ /// Decoded TEXM payload.
+ pub texm: TexmDocument,
+ /// Usage role in the prepared visual.
+ pub usage: PreparedTextureUsage,
}
impl PreparedVisual {
@@ -251,8 +315,16 @@ impl PreparedVisual {
}
/// Immutable prepared mission assets for rendering and game setup.
-#[derive(Clone, Debug, Default, Eq, PartialEq)]
+#[derive(Clone, Debug, Default)]
pub struct MissionAssets {
+ /// Mesh-backed models prepared for reachable visuals.
+ pub models: Vec<PreparedModel>,
+ /// WEAR tables prepared for reachable visuals.
+ pub wears: Vec<PreparedWear>,
+ /// MAT0 materials prepared for reachable visuals.
+ pub materials: Vec<PreparedMaterial>,
+ /// TEXM textures prepared for reachable visuals.
+ pub textures: Vec<PreparedTexture>,
/// Visuals prepared for all reachable prototype requests.
pub visuals: Vec<PreparedVisual>,
/// Visual ids available for each mission object index.
@@ -286,32 +358,41 @@ impl MissionAssets {
self.visuals.iter().find(|visual| visual.id == id)
}
+ /// Finds a prepared model by id.
+ #[must_use]
+ pub fn model_by_id(&self, id: AssetId<PreparedModel>) -> Option<&PreparedModel> {
+ self.models.iter().find(|model| model.id == id)
+ }
+
+ /// Finds a prepared material by id.
+ #[must_use]
+ pub fn material_by_id(&self, id: AssetId<PreparedMaterial>) -> Option<&PreparedMaterial> {
+ self.materials.iter().find(|material| material.id == id)
+ }
+
+ /// Finds a prepared texture by id.
+ #[must_use]
+ pub fn texture_by_id(&self, id: AssetId<PreparedTexture>) -> Option<&PreparedTexture> {
+ self.textures.iter().find(|texture| texture.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,
+ visual_count: self.visuals.len(),
+ model_count: self.models.len(),
+ material_count: self.materials.len(),
+ texture_count: self
+ .textures
+ .iter()
+ .filter(|texture| texture.usage == PreparedTextureUsage::Diffuse)
+ .count(),
+ lightmap_count: self
+ .textures
+ .iter()
+ .filter(|texture| texture.usage == PreparedTextureUsage::Lightmap)
+ .count(),
}
}
}
@@ -510,7 +591,14 @@ pub fn prepare_mission_assets_with_repository<R: ResourceRepository>(
}
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 model_ids = HashSet::new();
+ let mut wear_ids = HashSet::new();
+ let mut material_ids = HashSet::new();
+ let mut texture_ids = HashSet::new();
+ let mut models = Vec::new();
+ let mut wears = Vec::new();
+ let mut materials = Vec::new();
+ let mut textures = Vec::new();
let mut visuals = Vec::new();
let mut prototype_visual_ids = Vec::with_capacity(prototypes.len());
@@ -526,18 +614,34 @@ pub fn prepare_mission_assets_with_repository<R: ResourceRepository>(
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 {
+ let bundle = prepare_visual_with_repository_internal(repository, proto)?;
+ if bundle.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);
+ if let Some(model) = bundle.model {
+ if model_ids.insert(model.id) {
+ models.push(model);
+ }
+ }
+ if let Some(wear) = bundle.wear {
+ if wear_ids.insert(wear.id) {
+ wears.push(wear);
+ }
+ }
+ for material in bundle.materials {
+ if material_ids.insert(material.id) {
+ materials.push(material);
+ }
+ }
+ for texture in bundle.textures {
+ if texture_ids.insert(texture.id) {
+ textures.push(texture);
+ }
+ }
+ visuals.push(bundle.visual);
}
}
prototype_visual_ids.push(visual_id);
@@ -562,6 +666,10 @@ pub fn prepare_mission_assets_with_repository<R: ResourceRepository>(
}
Ok(MissionAssets {
+ models,
+ wears,
+ materials,
+ textures,
visuals,
object_visuals,
})
@@ -919,11 +1027,15 @@ pub fn prepare_visual(proto: &EffectivePrototype) -> Result<PreparedVisual, Asse
Ok(PreparedVisual {
id: AssetId::new(id),
mesh,
+ model_id: None,
+ wear_id: None,
model_nodes: 0,
model_slots: 0,
model_batches: 0,
material_count: 0,
material_ids: Vec::new(),
+ texture_ids: Vec::new(),
+ lightmap_ids: Vec::new(),
texture_count: 0,
lightmap_count: 0,
})
@@ -939,16 +1051,29 @@ pub fn prepare_visual_with_repository<R: ResourceRepository>(
repository: &R,
proto: &EffectivePrototype,
) -> Result<PreparedVisual, AssetError> {
- prepare_visual_with_repository_internal(repository, proto, None)
+ Ok(prepare_visual_with_repository_internal(repository, proto)?.visual)
+}
+
+struct PreparedVisualBundle {
+ visual: PreparedVisual,
+ model: Option<PreparedModel>,
+ wear: Option<PreparedWear>,
+ materials: Vec<PreparedMaterial>,
+ textures: Vec<PreparedTexture>,
}
fn prepare_visual_with_repository_internal<R: ResourceRepository>(
repository: &R,
proto: &EffectivePrototype,
- mut material_signature_by_id: Option<&mut HashMap<AssetId<PreparedMaterial>, Vec<u8>>>,
-) -> Result<PreparedVisual, AssetError> {
+) -> Result<PreparedVisualBundle, AssetError> {
let PrototypeGeometry::Mesh(mesh_key) = &proto.geometry else {
- return prepare_visual(proto);
+ return Ok(PreparedVisualBundle {
+ visual: prepare_visual(proto)?,
+ model: None,
+ wear: None,
+ materials: Vec::new(),
+ textures: Vec::new(),
+ });
};
let nres = decode_nres(
@@ -958,6 +1083,13 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
.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 model_id = AssetId::new(stable_model_id(proto));
+ let prepared_model = PreparedModel {
+ id: model_id,
+ source: mesh_key.clone(),
+ validated: model.clone(),
+ mesh_dependencies: proto.dependencies.clone(),
+ };
let wear_name = sibling_name(mesh_key, "wea")?;
let wear_key = ResourceKey {
@@ -967,11 +1099,26 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
};
let wear = decode_wear(&read_key(repository, &wear_key, Some("wear"))?)
.map_err(AssetError::Material)?;
+ let wear_id = AssetId::new(stable_wear_id(proto));
+ let prepared_wear = PreparedWear {
+ id: wear_id,
+ source: wear_key.clone(),
+ table: wear.clone(),
+ };
let mut material_count = 0;
let mut material_ids = Vec::with_capacity(wear.entries.len());
+ let mut prepared_materials = Vec::with_capacity(wear.entries.len());
+ let mut prepared_textures = Vec::new();
+ let mut texture_ids = Vec::new();
+ let mut lightmap_ids = Vec::new();
let mut texture_count = 0;
let mut lightmap_count = 0;
+ let lightmap_requests: Vec<_> = wear
+ .lightmaps
+ .iter()
+ .map(|lightmap| lightmap.lightmap.clone())
+ .collect();
for material_index in 0..wear.entries.len() {
let material_index = u16::try_from(material_index).map_err(|_| {
AssetError::InvalidPrototype("material index does not fit archive format".to_string())
@@ -981,42 +1128,64 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
material_count += 1;
let material_id = AssetId::new(stable_material_id(proto, material_index, &material.name));
material_ids.push(material_id);
- if let Some(registry) = material_signature_by_id.as_deref_mut() {
- 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)?;
+ let material_key = ResourceKey {
+ archive: parse_path("material.lib")?,
+ name: material.name.clone(),
+ type_id: Some(MAT0_KIND),
+ };
+ let texture_requests = material.document.texture_requests();
+ prepared_materials.push(PreparedMaterial {
+ id: material_id,
+ source: material_key,
+ name: material.name.clone(),
+ mat0: material.document.clone(),
+ texture_requests: texture_requests.clone(),
+ lightmap_requests: lightmap_requests.clone(),
+ });
+
+ for texture in texture_requests {
+ let prepared_texture = prepare_texture(
+ repository,
+ &texture,
+ PreparedTextureUsage::Diffuse,
+ )?;
+ texture_ids.push(prepared_texture.id);
+ prepared_textures.push(prepared_texture);
texture_count += 1;
}
}
for lightmap in &wear.lightmaps {
- resolve_lightmap(repository, &lightmap.lightmap)?;
+ let prepared_lightmap = prepare_texture(
+ repository,
+ &lightmap.lightmap,
+ PreparedTextureUsage::Lightmap,
+ )?;
+ lightmap_ids.push(prepared_lightmap.id);
+ prepared_textures.push(prepared_lightmap);
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,
- material_ids,
- texture_count,
- lightmap_count,
+ Ok(PreparedVisualBundle {
+ visual: PreparedVisual {
+ id: AssetId::new(stable_visual_id(proto)),
+ mesh: Some(mesh_key.clone()),
+ model_id: Some(model_id),
+ wear_id: Some(wear_id),
+ model_nodes: model.node_count,
+ model_slots: model.slots.len(),
+ model_batches: model.batches.len(),
+ material_count,
+ material_ids,
+ texture_ids,
+ lightmap_ids,
+ texture_count,
+ lightmap_count,
+ },
+ model: Some(prepared_model),
+ wear: Some(prepared_wear),
+ materials: prepared_materials,
+ textures: prepared_textures,
})
}
@@ -1248,6 +1417,32 @@ fn resolve_lightmap<R: ResourceRepository>(
resolve_texm(repository, name, LIGHTMAP_ARCHIVE, "lightmap")
}
+fn prepare_texture<R: ResourceRepository>(
+ repository: &R,
+ name: &ResourceName,
+ usage: PreparedTextureUsage,
+) -> Result<PreparedTexture, AssetError> {
+ let (archive, label) = match usage {
+ PreparedTextureUsage::Diffuse => (TEXTURES_ARCHIVE, "texture"),
+ PreparedTextureUsage::Lightmap => (LIGHTMAP_ARCHIVE, "lightmap"),
+ };
+ let key = ResourceKey {
+ archive: parse_path(archive)?,
+ name: name.clone(),
+ type_id: None,
+ };
+ let Some(bytes) = read_optional_key(repository, &key, Some(label))? else {
+ return Err(AssetError::MissingDependency(format!("{label} {name:?}")));
+ };
+ let texm = decode_texm(bytes).map_err(AssetError::Texture)?;
+ Ok(PreparedTexture {
+ id: AssetId::new(stable_texture_id(&key, usage)),
+ source: key,
+ texm,
+ usage,
+ })
+}
+
fn resolve_texm<R: ResourceRepository>(
repository: &R,
name: &ResourceName,
@@ -1323,6 +1518,17 @@ fn stable_visual_id(proto: &EffectivePrototype) -> u64 {
hasher.finish()
}
+fn stable_model_id(proto: &EffectivePrototype) -> u64 {
+ stable_visual_id(proto)
+}
+
+fn stable_wear_id(proto: &EffectivePrototype) -> u64 {
+ let mut hasher = StableHasher::default();
+ stable_visual_id(proto).hash(&mut hasher);
+ b"wear".hash(&mut hasher);
+ hasher.finish()
+}
+
fn stable_material_id(
proto: &EffectivePrototype,
material_index: u16,
@@ -1335,6 +1541,17 @@ fn stable_material_id(
hasher.finish()
}
+fn stable_texture_id(key: &ResourceKey, usage: PreparedTextureUsage) -> u64 {
+ let mut hasher = StableHasher::default();
+ key.archive.identity_bytes().hash(&mut hasher);
+ key.name.0.hash(&mut hasher);
+ match usage {
+ PreparedTextureUsage::Diffuse => 0_u8.hash(&mut hasher),
+ PreparedTextureUsage::Lightmap => 1_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}")))
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
index ca97409..d10647d 100644
--- a/crates/fparkan-runtime/src/lib.rs
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -23,9 +23,9 @@
use fparkan_assets::{
decode_mission_land_path, decode_mission_payload, decode_nres_payload,
derive_mission_land_paths, extend_graph_report_with_visual_dependencies, prepare_terrain_world,
- AssetError as AssetPreparationError, AssetManager, BuildCategory, MissionAssetPlan,
- MissionDocument, MissionError, MissionTerrainPaths, NresError, TerrainFormatError,
- TerrainPreparationError, TerrainWorld, TmaProfile,
+ AssetError as AssetPreparationError, AssetId, AssetManager, BuildCategory, MissionAssetPlan,
+ MissionDocument, MissionError, MissionTerrainPaths, NresError, PreparedVisual,
+ TerrainFormatError, TerrainPreparationError, TerrainWorld, TmaProfile,
};
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
use fparkan_prototype::{
@@ -149,6 +149,36 @@ pub struct MissionLoadTrace {
pub transforms: Vec<PlacedTransformProfile>,
}
+/// Ordered mission property preserved for later runtime stages.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MissionObjectProperty {
+ /// Raw property value words.
+ pub raw_value: [u32; 4],
+ /// Raw property name bytes.
+ pub name_raw: Vec<u8>,
+}
+
+/// Mission-derived object draft preserved for Stage 3 viewer/player work.
+#[derive(Clone, Debug, PartialEq)]
+pub struct MissionObjectDraft {
+ /// Original mission object id.
+ pub original_id: Option<OriginalObjectId>,
+ /// Raw mission resource reference.
+ pub resource_name_raw: Vec<u8>,
+ /// Raw identity/clan word from the mission.
+ pub identity_or_clan_raw: u32,
+ /// Raw position vector.
+ pub position: [f32; 3],
+ /// Raw orientation vector.
+ pub orientation_raw: [f32; 3],
+ /// Raw scale vector.
+ pub scale: [f32; 3],
+ /// Prepared visuals reachable from this mission object.
+ pub visual_ids: Vec<AssetId<PreparedVisual>>,
+ /// Ordered mission properties.
+ pub properties: Vec<MissionObjectProperty>,
+}
+
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct MissionLoadOptions {
fail_after_registered_objects: Option<usize>,
@@ -187,6 +217,10 @@ pub struct LoadedMission {
pub graph_unit_component_count: usize,
/// Mission prototype graph root count.
pub graph_root_count: usize,
+ /// Total materialized graph node count after visual dependency expansion.
+ pub graph_node_count: usize,
+ /// Total materialized graph edge count after visual dependency expansion.
+ pub graph_edge_count: usize,
/// Mission asset plan visual count after dependency preparation.
pub asset_visual_count: usize,
/// Expanded prototype requests resolved to effective prototypes.
@@ -254,6 +288,7 @@ struct LoadedMissionState {
prototype_report: PrototypeGraphReport,
mission_assets: MissionAssets,
asset_plan: MissionAssetPlan,
+ object_drafts: Vec<MissionObjectDraft>,
}
/// Engine error.
@@ -539,6 +574,28 @@ fn load_mission_with_options(
source,
})?;
let mission_asset_plan = mission_assets.to_plan();
+ let object_drafts: Vec<_> = mission
+ .objects
+ .iter()
+ .enumerate()
+ .map(|(index, object)| MissionObjectDraft {
+ original_id: u32::try_from(index).ok().map(OriginalObjectId),
+ resource_name_raw: object.resource_raw.clone(),
+ identity_or_clan_raw: object.identity_or_clan_raw,
+ position: object.position,
+ orientation_raw: object.orientation,
+ scale: object.scale,
+ visual_ids: mission_assets.visuals_for_object(index).to_vec(),
+ properties: object
+ .properties
+ .iter()
+ .map(|property| MissionObjectProperty {
+ raw_value: property.raw_value,
+ name_raw: property.name_raw.clone(),
+ })
+ .collect(),
+ })
+ .collect();
trace.phases.push(MissionLoadPhase::Assets);
let mut new_runtime_world = new_world(WorldConfig);
@@ -580,6 +637,8 @@ 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,
+ graph_node_count: prototype_graph.nodes.len(),
+ graph_edge_count: prototype_graph.edges.len(),
asset_visual_count: mission_asset_plan.visual_count,
graph_resolved_count: prototype_report.resolved_count,
graph_mesh_dependency_count: prototype_report.mesh_dependency_count,
@@ -608,6 +667,7 @@ fn load_mission_with_options(
prototype_report,
mission_assets,
asset_plan: mission_asset_plan,
+ object_drafts,
});
Ok((summary, trace))
}
@@ -697,6 +757,15 @@ pub fn loaded_mission_assets(engine: &Engine) -> Option<&MissionAssets> {
engine.loaded.as_ref().map(|state| &state.mission_assets)
}
+/// Returns mission-derived object drafts preserved for later runtime stages.
+#[must_use]
+pub fn loaded_mission_object_drafts(engine: &Engine) -> Option<&[MissionObjectDraft]> {
+ engine
+ .loaded
+ .as_ref()
+ .map(|state| state.object_drafts.as_slice())
+}
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SchedulerPresentation {
Headless,