2026-06-22 13:12:27 +04:00
|
|
|
#![forbid(unsafe_code)]
|
|
|
|
|
//! Asset manager ports and transactional preparation models.
|
|
|
|
|
|
2026-07-03 17:11:49 +04:00
|
|
|
use fparkan_material::{
|
2026-10-11 13:12:50 +04:00
|
|
|
decode_mat0, decode_wear, resolve_material, MaterialFallback, ResolvedMaterial, WearTable,
|
2026-07-18 06:35:20 +04:00
|
|
|
MAT0_KIND, WEAR_KIND,
|
2026-07-03 17:11:49 +04:00
|
|
|
};
|
2026-10-11 13:12:50 +04:00
|
|
|
pub use fparkan_material::{
|
|
|
|
|
sample_material_phase, Mat0Document, MaterialCoefficients, MaterialError, MaterialPhase,
|
|
|
|
|
MaterialPhaseSample,
|
|
|
|
|
};
|
2026-07-18 20:56:34 +04:00
|
|
|
use fparkan_mission_format::{decode_tma, decode_tma_land_path, ClanBody};
|
2026-06-23 22:32:50 +04:00
|
|
|
pub use fparkan_mission_format::{LpString, MissionDocument, MissionError, TmaProfile};
|
2026-07-03 17:11:49 +04:00
|
|
|
use fparkan_msh::{decode_msh, validate_msh, ModelAsset, MshError};
|
2026-06-22 13:12:27 +04:00
|
|
|
use fparkan_nres::{decode as decode_nres, ReadProfile};
|
2026-06-23 22:32:50 +04:00
|
|
|
pub use fparkan_nres::{NresDocument, NresError};
|
2026-06-23 22:05:16 +04:00
|
|
|
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy, ResourceName};
|
|
|
|
|
use fparkan_prototype::{
|
2026-10-18 11:38:50 +04:00
|
|
|
decode_bas_geometry, BasGeometry, EffectivePrototype, PrototypeGeometry, PrototypeGraph,
|
|
|
|
|
PrototypeGraphEdge, PrototypeGraphFailure, PrototypeGraphNodeKind, PrototypeGraphProvenance,
|
|
|
|
|
PrototypeGraphReport, PrototypeGraphRequiredness,
|
2026-06-23 22:05:16 +04:00
|
|
|
};
|
2026-09-06 05:22:12 +04:00
|
|
|
use fparkan_resource::{CachedResourceRepository, ResourceError, ResourceKey, ResourceRepository};
|
2026-06-23 22:32:50 +04:00
|
|
|
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};
|
2026-10-11 13:12:50 +04:00
|
|
|
use fparkan_texm::{decode_mip_rgba8, decode_texm, TexmDocument, TexmError};
|
|
|
|
|
pub use fparkan_texm::{PageRect, RgbaImage};
|
2026-09-06 05:22:12 +04:00
|
|
|
use fparkan_vfs::DirectoryVfs;
|
2026-10-11 13:12:50 +04:00
|
|
|
use std::collections::{hash_map::Entry, HashMap};
|
2026-06-22 13:12:27 +04:00
|
|
|
use std::fmt;
|
|
|
|
|
use std::hash::{Hash, Hasher};
|
|
|
|
|
use std::marker::PhantomData;
|
2026-10-11 13:12:50 +04:00
|
|
|
use std::path::{Path, PathBuf};
|
2026-06-22 13:12:27 +04:00
|
|
|
use std::sync::Arc;
|
|
|
|
|
|
|
|
|
|
const TEXTURES_ARCHIVE: &str = "textures.lib";
|
|
|
|
|
const LIGHTMAP_ARCHIVE: &str = "lightmap.lib";
|
|
|
|
|
|
2026-10-11 13:12:50 +04:00
|
|
|
/// Material coefficients decoded from one selected MAT0 phase.
|
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
|
|
|
pub struct PreparedMaterialPhase {
|
|
|
|
|
/// Raw NRes MAT0 entry flags retained from the archive directory.
|
|
|
|
|
pub attr1: u32,
|
|
|
|
|
/// Native material pipeline category `(attr1 >> 2) & 0xF`.
|
|
|
|
|
pub pipeline_category: u8,
|
|
|
|
|
/// Native material feature bit `attr1 & 0x40`.
|
|
|
|
|
pub native_flag_40: bool,
|
|
|
|
|
/// Phase parameters 4..6, normalized directional light RGB coefficient.
|
|
|
|
|
pub directional_rgb: [f32; 3],
|
|
|
|
|
/// Phase parameters 0..2, normalized additive RGB coefficient.
|
|
|
|
|
pub additive_rgb: [f32; 3],
|
|
|
|
|
/// Phase parameter 3, stored as a percentage.
|
|
|
|
|
pub diffuse_alpha: f32,
|
|
|
|
|
/// Phase parameters 8..10, normalized specular RGB coefficient.
|
|
|
|
|
pub specular_rgb: [f32; 3],
|
|
|
|
|
/// Phase parameter 16, integer specular power.
|
|
|
|
|
pub power: u8,
|
|
|
|
|
/// Signed phase parameter 17 selecting a TEXM Page rectangle, or -1.
|
|
|
|
|
pub page_index: i8,
|
|
|
|
|
/// Normalized atlas transform `[offset_x, offset_y, scale_x, scale_y]`.
|
|
|
|
|
pub page_uv_transform: [f32; 4],
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 13:17:06 +04:00
|
|
|
type WearValidationCache =
|
|
|
|
|
HashMap<(NormalizedPath, Vec<u8>), Result<fparkan_material::WearTable, String>>;
|
|
|
|
|
|
2026-10-11 13:12:50 +04:00
|
|
|
/// Extracts the native pipeline category from a MAT0 NRes entry's `attr1`.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub const fn material_pipeline_category(attr1: u32) -> u8 {
|
|
|
|
|
((attr1 >> 2) & 0x0F) as u8
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns whether a MAT0 NRes entry carries native feature bit `0x40`.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub const fn material_native_flag_40(attr1: u32) -> bool {
|
|
|
|
|
(attr1 & 0x40) != 0
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:32:50 +04:00
|
|
|
/// Canonical terrain archive paths derived from a mission land reference.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
2026-06-23 22:05:16 +04:00
|
|
|
pub struct MissionTerrainPaths {
|
|
|
|
|
/// Landscape mesh archive path.
|
|
|
|
|
pub land_msh: NormalizedPath,
|
|
|
|
|
/// Landscape map archive path.
|
|
|
|
|
pub land_map: NormalizedPath,
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 20:56:34 +04:00
|
|
|
/// Raw compiled-script base selected by one TMA clan.
|
|
|
|
|
///
|
|
|
|
|
/// The path remains raw legacy bytes until the runtime applies its path policy.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct MissionScriptBundleBase {
|
|
|
|
|
/// Index in the mission clan array.
|
|
|
|
|
pub clan_index: usize,
|
|
|
|
|
/// First clan resource path, without a required `.scr` suffix.
|
|
|
|
|
pub path_raw: Vec<u8>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
/// 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.
|
2026-06-23 22:32:50 +04:00
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`MissionError`] when the mission payload is malformed for the
|
|
|
|
|
/// selected profile.
|
2026-06-23 22:05:16 +04:00
|
|
|
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.
|
2026-06-23 22:32:50 +04:00
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`MissionError`] when the mission header or land path record cannot
|
|
|
|
|
/// be decoded.
|
2026-06-23 22:05:16 +04:00
|
|
|
pub fn decode_mission_land_path(
|
|
|
|
|
bytes: &[u8],
|
|
|
|
|
profile: TmaProfile,
|
|
|
|
|
) -> Result<LpString, MissionError> {
|
|
|
|
|
decode_tma_land_path(bytes, profile)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 20:56:34 +04:00
|
|
|
/// Returns non-empty script/formula bases selected by TMA clans.
|
|
|
|
|
///
|
|
|
|
|
/// The first resource has the same position in the standard and spatial clan
|
|
|
|
|
/// layouts. Its consumer-specific meaning is intentionally left to runtime.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn mission_script_bundle_bases(mission: &MissionDocument) -> Vec<MissionScriptBundleBase> {
|
|
|
|
|
mission
|
|
|
|
|
.clans
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.filter_map(|(clan_index, clan)| {
|
|
|
|
|
let path_raw = match &clan.body {
|
|
|
|
|
ClanBody::Standard { first_resource, .. } => first_resource.path.raw.clone(),
|
|
|
|
|
ClanBody::Spatial { first_resource, .. } => first_resource.raw.clone(),
|
|
|
|
|
};
|
|
|
|
|
(!path_raw.is_empty()).then_some(MissionScriptBundleBase {
|
|
|
|
|
clan_index,
|
|
|
|
|
path_raw,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
/// Builds canonical mission terrain paths from the mission `Land` reference.
|
2026-06-23 22:32:50 +04:00
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`PathError`] when the mission land reference is not a strict
|
|
|
|
|
/// relative legacy path.
|
|
|
|
|
pub fn derive_mission_land_paths(land_path: &LpString) -> Result<MissionTerrainPaths, PathError> {
|
2026-06-23 22:05:16 +04:00
|
|
|
let normalized = normalize_relative(&land_path.raw, PathPolicy::StrictLegacy)?;
|
|
|
|
|
let Some((parent, _stem)) = normalized.as_str().rsplit_once('/') else {
|
|
|
|
|
return Err(PathError::Empty);
|
|
|
|
|
};
|
2026-06-23 22:32:50 +04:00
|
|
|
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,
|
|
|
|
|
)?;
|
2026-06-23 22:05:16 +04:00
|
|
|
Ok(MissionTerrainPaths { land_msh, land_map })
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:32:50 +04:00
|
|
|
/// Decodes compatible `NRes` payload for terrain/document loading.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`NresError`] when the payload is not a compatible `NRes` archive.
|
2026-06-23 22:05:16 +04:00
|
|
|
pub fn decode_nres_payload(
|
|
|
|
|
bytes: Arc<[u8]>,
|
|
|
|
|
) -> Result<fparkan_nres::NresDocument, fparkan_nres::NresError> {
|
|
|
|
|
decode_nres(bytes, ReadProfile::Compatible)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:12:50 +04:00
|
|
|
/// Loads every authored RGBA8 mip and the selected phase coefficients from a
|
|
|
|
|
/// map-local standalone WEAR table.
|
|
|
|
|
pub fn load_standalone_wear_material_texture_rgba8_and_phase_from_root(
|
2026-09-06 05:22:12 +04:00
|
|
|
root: &Path,
|
|
|
|
|
wear_path: &Path,
|
|
|
|
|
material_index: u16,
|
2026-10-11 13:12:50 +04:00
|
|
|
) -> Result<(Vec<RgbaImage>, PreparedMaterialPhase), String> {
|
|
|
|
|
load_standalone_wear_material_texture_rgba8_and_phase_at_index_from_root(
|
|
|
|
|
root,
|
|
|
|
|
wear_path,
|
|
|
|
|
material_index,
|
|
|
|
|
0,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Loads an authored TEXM mip chain and MAT0 coefficients for one phase.
|
|
|
|
|
/// Empty phase texture names are treated as intentionally untextured.
|
|
|
|
|
pub fn load_standalone_wear_material_texture_rgba8_and_phase_at_index_from_root(
|
|
|
|
|
root: &Path,
|
|
|
|
|
wear_path: &Path,
|
|
|
|
|
material_index: u16,
|
|
|
|
|
phase_index: u16,
|
|
|
|
|
) -> Result<(Vec<RgbaImage>, PreparedMaterialPhase), String> {
|
|
|
|
|
let mut loader = StandaloneWearMaterialLoader::new(root);
|
|
|
|
|
loader.load(wear_path, material_index, phase_index)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Reusable standalone WEAR/MAT0/TEXM loader for map-local preview paths.
|
|
|
|
|
///
|
|
|
|
|
/// The native preview can request many rows from `Land1.wea` and `Land2.wea`.
|
|
|
|
|
/// Keeping one repository and one decoded table per path avoids repeating the
|
|
|
|
|
/// archive fingerprint, WEAR parse, and material lookup work for every row.
|
|
|
|
|
pub struct StandaloneWearMaterialLoader {
|
|
|
|
|
repository: CachedResourceRepository,
|
|
|
|
|
wears: HashMap<PathBuf, WearTable>,
|
|
|
|
|
materials: HashMap<(PathBuf, u16), ResolvedMaterial>,
|
|
|
|
|
loaded: HashMap<(PathBuf, u16, u16), (Vec<RgbaImage>, PreparedMaterialPhase)>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl StandaloneWearMaterialLoader {
|
|
|
|
|
/// Creates a loader over an immutable install root.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn new(root: &Path) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
repository: CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root))),
|
|
|
|
|
wears: HashMap::new(),
|
|
|
|
|
materials: HashMap::new(),
|
|
|
|
|
loaded: HashMap::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Loads one material row and phase from a map-local WEAR table.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns a formatted error when the table, material, or texture cannot
|
|
|
|
|
/// be decoded.
|
|
|
|
|
pub fn load(
|
|
|
|
|
&mut self,
|
|
|
|
|
wear_path: &Path,
|
|
|
|
|
material_index: u16,
|
|
|
|
|
phase_index: u16,
|
|
|
|
|
) -> Result<(Vec<RgbaImage>, PreparedMaterialPhase), String> {
|
|
|
|
|
let cache_key = wear_path.to_path_buf();
|
|
|
|
|
if !self.wears.contains_key(&cache_key) {
|
|
|
|
|
let wear = decode_wear(
|
|
|
|
|
&std::fs::read(wear_path)
|
|
|
|
|
.map_err(|err| format!("{}: {err}", wear_path.display()))?,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
|
self.wears.insert(cache_key.clone(), wear);
|
|
|
|
|
}
|
|
|
|
|
let material = self.resolve_material(&cache_key, material_index)?;
|
|
|
|
|
let loaded_key = (cache_key.clone(), material_index, phase_index);
|
|
|
|
|
if let Some(result) = self.loaded.get(&loaded_key) {
|
|
|
|
|
return Ok(result.clone());
|
|
|
|
|
}
|
|
|
|
|
let result = load_resolved_material_texture_rgba8_and_phase(
|
|
|
|
|
&self.repository,
|
|
|
|
|
&material,
|
|
|
|
|
phase_index,
|
|
|
|
|
)?;
|
|
|
|
|
self.loaded.insert(loaded_key, result.clone());
|
|
|
|
|
Ok(result)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the decoded MAT0 document for one map-local WEAR row.
|
|
|
|
|
///
|
|
|
|
|
/// The document is cloned at this boundary so callers can retain a
|
|
|
|
|
/// frame-sampling binding while the loader continues to cache resources.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns a formatted error when the WEAR table or material cannot be
|
|
|
|
|
/// resolved.
|
|
|
|
|
pub fn material_document(
|
|
|
|
|
&mut self,
|
|
|
|
|
wear_path: &Path,
|
|
|
|
|
material_index: u16,
|
|
|
|
|
) -> Result<Mat0Document, String> {
|
|
|
|
|
let cache_key = wear_path.to_path_buf();
|
|
|
|
|
self.ensure_wear(&cache_key)?;
|
|
|
|
|
self.resolve_material(&cache_key, material_index)
|
|
|
|
|
.map(|material| material.document)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ensure_wear(&mut self, cache_key: &Path) -> Result<(), String> {
|
|
|
|
|
if self.wears.contains_key(cache_key) {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
let wear = decode_wear(
|
|
|
|
|
&std::fs::read(cache_key).map_err(|err| format!("{}: {err}", cache_key.display()))?,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
|
self.wears.insert(cache_key.to_path_buf(), wear);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn resolve_material(
|
|
|
|
|
&mut self,
|
|
|
|
|
cache_key: &Path,
|
|
|
|
|
material_index: u16,
|
|
|
|
|
) -> Result<ResolvedMaterial, String> {
|
|
|
|
|
let material_key = (cache_key.to_path_buf(), material_index);
|
|
|
|
|
if let Some(material) = self.materials.get(&material_key) {
|
|
|
|
|
return Ok(material.clone());
|
|
|
|
|
}
|
|
|
|
|
let wear = self
|
|
|
|
|
.wears
|
|
|
|
|
.get(cache_key)
|
|
|
|
|
.ok_or_else(|| "standalone WEAR cache insertion failed".to_string())?;
|
|
|
|
|
let material = resolve_material(&self.repository, wear, material_index)
|
|
|
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
|
self.materials.insert(material_key, material.clone());
|
|
|
|
|
Ok(material)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Loads named map-local materials together with their complete MAT0
|
|
|
|
|
/// documents. This is the runtime form used by sky/environment bindings;
|
|
|
|
|
/// the older helper remains available for callers that only need phase zero.
|
|
|
|
|
pub fn load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root(
|
|
|
|
|
root: &Path,
|
|
|
|
|
wear_path: &Path,
|
|
|
|
|
phase_index: u16,
|
|
|
|
|
) -> Result<Vec<(Vec<u8>, Vec<RgbaImage>, PreparedMaterialPhase, Mat0Document)>, String> {
|
2026-09-06 05:22:12 +04:00
|
|
|
let wear = decode_wear(
|
|
|
|
|
&std::fs::read(wear_path).map_err(|err| format!("{}: {err}", wear_path.display()))?,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
|
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
|
2026-10-11 13:12:50 +04:00
|
|
|
wear.entries
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.map(|(index, entry)| {
|
|
|
|
|
let material_index =
|
|
|
|
|
u16::try_from(index).map_err(|_| "WEAR material index exceeds u16".to_string())?;
|
|
|
|
|
let material = resolve_material(&repository, &wear, material_index)
|
|
|
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
|
let (mips, phase) = load_resolved_material_texture_rgba8_and_phase(
|
|
|
|
|
&repository,
|
|
|
|
|
&material,
|
|
|
|
|
phase_index,
|
|
|
|
|
)?;
|
|
|
|
|
Ok((entry.material.0.clone(), mips, phase, material.document))
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Loads every named MAT0 entry in a map-local WEAR table through one cached
|
|
|
|
|
/// resource repository. Sky material rows use this path: their names are
|
|
|
|
|
/// material.lib entries, not direct textures.lib TEXM names.
|
|
|
|
|
pub fn load_standalone_wear_named_material_textures_rgba8_and_phases_from_root(
|
|
|
|
|
root: &Path,
|
|
|
|
|
wear_path: &Path,
|
|
|
|
|
phase_index: u16,
|
|
|
|
|
) -> Result<Vec<(Vec<u8>, Vec<RgbaImage>, PreparedMaterialPhase)>, String> {
|
|
|
|
|
let wear = decode_wear(
|
|
|
|
|
&std::fs::read(wear_path).map_err(|err| format!("{}: {err}", wear_path.display()))?,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
|
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
|
|
|
|
|
wear.entries
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.map(|(index, entry)| {
|
|
|
|
|
let material_index =
|
|
|
|
|
u16::try_from(index).map_err(|_| "WEAR material index exceeds u16".to_string())?;
|
|
|
|
|
let material = resolve_material(&repository, &wear, material_index)
|
|
|
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
|
let (mips, phase) = load_resolved_material_texture_rgba8_and_phase(
|
|
|
|
|
&repository,
|
|
|
|
|
&material,
|
|
|
|
|
phase_index,
|
|
|
|
|
)?;
|
|
|
|
|
Ok((entry.material.0.clone(), mips, phase))
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn load_resolved_material_texture_rgba8_and_phase<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
material: &ResolvedMaterial,
|
|
|
|
|
phase_index: u16,
|
|
|
|
|
) -> Result<(Vec<RgbaImage>, PreparedMaterialPhase), String> {
|
|
|
|
|
let phase = material
|
|
|
|
|
.document
|
|
|
|
|
.phases
|
|
|
|
|
.get(usize::from(phase_index))
|
|
|
|
|
.or_else(|| material.document.phases.first())
|
|
|
|
|
.ok_or_else(|| "MAT0 declares no material phases".to_string())?;
|
|
|
|
|
let texture_bytes = phase
|
|
|
|
|
.texture_raw
|
|
|
|
|
.iter()
|
|
|
|
|
.copied()
|
|
|
|
|
.take_while(|byte| *byte != 0)
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
let document = if texture_bytes.is_empty() {
|
|
|
|
|
None
|
|
|
|
|
} else {
|
|
|
|
|
let texture = ResourceName(texture_bytes);
|
|
|
|
|
let key = ResourceKey {
|
|
|
|
|
archive: parse_path(TEXTURES_ARCHIVE).map_err(|err| err.to_string())?,
|
|
|
|
|
name: texture.clone(),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
let bytes = read_optional_key(repository, &key, Some("texture"))
|
|
|
|
|
.map_err(|err| err.to_string())?
|
|
|
|
|
.ok_or_else(|| format!("missing texture {texture:?}"))?;
|
|
|
|
|
Some(decode_texm(bytes).map_err(|err| err.to_string())?)
|
|
|
|
|
};
|
|
|
|
|
let page_index = i8::from_ne_bytes([phase.parameters[17]]);
|
|
|
|
|
let page_uv = match document.as_ref() {
|
|
|
|
|
Some(document) => page_uv_transform(
|
|
|
|
|
document.width(),
|
|
|
|
|
document.height(),
|
|
|
|
|
&document.page_rects(),
|
|
|
|
|
page_index,
|
|
|
|
|
)?,
|
|
|
|
|
None => [0.0, 0.0, 1.0, 1.0],
|
|
|
|
|
};
|
|
|
|
|
let phase = PreparedMaterialPhase {
|
|
|
|
|
attr1: material.attr1,
|
|
|
|
|
pipeline_category: material_pipeline_category(material.attr1),
|
|
|
|
|
native_flag_40: material_native_flag_40(material.attr1),
|
|
|
|
|
directional_rgb: [
|
|
|
|
|
f32::from(phase.parameters[4]) / 255.0,
|
|
|
|
|
f32::from(phase.parameters[5]) / 255.0,
|
|
|
|
|
f32::from(phase.parameters[6]) / 255.0,
|
|
|
|
|
],
|
|
|
|
|
additive_rgb: [
|
|
|
|
|
f32::from(phase.parameters[0]) / 255.0,
|
|
|
|
|
f32::from(phase.parameters[1]) / 255.0,
|
|
|
|
|
f32::from(phase.parameters[2]) / 255.0,
|
|
|
|
|
],
|
|
|
|
|
diffuse_alpha: f32::from(phase.parameters[3]) * 0.01,
|
|
|
|
|
specular_rgb: [
|
|
|
|
|
f32::from(phase.parameters[8]) / 255.0,
|
|
|
|
|
f32::from(phase.parameters[9]) / 255.0,
|
|
|
|
|
f32::from(phase.parameters[10]) / 255.0,
|
|
|
|
|
],
|
|
|
|
|
power: phase.parameters[16],
|
|
|
|
|
page_index,
|
|
|
|
|
page_uv_transform: page_uv,
|
|
|
|
|
};
|
|
|
|
|
let mips = match document {
|
|
|
|
|
Some(document) => (0..document.mip_count())
|
|
|
|
|
.map(|level| decode_mip_rgba8(&document, u32::try_from(level).unwrap_or(u32::MAX)))
|
|
|
|
|
.collect::<Result<Vec<_>, _>>()
|
|
|
|
|
.map_err(|err| err.to_string())?,
|
|
|
|
|
None => vec![RgbaImage {
|
|
|
|
|
width: 1,
|
|
|
|
|
height: 1,
|
|
|
|
|
rgba8: vec![255, 255, 255, 255],
|
|
|
|
|
}],
|
|
|
|
|
};
|
|
|
|
|
Ok((mips, phase))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Loads a named `material.lib` MAT0 entry and its selected texture phase.
|
|
|
|
|
/// This is used by FX resources whose opcode carries a direct material
|
|
|
|
|
/// reference instead of a WEAR-table index.
|
|
|
|
|
pub fn load_material_name_texture_rgba8_and_phase_from_root(
|
|
|
|
|
root: &Path,
|
|
|
|
|
material_name: &str,
|
|
|
|
|
phase_index: u16,
|
|
|
|
|
) -> Result<(Vec<RgbaImage>, PreparedMaterialPhase), String> {
|
|
|
|
|
load_material_name_texture_rgba8_and_phase_with_document_from_root(
|
|
|
|
|
root,
|
|
|
|
|
material_name,
|
|
|
|
|
phase_index,
|
|
|
|
|
)
|
|
|
|
|
.map(|(mips, phase, _document)| (mips, phase))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Loads a named `material.lib` entry, its selected texture phase, and the
|
|
|
|
|
/// complete MAT0 document used by the runtime animation sampler.
|
|
|
|
|
pub fn load_material_name_texture_rgba8_and_phase_with_document_from_root(
|
|
|
|
|
root: &Path,
|
|
|
|
|
material_name: &str,
|
|
|
|
|
phase_index: u16,
|
|
|
|
|
) -> Result<(Vec<RgbaImage>, PreparedMaterialPhase, Mat0Document), String> {
|
|
|
|
|
if material_name.is_empty() {
|
|
|
|
|
return Err("material name is empty".to_string());
|
|
|
|
|
}
|
|
|
|
|
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
|
|
|
|
|
let archive = parse_path("material.lib").map_err(|err| err.to_string())?;
|
|
|
|
|
let archive_id = repository
|
|
|
|
|
.open_archive(&archive)
|
|
|
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
|
let name = ResourceName(material_name.as_bytes().to_vec());
|
|
|
|
|
let entry = repository
|
|
|
|
|
.find(archive_id, &name)
|
|
|
|
|
.map_err(|err| err.to_string())?
|
|
|
|
|
.ok_or_else(|| format!("missing material {material_name:?}"))?;
|
|
|
|
|
let info = repository
|
|
|
|
|
.entry_info(entry)
|
|
|
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
|
if info.key.type_id != Some(MAT0_KIND) {
|
|
|
|
|
return Err(format!("material {material_name:?} is not a MAT0 entry"));
|
|
|
|
|
}
|
|
|
|
|
let document = decode_mat0(
|
|
|
|
|
repository
|
|
|
|
|
.read(entry)
|
|
|
|
|
.map_err(|err| err.to_string())?
|
|
|
|
|
.as_slice(),
|
|
|
|
|
info.attr2,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
|
let material = ResolvedMaterial {
|
|
|
|
|
name: info.key.name,
|
|
|
|
|
fallback: MaterialFallback::Exact,
|
|
|
|
|
attr1: info.attr1,
|
|
|
|
|
document,
|
|
|
|
|
};
|
|
|
|
|
let document = material.document.clone();
|
|
|
|
|
let (mips, phase) =
|
|
|
|
|
load_resolved_material_texture_rgba8_and_phase(&repository, &material, phase_index)?;
|
|
|
|
|
Ok((mips, phase, document))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Reads one named entry from a legacy resource archive through the same
|
|
|
|
|
/// cached repository used by the material loaders.
|
|
|
|
|
pub fn load_resource_entry_bytes_from_root(
|
|
|
|
|
root: &Path,
|
|
|
|
|
archive_name: &str,
|
|
|
|
|
entry_name: &str,
|
|
|
|
|
) -> Result<Arc<[u8]>, String> {
|
|
|
|
|
if archive_name.is_empty() || entry_name.is_empty() {
|
|
|
|
|
return Err("resource archive and entry names must be non-empty".to_string());
|
|
|
|
|
}
|
|
|
|
|
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
|
|
|
|
|
let archive = parse_path(archive_name).map_err(|err| err.to_string())?;
|
|
|
|
|
let archive_id = repository
|
|
|
|
|
.open_archive(&archive)
|
|
|
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
|
let entry = repository
|
|
|
|
|
.find(archive_id, &ResourceName(entry_name.as_bytes().to_vec()))
|
|
|
|
|
.map_err(|err| err.to_string())?
|
|
|
|
|
.ok_or_else(|| format!("missing resource {archive_name}/{entry_name}"))?;
|
|
|
|
|
Ok(Arc::from(
|
|
|
|
|
repository
|
|
|
|
|
.read(entry)
|
|
|
|
|
.map_err(|err| err.to_string())?
|
|
|
|
|
.into_owned()
|
|
|
|
|
.into_boxed_slice(),
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Loads every authored mip of a named entry from the normal texture archive.
|
|
|
|
|
///
|
|
|
|
|
/// Environment material tables (`sky.wea`) contain direct texture entry names
|
|
|
|
|
/// rather than WEAR indirections, so they use this small archive helper.
|
|
|
|
|
pub fn load_texture_rgba8_mips_from_root(
|
|
|
|
|
root: &Path,
|
|
|
|
|
texture_name: &str,
|
|
|
|
|
) -> Result<Vec<RgbaImage>, String> {
|
|
|
|
|
if texture_name.is_empty() {
|
|
|
|
|
return Err("texture name is empty".to_string());
|
|
|
|
|
}
|
|
|
|
|
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
|
|
|
|
|
let archive = ResourceKey {
|
2026-09-06 05:22:12 +04:00
|
|
|
archive: parse_path(TEXTURES_ARCHIVE).map_err(|err| err.to_string())?,
|
2026-10-11 13:12:50 +04:00
|
|
|
name: ResourceName(texture_name.as_bytes().to_vec()),
|
2026-09-06 05:22:12 +04:00
|
|
|
type_id: None,
|
|
|
|
|
};
|
2026-10-11 13:12:50 +04:00
|
|
|
let archive_id = repository
|
|
|
|
|
.open_archive(&archive.archive)
|
|
|
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
|
let entry = repository
|
|
|
|
|
.find(archive_id, &archive.name)
|
2026-09-06 05:22:12 +04:00
|
|
|
.map_err(|err| err.to_string())?
|
2026-10-11 13:12:50 +04:00
|
|
|
.ok_or_else(|| format!("missing texture {texture_name:?}"))?;
|
|
|
|
|
let bytes = repository.read(entry).map_err(|err| err.to_string())?;
|
|
|
|
|
let document = decode_texm(Arc::from(bytes.as_slice().to_vec().into_boxed_slice()))
|
|
|
|
|
.map_err(|err| err.to_string())?;
|
|
|
|
|
(0..document.mip_count())
|
|
|
|
|
.map(|level| decode_mip_rgba8(&document, u32::try_from(level).unwrap_or(u32::MAX)))
|
|
|
|
|
.collect::<Result<Vec<_>, _>>()
|
|
|
|
|
.map_err(|err| err.to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the native normalized atlas transform for a signed MAT0 page index.
|
|
|
|
|
///
|
|
|
|
|
/// The transform is `[offset_x, offset_y, scale_x, scale_y]` and is applied as
|
|
|
|
|
/// `atlas_uv = offset + page_local_uv * scale`. A negative selector addresses
|
|
|
|
|
/// the complete texture.
|
|
|
|
|
pub fn page_uv_transform(
|
|
|
|
|
width: u32,
|
|
|
|
|
height: u32,
|
|
|
|
|
page_rects: &[PageRect],
|
|
|
|
|
page_index: i8,
|
|
|
|
|
) -> Result<[f32; 4], String> {
|
|
|
|
|
if width == 0 || height == 0 {
|
|
|
|
|
return Err("TEXM page transform has zero texture extent".to_string());
|
|
|
|
|
}
|
|
|
|
|
if page_index < 0 {
|
|
|
|
|
return Ok([0.0, 0.0, 1.0, 1.0]);
|
|
|
|
|
}
|
|
|
|
|
let rect = page_rects
|
|
|
|
|
.get(usize::from(page_index as u8))
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
format!(
|
|
|
|
|
"MAT0 page selector {page_index} exceeds TEXM Page rect count {}",
|
|
|
|
|
page_rects.len()
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
if rect.x < 0 || rect.y < 0 || rect.w <= 0 || rect.h <= 0 {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"TEXM Page rect is invalid: x={} y={} w={} h={}",
|
|
|
|
|
rect.x, rect.y, rect.w, rect.h
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
if i64::from(rect.x) + i64::from(rect.w) > i64::from(width)
|
|
|
|
|
|| i64::from(rect.y) + i64::from(rect.h) > i64::from(height)
|
|
|
|
|
{
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"TEXM Page rect exceeds base extent {width}x{height}: x={} y={} w={} h={}",
|
|
|
|
|
rect.x, rect.y, rect.w, rect.h
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
Ok([
|
|
|
|
|
f32::from(rect.x) / width as f32,
|
|
|
|
|
f32::from(rect.y) / height as f32,
|
|
|
|
|
f32::from(rect.w) / width as f32,
|
|
|
|
|
f32::from(rect.h) / height as f32,
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Crops decoded TEXM mips to a signed MAT0 page selector.
|
|
|
|
|
///
|
|
|
|
|
/// A selector of `-1` means that the texture has no page selection and is
|
|
|
|
|
/// returned unchanged. Non-negative selectors address the TEXM `Page` table;
|
|
|
|
|
/// rectangles are scaled with floor origin and ceil end for each mip level.
|
|
|
|
|
/// The returned images use page-local 0..1 UVs, matching the native atlas
|
|
|
|
|
/// sampling contract.
|
|
|
|
|
pub fn crop_rgba8_mips_to_page(
|
|
|
|
|
mips: Vec<RgbaImage>,
|
|
|
|
|
page_rects: &[PageRect],
|
|
|
|
|
page_index: i8,
|
|
|
|
|
) -> Result<Vec<RgbaImage>, String> {
|
|
|
|
|
if page_index < 0 {
|
|
|
|
|
return Ok(mips);
|
|
|
|
|
}
|
|
|
|
|
let (base_width_u32, base_height_u32) = mips
|
|
|
|
|
.first()
|
|
|
|
|
.map(|base| (base.width, base.height))
|
|
|
|
|
.ok_or_else(|| "cannot select a page from an empty TEXM mip chain".to_string())?;
|
|
|
|
|
let rect = page_rects
|
|
|
|
|
.get(usize::from(page_index as u8))
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
format!(
|
|
|
|
|
"MAT0 page selector {page_index} exceeds TEXM Page rect count {}",
|
|
|
|
|
page_rects.len()
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
if rect.x < 0 || rect.y < 0 || rect.w <= 0 || rect.h <= 0 {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"TEXM Page rect is invalid: x={} y={} w={} h={}",
|
|
|
|
|
rect.x, rect.y, rect.w, rect.h
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let base_x_end = i64::from(rect.x) + i64::from(rect.w);
|
|
|
|
|
let base_y_end = i64::from(rect.y) + i64::from(rect.h);
|
|
|
|
|
if base_x_end > i64::from(base_width_u32) || base_y_end > i64::from(base_height_u32) {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"TEXM Page rect exceeds base extent {}x{}: x={} y={} w={} h={}",
|
|
|
|
|
base_width_u32, base_height_u32, rect.x, rect.y, rect.w, rect.h
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
mips.into_iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.map(|(level, image)| {
|
|
|
|
|
let scale_width = i64::from(image.width);
|
|
|
|
|
let scale_height = i64::from(image.height);
|
|
|
|
|
let base_width = i64::from(base_width_u32);
|
|
|
|
|
let base_height = i64::from(base_height_u32);
|
|
|
|
|
let x0 = floor_mul_div(i64::from(rect.x), scale_width, base_width);
|
|
|
|
|
let x1 = ceil_mul_div(base_x_end, scale_width, base_width);
|
|
|
|
|
let y0 = floor_mul_div(i64::from(rect.y), scale_height, base_height);
|
|
|
|
|
let y1 = ceil_mul_div(base_y_end, scale_height, base_height);
|
|
|
|
|
let x0 = u32::try_from(x0)
|
|
|
|
|
.map_err(|_| format!("TEXM Page mip {level} x origin is invalid"))?;
|
|
|
|
|
let y0 = u32::try_from(y0)
|
|
|
|
|
.map_err(|_| format!("TEXM Page mip {level} y origin is invalid"))?;
|
|
|
|
|
let x1 =
|
|
|
|
|
u32::try_from(x1).map_err(|_| format!("TEXM Page mip {level} x end is invalid"))?;
|
|
|
|
|
let y1 =
|
|
|
|
|
u32::try_from(y1).map_err(|_| format!("TEXM Page mip {level} y end is invalid"))?;
|
|
|
|
|
if x0 >= x1 || y0 >= y1 || x1 > image.width || y1 > image.height {
|
|
|
|
|
return Err(format!(
|
|
|
|
|
"TEXM Page mip {level} scaled rect is outside {}x{}: {x0},{y0}..{x1},{y1}",
|
|
|
|
|
image.width, image.height
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let width = x1 - x0;
|
|
|
|
|
let height = y1 - y0;
|
|
|
|
|
let row_bytes = usize::try_from(width)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|width| width.checked_mul(4))
|
|
|
|
|
.ok_or_else(|| format!("TEXM Page mip {level} row size overflows"))?;
|
|
|
|
|
let capacity = usize::try_from(width)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|width| {
|
|
|
|
|
usize::try_from(height)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|height| width.checked_mul(height))
|
|
|
|
|
})
|
|
|
|
|
.and_then(|pixels| pixels.checked_mul(4))
|
|
|
|
|
.ok_or_else(|| format!("TEXM Page mip {level} size overflows"))?;
|
|
|
|
|
let mut rgba8 = Vec::with_capacity(capacity);
|
|
|
|
|
let source_stride = usize::try_from(image.width)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|width| width.checked_mul(4))
|
|
|
|
|
.ok_or_else(|| format!("TEXM Page mip {level} source stride overflows"))?;
|
|
|
|
|
let start_x = usize::try_from(x0)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|x| x.checked_mul(4))
|
|
|
|
|
.ok_or_else(|| format!("TEXM Page mip {level} x offset overflows"))?;
|
|
|
|
|
for row in y0..y1 {
|
|
|
|
|
let row_start = usize::try_from(row)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|row| row.checked_mul(source_stride))
|
|
|
|
|
.and_then(|offset| offset.checked_add(start_x))
|
|
|
|
|
.ok_or_else(|| format!("TEXM Page mip {level} row offset overflows"))?;
|
|
|
|
|
let row_end = row_start
|
|
|
|
|
.checked_add(row_bytes)
|
|
|
|
|
.ok_or_else(|| format!("TEXM Page mip {level} row end overflows"))?;
|
|
|
|
|
let source = image
|
|
|
|
|
.rgba8
|
|
|
|
|
.get(row_start..row_end)
|
|
|
|
|
.ok_or_else(|| format!("TEXM Page mip {level} row exceeds decoded pixels"))?;
|
|
|
|
|
rgba8.extend_from_slice(source);
|
|
|
|
|
}
|
|
|
|
|
Ok(RgbaImage {
|
|
|
|
|
width,
|
|
|
|
|
height,
|
|
|
|
|
rgba8,
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn floor_mul_div(value: i64, scale: i64, base: i64) -> i64 {
|
|
|
|
|
let product = value.saturating_mul(scale);
|
|
|
|
|
let quotient = product / base;
|
|
|
|
|
if product < 0 && product % base != 0 {
|
|
|
|
|
quotient - 1
|
|
|
|
|
} else {
|
|
|
|
|
quotient
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn ceil_mul_div(value: i64, scale: i64, base: i64) -> i64 {
|
|
|
|
|
let product = value.saturating_mul(scale);
|
|
|
|
|
let quotient = product / base;
|
|
|
|
|
if product > 0 && product % base != 0 {
|
|
|
|
|
quotient + 1
|
|
|
|
|
} else {
|
|
|
|
|
quotient
|
|
|
|
|
}
|
2026-09-06 05:22:12 +04:00
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
/// Decodes terrain documents and builds immutable terrain state.
|
2026-06-23 22:32:50 +04:00
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`TerrainPreparationError`] when terrain documents are malformed or
|
|
|
|
|
/// cannot be converted into runtime terrain state.
|
2026-06-23 22:05:16 +04:00
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
/// Stable typed identifier for a prepared asset.
|
2026-06-23 22:32:50 +04:00
|
|
|
#[derive(Debug)]
|
2026-06-22 13:12:27 +04:00
|
|
|
pub struct AssetId<T> {
|
|
|
|
|
raw: u64,
|
|
|
|
|
marker: PhantomData<T>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:32:50 +04:00
|
|
|
impl<T> Clone for AssetId<T> {
|
|
|
|
|
fn clone(&self) -> Self {
|
|
|
|
|
*self
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<T> Copy for AssetId<T> {}
|
|
|
|
|
|
|
|
|
|
impl<T> PartialEq for AssetId<T> {
|
|
|
|
|
fn eq(&self, other: &Self) -> bool {
|
|
|
|
|
self.raw == other.raw
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl<T> Eq for AssetId<T> {}
|
|
|
|
|
|
|
|
|
|
impl<T> Hash for AssetId<T> {
|
|
|
|
|
fn hash<H: Hasher>(&self, state: &mut H) {
|
|
|
|
|
self.raw.hash(state);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
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>,
|
2026-10-11 13:12:50 +04:00
|
|
|
/// Source registry entry type (`EXTO`/`INTO`/etc.) when preserved.
|
|
|
|
|
pub source_type: Option<u32>,
|
2026-07-03 17:11:49 +04:00
|
|
|
/// 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>>,
|
2026-06-22 13:12:27 +04:00
|
|
|
/// 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,
|
2026-06-23 22:05:16 +04:00
|
|
|
/// Typed material IDs available from the resolved visual.
|
|
|
|
|
pub material_ids: Vec<AssetId<PreparedMaterial>>,
|
2026-07-03 17:11:49 +04:00
|
|
|
/// 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>>,
|
2026-06-22 13:12:27 +04:00
|
|
|
/// Number of texture phase requests decoded as TEXM.
|
|
|
|
|
pub texture_count: usize,
|
|
|
|
|
/// Number of lightmap requests decoded as TEXM.
|
|
|
|
|
pub lightmap_count: usize,
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:12:50 +04:00
|
|
|
/// Component-local animation bindings loaded from an explicitly referenced
|
|
|
|
|
/// CTLD resource.
|
|
|
|
|
///
|
|
|
|
|
/// This data is kept beside mission component instances rather than on the
|
|
|
|
|
/// shared visual/model cache, because two instances of one mesh may use
|
|
|
|
|
/// different control resources.
|
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
|
|
|
pub struct PreparedComponentControl {
|
|
|
|
|
/// CTLD resource that supplied these bindings.
|
|
|
|
|
pub source: ResourceKey,
|
2026-10-18 11:02:53 +04:00
|
|
|
/// Number of nodes in the component model this control targets.
|
|
|
|
|
pub model_node_count: usize,
|
2026-10-11 13:12:50 +04:00
|
|
|
/// Enabled source rows in their original order, including duplicate nodes.
|
|
|
|
|
pub bindings: Vec<PreparedControlNodeBinding>,
|
2026-10-18 11:02:53 +04:00
|
|
|
/// Every raw 36-byte animation row in source order. The typed controller
|
|
|
|
|
/// map refers to these original row indices, including disabled rows.
|
|
|
|
|
pub timeline_rows: Vec<PreparedControlTimelineRow>,
|
|
|
|
|
/// Parsed typed records retained for the native controller evaluator.
|
|
|
|
|
pub typed_records: Vec<PreparedControlTypedRecord>,
|
|
|
|
|
/// Explicit NDP sidecar and its validated rows, when resolved.
|
|
|
|
|
pub node_data: Option<PreparedControlNodeDataTable>,
|
2026-10-11 22:17:45 +04:00
|
|
|
/// Parsed native effect rows in this component's control resource.
|
|
|
|
|
pub effect_section: PreparedControlEffectSection,
|
2026-10-11 22:38:53 +04:00
|
|
|
/// Component-local points selected for parsed effect rows, when present.
|
|
|
|
|
pub point_list: Option<PreparedControlPointList>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 11:38:50 +04:00
|
|
|
/// Component-local BAS geometry loaded from an explicitly referenced BAS
|
|
|
|
|
/// resource. This remains aligned with each component instance, not the shared
|
|
|
|
|
/// model cache.
|
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
|
|
|
pub struct PreparedComponentBas {
|
|
|
|
|
/// BAS resource that supplied this geometry.
|
|
|
|
|
pub source: ResourceKey,
|
|
|
|
|
/// Decoded BAS point sections.
|
|
|
|
|
pub geometry: BasGeometry,
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 11:02:53 +04:00
|
|
|
/// Raw and decoded fields from one 36-byte CTLD animation row.
|
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
|
|
|
pub struct PreparedControlTimelineRow {
|
|
|
|
|
/// Original row index, used by typed-record map values.
|
|
|
|
|
pub row_index: usize,
|
|
|
|
|
/// Target node index in the component model.
|
|
|
|
|
pub node_index: usize,
|
|
|
|
|
/// First animation endpoint.
|
|
|
|
|
pub frame_a: f32,
|
|
|
|
|
/// Second animation endpoint.
|
|
|
|
|
pub frame_b: f32,
|
|
|
|
|
/// Initial blend written by native Control during setup.
|
|
|
|
|
pub initial_blend: f32,
|
|
|
|
|
/// Native auxiliary words at row offsets +0x10, +0x14, +0x18, and +0x1C.
|
|
|
|
|
pub raw_parameters: [u32; 4],
|
|
|
|
|
/// Raw native row flags at +0x20.
|
|
|
|
|
pub flags: u32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One parsed 0xB0-byte typed-record body and its following value/string data.
|
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
|
|
|
pub struct PreparedControlTypedRecord {
|
|
|
|
|
/// Native record tag at body offset 0.
|
|
|
|
|
pub tag: u32,
|
|
|
|
|
/// All 44 raw little-endian words from the fixed 0xB0-byte body.
|
|
|
|
|
pub fixed_words: [u32; 44],
|
|
|
|
|
/// Raw values following the fixed body; tag-specific parsing is deferred.
|
|
|
|
|
pub values: Vec<u32>,
|
|
|
|
|
/// Raw bytes following the value array, excluding the required trailing NUL.
|
|
|
|
|
pub string_bytes: Vec<u8>,
|
|
|
|
|
/// Native tag-21 query provider resolved from the explicit NDP sidecar.
|
|
|
|
|
/// Other tags use `NotApplicable`.
|
|
|
|
|
pub gate_provider: PreparedControlGateProvider,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Initial tag-21 query provider resolution for one typed controller record.
|
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
|
|
|
pub enum PreparedControlGateProvider {
|
|
|
|
|
/// The record is not a tag-21 controller.
|
|
|
|
|
NotApplicable,
|
|
|
|
|
/// Its raw key selects a validated NDP row. Native Control initializes
|
|
|
|
|
/// this type-1 query value to 1.0 before runtime state changes.
|
|
|
|
|
Resolved {
|
|
|
|
|
key: u32,
|
|
|
|
|
ndp_source: ResourceKey,
|
|
|
|
|
row_count: usize,
|
|
|
|
|
initial_type1_value: f32,
|
|
|
|
|
},
|
|
|
|
|
/// No NDP reference was present, or its referenced archive/entry is absent.
|
|
|
|
|
MissingResource {
|
|
|
|
|
key: u32,
|
|
|
|
|
ndp_source: Option<ResourceKey>,
|
|
|
|
|
},
|
|
|
|
|
/// The key is not a row in the validated NDP table.
|
|
|
|
|
KeyOutOfRange {
|
|
|
|
|
key: u32,
|
|
|
|
|
ndp_source: ResourceKey,
|
|
|
|
|
row_count: usize,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Parsed NDP sidecar used as the native Control query-provider table.
|
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
|
|
|
pub struct PreparedControlNodeDataTable {
|
|
|
|
|
/// Explicit NDP resource paired with the component prototype.
|
|
|
|
|
pub source: ResourceKey,
|
|
|
|
|
/// Provider rows in their native table order.
|
|
|
|
|
pub rows: Vec<PreparedControlNodeDataRow>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One validated 76-byte NDP sidecar row.
|
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
|
|
|
pub struct PreparedControlNodeDataRow {
|
|
|
|
|
/// Raw native flag bits.
|
|
|
|
|
pub raw_flags: u32,
|
|
|
|
|
/// Maximum volume value at row offset +4.
|
|
|
|
|
pub max_volume: f32,
|
|
|
|
|
/// Mobility-per-volume value at row offset +8.
|
|
|
|
|
pub mobility_per_volume: f32,
|
|
|
|
|
/// Effect resource pair at row offset +12.
|
|
|
|
|
pub effect: PreparedControlResourcePair,
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:38:53 +04:00
|
|
|
/// Decoded point records selected by a component's Control configuration.
|
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
|
|
|
pub struct PreparedControlPointList {
|
|
|
|
|
/// CPT resource paired with the component control resource.
|
|
|
|
|
pub source: ResourceKey,
|
|
|
|
|
/// Point records in their original component-local order.
|
|
|
|
|
pub points: Vec<PreparedControlPoint>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One 36-byte native CPT point record and its parallel fixed-width name.
|
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
|
|
|
pub struct PreparedControlPoint {
|
|
|
|
|
/// Raw native flag bits.
|
|
|
|
|
pub raw_flags: u32,
|
|
|
|
|
/// Raw signed 32-bit word at byte offset +4 in the native record.
|
|
|
|
|
pub raw_signed_field_04: i32,
|
|
|
|
|
/// Raw signed 32-bit word at byte offset +8 in the native record.
|
|
|
|
|
pub raw_signed_field_08: i32,
|
|
|
|
|
/// Three stored coordinate values at byte offset +0x0C.
|
|
|
|
|
pub position: [f32; 3],
|
|
|
|
|
/// Three stored direction values at byte offset +0x18.
|
|
|
|
|
pub direction: [f32; 3],
|
|
|
|
|
/// Parallel raw 32-byte name entry, including NUL padding.
|
|
|
|
|
pub name: [u8; 32],
|
2026-10-11 22:17:45 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Status of the trailing typed/effect data in a CTLD resource.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub enum PreparedControlEffectSection {
|
|
|
|
|
/// The resource advertises no trailing typed or effect data.
|
|
|
|
|
Absent,
|
|
|
|
|
/// The resource contains a layout this reader does not yet understand.
|
|
|
|
|
/// Existing node bindings remain available in this case.
|
|
|
|
|
Unsupported {
|
|
|
|
|
/// Index of the first unparsed typed record, when one was present.
|
|
|
|
|
typed_record_index: Option<usize>,
|
|
|
|
|
/// Native tag on the first unparsed typed record, when available.
|
|
|
|
|
typed_record_tag: Option<u32>,
|
|
|
|
|
},
|
|
|
|
|
/// Effect rows whose native fixed-row layout is understood.
|
|
|
|
|
Parsed(Vec<PreparedControlEffectRow>),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One native type-4 effect row, with point indices kept relative to the
|
|
|
|
|
/// component-local CPT list selected by its control message.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct PreparedControlEffectRow {
|
|
|
|
|
/// Index of the enclosing fixed-size row table.
|
|
|
|
|
pub table_index: usize,
|
|
|
|
|
/// Index within the enclosing fixed-size row table.
|
|
|
|
|
pub row_index: usize,
|
|
|
|
|
/// The first three raw condition words consumed by the native checker.
|
|
|
|
|
pub raw_condition: [u32; 3],
|
|
|
|
|
/// Component-local offsets into the selected point list.
|
|
|
|
|
pub point_offsets: [u32; 3],
|
|
|
|
|
/// The raw native effect selector, including `u32::MAX` auto-selector.
|
|
|
|
|
pub selector: u32,
|
|
|
|
|
/// Raw fixed-width archive/resource strings from the row.
|
|
|
|
|
pub resource: PreparedControlResourcePair,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Fixed-width resource strings carried by a native Control effect row.
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
pub struct PreparedControlResourcePair {
|
|
|
|
|
/// Raw 32-byte archive field, including any trailing NUL padding.
|
|
|
|
|
pub archive: [u8; 32],
|
|
|
|
|
/// Raw 32-byte resource field, including any trailing NUL padding.
|
|
|
|
|
pub name: [u8; 32],
|
2026-10-11 13:12:50 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// One enabled CTLD row targeting a node in a component model.
|
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
|
|
|
pub struct PreparedControlNodeBinding {
|
|
|
|
|
/// Target node index in the component model.
|
|
|
|
|
pub node_index: usize,
|
|
|
|
|
/// First source animation endpoint, kept as an f32 value.
|
|
|
|
|
pub frame_a: f32,
|
|
|
|
|
/// Second source animation endpoint, kept as an f32 value.
|
|
|
|
|
pub frame_b: f32,
|
|
|
|
|
/// Initial source blend value.
|
|
|
|
|
pub initial_blend: f32,
|
|
|
|
|
/// Raw native row flags.
|
|
|
|
|
pub flags: u32,
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 11:02:53 +04:00
|
|
|
/// Per-component instance state for native tag-21 control animation.
|
|
|
|
|
///
|
|
|
|
|
/// Keep one value per model component instance; controls on shared models can
|
|
|
|
|
/// have independent clocks and gate samples.
|
|
|
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
|
|
|
pub struct ControlInstanceState {
|
|
|
|
|
initialized: bool,
|
|
|
|
|
node_times: Vec<Option<f32>>,
|
|
|
|
|
tag21_tracks: Vec<ControlTag21InstanceState>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
|
|
|
struct ControlTag21InstanceState {
|
|
|
|
|
phase: f32,
|
|
|
|
|
segment_start_ms: f32,
|
|
|
|
|
segment_end_ms: f32,
|
|
|
|
|
reciprocal_duration: f32,
|
|
|
|
|
initialized: bool,
|
|
|
|
|
maps: Vec<ControlTag21MapInstanceState>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, PartialEq)]
|
|
|
|
|
struct ControlTag21MapInstanceState {
|
|
|
|
|
row_index: usize,
|
|
|
|
|
previous: f32,
|
|
|
|
|
next: f32,
|
|
|
|
|
current: f32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for ControlTag21InstanceState {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
phase: 0.0,
|
|
|
|
|
segment_start_ms: 0.0,
|
|
|
|
|
segment_end_ms: 0.0,
|
|
|
|
|
reciprocal_duration: 1.0,
|
|
|
|
|
initialized: false,
|
|
|
|
|
maps: Vec::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Input or metadata prevented a safe native control sample.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
|
|
|
pub enum ControlSamplingError {
|
|
|
|
|
/// A state object was reused with a different model node count.
|
|
|
|
|
StateNodeCountMismatch { expected: usize, actual: usize },
|
|
|
|
|
/// A state object was reused with a different number of tag-21 records.
|
|
|
|
|
StateTimelineCountMismatch { expected: usize, actual: usize },
|
|
|
|
|
/// The caller did not provide one query-provider value per tag-21 record.
|
|
|
|
|
GateProviderCountMismatch { expected: usize, actual: usize },
|
|
|
|
|
/// A tag-21 record has no corresponding NDP provider resource.
|
|
|
|
|
MissingGateProvider { record_index: usize, key: u32 },
|
|
|
|
|
/// A tag-21 query key does not select a row in its NDP table.
|
|
|
|
|
GateProviderKeyOutOfRange {
|
|
|
|
|
record_index: usize,
|
|
|
|
|
key: u32,
|
|
|
|
|
row_count: usize,
|
|
|
|
|
},
|
|
|
|
|
/// A resolved provider lacks a finite current type-1 value.
|
|
|
|
|
MissingGateProviderValue { record_index: usize, key: u32 },
|
|
|
|
|
/// The record uses a tag-21 mode whose native selector is not yet decoded.
|
|
|
|
|
UnsupportedTag21Mode { record_index: usize, packed: u32 },
|
|
|
|
|
/// A tag-21 record did not retain a valid source-row map index.
|
|
|
|
|
InvalidTag21MapIndex {
|
|
|
|
|
record_index: usize,
|
|
|
|
|
map_index: usize,
|
|
|
|
|
},
|
|
|
|
|
/// A mapped source row reuses the previous typed row's current value.
|
|
|
|
|
///
|
|
|
|
|
/// This native flag is currently rejected so callers can use their static
|
|
|
|
|
/// fallback instead of silently sampling the wrong row value.
|
|
|
|
|
UnsupportedPreviousRowMapping { row_index: usize },
|
|
|
|
|
/// A mapped raw source row has an invalid rate at +0x18.
|
|
|
|
|
InvalidTransitionRate { row_index: usize },
|
|
|
|
|
/// A mapped raw source row uses external variable selectors at +0x10/+0x14.
|
|
|
|
|
UnsupportedVariableMapping { row_index: usize },
|
|
|
|
|
/// The controller computation produced a non-finite sample.
|
|
|
|
|
NonFiniteSample { record_index: usize },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Samples per-node animation times using the decoded Control timeline.
|
|
|
|
|
///
|
|
|
|
|
/// `native_clock_ms` is the absolute u32 millisecond clock passed to native
|
|
|
|
|
/// Control::Update. `gate_values` carries one provider type-1 float per tag-21
|
|
|
|
|
/// record, in source order. Native query 0x21820 returns 1 when that float is
|
|
|
|
|
/// zero, and the tag-21 update stops when that query returns 1; thus each
|
|
|
|
|
/// record updates when its provider value is nonzero. `None` values are
|
|
|
|
|
/// rejected explicitly. Values are not blend values. The state must be private
|
|
|
|
|
/// to one component instance and retained between calls. Rows and typed maps
|
|
|
|
|
/// are processed in source order, so later rows targeting one node win as in
|
|
|
|
|
/// native Control.
|
|
|
|
|
pub fn sample_control_node_times(
|
|
|
|
|
control: &PreparedComponentControl,
|
|
|
|
|
native_clock_ms: u32,
|
|
|
|
|
gate_values: &[Option<f32>],
|
|
|
|
|
state: &mut ControlInstanceState,
|
|
|
|
|
) -> Result<Vec<Option<f32>>, ControlSamplingError> {
|
|
|
|
|
let node_count = control.model_node_count;
|
|
|
|
|
let tag21_records: Vec<&PreparedControlTypedRecord> = control
|
|
|
|
|
.typed_records
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|record| record.tag == 21)
|
|
|
|
|
.collect();
|
|
|
|
|
if gate_values.len() != tag21_records.len() {
|
|
|
|
|
return Err(ControlSamplingError::GateProviderCountMismatch {
|
|
|
|
|
expected: tag21_records.len(),
|
|
|
|
|
actual: gate_values.len(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if state.initialized {
|
|
|
|
|
if state.node_times.len() != node_count {
|
|
|
|
|
return Err(ControlSamplingError::StateNodeCountMismatch {
|
|
|
|
|
expected: node_count,
|
|
|
|
|
actual: state.node_times.len(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if state.tag21_tracks.len() != tag21_records.len() {
|
|
|
|
|
return Err(ControlSamplingError::StateTimelineCountMismatch {
|
|
|
|
|
expected: tag21_records.len(),
|
|
|
|
|
actual: state.tag21_tracks.len(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
state.node_times = vec![Some(0.0); node_count];
|
|
|
|
|
for binding in &control.bindings {
|
|
|
|
|
if binding.flags & 4 == 0 && binding.node_index < node_count {
|
|
|
|
|
let blend = native_control_blend(binding.initial_blend, binding.flags);
|
|
|
|
|
state.node_times[binding.node_index] =
|
|
|
|
|
Some((1.0 - blend) * binding.frame_a + blend * binding.frame_b);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
state.tag21_tracks = tag21_records
|
|
|
|
|
.iter()
|
|
|
|
|
.enumerate()
|
|
|
|
|
.map(|(record_index, record)| {
|
|
|
|
|
let mut track = ControlTag21InstanceState::default();
|
|
|
|
|
for map_index in &record.values {
|
|
|
|
|
let row_index = usize::try_from(*map_index).unwrap_or(usize::MAX);
|
|
|
|
|
let Some(row) = control.timeline_rows.get(row_index) else {
|
|
|
|
|
return Err(ControlSamplingError::InvalidTag21MapIndex {
|
|
|
|
|
record_index,
|
|
|
|
|
map_index: row_index,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
track.maps.push(ControlTag21MapInstanceState {
|
|
|
|
|
row_index,
|
|
|
|
|
previous: row.initial_blend,
|
|
|
|
|
next: row.initial_blend,
|
|
|
|
|
current: row.initial_blend,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
Ok(track)
|
|
|
|
|
})
|
|
|
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
|
|
|
state.initialized = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let now_ms = native_clock_ms as f32;
|
|
|
|
|
for (record_index, record) in tag21_records.iter().enumerate() {
|
|
|
|
|
let packed = record.fixed_words[2];
|
|
|
|
|
let raw_flags = record.fixed_words[6];
|
|
|
|
|
let controller_flags = if raw_flags == u32::MAX { 5 } else { raw_flags };
|
|
|
|
|
if packed != 0 || controller_flags != 5 {
|
|
|
|
|
return Err(ControlSamplingError::UnsupportedTag21Mode {
|
|
|
|
|
record_index,
|
|
|
|
|
packed,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
let coefficient = f32::from_bits(record.fixed_words[9]);
|
|
|
|
|
let unused_coefficient = f32::from_bits(record.fixed_words[10]);
|
|
|
|
|
if !coefficient.is_finite() || !unused_coefficient.is_finite() {
|
|
|
|
|
return Err(ControlSamplingError::NonFiniteSample { record_index });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let key = record.fixed_words[1];
|
|
|
|
|
match &record.gate_provider {
|
|
|
|
|
PreparedControlGateProvider::Resolved {
|
|
|
|
|
key: provider_key,
|
|
|
|
|
row_count,
|
|
|
|
|
..
|
|
|
|
|
} if *provider_key == key && usize::try_from(key).is_ok_and(|key| key < *row_count) => {
|
|
|
|
|
}
|
|
|
|
|
PreparedControlGateProvider::KeyOutOfRange {
|
|
|
|
|
key: provider_key,
|
|
|
|
|
row_count,
|
|
|
|
|
..
|
|
|
|
|
} if *provider_key == key => {
|
|
|
|
|
return Err(ControlSamplingError::GateProviderKeyOutOfRange {
|
|
|
|
|
record_index,
|
|
|
|
|
key,
|
|
|
|
|
row_count: *row_count,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
PreparedControlGateProvider::MissingResource {
|
|
|
|
|
key: provider_key, ..
|
|
|
|
|
} if *provider_key == key => {
|
|
|
|
|
return Err(ControlSamplingError::MissingGateProvider { record_index, key });
|
|
|
|
|
}
|
|
|
|
|
_ => return Err(ControlSamplingError::MissingGateProvider { record_index, key }),
|
|
|
|
|
}
|
|
|
|
|
let provider_value = gate_values[record_index]
|
|
|
|
|
.ok_or(ControlSamplingError::MissingGateProviderValue { record_index, key })?;
|
|
|
|
|
if !provider_value.is_finite() {
|
|
|
|
|
return Err(ControlSamplingError::MissingGateProviderValue { record_index, key });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let track = &mut state.tag21_tracks[record_index];
|
|
|
|
|
let mut catch_up_steps = 0_usize;
|
|
|
|
|
while !track.initialized || now_ms > track.segment_end_ms {
|
|
|
|
|
if track.initialized {
|
|
|
|
|
// 2D260 calls 21A30 before opening the next interval. Since
|
|
|
|
|
// this loop runs only after `clock > end`, that sample has
|
|
|
|
|
// reached every typed map's next value.
|
|
|
|
|
for mapping in &mut track.maps {
|
|
|
|
|
mapping.current = mapping.next;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if provider_value == 0.0 {
|
|
|
|
|
// A closed native gate keeps the current value and rebases the
|
|
|
|
|
// next transition to this absolute sample time.
|
|
|
|
|
for mapping in &mut track.maps {
|
|
|
|
|
mapping.previous = mapping.current;
|
|
|
|
|
mapping.next = mapping.current;
|
|
|
|
|
}
|
|
|
|
|
track.segment_start_ms = now_ms;
|
|
|
|
|
track.segment_end_ms = now_ms;
|
|
|
|
|
track.reciprocal_duration = 1.0;
|
|
|
|
|
track.initialized = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let direction = match controller_flags & 3 {
|
|
|
|
|
1 => 1.0,
|
|
|
|
|
2 => -1.0,
|
|
|
|
|
_ => 0.0,
|
|
|
|
|
};
|
|
|
|
|
let mut next_phase = track.phase + coefficient * 0.45 * direction;
|
|
|
|
|
if controller_flags & 0x0C == 4 {
|
|
|
|
|
next_phase = wrap_control_value_once(next_phase);
|
|
|
|
|
} else if controller_flags & 0x0C == 8 {
|
|
|
|
|
next_phase = next_phase.clamp(0.0, 1.0);
|
|
|
|
|
}
|
|
|
|
|
let mut duration_ms = 0.0_f32;
|
|
|
|
|
for mapping in &mut track.maps {
|
|
|
|
|
let row = &control.timeline_rows[mapping.row_index];
|
|
|
|
|
if row.flags & 0x40 != 0 {
|
|
|
|
|
return Err(ControlSamplingError::UnsupportedPreviousRowMapping {
|
|
|
|
|
row_index: mapping.row_index,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if row.raw_parameters[0] != u32::MAX || row.raw_parameters[1] != u32::MAX {
|
|
|
|
|
return Err(ControlSamplingError::UnsupportedVariableMapping {
|
|
|
|
|
row_index: mapping.row_index,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
let rate = f32::from_bits(row.raw_parameters[2]);
|
|
|
|
|
if !rate.is_finite() || rate <= 0.0 {
|
|
|
|
|
return Err(ControlSamplingError::InvalidTransitionRate {
|
|
|
|
|
row_index: mapping.row_index,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
mapping.previous = mapping.current;
|
|
|
|
|
mapping.next = next_phase;
|
|
|
|
|
let direct_distance = (mapping.next - mapping.previous).abs();
|
|
|
|
|
let distance = if row.flags & 1 != 0 {
|
|
|
|
|
shortest_wrapped_distance(mapping.previous, mapping.next)
|
|
|
|
|
} else {
|
|
|
|
|
direct_distance
|
|
|
|
|
};
|
|
|
|
|
let transition_speed = coefficient.abs() * rate;
|
|
|
|
|
let candidate_duration = if transition_speed > 0.0 {
|
|
|
|
|
1000.0 * distance / transition_speed
|
|
|
|
|
} else {
|
|
|
|
|
100.0
|
|
|
|
|
};
|
|
|
|
|
if !candidate_duration.is_finite() {
|
|
|
|
|
return Err(ControlSamplingError::NonFiniteSample { record_index });
|
|
|
|
|
}
|
|
|
|
|
duration_ms = duration_ms.max(candidate_duration);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Native Control only substitutes 100 ms when the proposed
|
|
|
|
|
// interval is shorter than 1 ms; ordinary short transitions stay
|
|
|
|
|
// short (for example 22.5 ms at rate 20).
|
|
|
|
|
if duration_ms < 1.0 {
|
|
|
|
|
duration_ms = 100.0;
|
|
|
|
|
}
|
|
|
|
|
track.segment_start_ms = track.segment_end_ms;
|
|
|
|
|
track.phase = next_phase;
|
|
|
|
|
track.segment_end_ms = track.segment_start_ms + duration_ms;
|
|
|
|
|
let actual_duration = track.segment_end_ms - track.segment_start_ms;
|
|
|
|
|
track.reciprocal_duration = if actual_duration > 0.0 {
|
|
|
|
|
1.0 / actual_duration
|
|
|
|
|
} else {
|
|
|
|
|
1.0
|
|
|
|
|
};
|
|
|
|
|
track.initialized = true;
|
|
|
|
|
catch_up_steps += 1;
|
|
|
|
|
if catch_up_steps > 100_000 {
|
|
|
|
|
// shortcut: cap catch-up at 100,000 transitions; replace with a bounded
|
|
|
|
|
// fast-forward if valid long clock stalls must reproduce every transition.
|
|
|
|
|
return Err(ControlSamplingError::NonFiniteSample { record_index });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let duration = track.segment_end_ms - track.segment_start_ms;
|
|
|
|
|
let alpha = if duration > 0.0 {
|
|
|
|
|
((now_ms - track.segment_start_ms) * track.reciprocal_duration).clamp(0.0, 1.0)
|
|
|
|
|
} else {
|
|
|
|
|
1.0
|
|
|
|
|
};
|
|
|
|
|
for mapping in &mut track.maps {
|
|
|
|
|
let row = &control.timeline_rows[mapping.row_index];
|
|
|
|
|
if row.flags & 4 != 0 || row.node_index >= node_count {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let mut delta = mapping.next - mapping.previous;
|
|
|
|
|
if row.flags & 1 != 0 {
|
|
|
|
|
if delta > 0.5 {
|
|
|
|
|
delta -= 1.0;
|
|
|
|
|
} else if delta < -0.5 {
|
|
|
|
|
delta += 1.0;
|
|
|
|
|
}
|
|
|
|
|
mapping.current = wrap_control_value_once(mapping.previous + delta * alpha);
|
|
|
|
|
} else {
|
|
|
|
|
mapping.current = (mapping.previous + delta * alpha).clamp(0.0, 1.0);
|
|
|
|
|
}
|
|
|
|
|
if !mapping.current.is_finite() {
|
|
|
|
|
return Err(ControlSamplingError::NonFiniteSample { record_index });
|
|
|
|
|
}
|
|
|
|
|
let blend = native_control_blend(mapping.current, row.flags);
|
|
|
|
|
let time = (1.0 - blend) * row.frame_a + blend * row.frame_b;
|
|
|
|
|
if !time.is_finite() || time < 0.0 {
|
|
|
|
|
return Err(ControlSamplingError::NonFiniteSample { record_index });
|
|
|
|
|
}
|
|
|
|
|
state.node_times[row.node_index] = Some(time);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(state.node_times.clone())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn shortest_wrapped_distance(previous: f32, next: f32) -> f32 {
|
|
|
|
|
let direct = (next - previous).abs();
|
|
|
|
|
direct.min((1.0 - direct).abs())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn wrap_control_value_once(mut value: f32) -> f32 {
|
|
|
|
|
if value > 1.0 {
|
|
|
|
|
value -= 1.0;
|
|
|
|
|
}
|
|
|
|
|
if value < 0.0 {
|
|
|
|
|
value += 1.0;
|
|
|
|
|
}
|
|
|
|
|
value
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn native_control_blend(mut blend: f32, flags: u32) -> f32 {
|
|
|
|
|
if flags & 1 != 0 {
|
|
|
|
|
if blend > 1.0 {
|
|
|
|
|
blend -= 1.0;
|
|
|
|
|
}
|
|
|
|
|
if blend < 0.0 {
|
|
|
|
|
blend += 1.0;
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
blend = blend.clamp(0.0, 1.0);
|
|
|
|
|
}
|
|
|
|
|
if flags & 2 != 0 {
|
|
|
|
|
blend = 1.0 - blend;
|
|
|
|
|
}
|
|
|
|
|
blend
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 17:11:49 +04:00
|
|
|
/// 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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
/// 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>,
|
2026-07-03 17:11:49 +04:00
|
|
|
/// Source MAT0 resource.
|
|
|
|
|
pub source: ResourceKey,
|
|
|
|
|
/// Parsed material key retained for compatibility with older callers.
|
2026-06-23 22:05:16 +04:00
|
|
|
pub name: ResourceName,
|
2026-10-11 13:12:50 +04:00
|
|
|
/// Raw NRes MAT0 entry flags retained for runtime pipeline selection.
|
|
|
|
|
pub attr1: u32,
|
|
|
|
|
/// Native material pipeline category `(attr1 >> 2) & 0xF`.
|
|
|
|
|
pub pipeline_category: u8,
|
|
|
|
|
/// Native material feature bit `attr1 & 0x40`.
|
|
|
|
|
pub native_flag_40: bool,
|
2026-07-03 17:11:49 +04:00
|
|
|
/// 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,
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 09:52:20 +04:00
|
|
|
impl PreparedTexture {
|
|
|
|
|
/// Decodes one prepared texture mip into the renderer upload representation.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns the original TEXM decode error when the requested mip is invalid
|
|
|
|
|
/// or malformed.
|
|
|
|
|
pub fn decode_mip_rgba8(&self, level: u32) -> Result<RgbaImage, TexmError> {
|
|
|
|
|
decode_mip_rgba8(&self.texm, level)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
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.
|
2026-07-03 17:11:49 +04:00
|
|
|
#[derive(Clone, Debug, Default)]
|
2026-06-23 22:05:16 +04:00
|
|
|
pub struct MissionAssets {
|
2026-07-03 17:11:49 +04:00
|
|
|
/// 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>,
|
2026-06-23 22:05:16 +04:00
|
|
|
/// 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>>>,
|
2026-10-11 13:12:50 +04:00
|
|
|
/// Optional component-local controls aligned with each object's visual
|
|
|
|
|
/// component list. `None` means no CTLD pose override is available because
|
|
|
|
|
/// there is no explicit reference or its referenced archive/entry is absent.
|
|
|
|
|
pub object_component_controls: Vec<Vec<Option<PreparedComponentControl>>>,
|
2026-10-18 11:38:50 +04:00
|
|
|
/// Optional component-local BAS geometry aligned with each object's visual
|
|
|
|
|
/// component list. `None` means there is no explicit BAS reference or its
|
|
|
|
|
/// archive/entry is absent.
|
|
|
|
|
pub object_component_bas: Vec<Vec<Option<PreparedComponentBas>>>,
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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]
|
2026-06-23 22:32:50 +04:00
|
|
|
pub fn visuals_for_object(&self, object_index: usize) -> &[AssetId<PreparedVisual>] {
|
2026-06-23 22:05:16 +04:00
|
|
|
self.object_visuals
|
|
|
|
|
.get(object_index)
|
|
|
|
|
.map_or(&[], |values| values.as_slice())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Returns the first visual for a mission object index.
|
|
|
|
|
#[must_use]
|
2026-06-23 22:32:50 +04:00
|
|
|
pub fn visual_for_object(&self, object_index: usize) -> Option<AssetId<PreparedVisual>> {
|
2026-06-23 22:05:16 +04:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 17:11:49 +04:00
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
/// Converts mission assets into a coarse mission plan.
|
|
|
|
|
#[must_use]
|
|
|
|
|
pub fn to_plan(&self) -> MissionAssetPlan {
|
|
|
|
|
MissionAssetPlan {
|
2026-07-03 17:11:49 +04:00
|
|
|
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(),
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
/// 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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:54:09 +04:00
|
|
|
/// Bounded CPU-side asset preparation limits enforced before renderer upload.
|
2026-06-22 13:12:27 +04:00
|
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
2026-07-03 21:54:09 +04:00
|
|
|
pub struct AssetPreparationLimits {
|
|
|
|
|
/// Maximum number of unique prepared models.
|
|
|
|
|
pub max_models: Option<usize>,
|
|
|
|
|
/// Maximum number of unique prepared WEAR tables.
|
|
|
|
|
pub max_wears: Option<usize>,
|
|
|
|
|
/// Maximum number of unique prepared materials.
|
|
|
|
|
pub max_materials: Option<usize>,
|
|
|
|
|
/// Maximum number of unique prepared textures, including lightmaps.
|
|
|
|
|
pub max_textures: Option<usize>,
|
|
|
|
|
/// Maximum sum of unique texture base-level pixels.
|
|
|
|
|
pub max_texture_pixels: Option<u64>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Summary emitted by bounded asset preparation.
|
|
|
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
|
|
|
pub struct AssetPreparationReport {
|
|
|
|
|
/// Number of unique prepared models.
|
|
|
|
|
pub model_count: usize,
|
|
|
|
|
/// Number of unique prepared WEAR tables.
|
|
|
|
|
pub wear_count: usize,
|
|
|
|
|
/// Number of unique prepared materials.
|
|
|
|
|
pub material_count: usize,
|
|
|
|
|
/// Number of unique prepared textures, including lightmaps.
|
|
|
|
|
pub texture_count: usize,
|
|
|
|
|
/// Sum of unique texture base-level pixels.
|
|
|
|
|
pub texture_pixels: u64,
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Errors raised while preparing CPU-side assets.
|
2026-06-23 22:05:16 +04:00
|
|
|
#[derive(Debug)]
|
2026-06-22 13:12:27 +04:00
|
|
|
pub enum AssetError {
|
|
|
|
|
/// A required cross-resource dependency was not found.
|
|
|
|
|
MissingDependency(String),
|
|
|
|
|
/// A prototype did not describe a usable visual.
|
|
|
|
|
InvalidPrototype(String),
|
2026-07-03 21:54:09 +04:00
|
|
|
/// Asset preparation exceeded explicit limits.
|
|
|
|
|
BudgetExceeded(String),
|
2026-06-22 13:12:27 +04:00
|
|
|
/// A repository operation failed.
|
2026-06-23 22:05:16 +04:00
|
|
|
Resource {
|
|
|
|
|
/// Human context for the operation.
|
|
|
|
|
context: String,
|
|
|
|
|
/// Concrete repository source error.
|
2026-06-23 22:32:50 +04:00
|
|
|
source: Box<ResourceError>,
|
2026-06-23 22:05:16 +04:00
|
|
|
},
|
2026-06-22 13:12:27 +04:00
|
|
|
/// MSH parsing or validation failed.
|
2026-06-23 22:05:16 +04:00
|
|
|
Msh(MshError),
|
2026-06-22 13:12:27 +04:00
|
|
|
/// WEAR/MAT0 parsing or resolution failed.
|
2026-06-23 22:05:16 +04:00
|
|
|
Material(MaterialError),
|
2026-06-22 13:12:27 +04:00
|
|
|
/// TEXM parsing failed.
|
2026-06-23 22:05:16 +04:00
|
|
|
Texture(TexmError),
|
2026-06-23 22:32:50 +04:00
|
|
|
/// `NRes` decoding failed.
|
2026-06-23 22:05:16 +04:00
|
|
|
Nres(NresError),
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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}"),
|
2026-07-03 21:54:09 +04:00
|
|
|
Self::BudgetExceeded(value) => write!(f, "asset budget exceeded: {value}"),
|
2026-06-23 22:05:16 +04:00
|
|
|
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}"),
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
impl std::error::Error for AssetError {
|
|
|
|
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
|
|
|
match self {
|
2026-06-23 22:32:50 +04:00
|
|
|
Self::Resource { source, .. } => Some(source.as_ref()),
|
2026-06-23 22:05:16 +04:00
|
|
|
Self::Msh(source) => Some(source),
|
|
|
|
|
Self::Material(source) => Some(source),
|
|
|
|
|
Self::Texture(source) => Some(source),
|
|
|
|
|
Self::Nres(source) => Some(source),
|
2026-07-03 21:54:09 +04:00
|
|
|
Self::MissingDependency(_) | Self::InvalidPrototype(_) | Self::BudgetExceeded(_) => {
|
|
|
|
|
None
|
|
|
|
|
}
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-22 13:12:27 +04:00
|
|
|
|
|
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
/// 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> {
|
2026-06-23 22:32:50 +04:00
|
|
|
prepare_mission_assets_with_repository(&self.repository, root_prototype_spans, prototypes)
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:54:09 +04:00
|
|
|
/// Builds mission assets together with a bounded preparation report.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`AssetError`] if any visual dependency is missing or malformed,
|
|
|
|
|
/// or when explicit limits are exceeded.
|
|
|
|
|
pub fn prepare_mission_assets_profiled(
|
|
|
|
|
&self,
|
|
|
|
|
root_prototype_spans: &[std::ops::Range<usize>],
|
|
|
|
|
prototypes: &[EffectivePrototype],
|
|
|
|
|
limits: AssetPreparationLimits,
|
|
|
|
|
) -> Result<(MissionAssets, AssetPreparationReport), AssetError> {
|
|
|
|
|
prepare_mission_assets_profiled_with_repository(
|
|
|
|
|
&self.repository,
|
|
|
|
|
root_prototype_spans,
|
|
|
|
|
prototypes,
|
|
|
|
|
limits,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
/// Builds a mission plan by preparing each resolved prototype.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`AssetError`] if any visual dependency is missing or malformed.
|
2026-06-23 22:05:16 +04:00
|
|
|
pub fn build_mission_asset_plan(
|
2026-06-22 13:12:27 +04:00
|
|
|
&self,
|
2026-06-23 22:05:16 +04:00
|
|
|
prototypes: &[EffectivePrototype],
|
2026-06-22 13:12:27 +04:00
|
|
|
) -> 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 {
|
2026-06-23 22:05:16 +04:00
|
|
|
let visual_count = graph
|
|
|
|
|
.nodes
|
|
|
|
|
.iter()
|
|
|
|
|
.filter(|node| node.kind == PrototypeGraphNodeKind::Prototype)
|
|
|
|
|
.count();
|
2026-06-22 13:12:27 +04:00
|
|
|
MissionAssetPlan {
|
2026-06-23 22:05:16 +04:00
|
|
|
visual_count,
|
2026-06-22 13:12:27 +04:00
|
|
|
..MissionAssetPlan::default()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Builds a fully validated CPU-side mission asset plan.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`AssetError`] if any reachable visual dependency is missing or
|
|
|
|
|
/// malformed.
|
2026-06-23 22:05:16 +04:00
|
|
|
pub fn build_mission_asset_plan_with_repository<R: ResourceRepository>(
|
2026-06-22 13:12:27 +04:00
|
|
|
repository: &R,
|
2026-06-23 22:05:16 +04:00
|
|
|
prototypes: &[EffectivePrototype],
|
2026-06-22 13:12:27 +04:00
|
|
|
) -> Result<MissionAssetPlan, AssetError> {
|
2026-06-23 22:32:50 +04:00
|
|
|
let full_span = 0..prototypes.len();
|
|
|
|
|
let mission_assets = prepare_mission_assets_with_repository(
|
|
|
|
|
repository,
|
|
|
|
|
std::slice::from_ref(&full_span),
|
|
|
|
|
prototypes,
|
|
|
|
|
)?;
|
2026-06-23 22:05:16 +04:00
|
|
|
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],
|
2026-07-03 21:49:42 +04:00
|
|
|
) -> Result<MissionAssets, AssetError> {
|
2026-07-18 05:28:07 +04:00
|
|
|
Ok(prepare_mission_assets_profiled_with_repository(
|
|
|
|
|
repository,
|
|
|
|
|
root_prototype_spans,
|
|
|
|
|
prototypes,
|
|
|
|
|
AssetPreparationLimits::default(),
|
|
|
|
|
)?
|
|
|
|
|
.0)
|
2026-07-03 21:54:09 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Builds mission assets while enforcing explicit preparation limits.
|
|
|
|
|
///
|
|
|
|
|
/// # Errors
|
|
|
|
|
///
|
|
|
|
|
/// Returns [`AssetError`] if any visual dependency is missing or malformed, or
|
|
|
|
|
/// when explicit limits are exceeded.
|
|
|
|
|
pub fn prepare_mission_assets_profiled_with_repository<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
root_prototype_spans: &[std::ops::Range<usize>],
|
|
|
|
|
prototypes: &[EffectivePrototype],
|
|
|
|
|
limits: AssetPreparationLimits,
|
|
|
|
|
) -> Result<(MissionAssets, AssetPreparationReport), AssetError> {
|
2026-07-03 21:49:42 +04:00
|
|
|
prepare_mission_assets_with_repository_internal(
|
|
|
|
|
repository,
|
|
|
|
|
root_prototype_spans,
|
|
|
|
|
prototypes,
|
|
|
|
|
AssetIdentityPolicy::default(),
|
2026-07-03 21:54:09 +04:00
|
|
|
limits,
|
2026-07-03 21:49:42 +04:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 05:38:27 +04:00
|
|
|
// This orchestration intentionally keeps preparation, deduplication, and limit enforcement
|
|
|
|
|
// together so all fallible asset construction remains transactional.
|
2026-09-06 05:22:12 +04:00
|
|
|
|
2026-07-03 21:49:42 +04:00
|
|
|
fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
root_prototype_spans: &[std::ops::Range<usize>],
|
|
|
|
|
prototypes: &[EffectivePrototype],
|
|
|
|
|
identity_policy: AssetIdentityPolicy,
|
2026-07-03 21:54:09 +04:00
|
|
|
limits: AssetPreparationLimits,
|
|
|
|
|
) -> Result<(MissionAssets, AssetPreparationReport), AssetError> {
|
2026-06-23 22:05:16 +04:00
|
|
|
if prototypes.is_empty() {
|
2026-07-03 21:54:09 +04:00
|
|
|
return Ok((MissionAssets::default(), AssetPreparationReport::default()));
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
|
|
|
|
let mut visual_index_by_id: HashMap<AssetId<PreparedVisual>, PreparedVisualSignature> =
|
|
|
|
|
HashMap::new();
|
2026-07-03 21:49:42 +04:00
|
|
|
let mut model_index_by_id: HashMap<AssetId<PreparedModel>, PreparedModelSignature> =
|
|
|
|
|
HashMap::new();
|
|
|
|
|
let mut wear_index_by_id: HashMap<AssetId<PreparedWear>, PreparedWearSignature> =
|
|
|
|
|
HashMap::new();
|
|
|
|
|
let mut material_index_by_id: HashMap<AssetId<PreparedMaterial>, PreparedMaterialSignature> =
|
|
|
|
|
HashMap::new();
|
|
|
|
|
let mut texture_index_by_id: HashMap<AssetId<PreparedTexture>, PreparedTextureSignature> =
|
|
|
|
|
HashMap::new();
|
2026-07-03 17:11:49 +04:00
|
|
|
let mut models = Vec::new();
|
|
|
|
|
let mut wears = Vec::new();
|
|
|
|
|
let mut materials = Vec::new();
|
|
|
|
|
let mut textures = Vec::new();
|
2026-06-23 22:05:16 +04:00
|
|
|
let mut visuals = Vec::new();
|
|
|
|
|
let mut prototype_visual_ids = Vec::with_capacity(prototypes.len());
|
2026-07-18 06:35:20 +04:00
|
|
|
let mut preparation_cache = PreparationCache::default();
|
2026-06-22 13:12:27 +04:00
|
|
|
|
|
|
|
|
for proto in prototypes {
|
2026-07-18 05:38:27 +04:00
|
|
|
let visual_id = AssetId::new((identity_policy.visual)(proto));
|
2026-06-23 22:05:16 +04:00
|
|
|
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);
|
2026-07-18 06:29:29 +04:00
|
|
|
let bundle = prepare_visual_with_repository_internal(
|
|
|
|
|
repository,
|
|
|
|
|
proto,
|
|
|
|
|
identity_policy,
|
2026-07-18 06:35:20 +04:00
|
|
|
&mut preparation_cache,
|
2026-07-18 06:29:29 +04:00
|
|
|
)?;
|
2026-07-03 17:11:49 +04:00
|
|
|
if bundle.visual.id != visual_id {
|
2026-06-23 22:05:16 +04:00
|
|
|
// Defensive check. stable IDs are deterministic for the same inputs.
|
|
|
|
|
return Err(AssetError::InvalidPrototype(
|
|
|
|
|
"prepared visual id changed during preparation".to_string(),
|
|
|
|
|
));
|
|
|
|
|
}
|
2026-07-03 17:11:49 +04:00
|
|
|
if let Some(model) = bundle.model {
|
2026-07-03 21:49:42 +04:00
|
|
|
if insert_asset_signature(
|
|
|
|
|
&mut model_index_by_id,
|
|
|
|
|
model.id,
|
|
|
|
|
prepared_model_signature(&model),
|
|
|
|
|
"model",
|
|
|
|
|
)? {
|
2026-07-03 17:11:49 +04:00
|
|
|
models.push(model);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let Some(wear) = bundle.wear {
|
2026-07-03 21:49:42 +04:00
|
|
|
if insert_asset_signature(
|
|
|
|
|
&mut wear_index_by_id,
|
|
|
|
|
wear.id,
|
|
|
|
|
prepared_wear_signature(&wear),
|
|
|
|
|
"wear",
|
|
|
|
|
)? {
|
2026-07-03 17:11:49 +04:00
|
|
|
wears.push(wear);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for material in bundle.materials {
|
2026-07-03 21:49:42 +04:00
|
|
|
if insert_asset_signature(
|
|
|
|
|
&mut material_index_by_id,
|
|
|
|
|
material.id,
|
|
|
|
|
prepared_material_signature(&material),
|
|
|
|
|
"material",
|
|
|
|
|
)? {
|
2026-07-03 17:11:49 +04:00
|
|
|
materials.push(material);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for texture in bundle.textures {
|
2026-07-03 21:49:42 +04:00
|
|
|
if insert_asset_signature(
|
|
|
|
|
&mut texture_index_by_id,
|
|
|
|
|
texture.id,
|
|
|
|
|
prepared_texture_signature(&texture),
|
|
|
|
|
"texture",
|
|
|
|
|
)? {
|
2026-07-03 17:11:49 +04:00
|
|
|
textures.push(texture);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
visuals.push(bundle.visual);
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
prototype_visual_ids.push(visual_id);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:12:50 +04:00
|
|
|
let mut prototype_component_controls = Vec::with_capacity(prototypes.len());
|
2026-10-18 11:38:50 +04:00
|
|
|
let mut prototype_component_bas = Vec::with_capacity(prototypes.len());
|
2026-10-11 13:12:50 +04:00
|
|
|
for (prototype_index, prototype) in prototypes.iter().enumerate() {
|
2026-10-18 11:38:50 +04:00
|
|
|
let bas = match prototype.bas_resource.clone() {
|
|
|
|
|
Some(source) => {
|
|
|
|
|
prepare_component_bas_from_resource(repository, source, prototype_index)?
|
|
|
|
|
}
|
|
|
|
|
None => None,
|
|
|
|
|
};
|
|
|
|
|
prototype_component_bas.push(bas);
|
2026-10-11 13:12:50 +04:00
|
|
|
let Some(source) = prototype.control_resource.clone() else {
|
|
|
|
|
prototype_component_controls.push(None);
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let PrototypeGeometry::Mesh(mesh_key) = &prototype.geometry else {
|
|
|
|
|
prototype_component_controls.push(None);
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let model = models
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|model| model.source == *mesh_key)
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
AssetError::InvalidPrototype(format!(
|
|
|
|
|
"prototype {} references control resource {}:{} but its prepared model is missing",
|
|
|
|
|
String::from_utf8_lossy(&prototype.key.0.0),
|
|
|
|
|
source.archive.as_str(),
|
|
|
|
|
String::from_utf8_lossy(&source.name.0),
|
|
|
|
|
))
|
|
|
|
|
})?;
|
|
|
|
|
let control = prepare_component_control_from_resource(
|
|
|
|
|
repository,
|
|
|
|
|
source,
|
2026-10-11 22:38:53 +04:00
|
|
|
prototype.point_resource.clone(),
|
2026-10-18 11:02:53 +04:00
|
|
|
prototype.node_data_resource.clone(),
|
2026-10-11 13:12:50 +04:00
|
|
|
model.validated.node_count,
|
|
|
|
|
prototype_index,
|
|
|
|
|
)?;
|
|
|
|
|
prototype_component_controls.push(control);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
let mut object_visuals = Vec::with_capacity(root_prototype_spans.len());
|
2026-10-11 13:12:50 +04:00
|
|
|
let mut object_component_controls = Vec::with_capacity(root_prototype_spans.len());
|
2026-10-18 11:38:50 +04:00
|
|
|
let mut object_component_bas = Vec::with_capacity(root_prototype_spans.len());
|
2026-06-23 22:05:16 +04:00
|
|
|
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:?}"
|
|
|
|
|
)));
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
2026-06-23 22:05:16 +04:00
|
|
|
let mut ids = Vec::new();
|
2026-10-11 13:12:50 +04:00
|
|
|
let mut controls = Vec::new();
|
2026-10-18 11:38:50 +04:00
|
|
|
let mut bases = Vec::new();
|
2026-06-23 22:05:16 +04:00
|
|
|
for index in span.clone() {
|
|
|
|
|
let visual_id = prototype_visual_ids[index];
|
2026-10-11 13:12:50 +04:00
|
|
|
// A unit DAT is an instance tree. Reusing one prepared visual
|
|
|
|
|
// must not collapse two component instances (for example the two
|
|
|
|
|
// identical cannons on w_m_wlk2).
|
|
|
|
|
ids.push(visual_id);
|
|
|
|
|
controls.push(prototype_component_controls[index].clone());
|
2026-10-18 11:38:50 +04:00
|
|
|
bases.push(prototype_component_bas[index].clone());
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
2026-06-23 22:05:16 +04:00
|
|
|
object_visuals.push(ids);
|
2026-10-11 13:12:50 +04:00
|
|
|
object_component_controls.push(controls);
|
2026-10-18 11:38:50 +04:00
|
|
|
object_component_bas.push(bases);
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:54:09 +04:00
|
|
|
let assets = MissionAssets {
|
2026-07-03 17:11:49 +04:00
|
|
|
models,
|
|
|
|
|
wears,
|
|
|
|
|
materials,
|
|
|
|
|
textures,
|
2026-06-23 22:05:16 +04:00
|
|
|
visuals,
|
|
|
|
|
object_visuals,
|
2026-10-11 13:12:50 +04:00
|
|
|
object_component_controls,
|
2026-10-18 11:38:50 +04:00
|
|
|
object_component_bas,
|
2026-07-03 21:54:09 +04:00
|
|
|
};
|
|
|
|
|
let report = AssetPreparationReport {
|
|
|
|
|
model_count: assets.models.len(),
|
|
|
|
|
wear_count: assets.wears.len(),
|
|
|
|
|
material_count: assets.materials.len(),
|
|
|
|
|
texture_count: assets.textures.len(),
|
|
|
|
|
texture_pixels: assets
|
|
|
|
|
.textures
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|texture| u64::from(texture.texm.width()) * u64::from(texture.texm.height()))
|
|
|
|
|
.sum(),
|
|
|
|
|
};
|
|
|
|
|
enforce_asset_preparation_limits(&report, limits)?;
|
|
|
|
|
Ok((assets, report))
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
enum PreparedVisualSignature {
|
|
|
|
|
Mesh {
|
2026-07-03 16:58:17 +04:00
|
|
|
archive: Vec<u8>,
|
2026-06-23 22:05:16 +04:00
|
|
|
name: Vec<u8>,
|
|
|
|
|
type_id: Option<u32>,
|
2026-10-11 13:12:50 +04:00
|
|
|
source_type: Option<u32>,
|
2026-06-23 22:05:16 +04:00
|
|
|
dependency_count: usize,
|
|
|
|
|
},
|
|
|
|
|
NonGeometric {
|
2026-10-11 13:12:50 +04:00
|
|
|
source_type: Option<u32>,
|
2026-06-23 22:05:16 +04:00
|
|
|
dependency_count: usize,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:49:42 +04:00
|
|
|
#[derive(Clone, Copy)]
|
|
|
|
|
struct AssetIdentityPolicy {
|
2026-07-18 05:38:27 +04:00
|
|
|
visual: fn(&EffectivePrototype) -> u64,
|
|
|
|
|
model: fn(&EffectivePrototype) -> u64,
|
|
|
|
|
wear: fn(&EffectivePrototype) -> u64,
|
|
|
|
|
material: fn(&EffectivePrototype, u16, &ResourceName) -> u64,
|
|
|
|
|
texture: fn(&ResourceKey, PreparedTextureUsage) -> u64,
|
2026-07-03 21:49:42 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for AssetIdentityPolicy {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
2026-07-18 05:38:27 +04:00
|
|
|
visual: stable_visual_id,
|
|
|
|
|
model: stable_model_id,
|
|
|
|
|
wear: stable_wear_id,
|
|
|
|
|
material: stable_material_id,
|
|
|
|
|
texture: stable_texture_id,
|
2026-07-03 21:49:42 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
struct ResourceSignature {
|
|
|
|
|
archive: Vec<u8>,
|
|
|
|
|
name: Vec<u8>,
|
|
|
|
|
type_id: Option<u32>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
struct PreparedModelSignature {
|
|
|
|
|
source: ResourceSignature,
|
|
|
|
|
node_count: usize,
|
|
|
|
|
slot_count: usize,
|
|
|
|
|
batch_count: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
struct PreparedWearSignature {
|
|
|
|
|
source: ResourceSignature,
|
|
|
|
|
material_slots: usize,
|
|
|
|
|
lightmap_slots: usize,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
struct PreparedMaterialSignature {
|
|
|
|
|
source: ResourceSignature,
|
2026-10-11 13:12:50 +04:00
|
|
|
attr1: u32,
|
|
|
|
|
pipeline_category: u8,
|
|
|
|
|
native_flag_40: bool,
|
2026-07-03 21:49:42 +04:00
|
|
|
texture_requests: Vec<Vec<u8>>,
|
|
|
|
|
lightmap_requests: Vec<Vec<u8>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
|
|
|
struct PreparedTextureSignature {
|
|
|
|
|
source: ResourceSignature,
|
|
|
|
|
usage: PreparedTextureUsage,
|
|
|
|
|
width: u32,
|
|
|
|
|
height: u32,
|
|
|
|
|
mip_count: usize,
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
fn prepared_visual_signature(proto: &EffectivePrototype) -> PreparedVisualSignature {
|
|
|
|
|
match &proto.geometry {
|
|
|
|
|
PrototypeGeometry::Mesh(key) => PreparedVisualSignature::Mesh {
|
2026-07-03 16:58:17 +04:00
|
|
|
archive: key.archive.identity_bytes().to_vec(),
|
2026-06-23 22:05:16 +04:00
|
|
|
name: key.name.0.clone(),
|
|
|
|
|
type_id: key.type_id,
|
2026-10-11 13:12:50 +04:00
|
|
|
source_type: proto.source_type,
|
2026-06-23 22:05:16 +04:00
|
|
|
dependency_count: proto.dependencies.len(),
|
|
|
|
|
},
|
|
|
|
|
PrototypeGeometry::NonGeometric => PreparedVisualSignature::NonGeometric {
|
2026-10-11 13:12:50 +04:00
|
|
|
source_type: proto.source_type,
|
2026-06-23 22:05:16 +04:00
|
|
|
dependency_count: proto.dependencies.len(),
|
|
|
|
|
},
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:49:42 +04:00
|
|
|
fn resource_signature(key: &ResourceKey) -> ResourceSignature {
|
|
|
|
|
ResourceSignature {
|
|
|
|
|
archive: key.archive.identity_bytes().to_vec(),
|
|
|
|
|
name: key.name.0.clone(),
|
|
|
|
|
type_id: key.type_id,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn prepared_model_signature(model: &PreparedModel) -> PreparedModelSignature {
|
|
|
|
|
PreparedModelSignature {
|
|
|
|
|
source: resource_signature(&model.source),
|
|
|
|
|
node_count: model.validated.node_count,
|
|
|
|
|
slot_count: model.validated.slots.len(),
|
|
|
|
|
batch_count: model.validated.batches.len(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn prepared_wear_signature(wear: &PreparedWear) -> PreparedWearSignature {
|
|
|
|
|
PreparedWearSignature {
|
|
|
|
|
source: resource_signature(&wear.source),
|
|
|
|
|
material_slots: wear.table.entries.len(),
|
|
|
|
|
lightmap_slots: wear.table.lightmaps.len(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn prepared_material_signature(material: &PreparedMaterial) -> PreparedMaterialSignature {
|
|
|
|
|
PreparedMaterialSignature {
|
|
|
|
|
source: resource_signature(&material.source),
|
2026-10-11 13:12:50 +04:00
|
|
|
attr1: material.attr1,
|
|
|
|
|
pipeline_category: material.pipeline_category,
|
|
|
|
|
native_flag_40: material.native_flag_40,
|
2026-07-03 21:49:42 +04:00
|
|
|
texture_requests: material
|
|
|
|
|
.texture_requests
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|name| name.0.clone())
|
|
|
|
|
.collect(),
|
|
|
|
|
lightmap_requests: material
|
|
|
|
|
.lightmap_requests
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|name| name.0.clone())
|
|
|
|
|
.collect(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn prepared_texture_signature(texture: &PreparedTexture) -> PreparedTextureSignature {
|
|
|
|
|
PreparedTextureSignature {
|
|
|
|
|
source: resource_signature(&texture.source),
|
|
|
|
|
usage: texture.usage,
|
|
|
|
|
width: texture.texm.width(),
|
|
|
|
|
height: texture.texm.height(),
|
|
|
|
|
mip_count: texture.texm.mip_count(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn insert_asset_signature<T, S>(
|
|
|
|
|
signatures: &mut HashMap<AssetId<T>, S>,
|
|
|
|
|
id: AssetId<T>,
|
|
|
|
|
signature: S,
|
|
|
|
|
label: &str,
|
|
|
|
|
) -> Result<bool, AssetError>
|
|
|
|
|
where
|
|
|
|
|
S: Eq,
|
|
|
|
|
{
|
|
|
|
|
match signatures.entry(id) {
|
|
|
|
|
Entry::Occupied(existing) => {
|
|
|
|
|
if existing.get() != &signature {
|
|
|
|
|
return Err(AssetError::InvalidPrototype(format!(
|
|
|
|
|
"stable {label} id collision between unrelated assets"
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
Ok(false)
|
|
|
|
|
}
|
|
|
|
|
Entry::Vacant(entry) => {
|
|
|
|
|
entry.insert(signature);
|
|
|
|
|
Ok(true)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:54:09 +04:00
|
|
|
fn enforce_asset_preparation_limits(
|
|
|
|
|
report: &AssetPreparationReport,
|
|
|
|
|
limits: AssetPreparationLimits,
|
|
|
|
|
) -> Result<(), AssetError> {
|
|
|
|
|
if let Some(limit) = limits.max_models {
|
|
|
|
|
if report.model_count > limit {
|
|
|
|
|
return Err(AssetError::BudgetExceeded(format!(
|
|
|
|
|
"models={} exceeds limit={limit}",
|
|
|
|
|
report.model_count
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let Some(limit) = limits.max_wears {
|
|
|
|
|
if report.wear_count > limit {
|
|
|
|
|
return Err(AssetError::BudgetExceeded(format!(
|
|
|
|
|
"wears={} exceeds limit={limit}",
|
|
|
|
|
report.wear_count
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let Some(limit) = limits.max_materials {
|
|
|
|
|
if report.material_count > limit {
|
|
|
|
|
return Err(AssetError::BudgetExceeded(format!(
|
|
|
|
|
"materials={} exceeds limit={limit}",
|
|
|
|
|
report.material_count
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let Some(limit) = limits.max_textures {
|
|
|
|
|
if report.texture_count > limit {
|
|
|
|
|
return Err(AssetError::BudgetExceeded(format!(
|
|
|
|
|
"textures={} exceeds limit={limit}",
|
|
|
|
|
report.texture_count
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if let Some(limit) = limits.max_texture_pixels {
|
|
|
|
|
if report.texture_pixels > limit {
|
|
|
|
|
return Err(AssetError::BudgetExceeded(format!(
|
|
|
|
|
"texture_pixels={} exceeds limit={limit}",
|
|
|
|
|
report.texture_pixels
|
|
|
|
|
)));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
/// 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.
|
2026-07-18 05:38:27 +04:00
|
|
|
///
|
|
|
|
|
/// # Panics
|
|
|
|
|
///
|
|
|
|
|
/// Panics only if the hard-coded, host-compatible `material.lib` path stops satisfying
|
|
|
|
|
/// the path policy, which indicates a programming error in this module.
|
|
|
|
|
// The graph expansion is deliberately a single transactional traversal so every generated
|
|
|
|
|
// edge receives the correct parent provenance and node/edge sequence.
|
2026-07-18 11:43:33 +04:00
|
|
|
|
2026-09-06 05:22:12 +04:00
|
|
|
pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
|
2026-07-18 12:16:45 +04:00
|
|
|
repository: &R,
|
|
|
|
|
report: &mut PrototypeGraphReport,
|
|
|
|
|
graph: &mut PrototypeGraph,
|
|
|
|
|
prototypes: &[EffectivePrototype],
|
2026-06-23 22:05:16 +04:00
|
|
|
) {
|
2026-07-03 21:54:09 +04:00
|
|
|
if graph.visual_dependencies_expanded {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-09-06 05:22:12 +04:00
|
|
|
|
2026-07-03 17:03:40 +04:00
|
|
|
let material_archive = parse_path("material.lib")
|
|
|
|
|
.expect("static material archive path must satisfy host-compatible normalization");
|
|
|
|
|
let mut next_node = graph
|
|
|
|
|
.nodes
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|node| node.id.0)
|
|
|
|
|
.max()
|
|
|
|
|
.map_or(0, |value| value.saturating_add(1));
|
|
|
|
|
let mut next_edge = graph
|
|
|
|
|
.edges
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|edge| edge.id.0)
|
|
|
|
|
.max()
|
|
|
|
|
.map_or(0, |value| value.saturating_add(1));
|
2026-07-18 11:06:38 +04:00
|
|
|
let mut diffuse_texture_validation = HashMap::new();
|
|
|
|
|
let mut lightmap_texture_validation = HashMap::new();
|
2026-07-18 11:24:31 +04:00
|
|
|
let mut material_validation = HashMap::new();
|
2026-07-18 13:17:06 +04:00
|
|
|
let mut wear_validation = HashMap::new();
|
2026-07-18 12:24:35 +04:00
|
|
|
// Capture all base-graph anchors before this traversal appends visual
|
|
|
|
|
// resources and edges. Looking them up again from the growing graph would
|
|
|
|
|
// make each later prototype scan an ever larger collection.
|
|
|
|
|
let prototype_mesh_anchors: Vec<_> = (0..prototypes.len())
|
|
|
|
|
.map(|prototype_index| {
|
|
|
|
|
let prototype_node_id = prototype_node_id(graph, prototype_index)?;
|
|
|
|
|
let mesh_node_id = prototype_mesh_node_id(graph, prototype_node_id)?;
|
|
|
|
|
Some((mesh_node_id, mesh_edge_id(graph, prototype_node_id)))
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
2026-06-22 13:12:27 +04:00
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
for (prototype_index, prototype) in prototypes.iter().enumerate() {
|
|
|
|
|
let PrototypeGeometry::Mesh(mesh) = &prototype.geometry else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
report.wear_request_count += 1;
|
2026-07-18 12:24:35 +04:00
|
|
|
let Some((mesh_node_id, mesh_parent_edge)) = prototype_mesh_anchors
|
|
|
|
|
.get(prototype_index)
|
|
|
|
|
.copied()
|
|
|
|
|
.flatten()
|
|
|
|
|
else {
|
2026-07-03 17:03:40 +04:00
|
|
|
continue;
|
|
|
|
|
};
|
|
|
|
|
let root_index = root_index_for_prototype(graph, prototype_index);
|
2026-07-18 20:26:15 +04:00
|
|
|
let unit_component_index =
|
|
|
|
|
mesh_parent_edge.and_then(|edge| unit_component_index_for_edge(graph, edge));
|
2026-06-23 22:05:16 +04:00
|
|
|
|
2026-07-18 13:17:06 +04:00
|
|
|
match resolve_wear_table_cached(repository, mesh, &mut wear_validation) {
|
2026-06-23 22:05:16 +04:00
|
|
|
Ok(table) => {
|
|
|
|
|
report.wear_resolved_count += 1;
|
2026-07-03 17:03:40 +04:00
|
|
|
let wear_key = match wear_resource_key(mesh) {
|
|
|
|
|
Ok(key) => key,
|
|
|
|
|
Err(message) => {
|
|
|
|
|
push_visual_failure(
|
|
|
|
|
report,
|
|
|
|
|
graph,
|
|
|
|
|
prototype_index,
|
|
|
|
|
mesh.name.0.clone(),
|
|
|
|
|
PrototypeGraphEdge::MeshToWear,
|
|
|
|
|
PrototypeGraphRequiredness::Required,
|
|
|
|
|
&message.to_string(),
|
|
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
let wear_node_id = push_graph_resource_node(
|
|
|
|
|
graph,
|
|
|
|
|
PrototypeGraphNodeKind::WearResource,
|
|
|
|
|
wear_key.clone(),
|
|
|
|
|
&mut next_node,
|
|
|
|
|
);
|
|
|
|
|
let wear_edge_id = push_graph_edge(
|
|
|
|
|
graph,
|
|
|
|
|
mesh_node_id,
|
|
|
|
|
wear_node_id,
|
|
|
|
|
fparkan_prototype::PrototypeGraphEdgeKind::MeshToWear,
|
|
|
|
|
PrototypeGraphRequiredness::Required,
|
2026-07-03 21:44:26 +04:00
|
|
|
Some(provenance_for_resource(
|
|
|
|
|
root_index,
|
|
|
|
|
mesh_parent_edge,
|
2026-07-18 20:26:15 +04:00
|
|
|
unit_component_index,
|
2026-07-03 21:44:26 +04:00
|
|
|
&wear_key,
|
|
|
|
|
)),
|
2026-07-03 17:03:40 +04:00
|
|
|
&mut next_edge,
|
|
|
|
|
);
|
2026-06-23 22:05:16 +04:00
|
|
|
report.material_slot_count += table.entries.len();
|
|
|
|
|
for (material_index, _entry) in table.entries.iter().enumerate() {
|
2026-06-23 22:32:50 +04:00
|
|
|
let Ok(material_index) = u16::try_from(material_index) else {
|
2026-06-23 22:05:16 +04:00
|
|
|
push_visual_failure(
|
|
|
|
|
report,
|
|
|
|
|
graph,
|
|
|
|
|
prototype_index,
|
|
|
|
|
mesh.name.0.clone(),
|
|
|
|
|
PrototypeGraphEdge::WearToMaterial,
|
|
|
|
|
PrototypeGraphRequiredness::Required,
|
|
|
|
|
"material index does not fit archive format",
|
|
|
|
|
);
|
|
|
|
|
continue;
|
|
|
|
|
};
|
2026-07-18 11:24:31 +04:00
|
|
|
match resolve_material_validation_cached(
|
|
|
|
|
repository,
|
|
|
|
|
&table,
|
|
|
|
|
material_index,
|
|
|
|
|
&mut material_validation,
|
|
|
|
|
) {
|
2026-06-23 22:05:16 +04:00
|
|
|
Ok(material) => {
|
|
|
|
|
report.material_resolved_count += 1;
|
2026-07-03 17:03:40 +04:00
|
|
|
let material_key = ResourceKey {
|
|
|
|
|
archive: material_archive.clone(),
|
|
|
|
|
name: material.name.clone(),
|
|
|
|
|
type_id: Some(MAT0_KIND),
|
|
|
|
|
};
|
|
|
|
|
let material_node_id = push_graph_resource_node(
|
|
|
|
|
graph,
|
|
|
|
|
PrototypeGraphNodeKind::MaterialResource,
|
|
|
|
|
material_key.clone(),
|
|
|
|
|
&mut next_node,
|
|
|
|
|
);
|
|
|
|
|
let material_edge_id = push_graph_edge(
|
|
|
|
|
graph,
|
|
|
|
|
wear_node_id,
|
|
|
|
|
material_node_id,
|
|
|
|
|
fparkan_prototype::PrototypeGraphEdgeKind::WearToMaterial,
|
|
|
|
|
PrototypeGraphRequiredness::Required,
|
|
|
|
|
Some(provenance_for_resource(
|
|
|
|
|
root_index,
|
|
|
|
|
Some(wear_edge_id),
|
2026-07-18 20:26:15 +04:00
|
|
|
unit_component_index,
|
2026-07-03 17:03:40 +04:00
|
|
|
&material_key,
|
|
|
|
|
)),
|
|
|
|
|
&mut next_edge,
|
|
|
|
|
);
|
2026-06-23 22:05:16 +04:00
|
|
|
for texture in material.document.texture_requests() {
|
|
|
|
|
report.texture_request_count += 1;
|
2026-07-18 11:06:38 +04:00
|
|
|
match resolve_texm_validation_cached(
|
|
|
|
|
repository,
|
|
|
|
|
&texture,
|
|
|
|
|
TEXTURES_ARCHIVE,
|
|
|
|
|
"texture",
|
|
|
|
|
&mut diffuse_texture_validation,
|
|
|
|
|
) {
|
2026-07-03 17:03:40 +04:00
|
|
|
Ok(()) => {
|
|
|
|
|
report.texture_resolved_count += 1;
|
|
|
|
|
if let Ok(texture_key) =
|
|
|
|
|
texm_resource_key(TEXTURES_ARCHIVE, &texture)
|
|
|
|
|
{
|
|
|
|
|
let texture_node_id = push_graph_resource_node(
|
|
|
|
|
graph,
|
|
|
|
|
PrototypeGraphNodeKind::TextureResource,
|
|
|
|
|
texture_key.clone(),
|
|
|
|
|
&mut next_node,
|
|
|
|
|
);
|
|
|
|
|
push_graph_edge(
|
|
|
|
|
graph,
|
|
|
|
|
material_node_id,
|
|
|
|
|
texture_node_id,
|
|
|
|
|
fparkan_prototype::PrototypeGraphEdgeKind::MaterialToTexture,
|
|
|
|
|
PrototypeGraphRequiredness::Required,
|
|
|
|
|
Some(provenance_for_resource(
|
|
|
|
|
root_index,
|
|
|
|
|
Some(material_edge_id),
|
2026-07-18 20:26:15 +04:00
|
|
|
unit_component_index,
|
2026-07-03 17:03:40 +04:00
|
|
|
&texture_key,
|
|
|
|
|
)),
|
|
|
|
|
&mut next_edge,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-23 22:32:50 +04:00
|
|
|
Err(message) => {
|
|
|
|
|
push_visual_failure(
|
|
|
|
|
report,
|
|
|
|
|
graph,
|
|
|
|
|
prototype_index,
|
|
|
|
|
texture.0,
|
|
|
|
|
PrototypeGraphEdge::MaterialToTexture,
|
|
|
|
|
PrototypeGraphRequiredness::Required,
|
|
|
|
|
&message,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(message) => push_visual_failure(
|
|
|
|
|
report,
|
|
|
|
|
graph,
|
|
|
|
|
prototype_index,
|
|
|
|
|
mesh.name.0.clone(),
|
|
|
|
|
PrototypeGraphEdge::WearToMaterial,
|
|
|
|
|
PrototypeGraphRequiredness::Required,
|
2026-07-18 11:26:19 +04:00
|
|
|
&message.clone(),
|
2026-06-23 22:05:16 +04:00
|
|
|
),
|
2026-06-23 22:32:50 +04:00
|
|
|
}
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
|
|
|
|
for lightmap in &table.lightmaps {
|
|
|
|
|
report.lightmap_request_count += 1;
|
2026-07-18 11:06:38 +04:00
|
|
|
match resolve_texm_validation_cached(
|
|
|
|
|
repository,
|
|
|
|
|
&lightmap.lightmap,
|
|
|
|
|
LIGHTMAP_ARCHIVE,
|
|
|
|
|
"lightmap",
|
|
|
|
|
&mut lightmap_texture_validation,
|
|
|
|
|
) {
|
2026-07-03 17:03:40 +04:00
|
|
|
Ok(()) => {
|
|
|
|
|
report.lightmap_resolved_count += 1;
|
|
|
|
|
if let Ok(lightmap_key) =
|
|
|
|
|
texm_resource_key(LIGHTMAP_ARCHIVE, &lightmap.lightmap)
|
|
|
|
|
{
|
|
|
|
|
let lightmap_node_id = push_graph_resource_node(
|
|
|
|
|
graph,
|
|
|
|
|
PrototypeGraphNodeKind::LightmapResource,
|
|
|
|
|
lightmap_key.clone(),
|
|
|
|
|
&mut next_node,
|
|
|
|
|
);
|
|
|
|
|
push_graph_edge(
|
|
|
|
|
graph,
|
|
|
|
|
wear_node_id,
|
|
|
|
|
lightmap_node_id,
|
|
|
|
|
fparkan_prototype::PrototypeGraphEdgeKind::WearToLightmap,
|
|
|
|
|
PrototypeGraphRequiredness::Required,
|
|
|
|
|
Some(provenance_for_resource(
|
|
|
|
|
root_index,
|
|
|
|
|
Some(wear_edge_id),
|
2026-07-18 20:26:15 +04:00
|
|
|
unit_component_index,
|
2026-07-03 17:03:40 +04:00
|
|
|
&lightmap_key,
|
|
|
|
|
)),
|
|
|
|
|
&mut next_edge,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-23 22:32:50 +04:00
|
|
|
Err(message) => {
|
|
|
|
|
push_visual_failure(
|
|
|
|
|
report,
|
|
|
|
|
graph,
|
|
|
|
|
prototype_index,
|
|
|
|
|
lightmap.lightmap.0.clone(),
|
|
|
|
|
PrototypeGraphEdge::WearToLightmap,
|
|
|
|
|
PrototypeGraphRequiredness::Required,
|
|
|
|
|
&message,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(message) => push_visual_failure(
|
|
|
|
|
report,
|
|
|
|
|
graph,
|
|
|
|
|
prototype_index,
|
|
|
|
|
mesh.name.0.clone(),
|
|
|
|
|
PrototypeGraphEdge::MeshToWear,
|
|
|
|
|
PrototypeGraphRequiredness::Required,
|
2026-07-18 13:17:06 +04:00
|
|
|
&message,
|
2026-06-23 22:05:16 +04:00
|
|
|
),
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-03 21:54:09 +04:00
|
|
|
graph.visual_dependencies_expanded = true;
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 17:03:40 +04:00
|
|
|
fn push_graph_resource_node(
|
|
|
|
|
graph: &mut PrototypeGraph,
|
|
|
|
|
kind: PrototypeGraphNodeKind,
|
|
|
|
|
resource: ResourceKey,
|
|
|
|
|
next_node: &mut u32,
|
|
|
|
|
) -> fparkan_prototype::PrototypeGraphNodeId {
|
|
|
|
|
let id = fparkan_prototype::PrototypeGraphNodeId(*next_node);
|
|
|
|
|
*next_node = (*next_node).saturating_add(1);
|
|
|
|
|
graph
|
|
|
|
|
.nodes
|
2026-07-03 21:44:26 +04:00
|
|
|
.push(fparkan_prototype::PrototypeGraphNode::resource(
|
|
|
|
|
kind, resource, id,
|
|
|
|
|
));
|
2026-07-03 17:03:40 +04:00
|
|
|
id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn push_graph_edge(
|
|
|
|
|
graph: &mut PrototypeGraph,
|
|
|
|
|
from: fparkan_prototype::PrototypeGraphNodeId,
|
|
|
|
|
to: fparkan_prototype::PrototypeGraphNodeId,
|
|
|
|
|
kind: fparkan_prototype::PrototypeGraphEdgeKind,
|
|
|
|
|
requiredness: PrototypeGraphRequiredness,
|
|
|
|
|
provenance: Option<PrototypeGraphProvenance>,
|
|
|
|
|
next_edge: &mut u32,
|
|
|
|
|
) -> fparkan_prototype::PrototypeGraphEdgeId {
|
|
|
|
|
let id = fparkan_prototype::PrototypeGraphEdgeId(*next_edge);
|
|
|
|
|
*next_edge = (*next_edge).saturating_add(1);
|
2026-07-03 21:44:26 +04:00
|
|
|
graph
|
|
|
|
|
.edges
|
|
|
|
|
.push(fparkan_prototype::PrototypeGraphEdgeInstance {
|
|
|
|
|
id,
|
|
|
|
|
from,
|
|
|
|
|
to,
|
|
|
|
|
kind,
|
|
|
|
|
requiredness,
|
|
|
|
|
provenance,
|
|
|
|
|
});
|
2026-07-03 17:03:40 +04:00
|
|
|
id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn prototype_mesh_node_id(
|
|
|
|
|
graph: &PrototypeGraph,
|
|
|
|
|
prototype_node: fparkan_prototype::PrototypeGraphNodeId,
|
|
|
|
|
) -> Option<fparkan_prototype::PrototypeGraphNodeId> {
|
|
|
|
|
graph
|
|
|
|
|
.edges
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|edge| {
|
|
|
|
|
edge.from == prototype_node
|
|
|
|
|
&& matches!(
|
|
|
|
|
edge.kind,
|
|
|
|
|
fparkan_prototype::PrototypeGraphEdgeKind::PrototypeToMesh
|
|
|
|
|
)
|
|
|
|
|
})
|
|
|
|
|
.map(|edge| edge.to)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn provenance_for_resource(
|
|
|
|
|
root_index: usize,
|
|
|
|
|
parent_edge: Option<fparkan_prototype::PrototypeGraphEdgeId>,
|
2026-07-18 20:26:15 +04:00
|
|
|
unit_component_index: Option<usize>,
|
2026-07-03 17:03:40 +04:00
|
|
|
resource: &ResourceKey,
|
|
|
|
|
) -> PrototypeGraphProvenance {
|
|
|
|
|
PrototypeGraphProvenance {
|
|
|
|
|
root_index,
|
|
|
|
|
parent_edge,
|
2026-07-18 20:26:15 +04:00
|
|
|
unit_component_index,
|
2026-07-03 17:03:40 +04:00
|
|
|
archive: Some(resource.archive.display_lossy().to_string()),
|
|
|
|
|
resource: Some(resource.name.0.clone()),
|
|
|
|
|
span: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn wear_resource_key(mesh: &ResourceKey) -> Result<ResourceKey, AssetError> {
|
|
|
|
|
Ok(ResourceKey {
|
|
|
|
|
archive: mesh.archive.clone(),
|
|
|
|
|
name: sibling_name(mesh, "wea")?,
|
|
|
|
|
type_id: Some(WEAR_KIND),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn texm_resource_key(archive: &str, name: &ResourceName) -> Result<ResourceKey, AssetError> {
|
|
|
|
|
Ok(ResourceKey {
|
|
|
|
|
archive: parse_path(archive)?,
|
|
|
|
|
name: name.clone(),
|
|
|
|
|
type_id: None,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
/// 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,
|
2026-10-11 13:12:50 +04:00
|
|
|
source_type: proto.source_type,
|
2026-07-03 17:11:49 +04:00
|
|
|
model_id: None,
|
|
|
|
|
wear_id: None,
|
2026-06-22 13:12:27 +04:00
|
|
|
model_nodes: 0,
|
|
|
|
|
model_slots: 0,
|
|
|
|
|
model_batches: 0,
|
|
|
|
|
material_count: 0,
|
2026-06-23 22:05:16 +04:00
|
|
|
material_ids: Vec::new(),
|
2026-07-03 17:11:49 +04:00
|
|
|
texture_ids: Vec::new(),
|
|
|
|
|
lightmap_ids: Vec::new(),
|
2026-06-22 13:12:27 +04:00
|
|
|
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,
|
2026-06-23 22:05:16 +04:00
|
|
|
) -> Result<PreparedVisual, AssetError> {
|
2026-07-18 06:35:20 +04:00
|
|
|
let mut preparation_cache = PreparationCache::default();
|
2026-07-18 06:29:29 +04:00
|
|
|
Ok(prepare_visual_with_repository_internal(
|
|
|
|
|
repository,
|
|
|
|
|
proto,
|
|
|
|
|
AssetIdentityPolicy::default(),
|
2026-07-18 06:35:20 +04:00
|
|
|
&mut preparation_cache,
|
2026-07-18 06:29:29 +04:00
|
|
|
)?
|
|
|
|
|
.visual)
|
2026-07-03 17:11:49 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct PreparedVisualBundle {
|
|
|
|
|
visual: PreparedVisual,
|
|
|
|
|
model: Option<PreparedModel>,
|
|
|
|
|
wear: Option<PreparedWear>,
|
|
|
|
|
materials: Vec<PreparedMaterial>,
|
|
|
|
|
textures: Vec<PreparedTexture>,
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 06:29:29 +04:00
|
|
|
#[derive(Default)]
|
2026-07-18 06:35:20 +04:00
|
|
|
struct PreparationCache {
|
2026-07-18 11:10:47 +04:00
|
|
|
models: Vec<(ResourceKey, ModelAsset)>,
|
|
|
|
|
wears: Vec<(ResourceKey, WearTable)>,
|
2026-07-18 06:29:29 +04:00
|
|
|
diffuse: HashMap<Vec<u8>, PreparedTexture>,
|
|
|
|
|
lightmaps: HashMap<Vec<u8>, PreparedTexture>,
|
2026-07-18 06:35:20 +04:00
|
|
|
materials: HashMap<Vec<u8>, ResolvedMaterial>,
|
2026-07-18 06:29:29 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 05:38:27 +04:00
|
|
|
// Keeps the complete model -> WEAR -> material -> texture transaction in one fallible boundary.
|
2026-09-06 05:22:12 +04:00
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
fn prepare_visual_with_repository_internal<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
proto: &EffectivePrototype,
|
2026-07-03 21:49:42 +04:00
|
|
|
identity_policy: AssetIdentityPolicy,
|
2026-07-18 06:35:20 +04:00
|
|
|
preparation_cache: &mut PreparationCache,
|
2026-07-03 17:11:49 +04:00
|
|
|
) -> Result<PreparedVisualBundle, AssetError> {
|
2026-06-22 13:12:27 +04:00
|
|
|
let PrototypeGeometry::Mesh(mesh_key) = &proto.geometry else {
|
2026-07-03 17:11:49 +04:00
|
|
|
return Ok(PreparedVisualBundle {
|
|
|
|
|
visual: prepare_visual(proto)?,
|
|
|
|
|
model: None,
|
|
|
|
|
wear: None,
|
|
|
|
|
materials: Vec::new(),
|
|
|
|
|
textures: Vec::new(),
|
|
|
|
|
});
|
2026-06-22 13:12:27 +04:00
|
|
|
};
|
|
|
|
|
|
2026-07-18 11:10:47 +04:00
|
|
|
let model = prepare_model_cached(repository, mesh_key, preparation_cache)?;
|
2026-07-18 05:38:27 +04:00
|
|
|
let model_id = AssetId::new((identity_policy.model)(proto));
|
2026-07-03 17:11:49 +04:00
|
|
|
let prepared_model = PreparedModel {
|
|
|
|
|
id: model_id,
|
|
|
|
|
source: mesh_key.clone(),
|
|
|
|
|
validated: model.clone(),
|
|
|
|
|
mesh_dependencies: proto.dependencies.clone(),
|
|
|
|
|
};
|
2026-06-22 13:12:27 +04:00
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
};
|
2026-07-18 11:10:47 +04:00
|
|
|
let wear = prepare_wear_cached(repository, &wear_key, preparation_cache)?;
|
2026-07-18 05:38:27 +04:00
|
|
|
let wear_id = AssetId::new((identity_policy.wear)(proto));
|
2026-07-03 17:11:49 +04:00
|
|
|
let prepared_wear = PreparedWear {
|
|
|
|
|
id: wear_id,
|
|
|
|
|
source: wear_key.clone(),
|
|
|
|
|
table: wear.clone(),
|
|
|
|
|
};
|
2026-06-22 13:12:27 +04:00
|
|
|
|
|
|
|
|
let mut material_count = 0;
|
2026-06-23 22:05:16 +04:00
|
|
|
let mut material_ids = Vec::with_capacity(wear.entries.len());
|
2026-07-03 17:11:49 +04:00
|
|
|
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();
|
2026-06-22 13:12:27 +04:00
|
|
|
let mut texture_count = 0;
|
|
|
|
|
let mut lightmap_count = 0;
|
2026-07-03 17:11:49 +04:00
|
|
|
let lightmap_requests: Vec<_> = wear
|
|
|
|
|
.lightmaps
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|lightmap| lightmap.lightmap.clone())
|
|
|
|
|
.collect();
|
2026-06-22 13:12:27 +04:00
|
|
|
for material_index in 0..wear.entries.len() {
|
|
|
|
|
let material_index = u16::try_from(material_index).map_err(|_| {
|
2026-06-23 22:05:16 +04:00
|
|
|
AssetError::InvalidPrototype("material index does not fit archive format".to_string())
|
2026-06-22 13:12:27 +04:00
|
|
|
})?;
|
2026-06-23 22:32:50 +04:00
|
|
|
let material =
|
2026-07-18 06:35:20 +04:00
|
|
|
resolve_material_cached(repository, &wear, material_index, preparation_cache)?;
|
2026-06-22 13:12:27 +04:00
|
|
|
material_count += 1;
|
2026-07-18 05:38:27 +04:00
|
|
|
let material_id = AssetId::new((identity_policy.material)(
|
2026-07-03 21:49:42 +04:00
|
|
|
proto,
|
|
|
|
|
material_index,
|
|
|
|
|
&material.name,
|
|
|
|
|
));
|
2026-06-23 22:32:50 +04:00
|
|
|
material_ids.push(material_id);
|
2026-07-03 17:11:49 +04:00
|
|
|
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(),
|
2026-10-11 13:12:50 +04:00
|
|
|
attr1: material.attr1,
|
|
|
|
|
pipeline_category: material_pipeline_category(material.attr1),
|
|
|
|
|
native_flag_40: material_native_flag_40(material.attr1),
|
2026-07-03 17:11:49 +04:00
|
|
|
mat0: material.document.clone(),
|
|
|
|
|
texture_requests: texture_requests.clone(),
|
|
|
|
|
lightmap_requests: lightmap_requests.clone(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
for texture in texture_requests {
|
2026-07-18 06:29:29 +04:00
|
|
|
let prepared_texture = prepare_texture_cached(
|
2026-07-03 21:49:42 +04:00
|
|
|
repository,
|
|
|
|
|
&texture,
|
|
|
|
|
PreparedTextureUsage::Diffuse,
|
|
|
|
|
identity_policy,
|
2026-07-18 06:35:20 +04:00
|
|
|
preparation_cache,
|
2026-07-03 21:49:42 +04:00
|
|
|
)?;
|
2026-07-03 17:11:49 +04:00
|
|
|
texture_ids.push(prepared_texture.id);
|
|
|
|
|
prepared_textures.push(prepared_texture);
|
2026-06-22 13:12:27 +04:00
|
|
|
texture_count += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for lightmap in &wear.lightmaps {
|
2026-07-18 06:29:29 +04:00
|
|
|
let prepared_lightmap = prepare_texture_cached(
|
2026-07-03 17:11:49 +04:00
|
|
|
repository,
|
|
|
|
|
&lightmap.lightmap,
|
|
|
|
|
PreparedTextureUsage::Lightmap,
|
2026-07-03 21:49:42 +04:00
|
|
|
identity_policy,
|
2026-07-18 06:35:20 +04:00
|
|
|
preparation_cache,
|
2026-07-03 17:11:49 +04:00
|
|
|
)?;
|
|
|
|
|
lightmap_ids.push(prepared_lightmap.id);
|
|
|
|
|
prepared_textures.push(prepared_lightmap);
|
2026-06-22 13:12:27 +04:00
|
|
|
lightmap_count += 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 17:11:49 +04:00
|
|
|
Ok(PreparedVisualBundle {
|
|
|
|
|
visual: PreparedVisual {
|
2026-07-18 05:38:27 +04:00
|
|
|
id: AssetId::new((identity_policy.visual)(proto)),
|
2026-07-03 17:11:49 +04:00
|
|
|
mesh: Some(mesh_key.clone()),
|
2026-10-11 13:12:50 +04:00
|
|
|
source_type: proto.source_type,
|
2026-07-03 17:11:49 +04:00
|
|
|
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,
|
2026-06-22 13:12:27 +04:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn read_key<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
key: &ResourceKey,
|
|
|
|
|
label: Option<&str>,
|
|
|
|
|
) -> Result<Arc<[u8]>, AssetError> {
|
2026-06-23 22:05:16 +04:00
|
|
|
let label = label.unwrap_or("asset");
|
2026-06-23 22:32:50 +04:00
|
|
|
let archive = repository
|
2026-07-18 15:15:29 +04:00
|
|
|
.open_archive_unchanged(&key.archive)
|
2026-06-23 22:32:50 +04:00
|
|
|
.map_err(|err| map_resource_error(label, key, err))?;
|
|
|
|
|
let handle = repository
|
|
|
|
|
.find(archive, &key.name)
|
2026-06-23 22:05:16 +04:00
|
|
|
.map_err(|err| map_resource_error(label, key, err))?
|
|
|
|
|
.ok_or_else(|| AssetError::MissingDependency(format!("{label}: {key:?}")))?;
|
2026-06-22 13:12:27 +04:00
|
|
|
let bytes = repository
|
|
|
|
|
.read(handle)
|
2026-06-23 22:05:16 +04:00
|
|
|
.map_err(|err| map_resource_error(label, key, err))?;
|
2026-06-22 13:12:27 +04:00
|
|
|
Ok(Arc::from(bytes.into_owned()))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 11:10:47 +04:00
|
|
|
fn prepare_model_cached<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
key: &ResourceKey,
|
|
|
|
|
cache: &mut PreparationCache,
|
|
|
|
|
) -> Result<ModelAsset, AssetError> {
|
|
|
|
|
if let Some((_, model)) = cache
|
|
|
|
|
.models
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|(cached_key, _)| cached_key == key)
|
|
|
|
|
{
|
|
|
|
|
return Ok(model.clone());
|
|
|
|
|
}
|
|
|
|
|
let nres = decode_nres(
|
|
|
|
|
read_key(repository, key, Some("mesh"))?,
|
|
|
|
|
ReadProfile::Compatible,
|
|
|
|
|
)
|
|
|
|
|
.map_err(AssetError::Nres)?;
|
|
|
|
|
let document = decode_msh(&nres).map_err(AssetError::Msh)?;
|
|
|
|
|
let model = validate_msh(&document).map_err(AssetError::Msh)?;
|
|
|
|
|
cache.models.push((key.clone(), model.clone()));
|
|
|
|
|
Ok(model)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn prepare_wear_cached<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
key: &ResourceKey,
|
|
|
|
|
cache: &mut PreparationCache,
|
|
|
|
|
) -> Result<WearTable, AssetError> {
|
|
|
|
|
if let Some((_, wear)) = cache.wears.iter().find(|(cached_key, _)| cached_key == key) {
|
|
|
|
|
return Ok(wear.clone());
|
|
|
|
|
}
|
|
|
|
|
let wear =
|
|
|
|
|
decode_wear(&read_key(repository, key, Some("wear"))?).map_err(AssetError::Material)?;
|
|
|
|
|
cache.wears.push((key.clone(), wear.clone()));
|
|
|
|
|
Ok(wear)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:32:50 +04:00
|
|
|
fn map_resource_error(label: &str, key: &ResourceKey, source: ResourceError) -> AssetError {
|
2026-06-23 22:05:16 +04:00
|
|
|
AssetError::Resource {
|
|
|
|
|
context: format!(
|
|
|
|
|
"{label}: archive={} entry={}",
|
|
|
|
|
key.archive.as_str(),
|
|
|
|
|
String::from_utf8_lossy(&key.name.0),
|
|
|
|
|
),
|
2026-06-23 22:32:50 +04:00
|
|
|
source: Box::new(source),
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn resolve_wear_table<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
mesh: &ResourceKey,
|
|
|
|
|
) -> Result<fparkan_material::WearTable, AssetError> {
|
|
|
|
|
let archive = repository
|
2026-07-18 15:15:29 +04:00
|
|
|
.open_archive_unchanged(&mesh.archive)
|
2026-06-23 22:05:16 +04:00
|
|
|
.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)
|
|
|
|
|
))
|
|
|
|
|
})?;
|
2026-06-23 22:32:50 +04:00
|
|
|
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,
|
|
|
|
|
)
|
|
|
|
|
})?;
|
2026-06-23 22:05:16 +04:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 13:17:06 +04:00
|
|
|
fn resolve_wear_table_cached<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
mesh: &ResourceKey,
|
|
|
|
|
cache: &mut WearValidationCache,
|
|
|
|
|
) -> Result<fparkan_material::WearTable, String> {
|
|
|
|
|
let wear_key = wear_resource_key(mesh).map_err(|error| error.to_string())?;
|
|
|
|
|
let cache_key = (wear_key.archive.clone(), wear_key.name.0.clone());
|
|
|
|
|
if let Some(result) = cache.get(&cache_key) {
|
|
|
|
|
return result.clone();
|
|
|
|
|
}
|
|
|
|
|
let result = resolve_wear_table(repository, mesh).map_err(|error| error.to_string());
|
|
|
|
|
cache.insert(cache_key, result.clone());
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
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);
|
2026-06-23 22:32:50 +04:00
|
|
|
let parent_edge = parent_edge_for_failure(graph, prototype_index, edge);
|
2026-07-18 20:26:15 +04:00
|
|
|
let unit_component_index =
|
|
|
|
|
parent_edge.and_then(|value| unit_component_index_for_edge(graph, value));
|
2026-06-23 22:05:16 +04:00
|
|
|
let dependency = mesh_dependency_resource(graph, prototype_index);
|
|
|
|
|
report.failures.push(PrototypeGraphFailure {
|
|
|
|
|
root_index,
|
2026-06-23 22:32:50 +04:00
|
|
|
resource_raw: resource_raw.clone(),
|
2026-06-23 22:05:16 +04:00
|
|
|
edge,
|
|
|
|
|
message: message.to_string(),
|
|
|
|
|
requiredness,
|
|
|
|
|
provenance: Some(PrototypeGraphProvenance {
|
|
|
|
|
root_index,
|
|
|
|
|
parent_edge,
|
2026-07-18 20:26:15 +04:00
|
|
|
unit_component_index,
|
2026-06-23 22:05:16 +04:00
|
|
|
archive: dependency.map(|resource| resource.archive.as_str().to_string()),
|
|
|
|
|
resource: Some(resource_raw),
|
|
|
|
|
span: None,
|
|
|
|
|
}),
|
2026-06-23 22:32:50 +04:00
|
|
|
});
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 20:26:15 +04:00
|
|
|
fn unit_component_index_for_edge(
|
|
|
|
|
graph: &PrototypeGraph,
|
|
|
|
|
edge_id: fparkan_prototype::PrototypeGraphEdgeId,
|
|
|
|
|
) -> Option<usize> {
|
|
|
|
|
graph
|
|
|
|
|
.edges
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|edge| edge.id == edge_id)
|
|
|
|
|
.and_then(|edge| edge.provenance.as_ref())
|
|
|
|
|
.and_then(|provenance| provenance.unit_component_index)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
fn parent_edge_for_failure(
|
|
|
|
|
graph: &PrototypeGraph,
|
|
|
|
|
prototype_index: usize,
|
2026-06-23 22:32:50 +04:00
|
|
|
edge: PrototypeGraphEdge,
|
2026-06-23 22:05:16 +04:00
|
|
|
) -> Option<fparkan_prototype::PrototypeGraphEdgeId> {
|
|
|
|
|
let prototype_node_id = prototype_node_id(graph, prototype_index)?;
|
|
|
|
|
match edge {
|
|
|
|
|
PrototypeGraphEdge::MeshToWear
|
|
|
|
|
| PrototypeGraphEdge::WearToMaterial
|
|
|
|
|
| PrototypeGraphEdge::MaterialToTexture
|
2026-06-23 22:32:50 +04:00
|
|
|
| PrototypeGraphEdge::WearToLightmap => mesh_edge_id(graph, prototype_node_id)
|
|
|
|
|
.or_else(|| root_edge_id(graph, prototype_node_id)),
|
2026-06-23 22:05:16 +04:00
|
|
|
_ => root_edge_id(graph, prototype_node_id),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:32:50 +04:00
|
|
|
fn prototype_node_id(
|
|
|
|
|
graph: &PrototypeGraph,
|
|
|
|
|
prototype_index: usize,
|
|
|
|
|
) -> Option<fparkan_prototype::PrototypeGraphNodeId> {
|
2026-06-23 22:05:16 +04:00
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 11:06:38 +04:00
|
|
|
fn resolve_texm_validation_cached<R: ResourceRepository>(
|
2026-06-22 16:02:16 +04:00
|
|
|
repository: &R,
|
|
|
|
|
name: &ResourceName,
|
2026-07-18 11:06:38 +04:00
|
|
|
archive: &str,
|
|
|
|
|
label: &'static str,
|
|
|
|
|
cache: &mut HashMap<Vec<u8>, Result<(), String>>,
|
|
|
|
|
) -> Result<(), String> {
|
|
|
|
|
if let Some(result) = cache.get(&name.0) {
|
|
|
|
|
return result.clone();
|
|
|
|
|
}
|
|
|
|
|
let result = resolve_texm(repository, name, archive, label).map_err(|error| error.to_string());
|
|
|
|
|
cache.insert(name.0.clone(), result.clone());
|
|
|
|
|
result
|
2026-06-22 16:02:16 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-18 11:24:31 +04:00
|
|
|
fn resolve_material_validation_cached<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
table: &WearTable,
|
|
|
|
|
index: u16,
|
|
|
|
|
cache: &mut HashMap<Vec<u8>, Result<ResolvedMaterial, String>>,
|
|
|
|
|
) -> Result<ResolvedMaterial, String> {
|
|
|
|
|
let request = table
|
|
|
|
|
.entries
|
|
|
|
|
.get(usize::from(index))
|
|
|
|
|
.ok_or(MaterialError::WearIndexOutOfBounds {
|
|
|
|
|
index,
|
|
|
|
|
count: table.entries.len(),
|
|
|
|
|
})
|
|
|
|
|
.map_err(|error| error.to_string())?
|
|
|
|
|
.material
|
|
|
|
|
.0
|
|
|
|
|
.clone();
|
|
|
|
|
if let Some(result) = cache.get(&request) {
|
|
|
|
|
return result.clone();
|
|
|
|
|
}
|
|
|
|
|
let result = resolve_material(repository, table, index).map_err(|error| error.to_string());
|
|
|
|
|
cache.insert(request, result.clone());
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 11:07:12 +04:00
|
|
|
#[cfg(test)]
|
|
|
|
|
fn resolve_texture<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
name: &ResourceName,
|
|
|
|
|
) -> Result<(), AssetError> {
|
|
|
|
|
resolve_texm(repository, name, TEXTURES_ARCHIVE, "texture")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
fn resolve_lightmap<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
name: &ResourceName,
|
|
|
|
|
) -> Result<(), AssetError> {
|
|
|
|
|
resolve_texm(repository, name, LIGHTMAP_ARCHIVE, "lightmap")
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 17:11:49 +04:00
|
|
|
fn prepare_texture<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
name: &ResourceName,
|
|
|
|
|
usage: PreparedTextureUsage,
|
2026-07-03 21:49:42 +04:00
|
|
|
identity_policy: AssetIdentityPolicy,
|
2026-07-03 17:11:49 +04:00
|
|
|
) -> 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 {
|
2026-07-18 05:38:27 +04:00
|
|
|
id: AssetId::new((identity_policy.texture)(&key, usage)),
|
2026-07-03 17:11:49 +04:00
|
|
|
source: key,
|
|
|
|
|
texm,
|
|
|
|
|
usage,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 06:29:29 +04:00
|
|
|
fn prepare_texture_cached<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
name: &ResourceName,
|
|
|
|
|
usage: PreparedTextureUsage,
|
|
|
|
|
identity_policy: AssetIdentityPolicy,
|
2026-07-18 06:35:20 +04:00
|
|
|
cache: &mut PreparationCache,
|
2026-07-18 06:29:29 +04:00
|
|
|
) -> Result<PreparedTexture, AssetError> {
|
|
|
|
|
let entries = match usage {
|
|
|
|
|
PreparedTextureUsage::Diffuse => &mut cache.diffuse,
|
|
|
|
|
PreparedTextureUsage::Lightmap => &mut cache.lightmaps,
|
|
|
|
|
};
|
|
|
|
|
if let Some(texture) = entries.get(&name.0) {
|
|
|
|
|
return Ok(texture.clone());
|
|
|
|
|
}
|
|
|
|
|
let texture = prepare_texture(repository, name, usage, identity_policy)?;
|
|
|
|
|
entries.insert(name.0.clone(), texture.clone());
|
|
|
|
|
Ok(texture)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 06:35:20 +04:00
|
|
|
fn resolve_material_cached<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
table: &WearTable,
|
|
|
|
|
index: u16,
|
|
|
|
|
cache: &mut PreparationCache,
|
|
|
|
|
) -> Result<ResolvedMaterial, AssetError> {
|
|
|
|
|
let request = table
|
|
|
|
|
.entries
|
|
|
|
|
.get(usize::from(index))
|
|
|
|
|
.ok_or(MaterialError::WearIndexOutOfBounds {
|
|
|
|
|
index,
|
|
|
|
|
count: table.entries.len(),
|
|
|
|
|
})
|
|
|
|
|
.map_err(AssetError::Material)?
|
|
|
|
|
.material
|
|
|
|
|
.0
|
|
|
|
|
.clone();
|
|
|
|
|
if let Some(material) = cache.materials.get(&request) {
|
|
|
|
|
return Ok(material.clone());
|
|
|
|
|
}
|
|
|
|
|
let material = resolve_material(repository, table, index).map_err(AssetError::Material)?;
|
|
|
|
|
cache.materials.insert(request, material.clone());
|
|
|
|
|
Ok(material)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
fn resolve_texm<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
name: &ResourceName,
|
2026-06-22 16:02:16 +04:00
|
|
|
archive: &str,
|
|
|
|
|
label: &'static str,
|
2026-06-22 13:12:27 +04:00
|
|
|
) -> Result<(), AssetError> {
|
2026-06-22 16:02:16 +04:00
|
|
|
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:?}")));
|
|
|
|
|
};
|
2026-06-23 22:32:50 +04:00
|
|
|
decode_texm(bytes).map(|_| ()).map_err(AssetError::Texture)
|
2026-06-22 16:02:16 +04:00
|
|
|
}
|
2026-06-22 13:12:27 +04:00
|
|
|
|
2026-06-22 16:02:16 +04:00
|
|
|
fn read_optional_key<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
key: &ResourceKey,
|
|
|
|
|
label: Option<&str>,
|
|
|
|
|
) -> Result<Option<Arc<[u8]>>, AssetError> {
|
2026-07-18 15:15:29 +04:00
|
|
|
let archive = match repository.open_archive_unchanged(&key.archive) {
|
2026-06-22 16:02:16 +04:00
|
|
|
Ok(archive) => archive,
|
2026-06-30 02:34:25 +04:00
|
|
|
Err(ResourceError::MissingArchive { .. } | ResourceError::MissingEntry) => return Ok(None),
|
2026-06-23 22:05:16 +04:00
|
|
|
Err(err) => {
|
|
|
|
|
let label = label.unwrap_or("asset");
|
2026-06-23 22:32:50 +04:00
|
|
|
return Err(map_resource_error(label, key, err));
|
2026-06-23 22:05:16 +04:00
|
|
|
}
|
2026-06-22 16:02:16 +04:00
|
|
|
};
|
2026-06-23 22:32:50 +04:00
|
|
|
let Some(handle) = repository.find(archive, &key.name).map_err(|err| {
|
|
|
|
|
let label = label.unwrap_or("asset");
|
|
|
|
|
map_resource_error(label, key, err)
|
|
|
|
|
})?
|
2026-06-22 16:02:16 +04:00
|
|
|
else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
2026-06-23 22:32:50 +04:00
|
|
|
let bytes = repository.read(handle).map_err(|err| {
|
|
|
|
|
let label = label.unwrap_or("asset");
|
|
|
|
|
map_resource_error(label, key, err)
|
|
|
|
|
})?;
|
2026-06-22 16:02:16 +04:00
|
|
|
Ok(Some(Arc::from(bytes.into_owned())))
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:12:50 +04:00
|
|
|
fn prepare_component_control_from_resource<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
source: ResourceKey,
|
2026-10-11 22:38:53 +04:00
|
|
|
point_source: Option<ResourceKey>,
|
2026-10-18 11:02:53 +04:00
|
|
|
node_data_source: Option<ResourceKey>,
|
2026-10-11 13:12:50 +04:00
|
|
|
model_node_count: usize,
|
|
|
|
|
prototype_index: usize,
|
|
|
|
|
) -> Result<Option<PreparedComponentControl>, AssetError> {
|
|
|
|
|
let Some(payload) = read_optional_key(repository, &source, Some("component control"))? else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
2026-10-11 22:38:53 +04:00
|
|
|
let mut control =
|
|
|
|
|
decode_component_control_payload(&payload, source, model_node_count, prototype_index)?;
|
2026-10-18 11:02:53 +04:00
|
|
|
let node_data_payload = match node_data_source.as_ref() {
|
|
|
|
|
Some(source) => read_optional_key(repository, source, Some("component node data"))?,
|
|
|
|
|
None => None,
|
|
|
|
|
};
|
|
|
|
|
control.node_data = match (node_data_source.as_ref(), node_data_payload) {
|
|
|
|
|
(Some(source), Some(payload)) => Some(decode_component_node_data_table(
|
|
|
|
|
&payload,
|
|
|
|
|
source.clone(),
|
|
|
|
|
prototype_index,
|
|
|
|
|
)?),
|
|
|
|
|
_ => None,
|
|
|
|
|
};
|
|
|
|
|
resolve_control_gate_providers(&mut control, node_data_source.as_ref());
|
2026-10-11 22:38:53 +04:00
|
|
|
let has_effect_rows = matches!(
|
|
|
|
|
&control.effect_section,
|
|
|
|
|
PreparedControlEffectSection::Parsed(rows) if !rows.is_empty()
|
|
|
|
|
);
|
|
|
|
|
let Some(point_source) = point_source.filter(|_| has_effect_rows) else {
|
|
|
|
|
return Ok(Some(control));
|
|
|
|
|
};
|
|
|
|
|
let Some(point_payload) =
|
|
|
|
|
read_optional_key(repository, &point_source, Some("component point list"))?
|
|
|
|
|
else {
|
|
|
|
|
return Ok(Some(control));
|
|
|
|
|
};
|
|
|
|
|
control.point_list = Some(decode_component_point_list(
|
|
|
|
|
&point_payload,
|
|
|
|
|
point_source,
|
|
|
|
|
prototype_index,
|
|
|
|
|
)?);
|
|
|
|
|
Ok(Some(control))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 11:38:50 +04:00
|
|
|
fn prepare_component_bas_from_resource<R: ResourceRepository>(
|
|
|
|
|
repository: &R,
|
|
|
|
|
source: ResourceKey,
|
|
|
|
|
prototype_index: usize,
|
|
|
|
|
) -> Result<Option<PreparedComponentBas>, AssetError> {
|
|
|
|
|
let Some(payload) = read_optional_key(repository, &source, Some("component BAS geometry"))?
|
|
|
|
|
else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
let geometry = decode_bas_geometry(&payload).map_err(|error| {
|
|
|
|
|
AssetError::InvalidPrototype(format!(
|
|
|
|
|
"prototype {prototype_index} BAS geometry {}:{}: {error}",
|
|
|
|
|
source.archive.as_str(),
|
|
|
|
|
String::from_utf8_lossy(&source.name.0),
|
|
|
|
|
))
|
|
|
|
|
})?;
|
|
|
|
|
Ok(Some(PreparedComponentBas { source, geometry }))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 11:02:53 +04:00
|
|
|
fn decode_component_node_data_table(
|
|
|
|
|
payload: &[u8],
|
|
|
|
|
source: ResourceKey,
|
|
|
|
|
prototype_index: usize,
|
|
|
|
|
) -> Result<PreparedControlNodeDataTable, AssetError> {
|
|
|
|
|
const HEADER_BYTES: usize = 4;
|
|
|
|
|
const ROW_BYTES: usize = 76;
|
|
|
|
|
const ROW_LIMIT: usize = 65_536;
|
|
|
|
|
|
|
|
|
|
let invalid = |message: &str| {
|
|
|
|
|
AssetError::InvalidPrototype(format!(
|
|
|
|
|
"prototype {prototype_index} node data {}:{}: {message}",
|
|
|
|
|
source.archive.as_str(),
|
|
|
|
|
String::from_utf8_lossy(&source.name.0),
|
|
|
|
|
))
|
|
|
|
|
};
|
|
|
|
|
let row_count =
|
|
|
|
|
usize::try_from(control_u32_at(payload, 0).ok_or_else(|| invalid("missing row count"))?)
|
|
|
|
|
.map_err(|_| invalid("row count overflows usize"))?;
|
|
|
|
|
if row_count > ROW_LIMIT {
|
|
|
|
|
return Err(invalid("row count exceeds the supported limit"));
|
|
|
|
|
}
|
|
|
|
|
let table_bytes = row_count
|
|
|
|
|
.checked_mul(ROW_BYTES)
|
|
|
|
|
.ok_or_else(|| invalid("row table byte length overflows"))?;
|
|
|
|
|
let expected_len = HEADER_BYTES
|
|
|
|
|
.checked_add(table_bytes)
|
|
|
|
|
.ok_or_else(|| invalid("row table end offset overflows"))?;
|
|
|
|
|
if payload.len() != expected_len {
|
|
|
|
|
return Err(invalid(
|
|
|
|
|
"payload length does not match its declared 76-byte row count",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut rows = Vec::with_capacity(row_count);
|
|
|
|
|
for row_index in 0..row_count {
|
|
|
|
|
let start = HEADER_BYTES + row_index * ROW_BYTES;
|
|
|
|
|
let word = |offset: usize| {
|
|
|
|
|
control_u32_at(payload, start + offset).expect("validated 76-byte NDP row")
|
|
|
|
|
};
|
|
|
|
|
let mut archive = [0; 32];
|
|
|
|
|
let mut name = [0; 32];
|
|
|
|
|
archive.copy_from_slice(&payload[start + 12..start + 44]);
|
|
|
|
|
name.copy_from_slice(&payload[start + 44..start + 76]);
|
|
|
|
|
rows.push(PreparedControlNodeDataRow {
|
|
|
|
|
raw_flags: word(0),
|
|
|
|
|
max_volume: f32::from_bits(word(4)),
|
|
|
|
|
mobility_per_volume: f32::from_bits(word(8)),
|
|
|
|
|
effect: PreparedControlResourcePair { archive, name },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
Ok(PreparedControlNodeDataTable { source, rows })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn resolve_control_gate_providers(
|
|
|
|
|
control: &mut PreparedComponentControl,
|
|
|
|
|
node_data_source: Option<&ResourceKey>,
|
|
|
|
|
) {
|
|
|
|
|
const INITIAL_TYPE1_VALUE: f32 = 1.0;
|
|
|
|
|
let row_count = control.node_data.as_ref().map(|table| table.rows.len());
|
|
|
|
|
for record in control
|
|
|
|
|
.typed_records
|
|
|
|
|
.iter_mut()
|
|
|
|
|
.filter(|record| record.tag == 21)
|
|
|
|
|
{
|
|
|
|
|
let key = record.fixed_words[1];
|
|
|
|
|
record.gate_provider = match (control.node_data.as_ref(), row_count) {
|
|
|
|
|
(Some(table), Some(count)) if usize::try_from(key).is_ok_and(|key| key < count) => {
|
|
|
|
|
PreparedControlGateProvider::Resolved {
|
|
|
|
|
key,
|
|
|
|
|
ndp_source: table.source.clone(),
|
|
|
|
|
row_count: count,
|
|
|
|
|
initial_type1_value: INITIAL_TYPE1_VALUE,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
(Some(table), Some(count)) => PreparedControlGateProvider::KeyOutOfRange {
|
|
|
|
|
key,
|
|
|
|
|
ndp_source: table.source.clone(),
|
|
|
|
|
row_count: count,
|
|
|
|
|
},
|
|
|
|
|
_ => PreparedControlGateProvider::MissingResource {
|
|
|
|
|
key,
|
|
|
|
|
ndp_source: node_data_source.cloned(),
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:38:53 +04:00
|
|
|
fn decode_component_point_list(
|
|
|
|
|
payload: &[u8],
|
|
|
|
|
source: ResourceKey,
|
|
|
|
|
prototype_index: usize,
|
|
|
|
|
) -> Result<PreparedControlPointList, AssetError> {
|
|
|
|
|
const HEADER_BYTES: usize = 4;
|
|
|
|
|
const POINT_BYTES: usize = 36;
|
|
|
|
|
const NAME_BYTES: usize = 32;
|
|
|
|
|
const POINT_LIMIT: usize = 65_536;
|
|
|
|
|
|
|
|
|
|
let invalid = |message: &str| {
|
|
|
|
|
AssetError::InvalidPrototype(format!(
|
|
|
|
|
"prototype {prototype_index} point list {}:{}: {message}",
|
|
|
|
|
source.archive.as_str(),
|
|
|
|
|
String::from_utf8_lossy(&source.name.0),
|
|
|
|
|
))
|
|
|
|
|
};
|
|
|
|
|
let point_count =
|
|
|
|
|
usize::try_from(control_u32_at(payload, 0).ok_or_else(|| invalid("missing point count"))?)
|
|
|
|
|
.map_err(|_| invalid("point count overflows usize"))?;
|
|
|
|
|
if point_count > POINT_LIMIT {
|
|
|
|
|
return Err(invalid("point count exceeds the supported limit"));
|
|
|
|
|
}
|
|
|
|
|
let point_bytes = point_count
|
|
|
|
|
.checked_mul(POINT_BYTES)
|
|
|
|
|
.ok_or_else(|| invalid("point table byte length overflows"))?;
|
|
|
|
|
let names_start = HEADER_BYTES
|
|
|
|
|
.checked_add(point_bytes)
|
|
|
|
|
.ok_or_else(|| invalid("point name table offset overflows"))?;
|
|
|
|
|
let names_bytes = point_count
|
|
|
|
|
.checked_mul(NAME_BYTES)
|
|
|
|
|
.ok_or_else(|| invalid("point name table byte length overflows"))?;
|
|
|
|
|
let expected_len = names_start
|
|
|
|
|
.checked_add(names_bytes)
|
|
|
|
|
.ok_or_else(|| invalid("point list end offset overflows"))?;
|
|
|
|
|
if payload.len() != expected_len {
|
|
|
|
|
return Err(invalid(
|
|
|
|
|
"payload length does not match its point and name counts",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut points = Vec::with_capacity(point_count);
|
|
|
|
|
for index in 0..point_count {
|
|
|
|
|
let point_start = HEADER_BYTES + index * POINT_BYTES;
|
|
|
|
|
let position = control_vec3_at(payload, point_start + 12)
|
|
|
|
|
.expect("complete CPT point record was checked");
|
|
|
|
|
let direction = control_vec3_at(payload, point_start + 24)
|
|
|
|
|
.expect("complete CPT point record was checked");
|
|
|
|
|
if position
|
|
|
|
|
.into_iter()
|
|
|
|
|
.chain(direction)
|
|
|
|
|
.any(|value| !value.is_finite())
|
|
|
|
|
{
|
|
|
|
|
return Err(invalid(
|
|
|
|
|
"point position or direction contains a non-finite value",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let name_start = names_start + index * NAME_BYTES;
|
|
|
|
|
points.push(PreparedControlPoint {
|
|
|
|
|
raw_flags: control_u32_at(payload, point_start).expect("checked point flags"),
|
|
|
|
|
raw_signed_field_04: control_i32_at(payload, point_start + 4)
|
|
|
|
|
.expect("checked signed point word at +4"),
|
|
|
|
|
raw_signed_field_08: control_i32_at(payload, point_start + 8)
|
|
|
|
|
.expect("checked signed point word at +8"),
|
|
|
|
|
position,
|
|
|
|
|
direction,
|
|
|
|
|
name: payload[name_start..name_start + NAME_BYTES]
|
|
|
|
|
.try_into()
|
|
|
|
|
.expect("fixed point name is 32 bytes"),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
Ok(PreparedControlPointList { source, points })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn control_vec3_at(payload: &[u8], offset: usize) -> Option<[f32; 3]> {
|
|
|
|
|
Some([
|
|
|
|
|
control_f32_at(payload, offset)?,
|
|
|
|
|
control_f32_at(payload, offset.checked_add(4)?)?,
|
|
|
|
|
control_f32_at(payload, offset.checked_add(8)?)?,
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn control_f32_at(payload: &[u8], offset: usize) -> Option<f32> {
|
|
|
|
|
Some(f32::from_bits(control_u32_at(payload, offset)?))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn control_i32_at(payload: &[u8], offset: usize) -> Option<i32> {
|
|
|
|
|
Some(i32::from_le_bytes(
|
|
|
|
|
control_u32_at(payload, offset)?.to_le_bytes(),
|
|
|
|
|
))
|
2026-10-11 13:12:50 +04:00
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:17:45 +04:00
|
|
|
const CONTROL_TYPED_COMMON_PARSER_TAGS: &[u32] = &[1, 8, 9, 12, 13, 15, 19, 21, 25, 26];
|
|
|
|
|
const CONTROL_TYPED_RECORD_FIXED_BYTES: usize = 0xB0;
|
|
|
|
|
const CONTROL_TYPED_RECORD_COUNT_OFFSET: usize = 0xAC;
|
|
|
|
|
const CONTROL_TYPED_RECORD_LIMIT: usize = 4096;
|
|
|
|
|
const CONTROL_TYPED_VALUE_LIMIT: usize = 65_536;
|
|
|
|
|
const CONTROL_TYPED_STRING_LIMIT: usize = 4096;
|
|
|
|
|
const CONTROL_EFFECT_GROUP_LIMIT: usize = 4096;
|
|
|
|
|
const CONTROL_EFFECT_ROW_LIMIT: usize = 65_536;
|
|
|
|
|
const CONTROL_EFFECT_GROUP_HEADER_BYTES: usize = 0x54;
|
|
|
|
|
const CONTROL_EFFECT_ROW_BYTES: usize = 100;
|
|
|
|
|
|
2026-10-18 11:02:53 +04:00
|
|
|
struct DecodedComponentEffectSection {
|
|
|
|
|
section: PreparedControlEffectSection,
|
|
|
|
|
typed_records: Vec<PreparedControlTypedRecord>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:17:45 +04:00
|
|
|
fn decode_component_effect_section<F>(
|
|
|
|
|
payload: &[u8],
|
|
|
|
|
mut cursor: usize,
|
|
|
|
|
typed_record_count: usize,
|
|
|
|
|
effect_group_count: usize,
|
|
|
|
|
invalid: &F,
|
2026-10-18 11:02:53 +04:00
|
|
|
) -> Result<DecodedComponentEffectSection, AssetError>
|
2026-10-11 22:17:45 +04:00
|
|
|
where
|
|
|
|
|
F: Fn(&str) -> AssetError,
|
|
|
|
|
{
|
|
|
|
|
if typed_record_count == 0 && effect_group_count == 0 {
|
2026-10-18 11:02:53 +04:00
|
|
|
return Ok(DecodedComponentEffectSection {
|
|
|
|
|
section: if cursor == payload.len() {
|
|
|
|
|
PreparedControlEffectSection::Absent
|
|
|
|
|
} else {
|
|
|
|
|
PreparedControlEffectSection::Unsupported {
|
|
|
|
|
typed_record_index: None,
|
|
|
|
|
typed_record_tag: None,
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
typed_records: Vec::new(),
|
2026-10-11 22:17:45 +04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
if typed_record_count > CONTROL_TYPED_RECORD_LIMIT {
|
|
|
|
|
return Err(invalid("typed record count exceeds the supported limit"));
|
|
|
|
|
}
|
|
|
|
|
if effect_group_count > CONTROL_EFFECT_GROUP_LIMIT {
|
|
|
|
|
return Err(invalid("effect group count exceeds the supported limit"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 11:02:53 +04:00
|
|
|
let mut typed_records = Vec::with_capacity(typed_record_count);
|
2026-10-11 22:17:45 +04:00
|
|
|
for record_index in 0..typed_record_count {
|
|
|
|
|
let tag = control_u32_at(payload, cursor)
|
|
|
|
|
.ok_or_else(|| invalid("typed record is missing its tag"))?;
|
|
|
|
|
if !CONTROL_TYPED_COMMON_PARSER_TAGS.contains(&tag) {
|
2026-10-18 11:02:53 +04:00
|
|
|
return Ok(DecodedComponentEffectSection {
|
|
|
|
|
section: PreparedControlEffectSection::Unsupported {
|
|
|
|
|
typed_record_index: Some(record_index),
|
|
|
|
|
typed_record_tag: Some(tag),
|
|
|
|
|
},
|
|
|
|
|
typed_records,
|
2026-10-11 22:17:45 +04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let fixed_end = cursor
|
|
|
|
|
.checked_add(CONTROL_TYPED_RECORD_FIXED_BYTES)
|
|
|
|
|
.and_then(|offset| offset.checked_add(4))
|
|
|
|
|
.ok_or_else(|| invalid("typed record header offset overflows"))?;
|
|
|
|
|
if fixed_end > payload.len() {
|
|
|
|
|
return Err(invalid("typed record header extends past the payload"));
|
|
|
|
|
}
|
|
|
|
|
let value_count = usize::try_from(
|
|
|
|
|
control_u32_at(payload, cursor + CONTROL_TYPED_RECORD_COUNT_OFFSET)
|
|
|
|
|
.expect("typed record header was checked"),
|
|
|
|
|
)
|
|
|
|
|
.map_err(|_| invalid("typed record value count overflows usize"))?;
|
|
|
|
|
if value_count > CONTROL_TYPED_VALUE_LIMIT {
|
|
|
|
|
return Err(invalid(
|
|
|
|
|
"typed record value count exceeds the supported limit",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
let values_bytes = value_count
|
|
|
|
|
.checked_mul(4)
|
|
|
|
|
.ok_or_else(|| invalid("typed record value byte length overflows"))?;
|
|
|
|
|
let length_offset = cursor
|
|
|
|
|
.checked_add(CONTROL_TYPED_RECORD_FIXED_BYTES)
|
|
|
|
|
.and_then(|offset| offset.checked_add(values_bytes))
|
|
|
|
|
.ok_or_else(|| invalid("typed record tail offset overflows"))?;
|
|
|
|
|
let string_length = control_u32_at(payload, length_offset)
|
|
|
|
|
.ok_or_else(|| invalid("typed record length word extends past the payload"))?;
|
|
|
|
|
let after_length = length_offset
|
|
|
|
|
.checked_add(4)
|
|
|
|
|
.ok_or_else(|| invalid("typed record end offset overflows"))?;
|
|
|
|
|
|
|
|
|
|
// Native parsing returns immediately for length zero. For UINT32_MAX,
|
|
|
|
|
// its signed length+1 wraps to zero, also consuming no string bytes.
|
2026-10-18 11:02:53 +04:00
|
|
|
let (string_end, string_content_end) = if string_length == 0 || string_length == u32::MAX {
|
|
|
|
|
(after_length, after_length)
|
2026-10-11 22:17:45 +04:00
|
|
|
} else {
|
2026-10-18 11:02:53 +04:00
|
|
|
let string_bytes = usize::try_from(string_length)
|
2026-10-11 22:17:45 +04:00
|
|
|
.ok()
|
|
|
|
|
.and_then(|length| length.checked_add(1))
|
2026-10-18 11:02:53 +04:00
|
|
|
.ok_or_else(|| invalid("typed record string length overflows"))?;
|
|
|
|
|
if string_bytes > CONTROL_TYPED_STRING_LIMIT {
|
|
|
|
|
return Err(invalid("typed record string exceeds the supported limit"));
|
|
|
|
|
}
|
|
|
|
|
let string_end = after_length
|
|
|
|
|
.checked_add(string_bytes)
|
|
|
|
|
.ok_or_else(|| invalid("typed record string end overflows"))?;
|
|
|
|
|
if string_end > payload.len() {
|
|
|
|
|
return Err(invalid("typed record string extends past the payload"));
|
|
|
|
|
}
|
|
|
|
|
if payload[string_end - 1] != 0 {
|
|
|
|
|
return Err(invalid("typed record string is missing its trailing NUL"));
|
|
|
|
|
}
|
|
|
|
|
(string_end, string_end - 1)
|
2026-10-11 22:17:45 +04:00
|
|
|
};
|
2026-10-18 11:02:53 +04:00
|
|
|
let mut fixed_words = [0_u32; 44];
|
|
|
|
|
for (word_index, word) in fixed_words.iter_mut().enumerate() {
|
|
|
|
|
*word = control_u32_at(payload, cursor + word_index * 4)
|
|
|
|
|
.expect("typed record fixed header was checked");
|
2026-10-11 22:17:45 +04:00
|
|
|
}
|
2026-10-18 11:02:53 +04:00
|
|
|
let values = (0..value_count)
|
|
|
|
|
.map(|index| {
|
|
|
|
|
control_u32_at(
|
|
|
|
|
payload,
|
|
|
|
|
cursor + CONTROL_TYPED_RECORD_FIXED_BYTES + index * 4,
|
|
|
|
|
)
|
|
|
|
|
.expect("typed record values were checked")
|
|
|
|
|
})
|
|
|
|
|
.collect();
|
|
|
|
|
typed_records.push(PreparedControlTypedRecord {
|
|
|
|
|
tag,
|
|
|
|
|
fixed_words,
|
|
|
|
|
values,
|
|
|
|
|
string_bytes: payload[after_length..string_content_end].to_vec(),
|
|
|
|
|
gate_provider: if tag == 21 {
|
|
|
|
|
PreparedControlGateProvider::MissingResource {
|
|
|
|
|
key: fixed_words[1],
|
|
|
|
|
ndp_source: None,
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
PreparedControlGateProvider::NotApplicable
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-10-11 22:17:45 +04:00
|
|
|
cursor = string_end;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if effect_group_count == 0 {
|
2026-10-18 11:02:53 +04:00
|
|
|
return Ok(DecodedComponentEffectSection {
|
|
|
|
|
section: if cursor == payload.len() {
|
|
|
|
|
PreparedControlEffectSection::Parsed(Vec::new())
|
|
|
|
|
} else {
|
|
|
|
|
PreparedControlEffectSection::Unsupported {
|
|
|
|
|
typed_record_index: None,
|
|
|
|
|
typed_record_tag: None,
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
typed_records,
|
2026-10-11 22:17:45 +04:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cursor = cursor
|
|
|
|
|
.checked_add(CONTROL_EFFECT_GROUP_HEADER_BYTES)
|
|
|
|
|
.ok_or_else(|| invalid("effect group header end overflows"))?;
|
|
|
|
|
if cursor > payload.len() {
|
|
|
|
|
return Err(invalid("effect group header extends past the payload"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut effect_rows = Vec::new();
|
|
|
|
|
for table_index in 0..effect_group_count {
|
|
|
|
|
let row_count = usize::try_from(
|
|
|
|
|
control_u32_at(payload, cursor)
|
|
|
|
|
.ok_or_else(|| invalid("effect table is missing its row count"))?,
|
|
|
|
|
)
|
|
|
|
|
.map_err(|_| invalid("effect row count overflows usize"))?;
|
|
|
|
|
if row_count > CONTROL_EFFECT_ROW_LIMIT {
|
|
|
|
|
return Err(invalid("effect row count exceeds the supported limit"));
|
|
|
|
|
}
|
|
|
|
|
let rows_start = cursor
|
|
|
|
|
.checked_add(4)
|
|
|
|
|
.ok_or_else(|| invalid("effect row start overflows"))?;
|
|
|
|
|
let row_bytes = row_count
|
|
|
|
|
.checked_mul(CONTROL_EFFECT_ROW_BYTES)
|
|
|
|
|
.ok_or_else(|| invalid("effect row byte length overflows"))?;
|
|
|
|
|
let rows_end = rows_start
|
|
|
|
|
.checked_add(row_bytes)
|
|
|
|
|
.ok_or_else(|| invalid("effect table end overflows"))?;
|
|
|
|
|
if rows_end > payload.len() {
|
|
|
|
|
return Err(invalid("complete effect table extends past the payload"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for row_index in 0..row_count {
|
|
|
|
|
let row_start = rows_start + row_index * CONTROL_EFFECT_ROW_BYTES;
|
|
|
|
|
let row = &payload[row_start..row_start + CONTROL_EFFECT_ROW_BYTES];
|
|
|
|
|
if control_u32_at(row, 0x0C) != Some(4) {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
let raw_condition = [
|
|
|
|
|
control_u32_at(row, 0).expect("fixed effect row has condition word 0"),
|
|
|
|
|
control_u32_at(row, 4).expect("fixed effect row has condition word 1"),
|
|
|
|
|
control_u32_at(row, 8).expect("fixed effect row has condition word 2"),
|
|
|
|
|
];
|
|
|
|
|
let point_offsets = [
|
|
|
|
|
control_u32_at(row, 0x10).expect("fixed effect row has point offset 0"),
|
|
|
|
|
control_u32_at(row, 0x14).expect("fixed effect row has point offset 1"),
|
|
|
|
|
control_u32_at(row, 0x18).expect("fixed effect row has point offset 2"),
|
|
|
|
|
];
|
|
|
|
|
effect_rows.push(PreparedControlEffectRow {
|
|
|
|
|
table_index,
|
|
|
|
|
row_index,
|
|
|
|
|
raw_condition,
|
|
|
|
|
point_offsets,
|
|
|
|
|
selector: control_u32_at(row, 0x1C).expect("fixed effect row has selector"),
|
|
|
|
|
resource: PreparedControlResourcePair {
|
|
|
|
|
archive: row[0x24..0x44]
|
|
|
|
|
.try_into()
|
|
|
|
|
.expect("fixed archive field is 32 bytes"),
|
|
|
|
|
name: row[0x44..0x64]
|
|
|
|
|
.try_into()
|
|
|
|
|
.expect("fixed resource field is 32 bytes"),
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
cursor = rows_end;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if cursor != payload.len() {
|
2026-10-18 11:02:53 +04:00
|
|
|
return Ok(DecodedComponentEffectSection {
|
|
|
|
|
section: PreparedControlEffectSection::Unsupported {
|
|
|
|
|
typed_record_index: None,
|
|
|
|
|
typed_record_tag: None,
|
|
|
|
|
},
|
|
|
|
|
typed_records,
|
2026-10-11 22:17:45 +04:00
|
|
|
});
|
|
|
|
|
}
|
2026-10-18 11:02:53 +04:00
|
|
|
Ok(DecodedComponentEffectSection {
|
|
|
|
|
section: PreparedControlEffectSection::Parsed(effect_rows),
|
|
|
|
|
typed_records,
|
|
|
|
|
})
|
2026-10-11 22:17:45 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn control_u32_at(payload: &[u8], offset: usize) -> Option<u32> {
|
|
|
|
|
let end = offset.checked_add(4)?;
|
|
|
|
|
let bytes = payload.get(offset..end)?;
|
|
|
|
|
Some(u32::from_le_bytes(bytes.try_into().ok()?))
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:12:50 +04:00
|
|
|
fn decode_component_control_payload(
|
|
|
|
|
payload: &[u8],
|
|
|
|
|
source: ResourceKey,
|
|
|
|
|
model_node_count: usize,
|
|
|
|
|
prototype_index: usize,
|
|
|
|
|
) -> Result<PreparedComponentControl, AssetError> {
|
|
|
|
|
const HEADER_BYTES: usize = 5 * 4;
|
|
|
|
|
const FIXED_SECTION_BYTES: usize = 108;
|
|
|
|
|
const SECTION_FIXED_BYTES: usize = 156;
|
|
|
|
|
const SECTION_NODE_BYTES: usize = 16;
|
|
|
|
|
const ROW_BYTES: usize = 36;
|
|
|
|
|
|
|
|
|
|
let invalid = |message: &str| {
|
|
|
|
|
AssetError::InvalidPrototype(format!(
|
|
|
|
|
"prototype {prototype_index} control {}:{}: {message}",
|
|
|
|
|
source.archive.as_str(),
|
|
|
|
|
String::from_utf8_lossy(&source.name.0),
|
|
|
|
|
))
|
|
|
|
|
};
|
|
|
|
|
if payload.len() < HEADER_BYTES {
|
|
|
|
|
return Err(invalid("payload is shorter than its five-word header"));
|
|
|
|
|
}
|
|
|
|
|
let mut header = [0_u32; 5];
|
|
|
|
|
for (index, word) in header.iter_mut().enumerate() {
|
|
|
|
|
let offset = index * 4;
|
|
|
|
|
let bytes: [u8; 4] = payload[offset..offset + 4]
|
|
|
|
|
.try_into()
|
|
|
|
|
.expect("header slice is exactly four bytes");
|
|
|
|
|
*word = u32::from_le_bytes(bytes);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let section_count =
|
|
|
|
|
usize::try_from(header[0]).map_err(|_| invalid("section count overflows usize"))?;
|
|
|
|
|
let per_section_nodes =
|
|
|
|
|
usize::try_from(header[1]).map_err(|_| invalid("section node count overflows usize"))?;
|
|
|
|
|
let row_count =
|
|
|
|
|
usize::try_from(header[2]).map_err(|_| invalid("binding count overflows usize"))?;
|
2026-10-11 22:17:45 +04:00
|
|
|
let typed_record_count =
|
|
|
|
|
usize::try_from(header[3]).map_err(|_| invalid("typed record count overflows usize"))?;
|
|
|
|
|
let effect_group_count =
|
|
|
|
|
usize::try_from(header[4]).map_err(|_| invalid("effect group count overflows usize"))?;
|
2026-10-11 13:12:50 +04:00
|
|
|
let per_section_node_bytes = SECTION_NODE_BYTES
|
|
|
|
|
.checked_mul(per_section_nodes)
|
|
|
|
|
.ok_or_else(|| invalid("section node bytes overflow"))?;
|
|
|
|
|
let section_bytes = section_count
|
|
|
|
|
.checked_mul(
|
|
|
|
|
SECTION_FIXED_BYTES
|
|
|
|
|
.checked_add(per_section_node_bytes)
|
|
|
|
|
.ok_or_else(|| invalid("section size overflows"))?,
|
|
|
|
|
)
|
|
|
|
|
.ok_or_else(|| invalid("section table bytes overflow"))?;
|
|
|
|
|
let matrix_bytes = section_count
|
|
|
|
|
.checked_mul(section_count)
|
|
|
|
|
.and_then(|count| count.checked_mul(4))
|
|
|
|
|
.ok_or_else(|| invalid("section matrix bytes overflow"))?;
|
|
|
|
|
let row_start = HEADER_BYTES
|
|
|
|
|
.checked_add(FIXED_SECTION_BYTES)
|
|
|
|
|
.and_then(|offset| offset.checked_add(section_bytes))
|
|
|
|
|
.and_then(|offset| offset.checked_add(matrix_bytes))
|
|
|
|
|
.ok_or_else(|| invalid("binding table offset overflows"))?;
|
|
|
|
|
let row_bytes = row_count
|
|
|
|
|
.checked_mul(ROW_BYTES)
|
|
|
|
|
.ok_or_else(|| invalid("binding table bytes overflow"))?;
|
|
|
|
|
let row_end = row_start
|
|
|
|
|
.checked_add(row_bytes)
|
|
|
|
|
.ok_or_else(|| invalid("binding table end overflows"))?;
|
|
|
|
|
if row_end > payload.len() {
|
|
|
|
|
return Err(invalid("complete binding table extends past the payload"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut bindings = Vec::with_capacity(row_count);
|
2026-10-18 11:02:53 +04:00
|
|
|
let mut timeline_rows = Vec::with_capacity(row_count);
|
2026-10-11 13:12:50 +04:00
|
|
|
for row_index in 0..row_count {
|
|
|
|
|
let row_start = row_start + row_index * ROW_BYTES;
|
|
|
|
|
let row = &payload[row_start..row_start + ROW_BYTES];
|
|
|
|
|
let flags = u32::from_le_bytes(row[0x20..0x24].try_into().expect("row flags"));
|
|
|
|
|
let node_index = u32::from_le_bytes(row[0..4].try_into().expect("row node index")) as usize;
|
|
|
|
|
let frame_a = f32::from_bits(u32::from_le_bytes(row[4..8].try_into().expect("frame A")));
|
|
|
|
|
let frame_b = f32::from_bits(u32::from_le_bytes(row[8..12].try_into().expect("frame B")));
|
|
|
|
|
let initial_blend = f32::from_bits(u32::from_le_bytes(
|
|
|
|
|
row[12..16].try_into().expect("initial blend"),
|
|
|
|
|
));
|
2026-10-18 11:02:53 +04:00
|
|
|
let raw_parameters = [
|
|
|
|
|
u32::from_le_bytes(row[0x10..0x14].try_into().expect("row parameter +0x10")),
|
|
|
|
|
u32::from_le_bytes(row[0x14..0x18].try_into().expect("row parameter +0x14")),
|
|
|
|
|
u32::from_le_bytes(row[0x18..0x1C].try_into().expect("row parameter +0x18")),
|
|
|
|
|
u32::from_le_bytes(row[0x1C..0x20].try_into().expect("row parameter +0x1C")),
|
|
|
|
|
];
|
|
|
|
|
timeline_rows.push(PreparedControlTimelineRow {
|
|
|
|
|
row_index,
|
|
|
|
|
node_index,
|
|
|
|
|
frame_a,
|
|
|
|
|
frame_b,
|
|
|
|
|
initial_blend,
|
|
|
|
|
raw_parameters,
|
|
|
|
|
flags,
|
|
|
|
|
});
|
|
|
|
|
if flags & 4 != 0 {
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if node_index >= model_node_count {
|
|
|
|
|
return Err(invalid("enabled binding node index is outside the model"));
|
|
|
|
|
}
|
2026-10-11 13:12:50 +04:00
|
|
|
if !frame_a.is_finite()
|
|
|
|
|
|| !frame_b.is_finite()
|
|
|
|
|
|| !initial_blend.is_finite()
|
|
|
|
|
|| frame_a < 0.0
|
|
|
|
|
|| frame_b < 0.0
|
|
|
|
|
{
|
|
|
|
|
return Err(invalid(
|
|
|
|
|
"enabled binding contains a non-finite or negative value",
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
bindings.push(PreparedControlNodeBinding {
|
|
|
|
|
node_index,
|
|
|
|
|
frame_a,
|
|
|
|
|
frame_b,
|
|
|
|
|
initial_blend,
|
|
|
|
|
flags,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 11:02:53 +04:00
|
|
|
let decoded_effect = decode_component_effect_section(
|
2026-10-11 22:17:45 +04:00
|
|
|
payload,
|
|
|
|
|
row_end,
|
|
|
|
|
typed_record_count,
|
|
|
|
|
effect_group_count,
|
|
|
|
|
&invalid,
|
|
|
|
|
)?;
|
|
|
|
|
|
|
|
|
|
Ok(PreparedComponentControl {
|
|
|
|
|
source,
|
2026-10-18 11:02:53 +04:00
|
|
|
model_node_count,
|
2026-10-11 22:17:45 +04:00
|
|
|
bindings,
|
2026-10-18 11:02:53 +04:00
|
|
|
timeline_rows,
|
|
|
|
|
typed_records: decoded_effect.typed_records,
|
|
|
|
|
node_data: None,
|
|
|
|
|
effect_section: decoded_effect.section,
|
2026-10-11 22:38:53 +04:00
|
|
|
point_list: None,
|
2026-10-11 22:17:45 +04:00
|
|
|
})
|
2026-10-11 13:12:50 +04:00
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
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();
|
2026-10-11 13:12:50 +04:00
|
|
|
proto.source_type.hash(&mut hasher);
|
2026-06-22 13:12:27 +04:00
|
|
|
match &proto.geometry {
|
|
|
|
|
PrototypeGeometry::Mesh(key) => {
|
|
|
|
|
1_u8.hash(&mut hasher);
|
2026-07-03 16:58:17 +04:00
|
|
|
key.archive.identity_bytes().hash(&mut hasher);
|
2026-06-22 13:12:27 +04:00
|
|
|
key.name.0.hash(&mut hasher);
|
|
|
|
|
key.type_id.hash(&mut hasher);
|
|
|
|
|
}
|
|
|
|
|
PrototypeGeometry::NonGeometric => {
|
|
|
|
|
0_u8.hash(&mut hasher);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
hasher.finish()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 17:11:49 +04:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-23 22:05:16 +04:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 17:11:49 +04:00
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
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};
|
2026-06-22 16:02:16 +04:00
|
|
|
use fparkan_vfs::{DirectoryVfs, MemoryVfs, Vfs};
|
2026-06-22 13:12:27 +04:00
|
|
|
use std::path::PathBuf;
|
|
|
|
|
|
2026-10-18 11:02:53 +04:00
|
|
|
// Preserve the authored CTLD fixture bits; this is not the `f32` value of tau.
|
|
|
|
|
const AUTHORED_TAG21_RAW_PARAMETER_3: f32 = f32::from_bits(0x40C8_F5C3);
|
|
|
|
|
|
|
|
|
|
fn tag21_fullcycle_control() -> PreparedComponentControl {
|
|
|
|
|
let source = ResourceKey {
|
|
|
|
|
archive: parse_path("parts.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"u_all_def_a_01.ctl"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
let mut payload = component_control_payload(&[(1, 0.0, 4.0, 0.0, 1)]);
|
|
|
|
|
let row_start = 5 * 4 + 108;
|
|
|
|
|
payload[row_start + 0x10..row_start + 0x14].copy_from_slice(&u32::MAX.to_le_bytes());
|
|
|
|
|
payload[row_start + 0x14..row_start + 0x18].copy_from_slice(&u32::MAX.to_le_bytes());
|
|
|
|
|
payload[row_start + 0x18..row_start + 0x1C]
|
|
|
|
|
.copy_from_slice(&0.5_f32.to_bits().to_le_bytes());
|
|
|
|
|
payload[row_start + 0x1C..row_start + 0x20]
|
|
|
|
|
.copy_from_slice(&AUTHORED_TAG21_RAW_PARAMETER_3.to_bits().to_le_bytes());
|
|
|
|
|
payload[12..16].copy_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
|
|
|
|
|
let typed_start = payload.len();
|
|
|
|
|
let mut fixed = [0_u8; CONTROL_TYPED_RECORD_FIXED_BYTES];
|
|
|
|
|
fixed[0..4].copy_from_slice(&21_u32.to_le_bytes());
|
|
|
|
|
fixed[8..12].copy_from_slice(&0_u32.to_le_bytes());
|
|
|
|
|
fixed[0x18..0x1C].copy_from_slice(&u32::MAX.to_le_bytes());
|
|
|
|
|
fixed[0x24..0x28].copy_from_slice(&1.0_f32.to_bits().to_le_bytes());
|
|
|
|
|
fixed[0x28..0x2C].copy_from_slice(&1.0_f32.to_bits().to_le_bytes());
|
|
|
|
|
fixed[CONTROL_TYPED_RECORD_COUNT_OFFSET..CONTROL_TYPED_RECORD_COUNT_OFFSET + 4]
|
|
|
|
|
.copy_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
payload.extend_from_slice(&fixed);
|
|
|
|
|
payload.extend_from_slice(&0_u32.to_le_bytes());
|
|
|
|
|
payload.extend_from_slice(&0_u32.to_le_bytes());
|
|
|
|
|
assert_eq!(
|
|
|
|
|
typed_start + CONTROL_TYPED_RECORD_FIXED_BYTES + 8,
|
|
|
|
|
payload.len()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let mut control =
|
|
|
|
|
decode_component_control_payload(&payload, source, 2, 0).expect("tag21 CTLD");
|
|
|
|
|
control.typed_records[0].gate_provider = PreparedControlGateProvider::Resolved {
|
|
|
|
|
key: 0,
|
|
|
|
|
ndp_source: ResourceKey {
|
|
|
|
|
archive: parse_path("parts.rlb").expect("NDP archive"),
|
|
|
|
|
name: resource_name(b"u_all_def_a_01.ndp"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
},
|
|
|
|
|
row_count: 1,
|
|
|
|
|
initial_type1_value: 1.0,
|
|
|
|
|
};
|
|
|
|
|
control
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn tag21_sampler_replays_authored_fullcycle_clock_and_gate() {
|
|
|
|
|
let control = tag21_fullcycle_control();
|
|
|
|
|
assert_eq!(control.model_node_count, 2);
|
|
|
|
|
assert_eq!(control.typed_records.len(), 1);
|
|
|
|
|
assert_eq!(control.typed_records[0].tag, 21);
|
|
|
|
|
assert_eq!(control.typed_records[0].values, vec![0]);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
f32::from_bits(control.timeline_rows[0].raw_parameters[2]),
|
|
|
|
|
0.5
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
f32::from_bits(control.timeline_rows[0].raw_parameters[3]),
|
|
|
|
|
AUTHORED_TAG21_RAW_PARAMETER_3
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Native Control receives 1.0 from the first default NDP provider row.
|
|
|
|
|
// Since 0x21820 tests for zero and its caller stops on return value 1,
|
|
|
|
|
// a nonzero provider value opens the tag-21 update path.
|
|
|
|
|
let default_provider_value = 1.0_f32;
|
|
|
|
|
let gate_values = [Some(default_provider_value)];
|
|
|
|
|
let mut state = ControlInstanceState::default();
|
|
|
|
|
let at_zero = sample_control_node_times(&control, 0, &gate_values, &mut state)
|
|
|
|
|
.expect("initial tag21 sample");
|
|
|
|
|
assert_eq!(at_zero, vec![Some(0.0), Some(0.0)]);
|
|
|
|
|
|
|
|
|
|
let at_quarter_step = sample_control_node_times(&control, 225, &gate_values, &mut state)
|
|
|
|
|
.expect("quarter-step tag21 sample");
|
|
|
|
|
assert!((at_quarter_step[1].expect("node 1") - 0.45).abs() < 1.0e-5);
|
|
|
|
|
|
|
|
|
|
let at_first_transition =
|
|
|
|
|
sample_control_node_times(&control, 900, &gate_values, &mut state)
|
|
|
|
|
.expect("first tag21 transition");
|
|
|
|
|
assert!((at_first_transition[1].expect("node 1") - 1.8).abs() < 1.0e-5);
|
|
|
|
|
|
|
|
|
|
let at_second_transition =
|
|
|
|
|
sample_control_node_times(&control, 1800, &gate_values, &mut state)
|
|
|
|
|
.expect("second tag21 transition");
|
|
|
|
|
assert!((at_second_transition[1].expect("node 1") - 3.6).abs() < 1.0e-5);
|
|
|
|
|
|
|
|
|
|
let at_wrap = sample_control_node_times(&control, 2700, &gate_values, &mut state)
|
|
|
|
|
.expect("wrapped tag21 transition");
|
|
|
|
|
assert!((at_wrap[1].expect("node 1") - 1.4).abs() < 1.0e-5);
|
|
|
|
|
|
|
|
|
|
let at_fullcycle = sample_control_node_times(&control, 18_000, &gate_values, &mut state)
|
|
|
|
|
.expect("fullcycle tag21 transition");
|
|
|
|
|
assert!(
|
|
|
|
|
(at_fullcycle[1].expect("node 1") - 4.0).abs() < 1.0e-4,
|
|
|
|
|
"full-cycle node time was {at_fullcycle:?}"
|
|
|
|
|
);
|
|
|
|
|
let zero_provider_value = 0.0_f32;
|
|
|
|
|
let gate_closed = [Some(zero_provider_value)];
|
|
|
|
|
let mut closed_gate_state = ControlInstanceState::default();
|
|
|
|
|
for clock in [0, 100, 500, 1000] {
|
|
|
|
|
let sample =
|
|
|
|
|
sample_control_node_times(&control, clock, &gate_closed, &mut closed_gate_state)
|
|
|
|
|
.expect("closed native gate holds the initial blend");
|
|
|
|
|
assert_eq!(sample[1], Some(0.0));
|
|
|
|
|
}
|
|
|
|
|
let reopened_provider_value = 1.0_f32;
|
|
|
|
|
let reopened_gate = [Some(reopened_provider_value)];
|
|
|
|
|
let opened_gate =
|
|
|
|
|
sample_control_node_times(&control, 1001, &reopened_gate, &mut closed_gate_state)
|
|
|
|
|
.expect("open native gate starts a transition");
|
|
|
|
|
assert!(opened_gate[1].expect("node 1") > 0.0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn tag21_sampler_preserves_fast_rates_and_rejects_previous_row_mapping() {
|
|
|
|
|
let mut fast = tag21_fullcycle_control();
|
|
|
|
|
fast.timeline_rows[0].raw_parameters[2] = 20.0_f32.to_bits();
|
|
|
|
|
let mut fast_state = ControlInstanceState::default();
|
|
|
|
|
let at_half_transition =
|
|
|
|
|
sample_control_node_times(&fast, 11, &[Some(1.0)], &mut fast_state)
|
|
|
|
|
.expect("fast sample");
|
|
|
|
|
assert!((at_half_transition[1].expect("node 1") - 0.88).abs() < 1.0e-5);
|
|
|
|
|
let at_transition_end = sample_control_node_times(&fast, 23, &[Some(1.0)], &mut fast_state)
|
|
|
|
|
.expect("fast transition");
|
|
|
|
|
assert!((at_transition_end[1].expect("node 1") - 1.84).abs() < 1.0e-5);
|
|
|
|
|
|
|
|
|
|
let mut direct = tag21_fullcycle_control();
|
|
|
|
|
direct.timeline_rows[0].flags = 0;
|
|
|
|
|
direct.typed_records[0].fixed_words[9] = 2.0_f32.to_bits();
|
|
|
|
|
let direct_sample = sample_control_node_times(
|
|
|
|
|
&direct,
|
|
|
|
|
100,
|
|
|
|
|
&[Some(1.0)],
|
|
|
|
|
&mut ControlInstanceState::default(),
|
|
|
|
|
)
|
|
|
|
|
.expect("non-wrapping transition");
|
|
|
|
|
assert!((direct_sample[1].expect("node 1") - 0.4).abs() < 1.0e-5);
|
|
|
|
|
|
|
|
|
|
let mut previous_row = tag21_fullcycle_control();
|
|
|
|
|
previous_row.timeline_rows[0].flags = 0x40;
|
|
|
|
|
let error = sample_control_node_times(
|
|
|
|
|
&previous_row,
|
|
|
|
|
0,
|
|
|
|
|
&[Some(1.0)],
|
|
|
|
|
&mut ControlInstanceState::default(),
|
|
|
|
|
)
|
|
|
|
|
.expect_err("previous-row mapping must not silently use the wrong blend");
|
|
|
|
|
assert_eq!(
|
|
|
|
|
error,
|
|
|
|
|
ControlSamplingError::UnsupportedPreviousRowMapping { row_index: 0 }
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:12:50 +04:00
|
|
|
#[test]
|
|
|
|
|
fn component_control_rows_preserve_f32_values_and_source_order() {
|
|
|
|
|
let source = ResourceKey {
|
|
|
|
|
archive: parse_path("controls.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"parts.ctl"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
let payload = component_control_payload(&[
|
|
|
|
|
(99, f32::NAN, -1.0, -1.0, 4),
|
|
|
|
|
(1, 1.25, 2.5, 0.0, 0),
|
|
|
|
|
(1, 2.0, 0.0, 0.75, 1),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
let control =
|
|
|
|
|
decode_component_control_payload(&payload, source, 2, 7).expect("valid enabled rows");
|
|
|
|
|
|
|
|
|
|
assert_eq!(control.bindings.len(), 2);
|
|
|
|
|
assert_eq!(control.bindings[0].node_index, 1);
|
|
|
|
|
assert_eq!(control.bindings[0].frame_a, 1.25);
|
|
|
|
|
assert_eq!(control.bindings[0].frame_b, 2.5);
|
|
|
|
|
assert_eq!(control.bindings[0].initial_blend, 0.0);
|
|
|
|
|
assert_eq!(control.bindings[1].node_index, 1);
|
|
|
|
|
assert_eq!(control.bindings[1].frame_a, 2.0);
|
|
|
|
|
assert_eq!(control.bindings[1].flags, 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn component_control_rejects_bad_bounds_but_keeps_sampler_inputs_raw() {
|
|
|
|
|
let source = ResourceKey {
|
|
|
|
|
archive: parse_path("controls.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"parts.ctl"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
let mut overflow = vec![0; 20];
|
|
|
|
|
overflow[0..4].copy_from_slice(&u32::MAX.to_le_bytes());
|
|
|
|
|
assert!(decode_component_control_payload(&overflow, source.clone(), 2, 0).is_err());
|
|
|
|
|
|
|
|
|
|
let out_of_range_node = component_control_payload(&[(2, 0.0, 1.0, 0.0, 0)]);
|
|
|
|
|
assert!(
|
|
|
|
|
decode_component_control_payload(&out_of_range_node, source.clone(), 2, 0,).is_err()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let fallback_input = component_control_payload(&[(1, 0.0, 4.0, -0.5, 0)]);
|
|
|
|
|
let control = decode_component_control_payload(&fallback_input, source.clone(), 2, 0)
|
|
|
|
|
.expect("sampler decides out-of-map fallback and blend normalization");
|
|
|
|
|
assert_eq!(control.bindings[0].frame_b, 4.0);
|
|
|
|
|
assert_eq!(control.bindings[0].initial_blend, -0.5);
|
|
|
|
|
|
|
|
|
|
let non_finite = component_control_payload(&[(1, f32::NAN, 1.0, 0.0, 0)]);
|
|
|
|
|
assert!(decode_component_control_payload(&non_finite, source, 2, 0).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:17:45 +04:00
|
|
|
#[test]
|
2026-10-18 11:02:53 +04:00
|
|
|
fn component_control_unproven_effect_tags_preserve_node_bindings() {
|
2026-10-11 22:17:45 +04:00
|
|
|
let source = ResourceKey {
|
|
|
|
|
archive: parse_path("controls.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"parts.ctl"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
2026-10-18 11:02:53 +04:00
|
|
|
for tag in [2_u32, 5, 0xDEAD_BEEF] {
|
|
|
|
|
let mut payload = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]);
|
|
|
|
|
payload[12..16].copy_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
payload[16..20].copy_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
payload.extend_from_slice(&tag.to_le_bytes());
|
|
|
|
|
|
|
|
|
|
let control = decode_component_control_payload(&payload, source.clone(), 2, 3)
|
|
|
|
|
.expect("unproven tag is preserved as unsupported");
|
|
|
|
|
|
|
|
|
|
assert_eq!(control.bindings.len(), 1);
|
|
|
|
|
assert_eq!(control.bindings[0].node_index, 1);
|
|
|
|
|
assert_eq!(control.bindings[0].frame_b, 1.0);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
control.effect_section,
|
|
|
|
|
PreparedControlEffectSection::Unsupported {
|
|
|
|
|
typed_record_index: Some(0),
|
|
|
|
|
typed_record_tag: Some(tag),
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-10-11 22:17:45 +04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn component_control_zero_length_typed_record_ends_before_group_header() {
|
|
|
|
|
let source = ResourceKey {
|
|
|
|
|
archive: parse_path("controls.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"parts.ctl"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
let mut payload = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]);
|
|
|
|
|
payload[12..16].copy_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
payload[16..20].copy_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
append_typed_record(&mut payload, 21, 0);
|
|
|
|
|
let group_header_start = payload.len();
|
|
|
|
|
payload.resize(group_header_start + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0);
|
|
|
|
|
payload.extend_from_slice(&0_u32.to_le_bytes());
|
|
|
|
|
|
2026-10-18 11:02:53 +04:00
|
|
|
let control = decode_component_control_payload(&payload, source.clone(), 2, 3)
|
2026-10-11 22:17:45 +04:00
|
|
|
.expect("zero-length record has no trailing string byte");
|
|
|
|
|
|
|
|
|
|
assert_eq!(control.bindings.len(), 1);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
control.effect_section,
|
|
|
|
|
PreparedControlEffectSection::Parsed(Vec::new())
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn component_control_malformed_recognized_typed_tail_is_rejected() {
|
|
|
|
|
let source = ResourceKey {
|
|
|
|
|
archive: parse_path("controls.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"parts.ctl"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
let mut payload = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]);
|
|
|
|
|
payload[12..16].copy_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
append_typed_record(&mut payload, 21, 2);
|
|
|
|
|
|
|
|
|
|
assert!(decode_component_control_payload(&payload, source, 2, 3).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn component_control_type4_rows_preserve_fields_and_skip_other_types() {
|
|
|
|
|
let source = ResourceKey {
|
|
|
|
|
archive: parse_path("controls.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"parts.ctl"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
let mut payload = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]);
|
|
|
|
|
payload[12..16].copy_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
payload[16..20].copy_from_slice(&2_u32.to_le_bytes());
|
|
|
|
|
append_typed_record(&mut payload, 21, 0);
|
|
|
|
|
payload.resize(payload.len() + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0);
|
|
|
|
|
append_effect_table(
|
|
|
|
|
&mut payload,
|
|
|
|
|
&[
|
|
|
|
|
effect_row(
|
|
|
|
|
3,
|
|
|
|
|
[1, 2, 3],
|
|
|
|
|
[10, 0, 0],
|
|
|
|
|
8000,
|
|
|
|
|
"effects.rlb",
|
|
|
|
|
"door_open_02",
|
|
|
|
|
),
|
|
|
|
|
effect_row(
|
|
|
|
|
4,
|
|
|
|
|
[0, 7, 9],
|
|
|
|
|
[104, 105, 106],
|
|
|
|
|
17,
|
|
|
|
|
"effects.rlb",
|
|
|
|
|
"f_smalllight_y",
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
append_effect_table(
|
|
|
|
|
&mut payload,
|
|
|
|
|
&[effect_row(
|
|
|
|
|
4,
|
|
|
|
|
[13, 14, 15],
|
|
|
|
|
[85, 85, 85],
|
|
|
|
|
26,
|
|
|
|
|
"effects.rlb",
|
|
|
|
|
"f_gener_ball",
|
|
|
|
|
)],
|
|
|
|
|
);
|
|
|
|
|
|
2026-10-18 11:02:53 +04:00
|
|
|
let control = decode_component_control_payload(&payload, source.clone(), 2, 3)
|
2026-10-11 22:17:45 +04:00
|
|
|
.expect("complete type-4 effect tables");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
control.effect_section,
|
|
|
|
|
PreparedControlEffectSection::Parsed(vec![
|
|
|
|
|
PreparedControlEffectRow {
|
|
|
|
|
table_index: 0,
|
|
|
|
|
row_index: 1,
|
|
|
|
|
raw_condition: [0, 7, 9],
|
|
|
|
|
point_offsets: [104, 105, 106],
|
|
|
|
|
selector: 17,
|
|
|
|
|
resource: PreparedControlResourcePair {
|
|
|
|
|
archive: fixed_name("effects.rlb"),
|
|
|
|
|
name: fixed_name("f_smalllight_y"),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
PreparedControlEffectRow {
|
|
|
|
|
table_index: 1,
|
|
|
|
|
row_index: 0,
|
|
|
|
|
raw_condition: [13, 14, 15],
|
|
|
|
|
point_offsets: [85, 85, 85],
|
|
|
|
|
selector: 26,
|
|
|
|
|
resource: PreparedControlResourcePair {
|
|
|
|
|
archive: fixed_name("effects.rlb"),
|
|
|
|
|
name: fixed_name("f_gener_ball"),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
])
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 11:02:53 +04:00
|
|
|
#[test]
|
|
|
|
|
fn tag21_gate_provider_comes_from_validated_explicit_ndp() {
|
|
|
|
|
let source = ResourceKey {
|
|
|
|
|
archive: parse_path("parts.rlb").expect("CTL archive"),
|
|
|
|
|
name: resource_name(b"locator.ctl"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
let ndp_source = ResourceKey {
|
|
|
|
|
archive: parse_path("parts.rlb").expect("NDP archive"),
|
|
|
|
|
name: resource_name(b"locator.ndp"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
let control_payload = tag21_control_payload(0);
|
|
|
|
|
let ndp_payload = node_data_payload(1);
|
|
|
|
|
let repository = repository_with_archives(&[(
|
|
|
|
|
"parts.rlb",
|
|
|
|
|
&[
|
|
|
|
|
(b"locator.ctl", &control_payload),
|
|
|
|
|
(b"locator.ndp", &ndp_payload),
|
|
|
|
|
],
|
|
|
|
|
)]);
|
|
|
|
|
let prepared = prepare_component_control_from_resource(
|
|
|
|
|
&repository,
|
|
|
|
|
source.clone(),
|
|
|
|
|
None,
|
|
|
|
|
Some(ndp_source.clone()),
|
|
|
|
|
2,
|
|
|
|
|
0,
|
|
|
|
|
)
|
|
|
|
|
.expect("prepare CTL and NDP")
|
|
|
|
|
.expect("CTL exists");
|
|
|
|
|
let table = prepared.node_data.as_ref().expect("NDP table attached");
|
|
|
|
|
assert_eq!(table.source, ndp_source);
|
|
|
|
|
assert_eq!(table.rows.len(), 1);
|
|
|
|
|
assert_eq!(table.rows[0].max_volume, 6000.0);
|
|
|
|
|
assert_eq!(table.rows[0].mobility_per_volume, 500.0);
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
prepared.typed_records[0].gate_provider,
|
|
|
|
|
PreparedControlGateProvider::Resolved {
|
|
|
|
|
key: 0,
|
|
|
|
|
row_count: 1,
|
|
|
|
|
initial_type1_value: 1.0,
|
|
|
|
|
..
|
|
|
|
|
}
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
let mut state = ControlInstanceState::default();
|
|
|
|
|
sample_control_node_times(&prepared, 225, &[Some(1.0)], &mut state)
|
|
|
|
|
.expect("resolved nonzero provider opens the transition");
|
|
|
|
|
|
|
|
|
|
let missing_repository =
|
|
|
|
|
repository_with_archives(&[("parts.rlb", &[(b"locator.ctl", &control_payload)])]);
|
|
|
|
|
let missing = prepare_component_control_from_resource(
|
|
|
|
|
&missing_repository,
|
|
|
|
|
source.clone(),
|
|
|
|
|
None,
|
|
|
|
|
Some(ResourceKey {
|
|
|
|
|
archive: parse_path("parts.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"missing.ndp"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
}),
|
|
|
|
|
2,
|
|
|
|
|
0,
|
|
|
|
|
)
|
|
|
|
|
.expect("missing optional NDP is retained as metadata")
|
|
|
|
|
.expect("CTL exists");
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
sample_control_node_times(&missing, 0, &[None], &mut ControlInstanceState::default()),
|
|
|
|
|
Err(ControlSamplingError::MissingGateProvider { key: 0, .. })
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
let out_of_range_payload = tag21_control_payload(1);
|
|
|
|
|
let out_of_range_repository = repository_with_archives(&[(
|
|
|
|
|
"parts.rlb",
|
|
|
|
|
&[
|
|
|
|
|
(b"locator.ctl", &out_of_range_payload),
|
|
|
|
|
(b"locator.ndp", &ndp_payload),
|
|
|
|
|
],
|
|
|
|
|
)]);
|
|
|
|
|
let out_of_range = prepare_component_control_from_resource(
|
|
|
|
|
&out_of_range_repository,
|
|
|
|
|
source,
|
|
|
|
|
None,
|
|
|
|
|
Some(ResourceKey {
|
|
|
|
|
archive: parse_path("parts.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"locator.ndp"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
}),
|
|
|
|
|
2,
|
|
|
|
|
0,
|
|
|
|
|
)
|
|
|
|
|
.expect("out-of-range key remains inspectable")
|
|
|
|
|
.expect("CTL exists");
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
sample_control_node_times(
|
|
|
|
|
&out_of_range,
|
|
|
|
|
0,
|
|
|
|
|
&[None],
|
|
|
|
|
&mut ControlInstanceState::default(),
|
|
|
|
|
),
|
|
|
|
|
Err(ControlSamplingError::GateProviderKeyOutOfRange {
|
|
|
|
|
key: 1,
|
|
|
|
|
row_count: 1,
|
|
|
|
|
..
|
|
|
|
|
})
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
let mut malformed_ndp = ndp_payload;
|
|
|
|
|
malformed_ndp.push(0);
|
|
|
|
|
assert!(decode_component_node_data_table(&malformed_ndp, ndp_source, 0).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:38:53 +04:00
|
|
|
#[test]
|
|
|
|
|
fn component_control_loads_explicit_cpt_points_for_effect_offsets() {
|
|
|
|
|
let mut control = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]);
|
|
|
|
|
control[16..20].copy_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
control.resize(control.len() + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0);
|
|
|
|
|
append_effect_table(
|
|
|
|
|
&mut control,
|
|
|
|
|
&[
|
|
|
|
|
effect_row(
|
|
|
|
|
4,
|
|
|
|
|
[0; 3],
|
|
|
|
|
[104, 105, 106],
|
|
|
|
|
17,
|
|
|
|
|
"effects.rlb",
|
|
|
|
|
"f_smalllight_y",
|
|
|
|
|
),
|
|
|
|
|
effect_row(
|
|
|
|
|
4,
|
|
|
|
|
[0; 3],
|
|
|
|
|
[119, 120, 121],
|
|
|
|
|
3300,
|
|
|
|
|
"effects.rlb",
|
|
|
|
|
"smoke_fr_02",
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
let mut cpt = cpt_payload(122);
|
|
|
|
|
write_cpt_point(
|
|
|
|
|
&mut cpt,
|
|
|
|
|
104,
|
|
|
|
|
0,
|
|
|
|
|
23,
|
|
|
|
|
23,
|
|
|
|
|
[-12.964_036, 12.991_658, -21.119_995],
|
|
|
|
|
[0.0, 0.0, 1.0],
|
|
|
|
|
"Sign_Recharge1",
|
|
|
|
|
);
|
|
|
|
|
write_cpt_point(
|
|
|
|
|
&mut cpt,
|
|
|
|
|
119,
|
|
|
|
|
0x8000_0000,
|
|
|
|
|
24,
|
|
|
|
|
24,
|
|
|
|
|
[0.0, 0.166_994_02, 32.7812],
|
|
|
|
|
[0.0, 0.0, 1.0],
|
|
|
|
|
"Smoke_w",
|
|
|
|
|
);
|
|
|
|
|
let repository = repository_with_archives(&[
|
|
|
|
|
("controls.rlb", &[(b"parts.ctl", &control)]),
|
|
|
|
|
("points.rlb", &[(b"attachments.cpt", &cpt)]),
|
|
|
|
|
]);
|
|
|
|
|
let source = ResourceKey {
|
|
|
|
|
archive: parse_path("controls.rlb").expect("control archive"),
|
|
|
|
|
name: resource_name(b"parts.ctl"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
let point_source = ResourceKey {
|
|
|
|
|
archive: parse_path("points.rlb").expect("point archive"),
|
|
|
|
|
name: resource_name(b"attachments.cpt"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-18 11:02:53 +04:00
|
|
|
let control = prepare_component_control_from_resource(
|
|
|
|
|
&repository,
|
|
|
|
|
source,
|
|
|
|
|
Some(point_source),
|
|
|
|
|
None,
|
|
|
|
|
2,
|
|
|
|
|
0,
|
|
|
|
|
)
|
|
|
|
|
.expect("loaded CTL and its explicit CPT")
|
|
|
|
|
.expect("CTL entry");
|
2026-10-11 22:38:53 +04:00
|
|
|
let point_list = control.point_list.as_ref().expect("point list is attached");
|
|
|
|
|
|
|
|
|
|
assert_eq!(point_list.source.archive.as_str(), "points.rlb");
|
|
|
|
|
assert_eq!(point_list.source.name, resource_name(b"attachments.cpt"));
|
|
|
|
|
assert_eq!(point_list.points.len(), 122);
|
|
|
|
|
let PreparedControlEffectSection::Parsed(rows) = &control.effect_section else {
|
|
|
|
|
panic!("effect rows parsed");
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(rows[0].point_offsets, [104, 105, 106]);
|
|
|
|
|
assert_eq!(rows[1].point_offsets, [119, 120, 121]);
|
|
|
|
|
assert_eq!(point_list.points[104].raw_signed_field_04, 23);
|
|
|
|
|
assert_eq!(point_list.points[104].raw_signed_field_08, 23);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
point_list.points[104].position,
|
|
|
|
|
[-12.964_036, 12.991_658, -21.119_995]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(point_list.points[104].name, fixed_name("Sign_Recharge1"));
|
|
|
|
|
assert_eq!(point_list.points[119].raw_flags, 0x8000_0000);
|
|
|
|
|
assert_eq!(point_list.points[119].name, fixed_name("Smoke_w"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn component_point_list_rejects_bad_bounds_and_non_finite_points() {
|
|
|
|
|
let source = ResourceKey {
|
|
|
|
|
archive: parse_path("controls.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"parts.cpt"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
let valid = cpt_payload(1);
|
|
|
|
|
assert!(decode_component_point_list(&valid[..valid.len() - 1], source.clone(), 0).is_err());
|
|
|
|
|
let mut trailing = valid.clone();
|
|
|
|
|
trailing.push(0);
|
|
|
|
|
assert!(decode_component_point_list(&trailing, source.clone(), 0).is_err());
|
|
|
|
|
|
|
|
|
|
let mut non_finite = valid;
|
|
|
|
|
non_finite[16..20].copy_from_slice(&f32::INFINITY.to_bits().to_le_bytes());
|
|
|
|
|
assert!(decode_component_point_list(&non_finite, source, 0).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 11:38:50 +04:00
|
|
|
#[test]
|
|
|
|
|
fn mission_assets_keep_explicit_bas_geometry_at_component_index() {
|
|
|
|
|
let mut bas = Vec::new();
|
|
|
|
|
bas.extend_from_slice(&1_u32.to_le_bytes()); // first-section records
|
|
|
|
|
bas.extend_from_slice(&1_u32.to_le_bytes()); // declared count
|
|
|
|
|
for point in [[1.0_f32, 2.0, 3.0], [4.0, 5.0, 6.0]] {
|
|
|
|
|
for value in point {
|
|
|
|
|
bas.extend_from_slice(&value.to_le_bytes());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
bas.extend_from_slice(&0x1122_3344_u32.to_le_bytes());
|
|
|
|
|
bas.extend_from_slice(&0x5566_7788_u32.to_le_bytes());
|
|
|
|
|
bas.extend_from_slice(&1_u32.to_le_bytes()); // second-section records
|
|
|
|
|
bas.extend_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
for point in [[7.0_f32, 8.0, 9.0], [10.0, 11.0, 12.0]] {
|
|
|
|
|
for value in point {
|
|
|
|
|
bas.extend_from_slice(&value.to_le_bytes());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let repository = repository_with_archives(&[("fortif.rlb", &[(b"fr_b_plant.bas", &bas)])]);
|
|
|
|
|
let source = ResourceKey {
|
|
|
|
|
archive: parse_path("fortif.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"fr_b_plant.bas"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
let without_bas = EffectivePrototype {
|
|
|
|
|
key: fparkan_prototype::PrototypeKey(resource_name(b"unrelated")),
|
|
|
|
|
geometry: PrototypeGeometry::NonGeometric,
|
|
|
|
|
source: fparkan_prototype::PrototypeSource::ObjectsRegistry,
|
|
|
|
|
source_type: None,
|
|
|
|
|
dependencies: Vec::new(),
|
|
|
|
|
control_resource: None,
|
|
|
|
|
point_resource: None,
|
|
|
|
|
node_data_resource: None,
|
|
|
|
|
bas_resource: None,
|
|
|
|
|
};
|
|
|
|
|
let prototype = EffectivePrototype {
|
|
|
|
|
key: fparkan_prototype::PrototypeKey(resource_name(b"fr_b_plant")),
|
|
|
|
|
geometry: PrototypeGeometry::NonGeometric,
|
|
|
|
|
source: fparkan_prototype::PrototypeSource::ObjectsRegistry,
|
|
|
|
|
source_type: None,
|
|
|
|
|
dependencies: Vec::new(),
|
|
|
|
|
control_resource: None,
|
|
|
|
|
point_resource: None,
|
|
|
|
|
node_data_resource: None,
|
|
|
|
|
bas_resource: Some(source.clone()),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let assets =
|
|
|
|
|
prepare_mission_assets_with_repository(&repository, &[0..2], &[without_bas, prototype])
|
|
|
|
|
.expect("mission BAS sidecar");
|
|
|
|
|
assert_eq!(assets.object_component_bas[0].len(), 2);
|
|
|
|
|
assert!(assets.object_component_bas[0][0].is_none());
|
|
|
|
|
let prepared = assets.object_component_bas[0][1]
|
|
|
|
|
.as_ref()
|
|
|
|
|
.expect("BAS aligned with component one");
|
|
|
|
|
assert_eq!(prepared.source, source);
|
|
|
|
|
assert_eq!(prepared.geometry.first[0].points[1], [4.0, 5.0, 6.0]);
|
|
|
|
|
assert_eq!(prepared.geometry.first[0].opaque_words_a, [0x1122_3344]);
|
|
|
|
|
assert_eq!(prepared.geometry.second[0].points[1], [10.0, 11.0, 12.0]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn malformed_explicit_bas_geometry_is_reported_at_preparation_boundary() {
|
|
|
|
|
let bad_payload = u32::MAX.to_le_bytes();
|
|
|
|
|
let repository =
|
|
|
|
|
repository_with_archives(&[("fortif.rlb", &[(b"broken.bas", &bad_payload)])]);
|
|
|
|
|
let source = ResourceKey {
|
|
|
|
|
archive: parse_path("fortif.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"broken.bas"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let error = prepare_component_bas_from_resource(&repository, source, 3)
|
|
|
|
|
.expect_err("malformed BAS counts are rejected");
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
error,
|
|
|
|
|
AssetError::InvalidPrototype(message)
|
|
|
|
|
if message.contains("prototype 3 BAS geometry fortif.rlb:broken.bas")
|
|
|
|
|
&& message.contains("limit")
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:38:53 +04:00
|
|
|
#[test]
|
|
|
|
|
fn component_control_preserves_unresolved_point_offset_sentinels() {
|
|
|
|
|
let mut control = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]);
|
|
|
|
|
control[16..20].copy_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
control.resize(control.len() + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0);
|
|
|
|
|
append_effect_table(
|
|
|
|
|
&mut control,
|
|
|
|
|
&[effect_row(
|
|
|
|
|
4,
|
|
|
|
|
[0; 3],
|
|
|
|
|
[u32::MAX; 3],
|
|
|
|
|
17,
|
|
|
|
|
"effects.rlb",
|
|
|
|
|
"f_smalllight_y",
|
|
|
|
|
)],
|
|
|
|
|
);
|
|
|
|
|
let cpt = cpt_payload(1);
|
|
|
|
|
let repository = repository_with_archives(&[
|
|
|
|
|
("controls.rlb", &[(b"parts.ctl", &control)]),
|
|
|
|
|
("points.rlb", &[(b"attachments.cpt", &cpt)]),
|
|
|
|
|
]);
|
|
|
|
|
let source = ResourceKey {
|
|
|
|
|
archive: parse_path("controls.rlb").expect("control archive"),
|
|
|
|
|
name: resource_name(b"parts.ctl"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
let point_source = ResourceKey {
|
|
|
|
|
archive: parse_path("points.rlb").expect("point archive"),
|
|
|
|
|
name: resource_name(b"attachments.cpt"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-18 11:02:53 +04:00
|
|
|
let control = prepare_component_control_from_resource(
|
|
|
|
|
&repository,
|
|
|
|
|
source,
|
|
|
|
|
Some(point_source),
|
|
|
|
|
None,
|
|
|
|
|
2,
|
|
|
|
|
0,
|
|
|
|
|
)
|
|
|
|
|
.expect("point-offset sentinel is retained without guessed bounds")
|
|
|
|
|
.expect("CTL entry");
|
2026-10-11 22:38:53 +04:00
|
|
|
let PreparedControlEffectSection::Parsed(rows) = control.effect_section else {
|
|
|
|
|
panic!("effect rows parsed");
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(rows[0].point_offsets, [u32::MAX; 3]);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
control
|
|
|
|
|
.point_list
|
|
|
|
|
.expect("explicit CPT loaded")
|
|
|
|
|
.points
|
|
|
|
|
.len(),
|
|
|
|
|
1
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn component_control_keeps_missing_explicit_cpt_unavailable() {
|
|
|
|
|
let mut control = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]);
|
|
|
|
|
control[16..20].copy_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
control.resize(control.len() + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0);
|
|
|
|
|
append_effect_table(
|
|
|
|
|
&mut control,
|
|
|
|
|
&[effect_row(
|
|
|
|
|
4,
|
|
|
|
|
[0; 3],
|
|
|
|
|
[0; 3],
|
|
|
|
|
17,
|
|
|
|
|
"effects.rlb",
|
|
|
|
|
"f_smalllight_y",
|
|
|
|
|
)],
|
|
|
|
|
);
|
|
|
|
|
let repository = repository_with_archives(&[("controls.rlb", &[(b"parts.ctl", &control)])]);
|
|
|
|
|
let source = ResourceKey {
|
|
|
|
|
archive: parse_path("controls.rlb").expect("control archive"),
|
|
|
|
|
name: resource_name(b"parts.ctl"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
let missing_point_source = ResourceKey {
|
|
|
|
|
archive: parse_path("points.rlb").expect("point archive"),
|
|
|
|
|
name: resource_name(b"attachments.cpt"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let control = prepare_component_control_from_resource(
|
|
|
|
|
&repository,
|
|
|
|
|
source,
|
|
|
|
|
Some(missing_point_source),
|
2026-10-18 11:02:53 +04:00
|
|
|
None,
|
2026-10-11 22:38:53 +04:00
|
|
|
2,
|
|
|
|
|
0,
|
|
|
|
|
)
|
|
|
|
|
.expect("an absent optional CPT leaves rows inspectable")
|
|
|
|
|
.expect("CTL entry");
|
|
|
|
|
|
|
|
|
|
assert_eq!(control.point_list, None);
|
|
|
|
|
assert!(matches!(
|
|
|
|
|
control.effect_section,
|
|
|
|
|
PreparedControlEffectSection::Parsed(rows) if rows.len() == 1
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:17:45 +04:00
|
|
|
#[test]
|
|
|
|
|
fn component_control_max_length_uses_wrapped_empty_tail() {
|
|
|
|
|
let source = ResourceKey {
|
|
|
|
|
archive: parse_path("controls.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"parts.ctl"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
};
|
|
|
|
|
let mut payload = component_control_payload(&[(1, 0.0, 1.0, 0.25, 0)]);
|
|
|
|
|
payload[12..16].copy_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
payload[16..20].copy_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
append_typed_record(&mut payload, 21, u32::MAX);
|
|
|
|
|
payload.resize(payload.len() + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0);
|
|
|
|
|
append_effect_table(&mut payload, &[]);
|
|
|
|
|
|
|
|
|
|
let control = decode_component_control_payload(&payload, source, 2, 3)
|
|
|
|
|
.expect("wrapped empty string tail");
|
|
|
|
|
|
|
|
|
|
assert_eq!(control.bindings.len(), 1);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
control.effect_section,
|
|
|
|
|
PreparedControlEffectSection::Parsed(Vec::new())
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:12:50 +04:00
|
|
|
fn component_control_payload(rows: &[(u32, f32, f32, f32, u32)]) -> Vec<u8> {
|
|
|
|
|
const ROW_START: usize = 5 * 4 + 108;
|
|
|
|
|
const ROW_BYTES: usize = 36;
|
|
|
|
|
let mut payload = vec![0; ROW_START + rows.len() * ROW_BYTES];
|
|
|
|
|
payload[8..12].copy_from_slice(&u32::try_from(rows.len()).expect("count").to_le_bytes());
|
|
|
|
|
for (index, (node, frame_a, frame_b, blend, flags)) in rows.iter().enumerate() {
|
|
|
|
|
let row = ROW_START + index * ROW_BYTES;
|
|
|
|
|
payload[row..row + 4].copy_from_slice(&node.to_le_bytes());
|
|
|
|
|
payload[row + 4..row + 8].copy_from_slice(&frame_a.to_bits().to_le_bytes());
|
|
|
|
|
payload[row + 8..row + 12].copy_from_slice(&frame_b.to_bits().to_le_bytes());
|
|
|
|
|
payload[row + 12..row + 16].copy_from_slice(&blend.to_bits().to_le_bytes());
|
|
|
|
|
payload[row + 0x20..row + 0x24].copy_from_slice(&flags.to_le_bytes());
|
|
|
|
|
}
|
|
|
|
|
payload
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-18 11:02:53 +04:00
|
|
|
fn tag21_control_payload(provider_key: u32) -> Vec<u8> {
|
|
|
|
|
let mut payload = component_control_payload(&[(1, 0.0, 4.0, 0.0, 1)]);
|
|
|
|
|
let row_start = 5 * 4 + 108;
|
|
|
|
|
payload[row_start + 0x10..row_start + 0x14].copy_from_slice(&u32::MAX.to_le_bytes());
|
|
|
|
|
payload[row_start + 0x14..row_start + 0x18].copy_from_slice(&u32::MAX.to_le_bytes());
|
|
|
|
|
payload[row_start + 0x18..row_start + 0x1C]
|
|
|
|
|
.copy_from_slice(&0.5_f32.to_bits().to_le_bytes());
|
|
|
|
|
payload[row_start + 0x1C..row_start + 0x20]
|
|
|
|
|
.copy_from_slice(&AUTHORED_TAG21_RAW_PARAMETER_3.to_bits().to_le_bytes());
|
|
|
|
|
payload[12..16].copy_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
|
|
|
|
|
let mut fixed = [0_u8; CONTROL_TYPED_RECORD_FIXED_BYTES];
|
|
|
|
|
fixed[0..4].copy_from_slice(&21_u32.to_le_bytes());
|
|
|
|
|
fixed[4..8].copy_from_slice(&provider_key.to_le_bytes());
|
|
|
|
|
fixed[0x18..0x1C].copy_from_slice(&u32::MAX.to_le_bytes());
|
|
|
|
|
fixed[0x24..0x28].copy_from_slice(&1.0_f32.to_bits().to_le_bytes());
|
|
|
|
|
fixed[0x28..0x2C].copy_from_slice(&1.0_f32.to_bits().to_le_bytes());
|
|
|
|
|
fixed[CONTROL_TYPED_RECORD_COUNT_OFFSET..CONTROL_TYPED_RECORD_COUNT_OFFSET + 4]
|
|
|
|
|
.copy_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
payload.extend_from_slice(&fixed);
|
|
|
|
|
payload.extend_from_slice(&0_u32.to_le_bytes());
|
|
|
|
|
payload.extend_from_slice(&0_u32.to_le_bytes());
|
|
|
|
|
payload
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn node_data_payload(count: u32) -> Vec<u8> {
|
|
|
|
|
const ROW_BYTES: usize = 76;
|
|
|
|
|
let mut payload = vec![0; 4 + usize::try_from(count).expect("count") * ROW_BYTES];
|
|
|
|
|
payload[0..4].copy_from_slice(&count.to_le_bytes());
|
|
|
|
|
for row_index in 0..usize::try_from(count).expect("count") {
|
|
|
|
|
let start = 4 + row_index * ROW_BYTES;
|
|
|
|
|
payload[start..start + 4].copy_from_slice(&0_u32.to_le_bytes());
|
|
|
|
|
payload[start + 4..start + 8].copy_from_slice(&6000.0_f32.to_bits().to_le_bytes());
|
|
|
|
|
payload[start + 8..start + 12].copy_from_slice(&500.0_f32.to_bits().to_le_bytes());
|
|
|
|
|
write_fixed_name(&mut payload[start + 12..start + 44], "system.rlb");
|
|
|
|
|
write_fixed_name(&mut payload[start + 44..start + 76], "explode_frt_m.exp");
|
|
|
|
|
}
|
|
|
|
|
payload
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:17:45 +04:00
|
|
|
fn append_typed_record(payload: &mut Vec<u8>, tag: u32, string_length: u32) {
|
|
|
|
|
let record_start = payload.len();
|
|
|
|
|
payload.resize(record_start + CONTROL_TYPED_RECORD_FIXED_BYTES + 4, 0);
|
|
|
|
|
payload[record_start..record_start + 4].copy_from_slice(&tag.to_le_bytes());
|
|
|
|
|
payload[record_start + CONTROL_TYPED_RECORD_COUNT_OFFSET
|
|
|
|
|
..record_start + CONTROL_TYPED_RECORD_COUNT_OFFSET + 4]
|
|
|
|
|
.copy_from_slice(&0_u32.to_le_bytes());
|
|
|
|
|
payload[record_start + CONTROL_TYPED_RECORD_FIXED_BYTES
|
|
|
|
|
..record_start + CONTROL_TYPED_RECORD_FIXED_BYTES + 4]
|
|
|
|
|
.copy_from_slice(&string_length.to_le_bytes());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn append_effect_table(payload: &mut Vec<u8>, rows: &[[u8; CONTROL_EFFECT_ROW_BYTES]]) {
|
|
|
|
|
payload.extend_from_slice(&u32::try_from(rows.len()).expect("row count").to_le_bytes());
|
|
|
|
|
for row in rows {
|
|
|
|
|
payload.extend_from_slice(row);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn effect_row(
|
|
|
|
|
kind: u32,
|
|
|
|
|
condition: [u32; 3],
|
|
|
|
|
point_offsets: [u32; 3],
|
|
|
|
|
selector: u32,
|
|
|
|
|
archive: &str,
|
|
|
|
|
name: &str,
|
|
|
|
|
) -> [u8; CONTROL_EFFECT_ROW_BYTES] {
|
|
|
|
|
let mut row = [0_u8; CONTROL_EFFECT_ROW_BYTES];
|
|
|
|
|
for (index, word) in condition.into_iter().enumerate() {
|
|
|
|
|
let offset = index * 4;
|
|
|
|
|
row[offset..offset + 4].copy_from_slice(&word.to_le_bytes());
|
|
|
|
|
}
|
|
|
|
|
row[0x0C..0x10].copy_from_slice(&kind.to_le_bytes());
|
|
|
|
|
for (index, word) in point_offsets.into_iter().enumerate() {
|
|
|
|
|
let offset = 0x10 + index * 4;
|
|
|
|
|
row[offset..offset + 4].copy_from_slice(&word.to_le_bytes());
|
|
|
|
|
}
|
|
|
|
|
row[0x1C..0x20].copy_from_slice(&selector.to_le_bytes());
|
|
|
|
|
write_fixed_name(&mut row[0x24..0x44], archive);
|
|
|
|
|
write_fixed_name(&mut row[0x44..0x64], name);
|
|
|
|
|
row
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn fixed_name(value: &str) -> [u8; 32] {
|
|
|
|
|
let mut result = [0_u8; 32];
|
|
|
|
|
write_fixed_name(&mut result, value);
|
|
|
|
|
result
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write_fixed_name(target: &mut [u8], value: &str) {
|
|
|
|
|
target[..value.len()].copy_from_slice(value.as_bytes());
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 22:38:53 +04:00
|
|
|
fn cpt_payload(point_count: usize) -> Vec<u8> {
|
|
|
|
|
let mut payload = vec![0; 4 + point_count * 36 + point_count * 32];
|
|
|
|
|
payload[..4].copy_from_slice(
|
|
|
|
|
&u32::try_from(point_count)
|
|
|
|
|
.expect("point count")
|
|
|
|
|
.to_le_bytes(),
|
|
|
|
|
);
|
|
|
|
|
payload
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn write_cpt_point(
|
|
|
|
|
payload: &mut [u8],
|
|
|
|
|
index: usize,
|
|
|
|
|
raw_flags: u32,
|
|
|
|
|
signed_field_04: i32,
|
|
|
|
|
signed_field_08: i32,
|
|
|
|
|
position: [f32; 3],
|
|
|
|
|
direction: [f32; 3],
|
|
|
|
|
name: &str,
|
|
|
|
|
) {
|
|
|
|
|
let count = control_u32_at(payload, 0).expect("point count") as usize;
|
|
|
|
|
assert!(index < count);
|
|
|
|
|
let point_start = 4 + index * 36;
|
|
|
|
|
payload[point_start..point_start + 4].copy_from_slice(&raw_flags.to_le_bytes());
|
|
|
|
|
payload[point_start + 4..point_start + 8].copy_from_slice(&signed_field_04.to_le_bytes());
|
|
|
|
|
payload[point_start + 8..point_start + 12].copy_from_slice(&signed_field_08.to_le_bytes());
|
|
|
|
|
for (offset, value) in position.into_iter().chain(direction).enumerate() {
|
|
|
|
|
let start = point_start + 12 + offset * 4;
|
|
|
|
|
payload[start..start + 4].copy_from_slice(&value.to_bits().to_le_bytes());
|
|
|
|
|
}
|
|
|
|
|
let names_start = 4 + count * 36;
|
|
|
|
|
write_fixed_name(
|
|
|
|
|
&mut payload[names_start + index * 32..names_start + (index + 1) * 32],
|
|
|
|
|
name,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:12:50 +04:00
|
|
|
#[test]
|
|
|
|
|
fn page_uv_transform_matches_normalized_native_atlas_record() {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
page_uv_transform(
|
|
|
|
|
8,
|
|
|
|
|
4,
|
|
|
|
|
&[PageRect {
|
|
|
|
|
x: 2,
|
|
|
|
|
w: 4,
|
|
|
|
|
y: 1,
|
|
|
|
|
h: 2,
|
|
|
|
|
}],
|
|
|
|
|
0,
|
|
|
|
|
),
|
|
|
|
|
Ok([0.25, 0.25, 0.5, 0.5])
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(page_uv_transform(8, 4, &[], -1), Ok([0.0, 0.0, 1.0, 1.0]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn page_uv_transform_rejects_missing_or_out_of_bounds_page() {
|
|
|
|
|
let rects = [PageRect {
|
|
|
|
|
x: 7,
|
|
|
|
|
w: 2,
|
|
|
|
|
y: 0,
|
|
|
|
|
h: 1,
|
|
|
|
|
}];
|
|
|
|
|
assert!(page_uv_transform(8, 4, &[], 0).is_err());
|
|
|
|
|
assert!(page_uv_transform(8, 4, &rects, 0).is_err());
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 13:17:06 +04:00
|
|
|
#[test]
|
|
|
|
|
fn wear_validation_cache_replays_archive_qualified_failure() {
|
|
|
|
|
let repository = repository_with_archives(&[]);
|
|
|
|
|
let mesh = ResourceKey {
|
|
|
|
|
archive: parse_path("static.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"tree.msh"),
|
|
|
|
|
type_id: Some(0x4853_454D),
|
|
|
|
|
};
|
|
|
|
|
let mut cache = WearValidationCache::new();
|
|
|
|
|
cache.insert(
|
|
|
|
|
(mesh.archive.clone(), b"tree.wea".to_vec()),
|
|
|
|
|
Err("cached WEAR failure".to_string()),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
resolve_wear_table_cached(&repository, &mesh, &mut cache),
|
|
|
|
|
Err("cached WEAR failure".to_string())
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
#[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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 16:02:16 +04:00
|
|
|
#[test]
|
|
|
|
|
fn texture_resolver_does_not_fallback_to_lightmap_archive() {
|
|
|
|
|
let texm = texm_payload();
|
|
|
|
|
let repo = repository_with_archives(&[(
|
|
|
|
|
LIGHTMAP_ARCHIVE,
|
|
|
|
|
&[(b"TEX_ONLY".as_slice(), texm.as_slice())],
|
|
|
|
|
)]);
|
|
|
|
|
|
|
|
|
|
let err = resolve_texture(&repo, &resource_name(b"TEX_ONLY")).expect_err("missing texture");
|
|
|
|
|
|
|
|
|
|
assert!(matches!(err, AssetError::MissingDependency(_)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn lightmap_resolver_does_not_fallback_to_texture_archive() {
|
|
|
|
|
let texm = texm_payload();
|
|
|
|
|
let repo = repository_with_archives(&[(
|
|
|
|
|
TEXTURES_ARCHIVE,
|
|
|
|
|
&[(b"LM_ONLY".as_slice(), texm.as_slice())],
|
|
|
|
|
)]);
|
|
|
|
|
|
|
|
|
|
let err =
|
|
|
|
|
resolve_lightmap(&repo, &resource_name(b"LM_ONLY")).expect_err("missing lightmap");
|
|
|
|
|
|
|
|
|
|
assert!(matches!(err, AssetError::MissingDependency(_)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn texture_resolver_does_not_continue_after_malformed_texture() {
|
|
|
|
|
let malformed = b"not texm".as_slice();
|
|
|
|
|
let texm = texm_payload();
|
|
|
|
|
let repo = repository_with_archives(&[
|
|
|
|
|
(TEXTURES_ARCHIVE, &[(b"BAD".as_slice(), malformed)]),
|
|
|
|
|
(LIGHTMAP_ARCHIVE, &[(b"BAD".as_slice(), texm.as_slice())]),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
let err = resolve_texture(&repo, &resource_name(b"BAD")).expect_err("malformed texture");
|
|
|
|
|
|
|
|
|
|
assert!(matches!(err, AssetError::Texture(_)));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 16:58:17 +04:00
|
|
|
#[test]
|
|
|
|
|
fn stable_visual_id_uses_archive_identity_bytes() {
|
|
|
|
|
let first = EffectivePrototype {
|
|
|
|
|
key: fparkan_prototype::PrototypeKey(resource_name(b"mesh")),
|
|
|
|
|
geometry: PrototypeGeometry::Mesh(ResourceKey {
|
|
|
|
|
archive: normalize_relative(b"DATA/\xFF.lib", PathPolicy::HostCompatible)
|
|
|
|
|
.expect("archive"),
|
|
|
|
|
name: resource_name(b"mesh.msh"),
|
|
|
|
|
type_id: Some(0x4853_454D),
|
|
|
|
|
}),
|
|
|
|
|
source: fparkan_prototype::PrototypeSource::DirectArchive,
|
2026-10-11 13:12:50 +04:00
|
|
|
source_type: None,
|
2026-07-03 16:58:17 +04:00
|
|
|
dependencies: Vec::new(),
|
2026-10-11 13:12:50 +04:00
|
|
|
control_resource: None,
|
2026-10-11 22:38:53 +04:00
|
|
|
point_resource: None,
|
2026-10-18 11:02:53 +04:00
|
|
|
node_data_resource: None,
|
2026-10-18 11:38:50 +04:00
|
|
|
bas_resource: None,
|
2026-07-03 16:58:17 +04:00
|
|
|
};
|
|
|
|
|
let second = EffectivePrototype {
|
|
|
|
|
key: fparkan_prototype::PrototypeKey(resource_name(b"mesh")),
|
|
|
|
|
geometry: PrototypeGeometry::Mesh(ResourceKey {
|
|
|
|
|
archive: normalize_relative(b"DATA/\xFE.lib", PathPolicy::HostCompatible)
|
|
|
|
|
.expect("archive"),
|
|
|
|
|
name: resource_name(b"mesh.msh"),
|
|
|
|
|
type_id: Some(0x4853_454D),
|
|
|
|
|
}),
|
|
|
|
|
source: fparkan_prototype::PrototypeSource::DirectArchive,
|
2026-10-11 13:12:50 +04:00
|
|
|
source_type: None,
|
2026-07-03 16:58:17 +04:00
|
|
|
dependencies: Vec::new(),
|
2026-10-11 13:12:50 +04:00
|
|
|
control_resource: None,
|
2026-10-11 22:38:53 +04:00
|
|
|
point_resource: None,
|
2026-10-18 11:02:53 +04:00
|
|
|
node_data_resource: None,
|
2026-10-18 11:38:50 +04:00
|
|
|
bas_resource: None,
|
2026-07-03 16:58:17 +04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
assert_ne!(stable_visual_id(&first), stable_visual_id(&second));
|
2026-07-03 21:44:26 +04:00
|
|
|
assert_ne!(
|
|
|
|
|
prepared_visual_signature(&first),
|
|
|
|
|
prepared_visual_signature(&second)
|
|
|
|
|
);
|
2026-07-03 16:58:17 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 17:03:40 +04:00
|
|
|
#[test]
|
|
|
|
|
fn graph_materializes_visual_dependency_nodes_and_edges() {
|
|
|
|
|
let mesh_key = ResourceKey {
|
|
|
|
|
archive: parse_path("static.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"tree.msh"),
|
|
|
|
|
type_id: Some(0x4853_454D),
|
|
|
|
|
};
|
|
|
|
|
let mat0 = mat0_with_texture(b"TEX_A");
|
|
|
|
|
let texm = texm_payload();
|
|
|
|
|
let lightmap_texm = texm_payload();
|
|
|
|
|
let repo = repository_with_archives_meta(&[
|
|
|
|
|
(
|
|
|
|
|
"static.rlb",
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"tree.wea",
|
|
|
|
|
payload: b"1\n0 MAT_A\n\nLIGHTMAPS\n1\n0 LM_A\n",
|
|
|
|
|
type_id: WEAR_KIND,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"material.lib",
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"MAT_A",
|
|
|
|
|
payload: &mat0,
|
|
|
|
|
type_id: MAT0_KIND,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
TEXTURES_ARCHIVE,
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"TEX_A",
|
|
|
|
|
payload: &texm,
|
|
|
|
|
type_id: 0,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
LIGHTMAP_ARCHIVE,
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"LM_A",
|
|
|
|
|
payload: &lightmap_texm,
|
|
|
|
|
type_id: 0,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
let prototype = EffectivePrototype {
|
|
|
|
|
key: fparkan_prototype::PrototypeKey(resource_name(b"tree")),
|
|
|
|
|
geometry: PrototypeGeometry::Mesh(mesh_key.clone()),
|
|
|
|
|
source: fparkan_prototype::PrototypeSource::DirectArchive,
|
2026-10-11 13:12:50 +04:00
|
|
|
source_type: None,
|
2026-07-03 17:03:40 +04:00
|
|
|
dependencies: vec![mesh_key.clone()],
|
2026-10-11 13:12:50 +04:00
|
|
|
control_resource: None,
|
2026-10-11 22:38:53 +04:00
|
|
|
point_resource: None,
|
2026-10-18 11:02:53 +04:00
|
|
|
node_data_resource: None,
|
2026-10-18 11:38:50 +04:00
|
|
|
bas_resource: None,
|
2026-07-03 17:03:40 +04:00
|
|
|
};
|
|
|
|
|
let mut graph = prototype_graph_for_mesh(&prototype);
|
|
|
|
|
let mut report = PrototypeGraphReport {
|
|
|
|
|
root_count: 1,
|
|
|
|
|
direct_reference_count: 1,
|
|
|
|
|
resolved_count: 1,
|
|
|
|
|
mesh_dependency_count: 1,
|
|
|
|
|
..PrototypeGraphReport::default()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
extend_graph_report_with_visual_dependencies(
|
|
|
|
|
&repo,
|
|
|
|
|
&mut report,
|
|
|
|
|
&mut graph,
|
|
|
|
|
std::slice::from_ref(&prototype),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert!(graph
|
|
|
|
|
.nodes
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|node| node.kind == PrototypeGraphNodeKind::WearResource));
|
|
|
|
|
assert!(graph
|
|
|
|
|
.nodes
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|node| node.kind == PrototypeGraphNodeKind::MaterialResource));
|
|
|
|
|
assert!(graph
|
|
|
|
|
.nodes
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|node| node.kind == PrototypeGraphNodeKind::TextureResource));
|
|
|
|
|
assert!(graph
|
|
|
|
|
.nodes
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|node| node.kind == PrototypeGraphNodeKind::LightmapResource));
|
|
|
|
|
assert!(graph
|
|
|
|
|
.edges
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|edge| edge.kind == fparkan_prototype::PrototypeGraphEdgeKind::MeshToWear));
|
|
|
|
|
assert!(graph
|
|
|
|
|
.edges
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|edge| edge.kind == fparkan_prototype::PrototypeGraphEdgeKind::WearToMaterial));
|
|
|
|
|
assert!(graph
|
|
|
|
|
.edges
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|edge| edge.kind == fparkan_prototype::PrototypeGraphEdgeKind::MaterialToTexture));
|
|
|
|
|
assert!(graph
|
|
|
|
|
.edges
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|edge| edge.kind == fparkan_prototype::PrototypeGraphEdgeKind::WearToLightmap));
|
|
|
|
|
assert_eq!(report.wear_request_count, 1);
|
|
|
|
|
assert_eq!(report.wear_resolved_count, 1);
|
|
|
|
|
assert_eq!(report.material_slot_count, 1);
|
|
|
|
|
assert_eq!(report.material_resolved_count, 1);
|
|
|
|
|
assert_eq!(report.texture_request_count, 1);
|
|
|
|
|
assert_eq!(report.texture_resolved_count, 1);
|
|
|
|
|
assert_eq!(report.lightmap_request_count, 1);
|
|
|
|
|
assert_eq!(report.lightmap_resolved_count, 1);
|
|
|
|
|
assert!(report.failures.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:44:26 +04:00
|
|
|
#[test]
|
|
|
|
|
fn graph_visual_dependency_edges_preserve_root_and_parent_provenance() {
|
|
|
|
|
let mesh_key = ResourceKey {
|
|
|
|
|
archive: parse_path("static.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"tree.msh"),
|
|
|
|
|
type_id: Some(0x4853_454D),
|
|
|
|
|
};
|
|
|
|
|
let mat0 = mat0_with_texture(b"TEX_A");
|
|
|
|
|
let texm = texm_payload();
|
|
|
|
|
let lightmap_texm = texm_payload();
|
|
|
|
|
let repo = repository_with_archives_meta(&[
|
|
|
|
|
(
|
|
|
|
|
"static.rlb",
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"tree.wea",
|
|
|
|
|
payload: b"1\n0 MAT_A\n\nLIGHTMAPS\n1\n0 LM_A\n",
|
|
|
|
|
type_id: WEAR_KIND,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"material.lib",
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"MAT_A",
|
|
|
|
|
payload: &mat0,
|
|
|
|
|
type_id: MAT0_KIND,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
TEXTURES_ARCHIVE,
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"TEX_A",
|
|
|
|
|
payload: &texm,
|
|
|
|
|
type_id: 0,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
LIGHTMAP_ARCHIVE,
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"LM_A",
|
|
|
|
|
payload: &lightmap_texm,
|
|
|
|
|
type_id: 0,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
let prototype = EffectivePrototype {
|
|
|
|
|
key: fparkan_prototype::PrototypeKey(resource_name(b"tree")),
|
|
|
|
|
geometry: PrototypeGeometry::Mesh(mesh_key),
|
|
|
|
|
source: fparkan_prototype::PrototypeSource::DirectArchive,
|
2026-10-11 13:12:50 +04:00
|
|
|
source_type: None,
|
2026-07-03 21:44:26 +04:00
|
|
|
dependencies: Vec::new(),
|
2026-10-11 13:12:50 +04:00
|
|
|
control_resource: None,
|
2026-10-11 22:38:53 +04:00
|
|
|
point_resource: None,
|
2026-10-18 11:02:53 +04:00
|
|
|
node_data_resource: None,
|
2026-10-18 11:38:50 +04:00
|
|
|
bas_resource: None,
|
2026-07-03 21:44:26 +04:00
|
|
|
};
|
|
|
|
|
let mut graph = prototype_graph_for_mesh(&prototype);
|
2026-07-18 20:26:15 +04:00
|
|
|
graph
|
|
|
|
|
.edges
|
|
|
|
|
.iter_mut()
|
|
|
|
|
.find(|edge| edge.kind == fparkan_prototype::PrototypeGraphEdgeKind::PrototypeToMesh)
|
|
|
|
|
.expect("mesh edge")
|
|
|
|
|
.provenance = Some(PrototypeGraphProvenance {
|
|
|
|
|
root_index: 0,
|
|
|
|
|
parent_edge: Some(fparkan_prototype::PrototypeGraphEdgeId(0)),
|
|
|
|
|
unit_component_index: Some(3),
|
|
|
|
|
archive: Some("static.rlb".to_string()),
|
|
|
|
|
resource: Some(b"tree.msh".to_vec()),
|
|
|
|
|
span: None,
|
|
|
|
|
});
|
2026-07-03 21:44:26 +04:00
|
|
|
let mut report = PrototypeGraphReport {
|
|
|
|
|
root_count: 1,
|
|
|
|
|
direct_reference_count: 1,
|
|
|
|
|
resolved_count: 1,
|
|
|
|
|
mesh_dependency_count: 1,
|
|
|
|
|
..PrototypeGraphReport::default()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
extend_graph_report_with_visual_dependencies(
|
|
|
|
|
&repo,
|
|
|
|
|
&mut report,
|
|
|
|
|
&mut graph,
|
|
|
|
|
std::slice::from_ref(&prototype),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let wear_edge = graph
|
|
|
|
|
.edges
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|edge| edge.kind == fparkan_prototype::PrototypeGraphEdgeKind::MeshToWear)
|
|
|
|
|
.expect("wear edge");
|
|
|
|
|
let material_edge = graph
|
|
|
|
|
.edges
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|edge| edge.kind == fparkan_prototype::PrototypeGraphEdgeKind::WearToMaterial)
|
|
|
|
|
.expect("material edge");
|
|
|
|
|
let texture_edge = graph
|
|
|
|
|
.edges
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|edge| edge.kind == fparkan_prototype::PrototypeGraphEdgeKind::MaterialToTexture)
|
|
|
|
|
.expect("texture edge");
|
|
|
|
|
let lightmap_edge = graph
|
|
|
|
|
.edges
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|edge| edge.kind == fparkan_prototype::PrototypeGraphEdgeKind::WearToLightmap)
|
|
|
|
|
.expect("lightmap edge");
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
wear_edge
|
|
|
|
|
.provenance
|
|
|
|
|
.as_ref()
|
|
|
|
|
.expect("wear provenance")
|
|
|
|
|
.parent_edge,
|
|
|
|
|
Some(fparkan_prototype::PrototypeGraphEdgeId(1))
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
material_edge
|
|
|
|
|
.provenance
|
|
|
|
|
.as_ref()
|
|
|
|
|
.expect("material provenance")
|
|
|
|
|
.parent_edge,
|
|
|
|
|
Some(wear_edge.id)
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
texture_edge
|
|
|
|
|
.provenance
|
|
|
|
|
.as_ref()
|
|
|
|
|
.expect("texture provenance")
|
|
|
|
|
.parent_edge,
|
|
|
|
|
Some(material_edge.id)
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
lightmap_edge
|
|
|
|
|
.provenance
|
|
|
|
|
.as_ref()
|
|
|
|
|
.expect("lightmap provenance")
|
|
|
|
|
.parent_edge,
|
|
|
|
|
Some(wear_edge.id)
|
|
|
|
|
);
|
|
|
|
|
assert!(graph
|
|
|
|
|
.edges
|
|
|
|
|
.iter()
|
|
|
|
|
.filter_map(|edge| edge.provenance.as_ref())
|
|
|
|
|
.all(|provenance| provenance.root_index == 0));
|
2026-07-18 20:26:15 +04:00
|
|
|
for edge in [wear_edge, material_edge, texture_edge, lightmap_edge] {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
edge.provenance
|
|
|
|
|
.as_ref()
|
|
|
|
|
.and_then(|provenance| provenance.unit_component_index),
|
|
|
|
|
Some(3)
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-07-03 21:44:26 +04:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:54:09 +04:00
|
|
|
#[test]
|
|
|
|
|
fn graph_visual_dependency_expansion_is_idempotent() {
|
|
|
|
|
let mesh_key = ResourceKey {
|
|
|
|
|
archive: parse_path("static.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"tree.msh"),
|
|
|
|
|
type_id: Some(0x4853_454D),
|
|
|
|
|
};
|
|
|
|
|
let mat0 = mat0_with_texture(b"TEX_A");
|
|
|
|
|
let texm = texm_payload();
|
|
|
|
|
let lightmap_texm = texm_payload();
|
|
|
|
|
let repo = repository_with_archives_meta(&[
|
|
|
|
|
(
|
|
|
|
|
"static.rlb",
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"tree.wea",
|
|
|
|
|
payload: b"1\n0 MAT_A\n\nLIGHTMAPS\n1\n0 LM_A\n",
|
|
|
|
|
type_id: WEAR_KIND,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"material.lib",
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"MAT_A",
|
|
|
|
|
payload: &mat0,
|
|
|
|
|
type_id: MAT0_KIND,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
TEXTURES_ARCHIVE,
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"TEX_A",
|
|
|
|
|
payload: &texm,
|
|
|
|
|
type_id: 0,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
LIGHTMAP_ARCHIVE,
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"LM_A",
|
|
|
|
|
payload: &lightmap_texm,
|
|
|
|
|
type_id: 0,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
let prototype = EffectivePrototype {
|
|
|
|
|
key: fparkan_prototype::PrototypeKey(resource_name(b"tree")),
|
|
|
|
|
geometry: PrototypeGeometry::Mesh(mesh_key),
|
|
|
|
|
source: fparkan_prototype::PrototypeSource::DirectArchive,
|
2026-10-11 13:12:50 +04:00
|
|
|
source_type: None,
|
2026-07-03 21:54:09 +04:00
|
|
|
dependencies: Vec::new(),
|
2026-10-11 13:12:50 +04:00
|
|
|
control_resource: None,
|
2026-10-11 22:38:53 +04:00
|
|
|
point_resource: None,
|
2026-10-18 11:02:53 +04:00
|
|
|
node_data_resource: None,
|
2026-10-18 11:38:50 +04:00
|
|
|
bas_resource: None,
|
2026-07-03 21:54:09 +04:00
|
|
|
};
|
|
|
|
|
let mut graph = prototype_graph_for_mesh(&prototype);
|
|
|
|
|
let mut report = PrototypeGraphReport {
|
|
|
|
|
root_count: 1,
|
|
|
|
|
direct_reference_count: 1,
|
|
|
|
|
resolved_count: 1,
|
|
|
|
|
mesh_dependency_count: 1,
|
|
|
|
|
..PrototypeGraphReport::default()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
extend_graph_report_with_visual_dependencies(
|
|
|
|
|
&repo,
|
|
|
|
|
&mut report,
|
|
|
|
|
&mut graph,
|
|
|
|
|
std::slice::from_ref(&prototype),
|
|
|
|
|
);
|
|
|
|
|
let first = (graph.nodes.clone(), graph.edges.clone(), report.clone());
|
|
|
|
|
|
|
|
|
|
extend_graph_report_with_visual_dependencies(
|
|
|
|
|
&repo,
|
|
|
|
|
&mut report,
|
|
|
|
|
&mut graph,
|
|
|
|
|
std::slice::from_ref(&prototype),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert!(graph.visual_dependencies_expanded);
|
|
|
|
|
assert_eq!(graph.nodes, first.0);
|
|
|
|
|
assert_eq!(graph.edges, first.1);
|
|
|
|
|
assert_eq!(report, first.2);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:44:26 +04:00
|
|
|
#[test]
|
2026-10-11 13:12:50 +04:00
|
|
|
fn prepare_mission_assets_keep_instance_controls_beside_deduplicated_visuals() {
|
2026-07-03 21:44:26 +04:00
|
|
|
let mesh_key = ResourceKey {
|
|
|
|
|
archive: parse_path("static.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"tree.msh"),
|
|
|
|
|
type_id: Some(0x4853_454D),
|
|
|
|
|
};
|
2026-10-11 13:12:50 +04:00
|
|
|
let mut node = vec![0; 38];
|
|
|
|
|
node[8..].fill(0xff);
|
|
|
|
|
let msh = minimal_model_archive_with_nodes(&node);
|
2026-07-03 21:44:26 +04:00
|
|
|
let mat0 = mat0_with_texture(b"TEX_A");
|
|
|
|
|
let texm = texm_payload();
|
|
|
|
|
let lightmap_texm = texm_payload();
|
2026-10-11 22:38:53 +04:00
|
|
|
let mut first_control = component_control_payload(&[(0, 1.25, 2.5, 0.25, 0)]);
|
|
|
|
|
first_control[16..20].copy_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
first_control.resize(first_control.len() + CONTROL_EFFECT_GROUP_HEADER_BYTES, 0);
|
|
|
|
|
append_effect_table(
|
|
|
|
|
&mut first_control,
|
|
|
|
|
&[effect_row(
|
|
|
|
|
4,
|
|
|
|
|
[0; 3],
|
|
|
|
|
[0; 3],
|
|
|
|
|
17,
|
|
|
|
|
"effects.rlb",
|
|
|
|
|
"f_smalllight_y",
|
|
|
|
|
)],
|
|
|
|
|
);
|
2026-10-11 13:12:50 +04:00
|
|
|
let second_control = component_control_payload(&[(0, 2.5, 3.75, 0.75, 0)]);
|
2026-10-11 22:38:53 +04:00
|
|
|
let mut points = cpt_payload(1);
|
|
|
|
|
write_cpt_point(
|
|
|
|
|
&mut points,
|
|
|
|
|
0,
|
|
|
|
|
0x8000_0000,
|
|
|
|
|
-23,
|
|
|
|
|
-24,
|
|
|
|
|
[2.0, 3.0, 4.0],
|
|
|
|
|
[0.0, 0.0, 1.0],
|
|
|
|
|
"LightAnchor",
|
|
|
|
|
);
|
2026-07-03 21:44:26 +04:00
|
|
|
let repo = repository_with_archives_meta(&[
|
|
|
|
|
(
|
|
|
|
|
"static.rlb",
|
|
|
|
|
&[
|
|
|
|
|
TestNresEntry {
|
|
|
|
|
name: b"tree.msh",
|
|
|
|
|
payload: &msh,
|
|
|
|
|
type_id: 0x4853_454D,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
},
|
|
|
|
|
TestNresEntry {
|
|
|
|
|
name: b"tree.wea",
|
|
|
|
|
payload: b"1\n0 MAT_A\n\nLIGHTMAPS\n1\n0 LM_A\n",
|
|
|
|
|
type_id: WEAR_KIND,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"material.lib",
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"MAT_A",
|
|
|
|
|
payload: &mat0,
|
|
|
|
|
type_id: MAT0_KIND,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
TEXTURES_ARCHIVE,
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"TEX_A",
|
|
|
|
|
payload: &texm,
|
|
|
|
|
type_id: 0,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
LIGHTMAP_ARCHIVE,
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"LM_A",
|
|
|
|
|
payload: &lightmap_texm,
|
|
|
|
|
type_id: 0,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
2026-10-11 13:12:50 +04:00
|
|
|
(
|
|
|
|
|
"controls.rlb",
|
|
|
|
|
&[
|
|
|
|
|
TestNresEntry {
|
|
|
|
|
name: b"first.ctl",
|
|
|
|
|
payload: &first_control,
|
|
|
|
|
type_id: 0,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
},
|
|
|
|
|
TestNresEntry {
|
|
|
|
|
name: b"second.ctl",
|
|
|
|
|
payload: &second_control,
|
|
|
|
|
type_id: 0,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-10-11 22:38:53 +04:00
|
|
|
(
|
|
|
|
|
"points.rlb",
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"attachments.cpt",
|
|
|
|
|
payload: &points,
|
|
|
|
|
type_id: 0,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
2026-07-03 21:44:26 +04:00
|
|
|
]);
|
2026-10-11 13:12:50 +04:00
|
|
|
let prototypes = [
|
|
|
|
|
EffectivePrototype {
|
|
|
|
|
key: fparkan_prototype::PrototypeKey(resource_name(b"tree-a")),
|
|
|
|
|
geometry: PrototypeGeometry::Mesh(mesh_key.clone()),
|
|
|
|
|
source: fparkan_prototype::PrototypeSource::DirectArchive,
|
|
|
|
|
source_type: None,
|
|
|
|
|
dependencies: vec![mesh_key.clone()],
|
|
|
|
|
control_resource: Some(ResourceKey {
|
|
|
|
|
archive: parse_path("controls.rlb").expect("control archive"),
|
|
|
|
|
name: resource_name(b"first.ctl"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
}),
|
2026-10-11 22:38:53 +04:00
|
|
|
point_resource: Some(ResourceKey {
|
|
|
|
|
archive: parse_path("points.rlb").expect("point archive"),
|
|
|
|
|
name: resource_name(b"attachments.cpt"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
}),
|
2026-10-18 11:02:53 +04:00
|
|
|
node_data_resource: None,
|
2026-10-18 11:38:50 +04:00
|
|
|
bas_resource: None,
|
2026-10-11 13:12:50 +04:00
|
|
|
},
|
|
|
|
|
EffectivePrototype {
|
|
|
|
|
key: fparkan_prototype::PrototypeKey(resource_name(b"tree-b")),
|
|
|
|
|
geometry: PrototypeGeometry::Mesh(mesh_key.clone()),
|
|
|
|
|
source: fparkan_prototype::PrototypeSource::DirectArchive,
|
|
|
|
|
source_type: None,
|
|
|
|
|
dependencies: vec![mesh_key.clone()],
|
|
|
|
|
control_resource: Some(ResourceKey {
|
|
|
|
|
archive: parse_path("controls.rlb").expect("control archive"),
|
|
|
|
|
name: resource_name(b"second.ctl"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
}),
|
2026-10-11 22:38:53 +04:00
|
|
|
point_resource: None,
|
2026-10-18 11:02:53 +04:00
|
|
|
node_data_resource: None,
|
2026-10-18 11:38:50 +04:00
|
|
|
bas_resource: None,
|
2026-10-11 13:12:50 +04:00
|
|
|
},
|
|
|
|
|
EffectivePrototype {
|
|
|
|
|
key: fparkan_prototype::PrototypeKey(resource_name(b"tree-c")),
|
|
|
|
|
geometry: PrototypeGeometry::Mesh(mesh_key.clone()),
|
|
|
|
|
source: fparkan_prototype::PrototypeSource::DirectArchive,
|
|
|
|
|
source_type: None,
|
|
|
|
|
dependencies: vec![mesh_key],
|
|
|
|
|
control_resource: Some(ResourceKey {
|
|
|
|
|
archive: parse_path("controls.rlb").expect("control archive"),
|
|
|
|
|
name: resource_name(b"missing.ctl"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
}),
|
2026-10-11 22:38:53 +04:00
|
|
|
point_resource: None,
|
2026-10-18 11:02:53 +04:00
|
|
|
node_data_resource: None,
|
2026-10-18 11:38:50 +04:00
|
|
|
bas_resource: None,
|
2026-10-11 13:12:50 +04:00
|
|
|
},
|
|
|
|
|
];
|
2026-07-03 21:44:26 +04:00
|
|
|
|
2026-10-11 13:12:50 +04:00
|
|
|
let assets = prepare_mission_assets_with_repository(&repo, &[0..3], &prototypes)
|
|
|
|
|
.expect("prepared mission assets");
|
2026-07-03 21:44:26 +04:00
|
|
|
|
|
|
|
|
assert_eq!(assets.models.len(), 1);
|
|
|
|
|
assert_eq!(assets.wears.len(), 1);
|
|
|
|
|
assert_eq!(assets.materials.len(), 1);
|
|
|
|
|
assert_eq!(assets.textures.len(), 2);
|
|
|
|
|
assert_eq!(assets.visuals.len(), 1);
|
2026-10-11 13:12:50 +04:00
|
|
|
assert_eq!(
|
|
|
|
|
assets.object_visuals,
|
|
|
|
|
vec![vec![
|
|
|
|
|
assets.visuals[0].id,
|
|
|
|
|
assets.visuals[0].id,
|
|
|
|
|
assets.visuals[0].id
|
|
|
|
|
]]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(assets.object_component_controls.len(), 1);
|
|
|
|
|
let controls = &assets.object_component_controls[0];
|
|
|
|
|
assert_eq!(controls.len(), 3);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
controls[0]
|
|
|
|
|
.as_ref()
|
|
|
|
|
.expect("first component control")
|
|
|
|
|
.source
|
|
|
|
|
.name,
|
|
|
|
|
resource_name(b"first.ctl")
|
|
|
|
|
);
|
2026-10-11 22:38:53 +04:00
|
|
|
let first = controls[0].as_ref().expect("first component control");
|
|
|
|
|
let points = first.point_list.as_ref().expect("explicit CPT was loaded");
|
|
|
|
|
assert_eq!(points.source.archive.as_str(), "points.rlb");
|
|
|
|
|
assert_eq!(points.source.name, resource_name(b"attachments.cpt"));
|
|
|
|
|
assert_eq!(points.points.len(), 1);
|
|
|
|
|
assert_eq!(points.points[0].raw_signed_field_04, -23);
|
|
|
|
|
assert_eq!(points.points[0].raw_signed_field_08, -24);
|
|
|
|
|
assert_eq!(points.points[0].name, fixed_name("LightAnchor"));
|
2026-10-11 13:12:50 +04:00
|
|
|
assert_eq!(
|
|
|
|
|
controls[1]
|
|
|
|
|
.as_ref()
|
|
|
|
|
.expect("second component control")
|
|
|
|
|
.source
|
|
|
|
|
.name,
|
|
|
|
|
resource_name(b"second.ctl")
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
controls[2].is_none(),
|
|
|
|
|
"missing explicit CTL means no override"
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
controls[0].as_ref().expect("first control").bindings,
|
|
|
|
|
vec![PreparedControlNodeBinding {
|
|
|
|
|
node_index: 0,
|
|
|
|
|
frame_a: 1.25,
|
|
|
|
|
frame_b: 2.5,
|
|
|
|
|
initial_blend: 0.25,
|
|
|
|
|
flags: 0,
|
|
|
|
|
}]
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
controls[1].as_ref().expect("second control").bindings,
|
|
|
|
|
vec![PreparedControlNodeBinding {
|
|
|
|
|
node_index: 0,
|
|
|
|
|
frame_a: 2.5,
|
|
|
|
|
frame_b: 3.75,
|
|
|
|
|
initial_blend: 0.75,
|
|
|
|
|
flags: 0,
|
|
|
|
|
}]
|
|
|
|
|
);
|
2026-07-03 21:44:26 +04:00
|
|
|
|
|
|
|
|
let visual = &assets.visuals[0];
|
|
|
|
|
assert_eq!(visual.model_id, Some(assets.models[0].id));
|
|
|
|
|
assert_eq!(visual.wear_id, Some(assets.wears[0].id));
|
|
|
|
|
assert_eq!(visual.material_ids, vec![assets.materials[0].id]);
|
|
|
|
|
assert_eq!(visual.texture_ids.len(), 1);
|
|
|
|
|
assert_eq!(visual.lightmap_ids.len(), 1);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-11 13:12:50 +04:00
|
|
|
#[test]
|
|
|
|
|
fn non_geometric_explicit_control_does_not_break_visual_preparation() {
|
|
|
|
|
let repository = repository_with_archives(&[]);
|
|
|
|
|
let prototype = EffectivePrototype {
|
|
|
|
|
key: fparkan_prototype::PrototypeKey(resource_name(b"marker")),
|
|
|
|
|
geometry: PrototypeGeometry::NonGeometric,
|
|
|
|
|
source: fparkan_prototype::PrototypeSource::ObjectsRegistry,
|
|
|
|
|
source_type: None,
|
|
|
|
|
dependencies: Vec::new(),
|
|
|
|
|
control_resource: Some(ResourceKey {
|
|
|
|
|
archive: parse_path("controls.rlb").expect("control archive"),
|
|
|
|
|
name: resource_name(b"marker.ctl"),
|
|
|
|
|
type_id: None,
|
|
|
|
|
}),
|
2026-10-11 22:38:53 +04:00
|
|
|
point_resource: None,
|
2026-10-18 11:02:53 +04:00
|
|
|
node_data_resource: None,
|
2026-10-18 11:38:50 +04:00
|
|
|
bas_resource: None,
|
2026-10-11 13:12:50 +04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
let assets = prepare_mission_assets_with_repository(&repository, &[0..1], &[prototype])
|
|
|
|
|
.expect("non-geometric visual remains valid");
|
|
|
|
|
|
|
|
|
|
assert_eq!(assets.object_visuals.len(), 1);
|
|
|
|
|
assert_eq!(assets.object_component_controls, vec![vec![None]]);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 06:29:29 +04:00
|
|
|
#[test]
|
|
|
|
|
fn texture_preparation_cache_reuses_exact_request_within_one_mission() {
|
|
|
|
|
let texm = texm_payload();
|
|
|
|
|
let repo = repository_with_archives_meta(&[(
|
|
|
|
|
TEXTURES_ARCHIVE,
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"TEX_A",
|
|
|
|
|
payload: &texm,
|
|
|
|
|
type_id: 0,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
)]);
|
|
|
|
|
let name = resource_name(b"TEX_A");
|
2026-07-18 06:35:20 +04:00
|
|
|
let mut cache = PreparationCache::default();
|
2026-07-18 06:29:29 +04:00
|
|
|
|
|
|
|
|
let first = prepare_texture_cached(
|
|
|
|
|
&repo,
|
|
|
|
|
&name,
|
|
|
|
|
PreparedTextureUsage::Diffuse,
|
|
|
|
|
AssetIdentityPolicy::default(),
|
|
|
|
|
&mut cache,
|
|
|
|
|
)
|
|
|
|
|
.expect("first texture");
|
|
|
|
|
let second = prepare_texture_cached(
|
|
|
|
|
&repo,
|
|
|
|
|
&name,
|
|
|
|
|
PreparedTextureUsage::Diffuse,
|
|
|
|
|
AssetIdentityPolicy::default(),
|
|
|
|
|
&mut cache,
|
|
|
|
|
)
|
|
|
|
|
.expect("cached texture");
|
|
|
|
|
|
|
|
|
|
assert_eq!(first.id, second.id);
|
|
|
|
|
assert_eq!(cache.diffuse.len(), 1);
|
|
|
|
|
assert!(cache.lightmaps.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 06:35:20 +04:00
|
|
|
#[test]
|
|
|
|
|
fn material_preparation_cache_preserves_exact_wear_request() {
|
|
|
|
|
let mat0 = mat0_with_texture(b"TEX_A");
|
|
|
|
|
let repo = repository_with_archives_meta(&[(
|
|
|
|
|
"material.lib",
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"MAT_A",
|
|
|
|
|
payload: &mat0,
|
|
|
|
|
type_id: MAT0_KIND,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
)]);
|
|
|
|
|
let wear = decode_wear(b"1\n0 MAT_A\n").expect("wear");
|
|
|
|
|
let mut cache = PreparationCache::default();
|
|
|
|
|
|
|
|
|
|
let first = resolve_material_cached(&repo, &wear, 0, &mut cache).expect("first material");
|
|
|
|
|
let second = resolve_material_cached(&repo, &wear, 0, &mut cache).expect("cached material");
|
|
|
|
|
|
|
|
|
|
assert_eq!(first, second);
|
|
|
|
|
assert_eq!(cache.materials.len(), 1);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:49:42 +04:00
|
|
|
#[test]
|
|
|
|
|
fn forced_model_id_collision_is_rejected() {
|
|
|
|
|
assert_forced_collision(
|
|
|
|
|
AssetIdentityPolicy {
|
2026-07-18 05:38:27 +04:00
|
|
|
model: |_| 7,
|
2026-07-03 21:49:42 +04:00
|
|
|
..AssetIdentityPolicy::default()
|
|
|
|
|
},
|
|
|
|
|
"stable model id collision",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn forced_wear_id_collision_is_rejected() {
|
|
|
|
|
assert_forced_collision(
|
|
|
|
|
AssetIdentityPolicy {
|
2026-07-18 05:38:27 +04:00
|
|
|
wear: |_| 11,
|
2026-07-03 21:49:42 +04:00
|
|
|
..AssetIdentityPolicy::default()
|
|
|
|
|
},
|
|
|
|
|
"stable wear id collision",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn forced_material_id_collision_is_rejected() {
|
|
|
|
|
assert_forced_collision(
|
|
|
|
|
AssetIdentityPolicy {
|
2026-07-18 05:38:27 +04:00
|
|
|
material: |_, _, _| 13,
|
2026-07-03 21:49:42 +04:00
|
|
|
..AssetIdentityPolicy::default()
|
|
|
|
|
},
|
|
|
|
|
"stable material id collision",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn forced_texture_id_collision_is_rejected() {
|
|
|
|
|
assert_forced_collision(
|
|
|
|
|
AssetIdentityPolicy {
|
2026-07-18 05:38:27 +04:00
|
|
|
texture: |_, _| 17,
|
2026-07-03 21:49:42 +04:00
|
|
|
..AssetIdentityPolicy::default()
|
|
|
|
|
},
|
|
|
|
|
"stable texture id collision",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:54:09 +04:00
|
|
|
#[test]
|
|
|
|
|
fn profiled_asset_preparation_reports_unique_asset_counts() {
|
|
|
|
|
let (repo, prototypes) = collision_fixture();
|
|
|
|
|
|
|
|
|
|
let (assets, report) = prepare_mission_assets_profiled_with_repository(
|
|
|
|
|
&repo,
|
|
|
|
|
&[0..1, 1..2],
|
|
|
|
|
&prototypes,
|
|
|
|
|
AssetPreparationLimits::default(),
|
|
|
|
|
)
|
|
|
|
|
.expect("profiled assets");
|
|
|
|
|
|
|
|
|
|
assert_eq!(report.model_count, assets.models.len());
|
|
|
|
|
assert_eq!(report.wear_count, assets.wears.len());
|
|
|
|
|
assert_eq!(report.material_count, assets.materials.len());
|
|
|
|
|
assert_eq!(report.texture_count, assets.textures.len());
|
|
|
|
|
assert!(report.texture_pixels > 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn asset_preparation_limits_reject_texture_pixel_budget() {
|
|
|
|
|
let (repo, prototypes) = collision_fixture();
|
|
|
|
|
|
|
|
|
|
let err = prepare_mission_assets_profiled_with_repository(
|
|
|
|
|
&repo,
|
|
|
|
|
&[0..1, 1..2],
|
|
|
|
|
&prototypes,
|
|
|
|
|
AssetPreparationLimits {
|
|
|
|
|
max_texture_pixels: Some(1),
|
|
|
|
|
..AssetPreparationLimits::default()
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.expect_err("budget should fail");
|
|
|
|
|
|
|
|
|
|
assert!(matches!(err, AssetError::BudgetExceeded(_)));
|
|
|
|
|
assert!(err.to_string().contains("texture_pixels"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 17:03:40 +04:00
|
|
|
#[test]
|
|
|
|
|
fn graph_report_uses_strict_texture_archive_policy() {
|
|
|
|
|
let mesh_key = ResourceKey {
|
|
|
|
|
archive: parse_path("static.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"tree.msh"),
|
|
|
|
|
type_id: Some(0x4853_454D),
|
|
|
|
|
};
|
|
|
|
|
let mat0 = mat0_with_texture(b"TEX_A");
|
|
|
|
|
let texm = texm_payload();
|
|
|
|
|
let repo = repository_with_archives_meta(&[
|
|
|
|
|
(
|
|
|
|
|
"static.rlb",
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"tree.wea",
|
|
|
|
|
payload: b"1\n0 MAT_A\n",
|
|
|
|
|
type_id: WEAR_KIND,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"material.lib",
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"MAT_A",
|
|
|
|
|
payload: &mat0,
|
|
|
|
|
type_id: MAT0_KIND,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
LIGHTMAP_ARCHIVE,
|
|
|
|
|
&[TestNresEntry {
|
|
|
|
|
name: b"TEX_A",
|
|
|
|
|
payload: &texm,
|
|
|
|
|
type_id: 0,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
}],
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
let prototype = EffectivePrototype {
|
|
|
|
|
key: fparkan_prototype::PrototypeKey(resource_name(b"tree")),
|
|
|
|
|
geometry: PrototypeGeometry::Mesh(mesh_key.clone()),
|
|
|
|
|
source: fparkan_prototype::PrototypeSource::DirectArchive,
|
2026-10-11 13:12:50 +04:00
|
|
|
source_type: None,
|
2026-07-03 17:03:40 +04:00
|
|
|
dependencies: vec![mesh_key.clone()],
|
2026-10-11 13:12:50 +04:00
|
|
|
control_resource: None,
|
2026-10-11 22:38:53 +04:00
|
|
|
point_resource: None,
|
2026-10-18 11:02:53 +04:00
|
|
|
node_data_resource: None,
|
2026-10-18 11:38:50 +04:00
|
|
|
bas_resource: None,
|
2026-07-03 17:03:40 +04:00
|
|
|
};
|
|
|
|
|
let mut graph = prototype_graph_for_mesh(&prototype);
|
|
|
|
|
let mut report = PrototypeGraphReport {
|
|
|
|
|
root_count: 1,
|
|
|
|
|
direct_reference_count: 1,
|
|
|
|
|
resolved_count: 1,
|
|
|
|
|
mesh_dependency_count: 1,
|
|
|
|
|
..PrototypeGraphReport::default()
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
extend_graph_report_with_visual_dependencies(
|
|
|
|
|
&repo,
|
|
|
|
|
&mut report,
|
|
|
|
|
&mut graph,
|
|
|
|
|
std::slice::from_ref(&prototype),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert_eq!(report.texture_request_count, 1);
|
|
|
|
|
assert_eq!(report.texture_resolved_count, 0);
|
|
|
|
|
assert_eq!(report.failures.len(), 1);
|
2026-07-03 21:44:26 +04:00
|
|
|
assert_eq!(
|
|
|
|
|
report.failures[0].edge,
|
|
|
|
|
PrototypeGraphEdge::MaterialToTexture
|
|
|
|
|
);
|
2026-07-03 17:03:40 +04:00
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
#[test]
|
2026-06-22 15:55:37 +04:00
|
|
|
#[ignore = "requires licensed corpus"]
|
2026-06-22 13:12:27 +04:00
|
|
|
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]
|
2026-06-22 15:55:37 +04:00
|
|
|
#[ignore = "requires licensed corpus"]
|
2026-06-22 13:12:27 +04:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:49:42 +04:00
|
|
|
fn assert_forced_collision(policy: AssetIdentityPolicy, expected: &str) {
|
|
|
|
|
let (repo, prototypes) = collision_fixture();
|
|
|
|
|
|
|
|
|
|
let err = prepare_mission_assets_with_repository_internal(
|
|
|
|
|
&repo,
|
|
|
|
|
&[0..1, 1..2],
|
|
|
|
|
&prototypes,
|
|
|
|
|
policy,
|
2026-07-03 21:54:09 +04:00
|
|
|
AssetPreparationLimits::default(),
|
2026-07-03 21:49:42 +04:00
|
|
|
)
|
|
|
|
|
.expect_err("collision should fail");
|
|
|
|
|
|
|
|
|
|
assert!(err.to_string().contains(expected), "{err}");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 13:12:27 +04:00
|
|
|
fn fixture_root(part: &str) -> PathBuf {
|
2026-06-22 17:29:33 +04:00
|
|
|
let variable = match part {
|
|
|
|
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
|
|
|
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
|
|
|
|
_ => panic!("unknown licensed corpus part: {part}"),
|
|
|
|
|
};
|
|
|
|
|
let root = std::env::var_os(variable)
|
|
|
|
|
.map(PathBuf::from)
|
|
|
|
|
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
|
|
|
|
|
assert!(
|
|
|
|
|
root.is_dir(),
|
|
|
|
|
"licensed corpus root is missing: {}",
|
|
|
|
|
root.display()
|
|
|
|
|
);
|
|
|
|
|
root
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|
2026-06-22 16:02:16 +04:00
|
|
|
|
2026-07-03 21:49:42 +04:00
|
|
|
fn collision_fixture() -> (CachedResourceRepository, Vec<EffectivePrototype>) {
|
|
|
|
|
let msh = minimal_model_archive();
|
|
|
|
|
let mat0_a = mat0_with_texture(b"TEX_A");
|
|
|
|
|
let mat0_b = mat0_with_texture(b"TEX_B");
|
|
|
|
|
let texm_a = texm_payload();
|
|
|
|
|
let texm_b = texm_payload();
|
|
|
|
|
let lightmap_a = texm_payload();
|
|
|
|
|
let lightmap_b = texm_payload();
|
|
|
|
|
let repo = repository_with_archives_meta(&[
|
|
|
|
|
(
|
|
|
|
|
"static.rlb",
|
|
|
|
|
&[
|
|
|
|
|
TestNresEntry {
|
|
|
|
|
name: b"tree_a.msh",
|
|
|
|
|
payload: &msh,
|
|
|
|
|
type_id: 0x4853_454D,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
},
|
|
|
|
|
TestNresEntry {
|
|
|
|
|
name: b"tree_a.wea",
|
|
|
|
|
payload: b"1\n0 MAT_A\n\nLIGHTMAPS\n1\n0 LM_A\n",
|
|
|
|
|
type_id: WEAR_KIND,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
},
|
|
|
|
|
TestNresEntry {
|
|
|
|
|
name: b"tree_b.msh",
|
|
|
|
|
payload: &msh,
|
|
|
|
|
type_id: 0x4853_454D,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
},
|
|
|
|
|
TestNresEntry {
|
|
|
|
|
name: b"tree_b.wea",
|
|
|
|
|
payload: b"1\n0 MAT_B\n\nLIGHTMAPS\n1\n0 LM_B\n",
|
|
|
|
|
type_id: WEAR_KIND,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"material.lib",
|
|
|
|
|
&[
|
|
|
|
|
TestNresEntry {
|
|
|
|
|
name: b"MAT_A",
|
|
|
|
|
payload: &mat0_a,
|
|
|
|
|
type_id: MAT0_KIND,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
},
|
|
|
|
|
TestNresEntry {
|
|
|
|
|
name: b"MAT_B",
|
|
|
|
|
payload: &mat0_b,
|
|
|
|
|
type_id: MAT0_KIND,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
TEXTURES_ARCHIVE,
|
|
|
|
|
&[
|
|
|
|
|
TestNresEntry {
|
|
|
|
|
name: b"TEX_A",
|
|
|
|
|
payload: &texm_a,
|
|
|
|
|
type_id: 0,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
},
|
|
|
|
|
TestNresEntry {
|
|
|
|
|
name: b"TEX_B",
|
|
|
|
|
payload: &texm_b,
|
|
|
|
|
type_id: 0,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
LIGHTMAP_ARCHIVE,
|
|
|
|
|
&[
|
|
|
|
|
TestNresEntry {
|
|
|
|
|
name: b"LM_A",
|
|
|
|
|
payload: &lightmap_a,
|
|
|
|
|
type_id: 0,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
},
|
|
|
|
|
TestNresEntry {
|
|
|
|
|
name: b"LM_B",
|
|
|
|
|
payload: &lightmap_b,
|
|
|
|
|
type_id: 0,
|
|
|
|
|
attr2: 0,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
]);
|
|
|
|
|
let prototypes = vec![
|
|
|
|
|
EffectivePrototype {
|
|
|
|
|
key: fparkan_prototype::PrototypeKey(resource_name(b"tree_a")),
|
|
|
|
|
geometry: PrototypeGeometry::Mesh(ResourceKey {
|
|
|
|
|
archive: parse_path("static.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"tree_a.msh"),
|
|
|
|
|
type_id: Some(0x4853_454D),
|
|
|
|
|
}),
|
|
|
|
|
source: fparkan_prototype::PrototypeSource::DirectArchive,
|
2026-10-11 13:12:50 +04:00
|
|
|
source_type: None,
|
2026-07-03 21:49:42 +04:00
|
|
|
dependencies: Vec::new(),
|
2026-10-11 13:12:50 +04:00
|
|
|
control_resource: None,
|
2026-10-11 22:38:53 +04:00
|
|
|
point_resource: None,
|
2026-10-18 11:02:53 +04:00
|
|
|
node_data_resource: None,
|
2026-10-18 11:38:50 +04:00
|
|
|
bas_resource: None,
|
2026-07-03 21:49:42 +04:00
|
|
|
},
|
|
|
|
|
EffectivePrototype {
|
|
|
|
|
key: fparkan_prototype::PrototypeKey(resource_name(b"tree_b")),
|
|
|
|
|
geometry: PrototypeGeometry::Mesh(ResourceKey {
|
|
|
|
|
archive: parse_path("static.rlb").expect("archive"),
|
|
|
|
|
name: resource_name(b"tree_b.msh"),
|
|
|
|
|
type_id: Some(0x4853_454D),
|
|
|
|
|
}),
|
|
|
|
|
source: fparkan_prototype::PrototypeSource::DirectArchive,
|
2026-10-11 13:12:50 +04:00
|
|
|
source_type: None,
|
2026-07-03 21:49:42 +04:00
|
|
|
dependencies: Vec::new(),
|
2026-10-11 13:12:50 +04:00
|
|
|
control_resource: None,
|
2026-10-11 22:38:53 +04:00
|
|
|
point_resource: None,
|
2026-10-18 11:02:53 +04:00
|
|
|
node_data_resource: None,
|
2026-10-18 11:38:50 +04:00
|
|
|
bas_resource: None,
|
2026-07-03 21:49:42 +04:00
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
(repo, prototypes)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 16:02:16 +04:00
|
|
|
fn repository_with_archives(
|
|
|
|
|
archives: &[(&str, &[(&[u8], &[u8])])],
|
|
|
|
|
) -> CachedResourceRepository {
|
|
|
|
|
let mut vfs = MemoryVfs::default();
|
|
|
|
|
for (archive, entries) in archives {
|
|
|
|
|
let path = parse_path(archive).expect("archive path");
|
|
|
|
|
vfs.insert(path, Arc::from(build_nres(entries).into_boxed_slice()));
|
|
|
|
|
}
|
|
|
|
|
CachedResourceRepository::new(Arc::new(vfs))
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 17:03:40 +04:00
|
|
|
#[derive(Clone, Copy)]
|
|
|
|
|
struct TestNresEntry<'a> {
|
|
|
|
|
name: &'a [u8],
|
|
|
|
|
payload: &'a [u8],
|
|
|
|
|
type_id: u32,
|
|
|
|
|
attr2: u32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn repository_with_archives_meta(
|
|
|
|
|
archives: &[(&str, &[TestNresEntry<'_>])],
|
|
|
|
|
) -> CachedResourceRepository {
|
|
|
|
|
let mut vfs = MemoryVfs::default();
|
|
|
|
|
for (archive, entries) in archives {
|
|
|
|
|
let path = parse_path(archive).expect("archive path");
|
2026-07-03 21:44:26 +04:00
|
|
|
vfs.insert(
|
|
|
|
|
path,
|
|
|
|
|
Arc::from(build_nres_with_meta(entries).into_boxed_slice()),
|
|
|
|
|
);
|
2026-07-03 17:03:40 +04:00
|
|
|
}
|
|
|
|
|
CachedResourceRepository::new(Arc::new(vfs))
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 16:02:16 +04:00
|
|
|
fn texm_payload() -> Vec<u8> {
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
out.extend_from_slice(&0x6d78_6554_u32.to_le_bytes());
|
|
|
|
|
out.extend_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
out.extend_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
out.extend_from_slice(&1_u32.to_le_bytes());
|
|
|
|
|
out.extend_from_slice(&0_u32.to_le_bytes());
|
|
|
|
|
out.extend_from_slice(&0_u32.to_le_bytes());
|
|
|
|
|
out.extend_from_slice(&0_u32.to_le_bytes());
|
|
|
|
|
out.extend_from_slice(&565_u32.to_le_bytes());
|
|
|
|
|
out.extend_from_slice(&0xffff_u16.to_le_bytes());
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 17:03:40 +04:00
|
|
|
fn mat0_with_texture(texture: &[u8]) -> Vec<u8> {
|
|
|
|
|
let mut bytes = vec![0; 4 + 34];
|
|
|
|
|
bytes[0..2].copy_from_slice(&1_u16.to_le_bytes());
|
|
|
|
|
let len = texture.len().min(16);
|
|
|
|
|
bytes[22..22 + len].copy_from_slice(&texture[..len]);
|
|
|
|
|
bytes
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 21:44:26 +04:00
|
|
|
fn minimal_model_archive() -> Vec<u8> {
|
2026-10-11 13:12:50 +04:00
|
|
|
minimal_model_archive_with_nodes(&[])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn minimal_model_archive_with_nodes(nodes: &[u8]) -> Vec<u8> {
|
2026-07-03 21:44:26 +04:00
|
|
|
struct MshEntry<'a> {
|
|
|
|
|
type_id: u32,
|
|
|
|
|
attr3: u32,
|
|
|
|
|
name: &'a [u8],
|
|
|
|
|
payload: &'a [u8],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let entries = [
|
|
|
|
|
MshEntry {
|
|
|
|
|
type_id: 1,
|
|
|
|
|
attr3: 38,
|
|
|
|
|
name: b"Res1",
|
2026-10-11 13:12:50 +04:00
|
|
|
payload: nodes,
|
2026-07-03 21:44:26 +04:00
|
|
|
},
|
|
|
|
|
MshEntry {
|
|
|
|
|
type_id: 2,
|
|
|
|
|
attr3: 0,
|
|
|
|
|
name: b"Res2",
|
|
|
|
|
payload: &[0; 0x8c],
|
|
|
|
|
},
|
|
|
|
|
MshEntry {
|
|
|
|
|
type_id: 3,
|
|
|
|
|
attr3: 0,
|
|
|
|
|
name: b"Res3",
|
|
|
|
|
payload: &[],
|
|
|
|
|
},
|
|
|
|
|
MshEntry {
|
|
|
|
|
type_id: 6,
|
|
|
|
|
attr3: 0,
|
|
|
|
|
name: b"Res6",
|
|
|
|
|
payload: &[],
|
|
|
|
|
},
|
|
|
|
|
MshEntry {
|
|
|
|
|
type_id: 13,
|
|
|
|
|
attr3: 0,
|
|
|
|
|
name: b"Res13",
|
|
|
|
|
payload: &[],
|
|
|
|
|
},
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
let mut out = vec![0; 16];
|
|
|
|
|
let mut offsets = Vec::with_capacity(entries.len());
|
|
|
|
|
for entry in &entries {
|
|
|
|
|
offsets.push(u32::try_from(out.len()).expect("offset"));
|
|
|
|
|
out.extend_from_slice(entry.payload);
|
|
|
|
|
let padding = (8 - (out.len() % 8)) % 8;
|
|
|
|
|
out.resize(out.len() + padding, 0);
|
|
|
|
|
}
|
|
|
|
|
let mut order: Vec<usize> = (0..entries.len()).collect();
|
|
|
|
|
order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name));
|
|
|
|
|
for (idx, entry) in entries.iter().enumerate() {
|
|
|
|
|
push_u32(&mut out, entry.type_id);
|
|
|
|
|
push_u32(&mut out, 0);
|
|
|
|
|
push_u32(&mut out, 0);
|
|
|
|
|
push_u32(
|
|
|
|
|
&mut out,
|
|
|
|
|
u32::try_from(entry.payload.len()).expect("payload"),
|
|
|
|
|
);
|
|
|
|
|
push_u32(&mut out, entry.attr3);
|
|
|
|
|
let mut name_raw = [0; 36];
|
|
|
|
|
let len = name_raw.len().saturating_sub(1).min(entry.name.len());
|
|
|
|
|
name_raw[..len].copy_from_slice(&entry.name[..len]);
|
|
|
|
|
out.extend_from_slice(&name_raw);
|
|
|
|
|
push_u32(&mut out, offsets[idx]);
|
|
|
|
|
push_u32(&mut out, u32::try_from(order[idx]).expect("sort index"));
|
|
|
|
|
}
|
|
|
|
|
out[0..4].copy_from_slice(b"NRes");
|
|
|
|
|
out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
|
|
|
|
|
out[8..12].copy_from_slice(&u32::try_from(entries.len()).expect("count").to_le_bytes());
|
|
|
|
|
let total_size = u32::try_from(out.len()).expect("total size");
|
|
|
|
|
out[12..16].copy_from_slice(&total_size.to_le_bytes());
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 17:03:40 +04:00
|
|
|
fn prototype_graph_for_mesh(prototype: &EffectivePrototype) -> PrototypeGraph {
|
|
|
|
|
let root_node = fparkan_prototype::PrototypeGraphNode::root(
|
|
|
|
|
fparkan_prototype::PrototypeKey(prototype.key.0.clone()),
|
|
|
|
|
false,
|
|
|
|
|
fparkan_prototype::PrototypeGraphNodeId(0),
|
|
|
|
|
);
|
|
|
|
|
let prototype_node = fparkan_prototype::PrototypeGraphNode::prototype(
|
|
|
|
|
prototype.key.clone(),
|
|
|
|
|
fparkan_prototype::PrototypeGraphNodeId(1),
|
|
|
|
|
);
|
|
|
|
|
let mesh_key = match &prototype.geometry {
|
|
|
|
|
PrototypeGeometry::Mesh(mesh) => mesh.clone(),
|
|
|
|
|
PrototypeGeometry::NonGeometric => panic!("mesh prototype expected"),
|
|
|
|
|
};
|
|
|
|
|
let mesh_node = fparkan_prototype::PrototypeGraphNode::mesh(
|
|
|
|
|
mesh_key,
|
|
|
|
|
fparkan_prototype::PrototypeGraphNodeId(2),
|
|
|
|
|
);
|
|
|
|
|
PrototypeGraph {
|
|
|
|
|
roots: vec![prototype.key.clone()],
|
|
|
|
|
prototype_requests: vec![prototype.key.clone()],
|
|
|
|
|
root_prototype_request_spans: std::iter::once(0..1).collect(),
|
2026-07-18 20:15:26 +04:00
|
|
|
root_unit_components: vec![Vec::new()],
|
2026-10-18 11:38:50 +04:00
|
|
|
root_unit_dat_header_flags: vec![None],
|
2026-07-03 21:54:09 +04:00
|
|
|
visual_dependencies_expanded: false,
|
2026-07-03 17:03:40 +04:00
|
|
|
nodes: vec![root_node, prototype_node, mesh_node],
|
|
|
|
|
edges: vec![
|
|
|
|
|
fparkan_prototype::PrototypeGraphEdgeInstance {
|
|
|
|
|
id: fparkan_prototype::PrototypeGraphEdgeId(0),
|
|
|
|
|
from: fparkan_prototype::PrototypeGraphNodeId(0),
|
|
|
|
|
to: fparkan_prototype::PrototypeGraphNodeId(1),
|
|
|
|
|
kind: fparkan_prototype::PrototypeGraphEdgeKind::MissionToRoot,
|
|
|
|
|
requiredness: PrototypeGraphRequiredness::Required,
|
|
|
|
|
provenance: None,
|
|
|
|
|
},
|
|
|
|
|
fparkan_prototype::PrototypeGraphEdgeInstance {
|
|
|
|
|
id: fparkan_prototype::PrototypeGraphEdgeId(1),
|
|
|
|
|
from: fparkan_prototype::PrototypeGraphNodeId(1),
|
|
|
|
|
to: fparkan_prototype::PrototypeGraphNodeId(2),
|
|
|
|
|
kind: fparkan_prototype::PrototypeGraphEdgeKind::PrototypeToMesh,
|
|
|
|
|
requiredness: PrototypeGraphRequiredness::Required,
|
|
|
|
|
provenance: None,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 16:02:16 +04:00
|
|
|
fn build_nres(entries: &[(&[u8], &[u8])]) -> Vec<u8> {
|
|
|
|
|
let mut out = vec![0; 16];
|
|
|
|
|
let mut offsets = Vec::with_capacity(entries.len());
|
|
|
|
|
for (_, payload) in entries {
|
|
|
|
|
offsets.push(u32::try_from(out.len()).expect("offset"));
|
|
|
|
|
out.extend_from_slice(payload);
|
|
|
|
|
let padding = (8 - (out.len() % 8)) % 8;
|
|
|
|
|
out.resize(out.len() + padding, 0);
|
|
|
|
|
}
|
|
|
|
|
let mut order: Vec<usize> = (0..entries.len()).collect();
|
|
|
|
|
order.sort_by(|left, right| entries[*left].0.cmp(entries[*right].0));
|
|
|
|
|
for (idx, (name, payload)) in entries.iter().enumerate() {
|
|
|
|
|
push_u32(&mut out, 0);
|
|
|
|
|
push_u32(&mut out, 0);
|
|
|
|
|
push_u32(&mut out, 0);
|
|
|
|
|
push_u32(&mut out, u32::try_from(payload.len()).expect("payload"));
|
|
|
|
|
push_u32(&mut out, 0);
|
|
|
|
|
let mut name_raw = [0; 36];
|
|
|
|
|
let len = name_raw.len().saturating_sub(1).min(name.len());
|
|
|
|
|
name_raw[..len].copy_from_slice(&name[..len]);
|
|
|
|
|
out.extend_from_slice(&name_raw);
|
|
|
|
|
push_u32(&mut out, offsets[idx]);
|
|
|
|
|
push_u32(&mut out, u32::try_from(order[idx]).expect("sort index"));
|
|
|
|
|
}
|
|
|
|
|
out[0..4].copy_from_slice(b"NRes");
|
|
|
|
|
out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
|
|
|
|
|
out[8..12].copy_from_slice(&u32::try_from(entries.len()).expect("count").to_le_bytes());
|
|
|
|
|
let total_size = u32::try_from(out.len()).expect("total size");
|
|
|
|
|
out[12..16].copy_from_slice(&total_size.to_le_bytes());
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 17:03:40 +04:00
|
|
|
fn build_nres_with_meta(entries: &[TestNresEntry<'_>]) -> Vec<u8> {
|
|
|
|
|
let mut out = vec![0; 16];
|
|
|
|
|
let mut offsets = Vec::with_capacity(entries.len());
|
|
|
|
|
for entry in entries {
|
|
|
|
|
offsets.push(u32::try_from(out.len()).expect("offset"));
|
|
|
|
|
out.extend_from_slice(entry.payload);
|
|
|
|
|
let padding = (8 - (out.len() % 8)) % 8;
|
|
|
|
|
out.resize(out.len() + padding, 0);
|
|
|
|
|
}
|
|
|
|
|
let mut order: Vec<usize> = (0..entries.len()).collect();
|
|
|
|
|
order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name));
|
|
|
|
|
for (idx, entry) in entries.iter().enumerate() {
|
|
|
|
|
push_u32(&mut out, entry.type_id);
|
|
|
|
|
push_u32(&mut out, 0);
|
|
|
|
|
push_u32(&mut out, entry.attr2);
|
2026-07-03 21:44:26 +04:00
|
|
|
push_u32(
|
|
|
|
|
&mut out,
|
|
|
|
|
u32::try_from(entry.payload.len()).expect("payload"),
|
|
|
|
|
);
|
2026-07-03 17:03:40 +04:00
|
|
|
push_u32(&mut out, 0);
|
|
|
|
|
let mut name_raw = [0; 36];
|
|
|
|
|
let len = name_raw.len().saturating_sub(1).min(entry.name.len());
|
|
|
|
|
name_raw[..len].copy_from_slice(&entry.name[..len]);
|
|
|
|
|
out.extend_from_slice(&name_raw);
|
|
|
|
|
push_u32(&mut out, offsets[idx]);
|
|
|
|
|
push_u32(&mut out, u32::try_from(order[idx]).expect("sort index"));
|
|
|
|
|
}
|
|
|
|
|
out[0..4].copy_from_slice(b"NRes");
|
|
|
|
|
out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
|
|
|
|
|
out[8..12].copy_from_slice(&u32::try_from(entries.len()).expect("count").to_le_bytes());
|
|
|
|
|
let total_size = u32::try_from(out.len()).expect("total size");
|
|
|
|
|
out[12..16].copy_from_slice(&total_size.to_le_bytes());
|
|
|
|
|
out
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 16:02:16 +04:00
|
|
|
fn push_u32(out: &mut Vec<u8>, value: u32) {
|
|
|
|
|
out.extend_from_slice(&value.to_le_bytes());
|
|
|
|
|
}
|
2026-06-22 13:12:27 +04:00
|
|
|
}
|