aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
Diffstat (limited to 'crates')
-rw-r--r--crates/fparkan-animation/src/lib.rs50
-rw-r--r--crates/fparkan-assets/src/lib.rs1126
-rw-r--r--crates/fparkan-fx/src/atmosphere.rs1756
-rw-r--r--crates/fparkan-fx/src/environment.rs2803
-rw-r--r--crates/fparkan-fx/src/lib.rs40
-rw-r--r--crates/fparkan-fx/src/shadow.rs1785
-rw-r--r--crates/fparkan-fx/src/sky.rs2079
-rw-r--r--crates/fparkan-material/src/lib.rs932
-rw-r--r--crates/fparkan-msh/src/lib.rs712
-rw-r--r--crates/fparkan-prototype/src/lib.rs417
-rw-r--r--crates/fparkan-render/src/lib.rs88
-rw-r--r--crates/fparkan-runtime/src/lib.rs6
-rw-r--r--crates/fparkan-terrain-format/src/lib.rs149
-rw-r--r--crates/fparkan-terrain/src/lib.rs73
14 files changed, 11552 insertions, 464 deletions
diff --git a/crates/fparkan-animation/src/lib.rs b/crates/fparkan-animation/src/lib.rs
index c07c5b8..ab606ad 100644
--- a/crates/fparkan-animation/src/lib.rs
+++ b/crates/fparkan-animation/src/lib.rs
@@ -254,12 +254,16 @@ impl AnimKey24 {
];
let time = AnimationTime(read_f32(bytes, 12)?);
validate_time(time)?;
- let raw_rotation = [
+ let disk_wxyz = [
f32::from(read_i16(bytes, 16)?) / 32767.0,
f32::from(read_i16(bytes, 18)?) / 32767.0,
f32::from(read_i16(bytes, 20)?) / 32767.0,
f32::from(read_i16(bytes, 22)?) / 32767.0,
];
+ // Native AniMesh stores the scalar first (WXYZ). The portable pose
+ // contract is XYZW, matching the rotate/multiply helpers below. Keep
+ // the decoded values unnormalized; sampling_pose owns normalization.
+ let raw_rotation = [disk_wxyz[1], disk_wxyz[2], disk_wxyz[3], disk_wxyz[0]];
Ok(Self {
time,
pose: Pose {
@@ -870,9 +874,47 @@ mod tests {
assert_eq!(key.time, AnimationTime(12.5));
assert_eq!(key.pose.translation, [-1.0, 2.0, 0.0]);
- assert!(key.pose.rotation[1] < 0.0);
- assert!((key.pose.rotation[1] + std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
- assert!((key.pose.rotation[3] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
+ assert!(key.pose.rotation[0] < 0.0);
+ assert!((key.pose.rotation[0] + std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
+ assert!((key.pose.rotation[2] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
+ }
+
+ #[test]
+ fn anim_key24_preserves_disk_wxyz_as_internal_xyzw() {
+ let mut bytes = [0_u8; 24];
+ bytes[12..16].copy_from_slice(&0.0_f32.to_bits().to_le_bytes());
+ bytes[16..18].copy_from_slice(&32609_i16.to_le_bytes());
+ bytes[18..20].copy_from_slice(&0_i16.to_le_bytes());
+ bytes[20..22].copy_from_slice(&0_i16.to_le_bytes());
+ bytes[22..24].copy_from_slice(&3211_i16.to_le_bytes());
+
+ let key = AnimKey24::decode(&bytes).expect("bunker key");
+ let expected = [0.0, 0.0, 3211.0 / 32767.0, 32609.0 / 32767.0];
+
+ assert_eq!(key.pose.rotation, expected);
+ }
+
+ #[test]
+ fn bunker_fallback_pose_rotates_a_point_with_native_matrix_contract() {
+ let mut bytes = [0_u8; 24];
+ bytes[12..16].copy_from_slice(&0.0_f32.to_bits().to_le_bytes());
+ bytes[16..18].copy_from_slice(&32609_i16.to_le_bytes());
+ bytes[18..20].copy_from_slice(&0_i16.to_le_bytes());
+ bytes[20..22].copy_from_slice(&0_i16.to_le_bytes());
+ bytes[22..24].copy_from_slice(&3211_i16.to_le_bytes());
+
+ let pose = AnimKey24::decode(&bytes)
+ .expect("bunker key")
+ .sampling_pose();
+ let rotated = rotate_point(pose.rotation, [0.0, 1.0, 0.0]);
+
+ assert!((pose.rotation[0] - 0.0).abs() < 0.000_001);
+ assert!((pose.rotation[1] - 0.0).abs() < 0.000_001);
+ assert!((pose.rotation[2] - 0.097_995_8).abs() < 0.000_01);
+ assert!((pose.rotation[3] - 0.995_186_8).abs() < 0.000_01);
+ assert!((rotated[0] + 0.195_048_3).abs() < 0.000_01);
+ assert!((rotated[1] - 0.980_793_6).abs() < 0.000_01);
+ assert!(rotated[2].abs() < 0.000_01);
}
#[test]
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs
index 3193e3f..8f45c8e 100644
--- a/crates/fparkan-assets/src/lib.rs
+++ b/crates/fparkan-assets/src/lib.rs
@@ -2,9 +2,13 @@
//! Asset manager ports and transactional preparation models.
use fparkan_material::{
- decode_wear, resolve_material, Mat0Document, MaterialError, ResolvedMaterial, WearTable,
+ decode_mat0, decode_wear, resolve_material, MaterialFallback, ResolvedMaterial, WearTable,
MAT0_KIND, WEAR_KIND,
};
+pub use fparkan_material::{
+ sample_material_phase, Mat0Document, MaterialCoefficients, MaterialError, MaterialPhase,
+ MaterialPhaseSample,
+};
use fparkan_mission_format::{decode_tma, decode_tma_land_path, ClanBody};
pub use fparkan_mission_format::{LpString, MissionDocument, MissionError, TmaProfile};
use fparkan_msh::{decode_msh, validate_msh, ModelAsset, MshError};
@@ -20,21 +24,59 @@ use fparkan_resource::{CachedResourceRepository, ResourceError, ResourceKey, Res
pub use fparkan_terrain::{TerrainError, TerrainWorld};
use fparkan_terrain_format::{decode_build_dat, decode_land_map, decode_land_msh};
pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError};
-use fparkan_texm::{decode_mip_rgba8, decode_texm, RgbaImage, TexmDocument, TexmError};
+use fparkan_texm::{decode_mip_rgba8, decode_texm, TexmDocument, TexmError};
+pub use fparkan_texm::{PageRect, RgbaImage};
use fparkan_vfs::DirectoryVfs;
-use std::collections::{hash_map::Entry, HashMap, HashSet};
+use std::collections::{hash_map::Entry, HashMap};
use std::fmt;
use std::hash::{Hash, Hasher};
use std::marker::PhantomData;
-use std::path::Path;
+use std::path::{Path, PathBuf};
use std::sync::Arc;
const TEXTURES_ARCHIVE: &str = "textures.lib";
const LIGHTMAP_ARCHIVE: &str = "lightmap.lib";
+/// 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],
+}
+
type WearValidationCache =
HashMap<(NormalizedPath, Vec<u8>), Result<fparkan_material::WearTable, String>>;
+/// 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
+}
+
/// Canonical terrain archive paths derived from a mission land reference.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionTerrainPaths {
@@ -169,35 +211,587 @@ pub fn decode_nres_payload(
decode_nres(bytes, ReadProfile::Compatible)
}
-/// Loads a phase-zero diffuse texture from a map-local standalone WEAR file.
-///
-/// The sidecar stays beside `Land.msh`; MAT0 and TEXM remain in the normal
-/// game-root archives.
-pub fn load_standalone_wear_material_texture_mip0_rgba8_from_root(
+/// 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(
root: &Path,
wear_path: &Path,
material_index: u16,
-) -> Result<RgbaImage, String> {
+) -> 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> {
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)));
- let material =
- resolve_material(&repository, &wear, material_index).map_err(|err| err.to_string())?;
- let texture = material.document.primary_texture().ok_or_else(|| {
- "MAT0 phase zero declares an intentionally untextured material".to_string()
- })?;
- let key = ResourceKey {
+ 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 {
archive: parse_path(TEXTURES_ARCHIVE).map_err(|err| err.to_string())?,
- name: texture.clone(),
+ name: ResourceName(texture_name.as_bytes().to_vec()),
type_id: None,
};
- let bytes = read_optional_key(&repository, &key, Some("texture"))
+ let archive_id = repository
+ .open_archive(&archive.archive)
+ .map_err(|err| err.to_string())?;
+ let entry = repository
+ .find(archive_id, &archive.name)
.map_err(|err| err.to_string())?
- .ok_or_else(|| format!("missing texture {texture:?}"))?;
- let document = decode_texm(bytes).map_err(|err| err.to_string())?;
- decode_mip_rgba8(&document, 0).map_err(|err| err.to_string())
+ .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
+ }
}
/// Decodes terrain documents and builds immutable terrain state.
@@ -271,6 +865,8 @@ pub struct PreparedVisual {
pub id: AssetId<PreparedVisual>,
/// Optional mesh resource backing the visual.
pub mesh: Option<ResourceKey>,
+ /// Source registry entry type (`EXTO`/`INTO`/etc.) when preserved.
+ pub source_type: Option<u32>,
/// 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.
@@ -295,6 +891,35 @@ pub struct PreparedVisual {
pub lightmap_count: usize,
}
+/// 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,
+ /// Enabled source rows in their original order, including duplicate nodes.
+ pub bindings: Vec<PreparedControlNodeBinding>,
+}
+
+/// 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,
+}
+
/// CPU-side validated model ready for a renderer upload path.
#[derive(Clone, Debug, PartialEq)]
pub struct PreparedModel {
@@ -328,6 +953,12 @@ pub struct PreparedMaterial {
pub source: ResourceKey,
/// Parsed material key retained for compatibility with older callers.
pub name: ResourceName,
+ /// 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,
/// Decoded MAT0 payload.
pub mat0: Mat0Document,
/// Texture requests declared by MAT0 phases.
@@ -393,6 +1024,10 @@ pub struct MissionAssets {
pub visuals: Vec<PreparedVisual>,
/// Visual ids available for each mission object index.
pub object_visuals: Vec<Vec<AssetId<PreparedVisual>>>,
+ /// 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>>>,
}
impl MissionAssets {
@@ -829,7 +1464,38 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>(
prototype_visual_ids.push(visual_id);
}
+ let mut prototype_component_controls = Vec::with_capacity(prototypes.len());
+ for (prototype_index, prototype) in prototypes.iter().enumerate() {
+ 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,
+ model.validated.node_count,
+ prototype_index,
+ )?;
+ prototype_component_controls.push(control);
+ }
+
let mut object_visuals = Vec::with_capacity(root_prototype_spans.len());
+ let mut object_component_controls = Vec::with_capacity(root_prototype_spans.len());
for (root_index, span) in root_prototype_spans.iter().enumerate() {
if span.start > span.end || span.end > prototype_visual_ids.len() {
return Err(AssetError::InvalidPrototype(format!(
@@ -837,14 +1503,17 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>(
)));
}
let mut ids = Vec::new();
- let mut dedup = HashSet::new();
+ let mut controls = Vec::new();
for index in span.clone() {
let visual_id = prototype_visual_ids[index];
- if dedup.insert(visual_id) {
- ids.push(visual_id);
- }
+ // 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());
}
object_visuals.push(ids);
+ object_component_controls.push(controls);
}
let assets = MissionAssets {
@@ -854,6 +1523,7 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>(
textures,
visuals,
object_visuals,
+ object_component_controls,
};
let report = AssetPreparationReport {
model_count: assets.models.len(),
@@ -876,9 +1546,11 @@ enum PreparedVisualSignature {
archive: Vec<u8>,
name: Vec<u8>,
type_id: Option<u32>,
+ source_type: Option<u32>,
dependency_count: usize,
},
NonGeometric {
+ source_type: Option<u32>,
dependency_count: usize,
},
}
@@ -929,6 +1601,9 @@ struct PreparedWearSignature {
#[derive(Clone, Debug, Eq, PartialEq)]
struct PreparedMaterialSignature {
source: ResourceSignature,
+ attr1: u32,
+ pipeline_category: u8,
+ native_flag_40: bool,
texture_requests: Vec<Vec<u8>>,
lightmap_requests: Vec<Vec<u8>>,
}
@@ -948,9 +1623,11 @@ fn prepared_visual_signature(proto: &EffectivePrototype) -> PreparedVisualSignat
archive: key.archive.identity_bytes().to_vec(),
name: key.name.0.clone(),
type_id: key.type_id,
+ source_type: proto.source_type,
dependency_count: proto.dependencies.len(),
},
PrototypeGeometry::NonGeometric => PreparedVisualSignature::NonGeometric {
+ source_type: proto.source_type,
dependency_count: proto.dependencies.len(),
},
}
@@ -984,6 +1661,9 @@ fn prepared_wear_signature(wear: &PreparedWear) -> PreparedWearSignature {
fn prepared_material_signature(material: &PreparedMaterial) -> PreparedMaterialSignature {
PreparedMaterialSignature {
source: resource_signature(&material.source),
+ attr1: material.attr1,
+ pipeline_category: material.pipeline_category,
+ native_flag_40: material.native_flag_40,
texture_requests: material
.texture_requests
.iter()
@@ -1462,6 +2142,7 @@ pub fn prepare_visual(proto: &EffectivePrototype) -> Result<PreparedVisual, Asse
Ok(PreparedVisual {
id: AssetId::new(id),
mesh,
+ source_type: proto.source_type,
model_id: None,
wear_id: None,
model_nodes: 0,
@@ -1590,6 +2271,9 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
id: material_id,
source: material_key,
name: material.name.clone(),
+ attr1: material.attr1,
+ pipeline_category: material_pipeline_category(material.attr1),
+ native_flag_40: material_native_flag_40(material.attr1),
mat0: material.document.clone(),
texture_requests: texture_requests.clone(),
lightmap_requests: lightmap_requests.clone(),
@@ -1626,6 +2310,7 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
visual: PreparedVisual {
id: AssetId::new((identity_policy.visual)(proto)),
mesh: Some(mesh_key.clone()),
+ source_type: proto.source_type,
model_id: Some(model_id),
wear_id: Some(wear_id),
model_nodes: model.node_count,
@@ -2097,6 +2782,123 @@ fn read_optional_key<R: ResourceRepository>(
Ok(Some(Arc::from(bytes.into_owned())))
}
+fn prepare_component_control_from_resource<R: ResourceRepository>(
+ repository: &R,
+ source: ResourceKey,
+ 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);
+ };
+ decode_component_control_payload(&payload, source, model_node_count, prototype_index).map(Some)
+}
+
+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"))?;
+ 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);
+ 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"));
+ if flags & 4 != 0 {
+ continue;
+ }
+ let node_index = u32::from_le_bytes(row[0..4].try_into().expect("row node index")) as usize;
+ if node_index >= model_node_count {
+ return Err(invalid("enabled binding node index is outside the model"));
+ }
+ 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"),
+ ));
+ 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,
+ });
+ }
+
+ Ok(PreparedComponentControl { source, bindings })
+}
+
fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, AssetError> {
let dot = key
.name
@@ -2114,6 +2916,7 @@ fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, Asse
fn stable_visual_id(proto: &EffectivePrototype) -> u64 {
let mut hasher = StableHasher::default();
+ proto.source_type.hash(&mut hasher);
match &proto.geometry {
PrototypeGeometry::Mesh(key) => {
1_u8.hash(&mut hasher);
@@ -2198,6 +3001,105 @@ mod tests {
use std::path::PathBuf;
#[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());
+ }
+
+ 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
+ }
+
+ #[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());
+ }
+
+ #[test]
fn wear_validation_cache_replays_archive_qualified_failure() {
let repository = repository_with_archives(&[]);
let mesh = ResourceKey {
@@ -2279,7 +3181,9 @@ mod tests {
type_id: Some(0x4853_454D),
}),
source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
dependencies: Vec::new(),
+ control_resource: None,
};
let second = EffectivePrototype {
key: fparkan_prototype::PrototypeKey(resource_name(b"mesh")),
@@ -2290,7 +3194,9 @@ mod tests {
type_id: Some(0x4853_454D),
}),
source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
dependencies: Vec::new(),
+ control_resource: None,
};
assert_ne!(stable_visual_id(&first), stable_visual_id(&second));
@@ -2352,7 +3258,9 @@ mod tests {
key: fparkan_prototype::PrototypeKey(resource_name(b"tree")),
geometry: PrototypeGeometry::Mesh(mesh_key.clone()),
source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
dependencies: vec![mesh_key.clone()],
+ control_resource: None,
};
let mut graph = prototype_graph_for_mesh(&prototype);
let mut report = PrototypeGraphReport {
@@ -2465,7 +3373,9 @@ mod tests {
key: fparkan_prototype::PrototypeKey(resource_name(b"tree")),
geometry: PrototypeGeometry::Mesh(mesh_key),
source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
dependencies: Vec::new(),
+ control_resource: None,
};
let mut graph = prototype_graph_for_mesh(&prototype);
graph
@@ -2616,7 +3526,9 @@ mod tests {
key: fparkan_prototype::PrototypeKey(resource_name(b"tree")),
geometry: PrototypeGeometry::Mesh(mesh_key),
source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
dependencies: Vec::new(),
+ control_resource: None,
};
let mut graph = prototype_graph_for_mesh(&prototype);
let mut report = PrototypeGraphReport {
@@ -2649,16 +3561,20 @@ mod tests {
}
#[test]
- fn prepare_single_visual_mission_assets_materialize_model_wear_material_and_texture_payloads() {
+ fn prepare_mission_assets_keep_instance_controls_beside_deduplicated_visuals() {
let mesh_key = ResourceKey {
archive: parse_path("static.rlb").expect("archive"),
name: resource_name(b"tree.msh"),
type_id: Some(0x4853_454D),
};
- let msh = minimal_model_archive();
+ let mut node = vec![0; 38];
+ node[8..].fill(0xff);
+ let msh = minimal_model_archive_with_nodes(&node);
let mat0 = mat0_with_texture(b"TEX_A");
let texm = texm_payload();
let lightmap_texm = texm_payload();
+ let first_control = component_control_payload(&[(0, 1.25, 2.5, 0.25, 0)]);
+ let second_control = component_control_payload(&[(0, 2.5, 3.75, 0.75, 0)]);
let repo = repository_with_archives_meta(&[
(
"static.rlb",
@@ -2704,27 +3620,122 @@ mod tests {
attr2: 0,
}],
),
+ (
+ "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,
+ },
+ ],
+ ),
]);
- let prototype = EffectivePrototype {
- key: fparkan_prototype::PrototypeKey(resource_name(b"tree")),
- geometry: PrototypeGeometry::Mesh(mesh_key),
- source: fparkan_prototype::PrototypeSource::DirectArchive,
- dependencies: Vec::new(),
- };
+ 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,
+ }),
+ },
+ 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,
+ }),
+ },
+ 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,
+ }),
+ },
+ ];
- let assets = prepare_mission_assets_with_repository(
- &repo,
- std::slice::from_ref(&(0..1)),
- std::slice::from_ref(&prototype),
- )
- .expect("prepared mission assets");
+ let assets = prepare_mission_assets_with_repository(&repo, &[0..3], &prototypes)
+ .expect("prepared mission assets");
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);
- assert_eq!(assets.object_visuals, vec![vec![assets.visuals[0].id]]);
+ 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")
+ );
+ 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,
+ }]
+ );
let visual = &assets.visuals[0];
assert_eq!(visual.model_id, Some(assets.models[0].id));
@@ -2735,6 +3746,29 @@ mod tests {
}
#[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,
+ }),
+ };
+
+ 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]]);
+ }
+
+ #[test]
fn texture_preparation_cache_reuses_exact_request_within_one_mission() {
let texm = texm_payload();
let repo = repository_with_archives_meta(&[(
@@ -2917,7 +3951,9 @@ mod tests {
key: fparkan_prototype::PrototypeKey(resource_name(b"tree")),
geometry: PrototypeGeometry::Mesh(mesh_key.clone()),
source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
dependencies: vec![mesh_key.clone()],
+ control_resource: None,
};
let mut graph = prototype_graph_for_mesh(&prototype);
let mut report = PrototypeGraphReport {
@@ -3124,7 +4160,9 @@ mod tests {
type_id: Some(0x4853_454D),
}),
source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
dependencies: Vec::new(),
+ control_resource: None,
},
EffectivePrototype {
key: fparkan_prototype::PrototypeKey(resource_name(b"tree_b")),
@@ -3134,7 +4172,9 @@ mod tests {
type_id: Some(0x4853_454D),
}),
source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
dependencies: Vec::new(),
+ control_resource: None,
},
];
(repo, prototypes)
@@ -3196,6 +4236,10 @@ mod tests {
}
fn minimal_model_archive() -> Vec<u8> {
+ minimal_model_archive_with_nodes(&[])
+ }
+
+ fn minimal_model_archive_with_nodes(nodes: &[u8]) -> Vec<u8> {
struct MshEntry<'a> {
type_id: u32,
attr3: u32,
@@ -3208,7 +4252,7 @@ mod tests {
type_id: 1,
attr3: 38,
name: b"Res1",
- payload: &[],
+ payload: nodes,
},
MshEntry {
type_id: 2,
diff --git a/crates/fparkan-fx/src/atmosphere.rs b/crates/fparkan-fx/src/atmosphere.rs
new file mode 100644
index 0000000..53ae81b
--- /dev/null
+++ b/crates/fparkan-fx/src/atmosphere.rs
@@ -0,0 +1,1756 @@
+//! Parser and sampler for the original sky.ske atmosphere schedule.
+//!
+//! The game stores a schedule rather than a list of named weather presets.
+//! A schedule contains one or more compressed in-game days. Track durations
+//! are real seconds, while key dates are hours, minutes, and seconds in the
+//! in-game day. Sampling selects tracks in file order and interpolates the
+//! two surrounding keys on the complete cyclic schedule, including adjacent
+//! track boundaries.
+//!
+//! Fields not consumed by the original atmosphere renderer keep neutral names
+//! such as raw, header_values, and parameters. They are retained so a later
+//! renderer can use them without changing the file parser.
+
+use fparkan_binary::{read_lp_bytes, Cursor, DecodeError};
+use std::fmt;
+
+/// sky.ske header marker.
+pub const SKE_HEADER_MARKER: i32 = -1;
+/// Supported sky.ske version.
+pub const SKE_VERSION: u32 = 5;
+/// Number of color parameters emitted by the sky sampler.
+pub const SKY_COLOR_COUNT: usize = 13;
+/// Number of packed colors in every on-disk key palette.
+pub const KEY_COLOR_COUNT: usize = 15;
+/// Number of fixed length-prefixed strings in every key.
+pub const KEY_STRING_COUNT: usize = 6;
+/// Number of words in an on-disk date.
+pub const DATE_WORD_COUNT: usize = 8;
+
+const MAX_TRACKS: u32 = 256;
+const MAX_KEYS: u32 = 16_384;
+const MAX_STRING_BYTES: u32 = 64 * 1024;
+const MAX_REFERENCES: u32 = 256;
+const DAY_SECONDS: f32 = 86_400.0;
+const COLOR_MIN_COMPONENT: f32 = 80.0 / 255.0;
+const WEATHER_ALPHA: f32 = 150.0 / 255.0;
+
+/// A decoded atmosphere error.
+#[derive(Clone, Debug, PartialEq)]
+pub enum AtmosphereError {
+ /// A bounded binary read failed.
+ Decode(DecodeError),
+ /// The file marker or version is not supported.
+ InvalidHeader {
+ /// Header marker read from the file.
+ marker: i32,
+ /// Header version read from the file.
+ version: u32,
+ },
+ /// A track has a version other than one.
+ InvalidTrackVersion {
+ /// Track number.
+ track_index: usize,
+ /// Version read from the file.
+ version: u32,
+ },
+ /// A key has a version other than three.
+ InvalidKeyVersion {
+ /// Track number.
+ track_index: usize,
+ /// Key number.
+ key_index: usize,
+ /// Version read from the file.
+ version: u32,
+ },
+ /// A date contains an impossible time of day.
+ InvalidDate {
+ /// Context in which the date appeared.
+ context: &'static str,
+ /// Hour word.
+ hours: u32,
+ /// Minute word.
+ minutes: u32,
+ /// Second word.
+ seconds: u32,
+ },
+ /// A key date is earlier than the previous key date.
+ NonMonotonicKey {
+ /// Track number.
+ track_index: usize,
+ /// Key number that is out of order.
+ key_index: usize,
+ /// Previous key time in seconds.
+ previous_seconds: u32,
+ /// Current key time in seconds.
+ current_seconds: u32,
+ },
+ /// A track contains no key and therefore cannot be sampled.
+ EmptyTrack {
+ /// Track number.
+ track_index: usize,
+ },
+ /// A floating point value in a key is NaN or infinite.
+ NonFiniteValue {
+ /// Track number.
+ track_index: usize,
+ /// Key number.
+ key_index: usize,
+ /// Neutral field name.
+ field: &'static str,
+ /// Array element.
+ index: usize,
+ },
+ /// A real duration could not be represented.
+ InvalidDuration {
+ /// Track number.
+ track_index: usize,
+ },
+}
+
+impl From<DecodeError> for AtmosphereError {
+ fn from(value: DecodeError) -> Self {
+ Self::Decode(value)
+ }
+}
+
+impl fmt::Display for AtmosphereError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Decode(source) => write!(f, "{source}"),
+ Self::InvalidHeader { marker, version } => {
+ write!(f, "unsupported sky.ske header marker={marker} version={version}")
+ }
+ Self::InvalidTrackVersion {
+ track_index,
+ version,
+ } => write!(f, "unsupported sky.ske track {track_index} version {version}"),
+ Self::InvalidKeyVersion {
+ track_index,
+ key_index,
+ version,
+ } => write!(
+ f,
+ "unsupported sky.ske track {track_index} key {key_index} version {version}"
+ ),
+ Self::InvalidDate {
+ context,
+ hours,
+ minutes,
+ seconds,
+ } => write!(
+ f,
+ "invalid sky.ske {context} time {hours:02}:{minutes:02}:{seconds:02}"
+ ),
+ Self::NonMonotonicKey {
+ track_index,
+ key_index,
+ previous_seconds,
+ current_seconds,
+ } => write!(
+ f,
+ "sky.ske track {track_index} key {key_index} goes backward from {previous_seconds}s to {current_seconds}s"
+ ),
+ Self::EmptyTrack { track_index } => {
+ write!(f, "sky.ske track {track_index} has no keys")
+ }
+ Self::NonFiniteValue {
+ track_index,
+ key_index,
+ field,
+ index,
+ } => write!(
+ f,
+ "sky.ske track {track_index} key {key_index} has non-finite {field}[{index}]"
+ ),
+ Self::InvalidDuration { track_index } => {
+ write!(f, "sky.ske track {track_index} has an invalid duration")
+ }
+ }
+ }
+}
+
+impl std::error::Error for AtmosphereError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Decode(source) => Some(source),
+ Self::InvalidHeader { .. }
+ | Self::InvalidTrackVersion { .. }
+ | Self::InvalidKeyVersion { .. }
+ | Self::InvalidDate { .. }
+ | Self::NonMonotonicKey { .. }
+ | Self::EmptyTrack { .. }
+ | Self::NonFiniteValue { .. }
+ | Self::InvalidDuration { .. } => None,
+ }
+ }
+}
+
+/// The eight words copied by the original date reader.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct SkeDate {
+ /// Raw date words, preserved in file order.
+ pub words: [u32; DATE_WORD_COUNT],
+}
+
+impl SkeDate {
+ /// Constructs a date from its raw words.
+ #[must_use]
+ pub const fn from_words(words: [u32; DATE_WORD_COUNT]) -> Self {
+ Self { words }
+ }
+
+ /// In-game hour word.
+ #[must_use]
+ pub const fn hours(self) -> u32 {
+ self.words[3]
+ }
+
+ /// In-game minute word.
+ #[must_use]
+ pub const fn minutes(self) -> u32 {
+ self.words[4]
+ }
+
+ /// In-game second word.
+ #[must_use]
+ pub const fn seconds(self) -> u32 {
+ self.words[5]
+ }
+
+ /// Returns the time of day in seconds.
+ #[must_use]
+ pub const fn seconds_of_day(self) -> u32 {
+ self.hours() * 3_600 + self.minutes() * 60 + self.seconds()
+ }
+
+ /// Returns the hour/minute position used by the original atmosphere
+ /// scheduler. The serialized seconds word is deliberately ignored by the
+ /// scheduler and is retained only in [`Self::words`].
+ #[must_use]
+ pub const fn hour_minute_seconds(self) -> u32 {
+ (self.hours() * 60 + self.minutes()) * 60
+ }
+
+ /// Returns the hour and minute duration marker used by the original track
+ /// constructor.
+ #[must_use]
+ pub const fn real_duration_marker(self) -> u32 {
+ self.hour_minute_seconds()
+ }
+}
+
+/// A packed original ARGB color (0xAARRGGBB).
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct PackedColor(pub u32);
+
+impl PackedColor {
+ /// Returns the raw ARGB word.
+ #[must_use]
+ pub const fn raw(self) -> u32 {
+ self.0
+ }
+
+ /// Decodes the word to normalized RGBA components.
+ #[must_use]
+ pub fn rgba(self) -> [f32; 4] {
+ [
+ f32::from((self.0 >> 16) as u8) / 255.0,
+ f32::from((self.0 >> 8) as u8) / 255.0,
+ f32::from(self.0 as u8) / 255.0,
+ f32::from((self.0 >> 24) as u8) / 255.0,
+ ]
+ }
+
+ /// Interpolates two packed colors in normalized channel space and packs
+ /// the result back to ARGB.
+ #[must_use]
+ pub fn lerp(self, other: Self, factor: f32) -> Self {
+ let a = self.rgba();
+ let b = other.rgba();
+ Self::from_rgba([
+ lerp(a[0], b[0], factor),
+ lerp(a[1], b[1], factor),
+ lerp(a[2], b[2], factor),
+ lerp(a[3], b[3], factor),
+ ])
+ }
+
+ /// Packs normalized RGBA components as ARGB.
+ #[must_use]
+ pub fn from_rgba(rgba: [f32; 4]) -> Self {
+ let channel = |value: f32| -> u32 { (value.clamp(0.0, 1.0) * 255.0).round() as u32 };
+ Self(
+ (channel(rgba[3]) << 24)
+ | (channel(rgba[0]) << 16)
+ | (channel(rgba[1]) << 8)
+ | channel(rgba[2]),
+ )
+ }
+}
+
+/// A length-prefixed string from a key.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AtmosphereString {
+ /// Original bytes without a terminating NUL.
+ pub raw: Vec<u8>,
+ /// Lossy UTF-8 view. Resource names in the original files are ASCII.
+ pub text: String,
+}
+
+impl AtmosphereString {
+ /// Returns the decoded text.
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ &self.text
+ }
+
+ /// Whether the length-prefixed value is empty.
+ #[must_use]
+ pub fn is_empty(&self) -> bool {
+ self.raw.is_empty()
+ }
+}
+
+/// A key kind from the original atmosphere dispatcher.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum AtmosphereKind {
+ /// Starts or updates the sun object.
+ SunStart,
+ /// Stops the sun object.
+ SunStop,
+ /// An unclassified state key with raw kind two.
+ State2,
+ /// Starts rain.
+ RainStart,
+ /// Stops rain.
+ RainStop,
+ /// Starts snow.
+ SnowStart,
+ /// Stops snow.
+ SnowStop,
+ /// An interpolated state key with raw kind seven.
+ State7,
+ /// Starts lightning.
+ LightningStart,
+ /// Stops lightning.
+ LightningStop,
+ /// A kind not present in the known original dispatcher.
+ Unknown(u32),
+}
+
+impl AtmosphereKind {
+ /// Converts the raw kind word without discarding unknown values.
+ #[must_use]
+ pub const fn from_raw(raw: u32) -> Self {
+ match raw {
+ 0 => Self::SunStart,
+ 1 => Self::SunStop,
+ 2 => Self::State2,
+ 3 => Self::RainStart,
+ 4 => Self::RainStop,
+ 5 => Self::SnowStart,
+ 6 => Self::SnowStop,
+ 7 => Self::State7,
+ 8 => Self::LightningStart,
+ 9 => Self::LightningStop,
+ other => Self::Unknown(other),
+ }
+ }
+
+ /// Returns the original kind word.
+ #[must_use]
+ pub const fn raw(self) -> u32 {
+ match self {
+ Self::SunStart => 0,
+ Self::SunStop => 1,
+ Self::State2 => 2,
+ Self::RainStart => 3,
+ Self::RainStop => 4,
+ Self::SnowStart => 5,
+ Self::SnowStop => 6,
+ Self::State7 => 7,
+ Self::LightningStart => 8,
+ Self::LightningStop => 9,
+ Self::Unknown(raw) => raw,
+ }
+ }
+}
+
+/// One exact key record from a version-five sky.ske.
+#[derive(Clone, Debug, PartialEq)]
+pub struct AtmosphereKey {
+ /// Key record version (three in the original files).
+ pub version: u32,
+ /// In-game date of this key.
+ pub date: SkeDate,
+ /// Decoded key kind.
+ pub kind: AtmosphereKind,
+ /// Original kind word.
+ pub kind_raw: u32,
+ /// Opaque key word following the kind.
+ pub raw: u32,
+ /// Four packed colors at the start of the key payload.
+ pub header_colors: [PackedColor; 4],
+ /// Two floating point values following header_colors.
+ pub header_values: [f32; 2],
+ /// Fifteen packed colors in the key palette.
+ pub colors: [PackedColor; KEY_COLOR_COUNT],
+ /// Six fixed length-prefixed strings.
+ pub strings: Vec<AtmosphereString>,
+ /// Four trailing floating point values.
+ pub parameters: [f32; 4],
+ /// Length-prefixed resource references.
+ pub references: Vec<AtmosphereString>,
+}
+
+impl AtmosphereKey {
+ /// Returns non-empty resource references in file order.
+ #[must_use]
+ pub fn resource_references(&self) -> Vec<&AtmosphereString> {
+ self.references
+ .iter()
+ .filter(|reference| !reference.is_empty())
+ .collect()
+ }
+}
+
+/// One atmosphere track.
+#[derive(Clone, Debug, PartialEq)]
+pub struct AtmosphereTrack {
+ /// Track version (one in the original files).
+ pub version: u32,
+ /// Track start date copied from disk.
+ pub start: SkeDate,
+ /// Track end/duration date copied from disk.
+ pub end: SkeDate,
+ /// Keys in file order.
+ pub keys: Vec<AtmosphereKey>,
+ /// Real seconds assigned to this track by the original constructor.
+ pub duration_seconds: u32,
+}
+
+impl AtmosphereTrack {
+ /// Returns a key's in-game time in seconds.
+ #[must_use]
+ pub fn key_seconds(&self, index: usize) -> Option<u32> {
+ self.keys.get(index).map(|key| key.date.seconds_of_day())
+ }
+}
+
+/// The final forty bytes of a sky.ske.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct AtmosphereTrailer {
+ /// Trailer date copied from disk.
+ pub date: SkeDate,
+ /// Two opaque trailer words.
+ pub values: [u32; 2],
+}
+
+/// A timeline record emitted by TypedAtmosphere::timeline and
+/// TypedAtmosphere::events_between.
+#[derive(Clone, Debug, PartialEq)]
+pub struct AtmosphereEvent {
+ /// Track containing the key.
+ pub track_index: usize,
+ /// Key containing the event.
+ pub key_index: usize,
+ /// In-game time of the event in seconds.
+ pub day_seconds: f32,
+ /// Real time from the beginning of the repeating schedule.
+ pub real_seconds: f32,
+ /// Decoded event kind.
+ pub kind: AtmosphereKind,
+ /// Opaque key word.
+ pub raw: u32,
+ /// Non-empty resource references attached to the key.
+ pub resources: Vec<AtmosphereString>,
+}
+
+/// A sky sample with the thirteen color parameters used by the original
+/// CSky sampler.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SkySample {
+ /// Colors zero through eleven: four header colors, then two reordered
+ /// groups of four palette colors.
+ pub colors: [PackedColor; SKY_COLOR_COUNT],
+ /// The two floating point values copied from the key header values.
+ pub values: [f32; 2],
+ /// The two remaining packed color parameters.
+ pub packed: [PackedColor; 2],
+}
+
+/// Birth payload generated by the native atmosphere dispatcher for a named
+/// celestial object.
+///
+/// The dispatcher does not serialize this payload in `sky.ske`. For a kind
+/// zero key it chooses the angle pair from the fixed string in slot zero and
+/// computes the lifetime from the following matching kind one key. Keeping
+/// the generated values beside the sample lets the renderer reproduce the
+/// native object without inventing a start time on its first active frame.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SunBirth {
+ /// Absolute real schedule time at which this interval began.
+ pub start_seconds: f32,
+ /// Real schedule duration until the matching stop key.
+ pub lifetime_seconds: f32,
+ /// Native birth angles in degrees.
+ pub initial_angles_degrees: [f32; 2],
+ /// WEA material row selected by the dispatcher.
+ pub material_row: usize,
+}
+
+/// A sampled sun parameter set. The same payload shape is used for the moon
+/// because the original dispatcher creates both from the same object class.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SunSample {
+ /// Whether the latest SunStart/SunStop lifecycle interval is active.
+ pub active: bool,
+ /// RGB color multiplied by the third trailing key parameter.
+ pub rgb: [f32; 3],
+ /// First two trailing key parameters, kept with neutral names.
+ pub values: [f32; 2],
+ /// Two packed parameters consumed by the original sun object.
+ pub packed: [PackedColor; 2],
+ /// The third trailing parameter used for RGB intensity.
+ pub intensity: f32,
+ /// Generated birth payload for the current named interval, if active.
+ pub birth: Option<SunBirth>,
+}
+
+/// A sampled moon parameter set with the native sun-object layout.
+pub type MoonSample = SunSample;
+
+/// A sampled precipitation or lightning state.
+#[derive(Clone, Debug, PartialEq)]
+pub struct WeatherSample {
+ /// Whether the corresponding start/stop state is active.
+ pub active: bool,
+ /// Interpolated fourth trailing key parameter.
+ pub intensity: f32,
+ /// Effective color. Active weather follows the original 80/255 RGB floor
+ /// and 150/255 alpha used by the particle objects.
+ pub color: [f32; 4],
+ /// Interpolated source color before the particle floor is applied.
+ pub raw_color: [f32; 4],
+ /// Resource references for the currently active interval.
+ pub resources: Vec<AtmosphereString>,
+}
+
+/// A complete atmosphere sample.
+#[derive(Clone, Debug, PartialEq)]
+pub struct AtmosphereFrame {
+ /// Requested real schedule time after finite-value sanitization.
+ pub real_seconds: f32,
+ /// Selected track.
+ pub track_index: usize,
+ /// Real seconds within the selected track.
+ pub track_seconds: f32,
+ /// In-game seconds within the selected day.
+ pub day_seconds: f32,
+ /// Interpolated sky parameters.
+ pub sky: SkySample,
+ /// Interpolated sun parameters.
+ pub sun: SunSample,
+ /// Interpolated moon parameters and its independent lifecycle.
+ pub moon: MoonSample,
+ /// Rain state.
+ pub rain: WeatherSample,
+ /// Snow state.
+ pub snow: WeatherSample,
+ /// Lightning state.
+ pub lightning: WeatherSample,
+}
+
+/// A parsed, repeating version-five atmosphere schedule.
+#[derive(Clone, Debug, PartialEq)]
+pub struct TypedAtmosphere {
+ /// Header marker.
+ pub marker: i32,
+ /// Header version.
+ pub version: u32,
+ /// Tracks in file order.
+ pub tracks: Vec<AtmosphereTrack>,
+ /// Trailer copied from disk.
+ pub trailer: AtmosphereTrailer,
+}
+
+impl TypedAtmosphere {
+ /// Parses a complete version-five sky.ske byte slice.
+ ///
+ /// The parser consumes every byte, bounds all declared counts and strings,
+ /// checks every floating point field, and preserves unknown kinds and
+ /// payload words.
+ ///
+ /// Returns an error for unsupported versions, malformed dates,
+ /// non-finite values, truncation, or trailing bytes.
+ pub fn parse(bytes: &[u8]) -> Result<Self, AtmosphereError> {
+ let mut cursor = Cursor::new(bytes);
+ let marker = cursor.read_i32_le()?;
+ let version = cursor.read_u32_le()?;
+ let track_count = cursor.read_u32_le()?;
+ if marker != SKE_HEADER_MARKER || version != SKE_VERSION {
+ return Err(AtmosphereError::InvalidHeader { marker, version });
+ }
+ checked_count(track_count, MAX_TRACKS)?;
+
+ let mut tracks = Vec::with_capacity(track_count as usize);
+ for track_index in 0..track_count as usize {
+ let track_version = cursor.read_u32_le()?;
+ if track_version != 1 {
+ return Err(AtmosphereError::InvalidTrackVersion {
+ track_index,
+ version: track_version,
+ });
+ }
+ let key_count = cursor.read_u32_le()?;
+ checked_count(key_count, MAX_KEYS)?;
+ let start = read_date(&mut cursor, "track start")?;
+ let end = read_date(&mut cursor, "track end")?;
+ let duration_seconds = end.real_duration_marker();
+ if duration_seconds == 0 {
+ return Err(AtmosphereError::InvalidDuration { track_index });
+ }
+ if key_count == 0 {
+ return Err(AtmosphereError::EmptyTrack { track_index });
+ }
+
+ let mut keys = Vec::with_capacity(key_count as usize);
+ let mut previous_seconds = None;
+ for key_index in 0..key_count as usize {
+ let key = read_key(&mut cursor, track_index, key_index)?;
+ let current_seconds = key.date.hour_minute_seconds();
+ if let Some(previous_seconds) = previous_seconds {
+ if current_seconds < previous_seconds {
+ return Err(AtmosphereError::NonMonotonicKey {
+ track_index,
+ key_index,
+ previous_seconds,
+ current_seconds,
+ });
+ }
+ }
+ previous_seconds = Some(current_seconds);
+ keys.push(key);
+ }
+ tracks.push(AtmosphereTrack {
+ version: track_version,
+ start,
+ end,
+ keys,
+ duration_seconds,
+ });
+ }
+
+ let trailer = AtmosphereTrailer {
+ date: read_date(&mut cursor, "trailer")?,
+ values: [cursor.read_u32_le()?, cursor.read_u32_le()?],
+ };
+ cursor.require_eof()?;
+ Ok(Self {
+ marker,
+ version,
+ tracks,
+ trailer,
+ })
+ }
+
+ /// Returns the duration of one complete repeating schedule.
+ #[must_use]
+ pub fn total_duration_seconds(&self) -> f32 {
+ self.tracks
+ .iter()
+ .map(|track| track.duration_seconds as f32)
+ .sum()
+ }
+
+ /// Returns the integer real-time offset selected by the trailer for the
+ /// initial atmosphere clock. The trailer's first word selects a track;
+ /// its hour/minute words are scaled by that track's duration and floored
+ /// exactly as in the original constructor.
+ #[must_use]
+ pub fn initial_offset_seconds(&self) -> u32 {
+ let track_index = usize::try_from(self.trailer.date.words[0]).ok();
+ let Some(track) = track_index.and_then(|index| self.tracks.get(index)) else {
+ return 0;
+ };
+ let scaled = u64::from(track.duration_seconds)
+ .saturating_mul(u64::from(self.trailer.date.hour_minute_seconds()))
+ / DAY_SECONDS_U64;
+ let track_offset = track_index
+ .map(|index| self.track_offset_seconds(index))
+ .unwrap_or(0);
+ u32::try_from(track_offset.saturating_add(scaled)).unwrap_or(u32::MAX)
+ }
+
+ /// Samples at real seconds from the beginning of the repeating schedule.
+ ///
+ /// Each track occupies its original real duration. Its in-game day is
+ /// then traversed from 00:00 through 24:00. Non-finite input is treated
+ /// as zero so a renderer cannot poison a frame with NaN coordinates.
+ #[must_use]
+ pub fn sample(&self, real_seconds: f32) -> AtmosphereFrame {
+ let real_seconds = if real_seconds.is_finite() {
+ real_seconds
+ } else {
+ 0.0
+ };
+ if self.tracks.is_empty() {
+ return empty_frame(real_seconds);
+ }
+ let total = self.total_duration_seconds();
+ let wrapped = real_seconds.rem_euclid(total);
+ let (track_index, track_seconds) = self.locate_track(wrapped);
+ let track = &self.tracks[track_index];
+ let day_seconds = track_seconds / track.duration_seconds as f32 * DAY_SECONDS;
+ let points = self.schedule_points();
+ self.sample_track(
+ real_seconds,
+ wrapped,
+ real_seconds,
+ track_index,
+ track_seconds,
+ day_seconds,
+ &points,
+ )
+ }
+
+ /// Samples a selected track directly in in-game seconds.
+ ///
+ /// This is useful for deterministic tests and tools that already have the
+ /// original game clock. The day wraps at 24 hours.
+ #[must_use]
+ pub fn sample_day(&self, track_index: usize, day_seconds: f32) -> AtmosphereFrame {
+ if self.tracks.is_empty() {
+ return empty_frame(0.0);
+ }
+ let track_index = track_index.min(self.tracks.len() - 1);
+ let track = &self.tracks[track_index];
+ let day_seconds = if day_seconds.is_finite() {
+ day_seconds.rem_euclid(DAY_SECONDS)
+ } else {
+ 0.0
+ };
+ let track_seconds = day_seconds / DAY_SECONDS * track.duration_seconds as f32;
+ let schedule_seconds = self.track_offset_seconds(track_index) as f32 + track_seconds;
+ let wrapped = schedule_seconds.rem_euclid(self.total_duration_seconds());
+ let points = self.schedule_points();
+ self.sample_track(
+ schedule_seconds,
+ wrapped,
+ schedule_seconds,
+ track_index,
+ track_seconds,
+ day_seconds,
+ &points,
+ )
+ }
+
+ /// Returns every key in file order with real schedule offsets.
+ #[must_use]
+ pub fn timeline(&self) -> Vec<AtmosphereEvent> {
+ self.schedule_points()
+ .into_iter()
+ .map(|point| {
+ event_for_key(
+ point.track_index,
+ point.key_index,
+ point.key,
+ point.real_seconds as f32,
+ )
+ })
+ .collect()
+ }
+
+ /// Returns timeline records crossed by (start, end], including schedule
+ /// wraparound and track boundaries.
+ #[must_use]
+ pub fn events_between(&self, start: f32, end: f32) -> Vec<AtmosphereEvent> {
+ if self.tracks.is_empty() || !start.is_finite() || !end.is_finite() || end <= start {
+ return Vec::new();
+ }
+ let total = self.total_duration_seconds();
+ if total <= 0.0 || !total.is_finite() {
+ return Vec::new();
+ }
+ let base = self.timeline();
+ if base.is_empty() {
+ return Vec::new();
+ }
+ let first_cycle = (start / total).floor() as i64 - 1;
+ let last_cycle = (end / total).ceil() as i64 + 1;
+ let mut out = Vec::new();
+ for cycle in first_cycle..=last_cycle {
+ let offset = cycle as f32 * total;
+ for event in &base {
+ let event_time = event.real_seconds + offset;
+ if event_time > start && event_time <= end {
+ let mut event = event.clone();
+ event.real_seconds = event_time;
+ out.push(event);
+ }
+ }
+ }
+ out.sort_by(|a, b| {
+ a.real_seconds
+ .partial_cmp(&b.real_seconds)
+ .unwrap_or(std::cmp::Ordering::Equal)
+ });
+ out
+ }
+
+ fn locate_track(&self, wrapped_seconds: f32) -> (usize, f32) {
+ let mut offset = 0.0;
+ for (index, track) in self.tracks.iter().enumerate() {
+ let duration = track.duration_seconds as f32;
+ if wrapped_seconds < offset + duration || index + 1 == self.tracks.len() {
+ return (index, (wrapped_seconds - offset).clamp(0.0, duration));
+ }
+ offset += duration;
+ }
+ (self.tracks.len() - 1, 0.0)
+ }
+
+ fn track_offset_seconds(&self, track_index: usize) -> u64 {
+ self.tracks
+ .iter()
+ .take(track_index)
+ .map(|track| u64::from(track.duration_seconds))
+ .sum()
+ }
+
+ fn schedule_points(&self) -> Vec<SchedulePoint<'_>> {
+ let mut points = Vec::new();
+ let mut track_offset = 0_u64;
+ for (track_index, track) in self.tracks.iter().enumerate() {
+ for (key_index, key) in track.keys.iter().enumerate() {
+ let key_offset = u64::from(track.duration_seconds)
+ .saturating_mul(u64::from(key.date.hour_minute_seconds()))
+ / DAY_SECONDS_U64;
+ points.push(SchedulePoint {
+ track_index,
+ key_index,
+ key,
+ real_seconds: track_offset.saturating_add(key_offset),
+ });
+ }
+ track_offset = track_offset.saturating_add(u64::from(track.duration_seconds));
+ }
+ points
+ }
+
+ fn sample_track(
+ &self,
+ real_seconds: f32,
+ sample_seconds: f32,
+ birth_seconds: f32,
+ track_index: usize,
+ track_seconds: f32,
+ day_seconds: f32,
+ points: &[SchedulePoint<'_>],
+ ) -> AtmosphereFrame {
+ let interpolation =
+ interpolate_schedule(points, sample_seconds, self.total_duration_seconds());
+ let sky = sky_sample(&interpolation);
+ let total_seconds = self.total_duration_seconds();
+ let sun_birth = celestial_birth(points, birth_seconds, total_seconds, b"sun");
+ let moon_birth = celestial_birth(points, birth_seconds, total_seconds, b"moon");
+ let sun = sun_sample(&interpolation, sun_birth);
+ let moon = sun_sample(&interpolation, moon_birth);
+ let rain = weather_sample(
+ points,
+ sample_seconds,
+ self.total_duration_seconds(),
+ &[AtmosphereKind::RainStart, AtmosphereKind::RainStop],
+ );
+ let snow = weather_sample(
+ points,
+ sample_seconds,
+ self.total_duration_seconds(),
+ &[AtmosphereKind::SnowStart, AtmosphereKind::SnowStop],
+ );
+ let lightning = weather_sample(
+ points,
+ sample_seconds,
+ self.total_duration_seconds(),
+ &[
+ AtmosphereKind::LightningStart,
+ AtmosphereKind::LightningStop,
+ ],
+ );
+ AtmosphereFrame {
+ real_seconds,
+ track_index,
+ track_seconds,
+ day_seconds,
+ sky,
+ sun,
+ moon,
+ rain,
+ snow,
+ lightning,
+ }
+ }
+}
+
+const DAY_SECONDS_U64: u64 = 86_400;
+
+#[derive(Clone, Copy, Debug)]
+struct SchedulePoint<'a> {
+ track_index: usize,
+ key_index: usize,
+ key: &'a AtmosphereKey,
+ real_seconds: u64,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct InterpolatedKey {
+ header_colors: [PackedColor; 4],
+ header_values: [f32; 2],
+ colors: [PackedColor; KEY_COLOR_COUNT],
+ parameters: [f32; 4],
+}
+
+fn read_date(cursor: &mut Cursor<'_>, context: &'static str) -> Result<SkeDate, AtmosphereError> {
+ let mut words = [0_u32; DATE_WORD_COUNT];
+ for word in &mut words {
+ *word = cursor.read_u32_le()?;
+ }
+ let date = SkeDate::from_words(words);
+ if date.hours() > 24
+ || date.minutes() > 59
+ || date.seconds() > 59
+ || (date.hours() == 24 && (date.minutes() != 0 || date.seconds() != 0))
+ {
+ return Err(AtmosphereError::InvalidDate {
+ context,
+ hours: date.hours(),
+ minutes: date.minutes(),
+ seconds: date.seconds(),
+ });
+ }
+ Ok(date)
+}
+
+fn read_key(
+ cursor: &mut Cursor<'_>,
+ track_index: usize,
+ key_index: usize,
+) -> Result<AtmosphereKey, AtmosphereError> {
+ let version = cursor.read_u32_le()?;
+ if version != 3 {
+ return Err(AtmosphereError::InvalidKeyVersion {
+ track_index,
+ key_index,
+ version,
+ });
+ }
+ let date = read_date(cursor, "key")?;
+ let kind_raw = cursor.read_u32_le()?;
+ let raw = cursor.read_u32_le()?;
+ let mut header_colors = [PackedColor::default(); 4];
+ for color in &mut header_colors {
+ *color = PackedColor(cursor.read_u32_le()?);
+ }
+ let header_values = [cursor.read_f32_le()?, cursor.read_f32_le()?];
+ let mut colors = [PackedColor::default(); KEY_COLOR_COUNT];
+ for color in &mut colors {
+ *color = PackedColor(cursor.read_u32_le()?);
+ }
+ let mut strings = Vec::with_capacity(KEY_STRING_COUNT);
+ for _ in 0..KEY_STRING_COUNT {
+ strings.push(read_string(cursor)?);
+ }
+ let parameters = [
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ ];
+ for (index, value) in header_values.iter().enumerate() {
+ if !value.is_finite() {
+ return Err(AtmosphereError::NonFiniteValue {
+ track_index,
+ key_index,
+ field: "header_values",
+ index,
+ });
+ }
+ }
+ for (index, value) in parameters.iter().enumerate() {
+ if !value.is_finite() {
+ return Err(AtmosphereError::NonFiniteValue {
+ track_index,
+ key_index,
+ field: "parameters",
+ index,
+ });
+ }
+ }
+ let reference_count = cursor.read_u32_le()?;
+ checked_count(reference_count, MAX_REFERENCES)?;
+ let mut references = Vec::with_capacity(reference_count as usize);
+ for _ in 0..reference_count {
+ references.push(read_string(cursor)?);
+ }
+ Ok(AtmosphereKey {
+ version,
+ date,
+ kind: AtmosphereKind::from_raw(kind_raw),
+ kind_raw,
+ raw,
+ header_colors,
+ header_values,
+ colors,
+ strings,
+ parameters,
+ references,
+ })
+}
+
+fn read_string(cursor: &mut Cursor<'_>) -> Result<AtmosphereString, AtmosphereError> {
+ let raw = read_lp_bytes(cursor, MAX_STRING_BYTES)?;
+ let text = String::from_utf8_lossy(&raw).into_owned();
+ Ok(AtmosphereString { raw, text })
+}
+
+fn checked_count(count: u32, limit: u32) -> Result<(), AtmosphereError> {
+ if count > limit {
+ return Err(AtmosphereError::Decode(DecodeError::LimitExceeded {
+ count: u64::from(count),
+ limit: u64::from(limit),
+ }));
+ }
+ Ok(())
+}
+
+fn lerp(a: f32, b: f32, factor: f32) -> f32 {
+ a + (b - a) * factor.clamp(0.0, 1.0)
+}
+
+fn interpolate_schedule(
+ points: &[SchedulePoint<'_>],
+ schedule_seconds: f32,
+ total_seconds: f32,
+) -> InterpolatedKey {
+ let (before, after, factor) = surrounding_points(points, schedule_seconds, total_seconds);
+ InterpolatedKey {
+ header_colors: std::array::from_fn(|index| {
+ before.key.header_colors[index].lerp(after.key.header_colors[index], factor)
+ }),
+ header_values: std::array::from_fn(|index| {
+ lerp(
+ before.key.header_values[index],
+ after.key.header_values[index],
+ factor,
+ )
+ }),
+ colors: std::array::from_fn(|index| {
+ before.key.colors[index].lerp(after.key.colors[index], factor)
+ }),
+ parameters: std::array::from_fn(|index| {
+ lerp(
+ before.key.parameters[index],
+ after.key.parameters[index],
+ factor,
+ )
+ }),
+ }
+}
+
+fn surrounding_points<'a>(
+ points: &'a [SchedulePoint<'a>],
+ schedule_seconds: f32,
+ total_seconds: f32,
+) -> (&'a SchedulePoint<'a>, &'a SchedulePoint<'a>, f32) {
+ debug_assert!(!points.is_empty());
+ if points.len() == 1 {
+ return (&points[0], &points[0], 0.0);
+ }
+ let sample_seconds = schedule_seconds.rem_euclid(total_seconds);
+
+ // The native previous-key helper is called with current time + 1 and a
+ // strict comparison, which makes an integer key at the current time the
+ // previous key. `<=` expresses that behavior without a frame epsilon.
+ let mut previous_index = None;
+ let mut next_index = None;
+ for (index, point) in points.iter().enumerate() {
+ let point_seconds = point.real_seconds as f32;
+ if point_seconds <= sample_seconds {
+ previous_index = Some(index);
+ }
+ if next_index.is_none() && point_seconds >= sample_seconds {
+ // Prefer the last key when several track boundaries share the
+ // same real timestamp. This selects the newly entered track.
+ let mut end = index;
+ while end + 1 < points.len() && points[end + 1].real_seconds == point.real_seconds {
+ end += 1;
+ }
+ next_index = Some(end);
+ }
+ }
+
+ let previous_index = previous_index.unwrap_or(points.len() - 1);
+ let next_index = next_index.unwrap_or(0);
+ let previous_wrap = if points[previous_index].real_seconds as f32 > sample_seconds {
+ total_seconds
+ } else {
+ 0.0
+ };
+ let next_wrap = if (points[next_index].real_seconds as f32) < sample_seconds {
+ total_seconds
+ } else {
+ 0.0
+ };
+ let previous_seconds = points[previous_index].real_seconds as f32 - previous_wrap;
+ let next_seconds = points[next_index].real_seconds as f32 + next_wrap;
+ let span = (next_seconds - previous_seconds).max(0.0);
+ let factor = if span > 0.0 {
+ ((sample_seconds - previous_seconds) / span).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+ (&points[previous_index], &points[next_index], factor)
+}
+
+fn sky_sample(key: &InterpolatedKey) -> SkySample {
+ let colors = [
+ key.header_colors[1],
+ key.header_colors[2],
+ key.header_colors[0],
+ key.header_colors[3],
+ key.colors[0],
+ key.colors[3],
+ key.colors[1],
+ key.colors[2],
+ key.colors[4],
+ key.colors[7],
+ key.colors[5],
+ key.colors[6],
+ key.colors[8],
+ ];
+ SkySample {
+ colors,
+ values: key.header_values,
+ packed: [key.colors[11], key.colors[13]],
+ }
+}
+
+fn sun_sample(key: &InterpolatedKey, birth: Option<SunBirth>) -> SunSample {
+ let source = key.colors[12].rgba();
+ let intensity = key.parameters[2];
+ SunSample {
+ active: birth.is_some(),
+ rgb: [
+ source[0] * intensity,
+ source[1] * intensity,
+ source[2] * intensity,
+ ],
+ values: [key.parameters[0], key.parameters[1]],
+ packed: [key.colors[10], key.colors[14]],
+ intensity,
+ birth,
+ }
+}
+
+fn weather_sample(
+ points: &[SchedulePoint<'_>],
+ schedule_seconds: f32,
+ total_seconds: f32,
+ markers: &[AtmosphereKind; 2],
+) -> WeatherSample {
+ let (active, resources) = active_interval(points, schedule_seconds, total_seconds, markers);
+ let interpolation = interpolate_schedule(points, schedule_seconds, total_seconds);
+ let raw_color = interpolation.colors[12].rgba();
+ let intensity = if active {
+ interpolation.parameters[3].max(0.0)
+ } else {
+ 0.0
+ };
+ let color = if active {
+ [
+ raw_color[0].max(COLOR_MIN_COMPONENT),
+ raw_color[1].max(COLOR_MIN_COMPONENT),
+ raw_color[2].max(COLOR_MIN_COMPONENT),
+ WEATHER_ALPHA,
+ ]
+ } else {
+ [0.0; 4]
+ };
+ WeatherSample {
+ active,
+ intensity,
+ color,
+ raw_color,
+ resources,
+ }
+}
+
+fn active_interval(
+ points: &[SchedulePoint<'_>],
+ schedule_seconds: f32,
+ total_seconds: f32,
+ markers: &[AtmosphereKind; 2],
+) -> (bool, Vec<AtmosphereString>) {
+ let sample_seconds = schedule_seconds.rem_euclid(total_seconds);
+ let mut latest = None;
+ for point in points {
+ if point.key.kind != markers[0] && point.key.kind != markers[1] {
+ continue;
+ }
+ if point.real_seconds as f32 <= sample_seconds {
+ latest = Some(point);
+ }
+ }
+ let Some(latest) = latest.or_else(|| {
+ points
+ .iter()
+ .rev()
+ .find(|point| point.key.kind == markers[0] || point.key.kind == markers[1])
+ }) else {
+ return (false, Vec::new());
+ };
+ if latest.key.kind == markers[0] {
+ (
+ true,
+ latest
+ .key
+ .resource_references()
+ .into_iter()
+ .cloned()
+ .collect(),
+ )
+ } else {
+ (false, Vec::new())
+ }
+}
+
+/// Reconstructs the native birth payload for one named celestial object.
+///
+/// The dispatcher compares the first fixed key string with `"sun"`; every
+/// other celestial name uses the moon angle pair. It then scans forward for
+/// the next matching stop event and stores the real-time difference as the
+/// lifetime. Schedule points already contain the native integer floor, so no
+/// fractional day-time conversion is introduced here.
+fn celestial_birth(
+ points: &[SchedulePoint<'_>],
+ schedule_seconds: f32,
+ total_seconds: f32,
+ name: &[u8],
+) -> Option<SunBirth> {
+ if points.is_empty() || !total_seconds.is_finite() || total_seconds <= 0.0 {
+ return None;
+ }
+ let schedule_seconds = if schedule_seconds.is_finite() {
+ schedule_seconds
+ } else {
+ 0.0
+ };
+ let cycle = (schedule_seconds / total_seconds).floor();
+ let sample_absolute = cycle * total_seconds + schedule_seconds.rem_euclid(total_seconds);
+
+ let mut latest: Option<(f32, usize, AtmosphereKind)> = None;
+ for (index, point) in points.iter().enumerate() {
+ if !matches!(
+ point.key.kind,
+ AtmosphereKind::SunStart | AtmosphereKind::SunStop
+ ) || point
+ .key
+ .strings
+ .first()
+ .map(|string| string.raw.as_slice())
+ != Some(name)
+ {
+ continue;
+ }
+ let base = point.real_seconds as f32;
+ // Only the surrounding cycles can contain the latest event. Keeping
+ // all three also handles sample_day values at either schedule edge.
+ for cycle_delta in -1..=1 {
+ let time = base + (cycle + cycle_delta as f32) * total_seconds;
+ if time > sample_absolute {
+ continue;
+ }
+ let replace = latest
+ .map(|(old_time, old_index, _)| {
+ time > old_time || (time == old_time && index >= old_index)
+ })
+ .unwrap_or(true);
+ if replace {
+ latest = Some((time, index, point.key.kind));
+ }
+ }
+ }
+ let (start_seconds, start_index, kind) = latest?;
+ if kind != AtmosphereKind::SunStart {
+ return None;
+ }
+
+ let mut stop_seconds = None;
+ for point in points {
+ if point.key.kind != AtmosphereKind::SunStop
+ || point
+ .key
+ .strings
+ .first()
+ .map(|string| string.raw.as_slice())
+ != Some(name)
+ {
+ continue;
+ }
+ let base = point.real_seconds as f32;
+ for cycle_delta in -1..=2 {
+ let time = base + (cycle + cycle_delta as f32) * total_seconds;
+ if time <= start_seconds {
+ continue;
+ }
+ if stop_seconds.map(|old| time < old).unwrap_or(true) {
+ stop_seconds = Some(time);
+ }
+ }
+ }
+ let lifetime_seconds = stop_seconds
+ .map(|stop| stop - start_seconds)
+ .filter(|lifetime| lifetime.is_finite() && *lifetime > 0.0)?;
+ let (initial_angles_degrees, material_row) = if name == b"sun" {
+ ([90.0, 30.0], 3)
+ } else {
+ ([0.0, 50.0], 4)
+ };
+ let _ = start_index;
+ Some(SunBirth {
+ start_seconds,
+ lifetime_seconds,
+ initial_angles_degrees,
+ material_row,
+ })
+}
+
+fn event_for_key(
+ track_index: usize,
+ key_index: usize,
+ key: &AtmosphereKey,
+ real_seconds: f32,
+) -> AtmosphereEvent {
+ AtmosphereEvent {
+ track_index,
+ key_index,
+ day_seconds: key.date.hour_minute_seconds() as f32,
+ real_seconds,
+ kind: key.kind,
+ raw: key.raw,
+ resources: key.resource_references().into_iter().cloned().collect(),
+ }
+}
+
+fn empty_frame(real_seconds: f32) -> AtmosphereFrame {
+ AtmosphereFrame {
+ real_seconds,
+ track_index: 0,
+ track_seconds: 0.0,
+ day_seconds: 0.0,
+ sky: SkySample {
+ colors: [PackedColor::default(); SKY_COLOR_COUNT],
+ values: [0.0; 2],
+ packed: [PackedColor::default(); 2],
+ },
+ sun: SunSample {
+ active: false,
+ rgb: [0.0; 3],
+ values: [0.0; 2],
+ packed: [PackedColor::default(); 2],
+ intensity: 0.0,
+ birth: None,
+ },
+ moon: SunSample {
+ active: false,
+ rgb: [0.0; 3],
+ values: [0.0; 2],
+ packed: [PackedColor::default(); 2],
+ intensity: 0.0,
+ birth: None,
+ },
+ rain: WeatherSample {
+ active: false,
+ intensity: 0.0,
+ color: [0.0; 4],
+ raw_color: [0.0; 4],
+ resources: Vec::new(),
+ },
+ snow: WeatherSample {
+ active: false,
+ intensity: 0.0,
+ color: [0.0; 4],
+ raw_color: [0.0; 4],
+ resources: Vec::new(),
+ },
+ lightning: WeatherSample {
+ active: false,
+ intensity: 0.0,
+ color: [0.0; 4],
+ raw_color: [0.0; 4],
+ resources: Vec::new(),
+ },
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::path::{Path, PathBuf};
+
+ fn date(hours: u32, minutes: u32, seconds: u32) -> [u32; DATE_WORD_COUNT] {
+ [0, 0, 0, hours, minutes, seconds, 0, 0]
+ }
+
+ fn lp(bytes: &[u8], out: &mut Vec<u8>) {
+ out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
+ out.extend_from_slice(bytes);
+ }
+
+ fn key(
+ out: &mut Vec<u8>,
+ hours: u32,
+ minutes: u32,
+ kind: u32,
+ palette: [u32; KEY_COLOR_COUNT],
+ tail: [f32; 4],
+ references: &[&[u8]],
+ ) {
+ key_with_seconds(out, hours, minutes, 0, kind, palette, tail, references);
+ }
+
+ fn key_with_seconds(
+ out: &mut Vec<u8>,
+ hours: u32,
+ minutes: u32,
+ seconds: u32,
+ kind: u32,
+ palette: [u32; KEY_COLOR_COUNT],
+ tail: [f32; 4],
+ references: &[&[u8]],
+ ) {
+ out.extend_from_slice(&3_u32.to_le_bytes());
+ for word in date(hours, minutes, seconds) {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ out.extend_from_slice(&kind.to_le_bytes());
+ out.extend_from_slice(&0xAABB_CCDD_u32.to_le_bytes());
+ for value in [0xFF11_2233_u32, 0xFF44_5566, 0xFF77_8899, 0xFFAA_BBCC] {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+ for value in [0.25_f32, 0.75_f32] {
+ out.extend_from_slice(&value.to_bits().to_le_bytes());
+ }
+ for value in palette {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+ for _ in 0..KEY_STRING_COUNT {
+ lp(b"", out);
+ }
+ for value in tail {
+ out.extend_from_slice(&value.to_bits().to_le_bytes());
+ }
+ out.extend_from_slice(&(references.len() as u32).to_le_bytes());
+ for reference in references {
+ lp(reference, out);
+ }
+ }
+
+ fn document(key_count: u32, keys: impl FnOnce(&mut Vec<u8>)) -> Vec<u8> {
+ let mut out = Vec::new();
+ out.extend_from_slice(&SKE_HEADER_MARKER.to_le_bytes());
+ out.extend_from_slice(&SKE_VERSION.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(&key_count.to_le_bytes());
+ for word in date(23, 59, 0) {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ for word in date(0, 15, 0) {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ keys(&mut out);
+ for word in date(1, 30, 0) {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ out.extend_from_slice(&0_u32.to_le_bytes());
+ out.extend_from_slice(&1_u32.to_le_bytes());
+ out
+ }
+
+ fn two_track_document(palette: [u32; KEY_COLOR_COUNT]) -> Vec<u8> {
+ let mut out = Vec::new();
+ out.extend_from_slice(&SKE_HEADER_MARKER.to_le_bytes());
+ out.extend_from_slice(&SKE_VERSION.to_le_bytes());
+ out.extend_from_slice(&2_u32.to_le_bytes());
+
+ out.extend_from_slice(&1_u32.to_le_bytes());
+ out.extend_from_slice(&2_u32.to_le_bytes());
+ for word in date(23, 59, 0) {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ for word in date(0, 1, 0) {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ key_with_seconds(&mut out, 23, 0, 0, 7, palette, [0.0, 0.0, 0.0, 0.0], &[]);
+ key_with_seconds(&mut out, 23, 59, 59, 7, palette, [0.0, 0.0, 0.0, 0.0], &[]);
+
+ out.extend_from_slice(&1_u32.to_le_bytes());
+ out.extend_from_slice(&3_u32.to_le_bytes());
+ for word in date(23, 59, 0) {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ for word in date(0, 2, 0) {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ key_with_seconds(
+ &mut out,
+ 0,
+ 0,
+ 0,
+ 3,
+ palette,
+ [0.0, 0.0, 2.0, 1.0],
+ &[b"rain"],
+ );
+ key_with_seconds(
+ &mut out,
+ 6,
+ 0,
+ 0,
+ 7,
+ palette,
+ [0.0, 0.0, 2.0, 1.0],
+ &[b"future"],
+ );
+ key_with_seconds(
+ &mut out,
+ 12,
+ 0,
+ 0,
+ 4,
+ palette,
+ [0.0, 0.0, 0.0, 0.0],
+ &[b"stop"],
+ );
+
+ for word in [1_u32, 0, 0, 12, 0, 0, 0, 0] {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ out.extend_from_slice(&0_u32.to_le_bytes());
+ out.extend_from_slice(&1_u32.to_le_bytes());
+ out
+ }
+
+ #[test]
+ fn parses_exact_v5_layout_and_rejects_trailing_bytes() {
+ let palette = [0xFF00_0000_u32; KEY_COLOR_COUNT];
+ let bytes = document(1, |out| {
+ key(out, 0, 0, 3, palette, [0.0, 0.0, 0.0, 1.0], &[b"rain"])
+ });
+ let parsed = TypedAtmosphere::parse(&bytes).expect("v5 parse");
+ assert_eq!(parsed.marker, -1);
+ assert_eq!(parsed.version, 5);
+ assert_eq!(parsed.tracks.len(), 1);
+ assert_eq!(parsed.tracks[0].duration_seconds, 900);
+ assert_eq!(parsed.tracks[0].keys[0].references[0].as_str(), "rain");
+ assert_eq!(parsed.trailer.date.hours(), 1);
+ assert!(matches!(
+ TypedAtmosphere::parse(&[bytes, vec![0]].concat()),
+ Err(AtmosphereError::Decode(DecodeError::TrailingBytes { .. }))
+ ));
+ }
+
+ #[test]
+ fn maps_original_sky_color_order_and_interpolates() {
+ let first_palette = std::array::from_fn(|i| 0xFF00_0000_u32 | i as u32);
+ let second_palette = std::array::from_fn(|i| 0xFF00_1000_u32 | (i as u32) << 4);
+ let bytes = document(2, |out| {
+ key(out, 0, 0, 7, first_palette, [0.0, 0.0, 0.0, 0.0], &[]);
+ key(out, 12, 0, 7, second_palette, [1.0, 2.0, 3.0, 1.0], &[]);
+ });
+ let parsed = TypedAtmosphere::parse(&bytes).expect("parse");
+ let start = parsed.sample_day(0, 0.0);
+ assert_eq!(start.sky.colors[0].raw(), 0xFF44_5566);
+ assert_eq!(start.sky.colors[1].raw(), 0xFF77_8899);
+ assert_eq!(start.sky.colors[2].raw(), 0xFF11_2233);
+ assert_eq!(start.sky.colors[4].raw(), first_palette[0]);
+ assert_eq!(start.sky.colors[5].raw(), first_palette[3]);
+ assert_eq!(start.sky.colors[8].raw(), first_palette[4]);
+ assert_eq!(start.sky.colors[12].raw(), first_palette[8]);
+ assert_eq!(start.sun.intensity, 0.0);
+ let middle = parsed.sample_day(0, 6.0 * 3_600.0);
+ assert!((middle.sun.intensity - 1.5).abs() < 0.01);
+ assert_eq!(middle.rain.intensity, 0.0);
+ }
+
+ #[test]
+ fn wraps_last_key_to_first_and_emits_events() {
+ let palette = [0xFF00_0000_u32; KEY_COLOR_COUNT];
+ let bytes = document(2, |out| {
+ key(out, 6, 0, 3, palette, [0.0, 0.0, 0.0, 0.0], &[b"rain"]);
+ key(out, 12, 0, 4, palette, [1.0, 1.0, 1.0, 0.0], &[]);
+ });
+ let parsed = TypedAtmosphere::parse(&bytes).expect("parse");
+ let near_midnight = parsed.sample_day(0, 23.0 * 3_600.0);
+ assert!(!near_midnight.rain.active);
+ let events = parsed.events_between(0.0, 300.0);
+ assert_eq!(events.len(), 1);
+ assert_eq!(events[0].kind, AtmosphereKind::RainStart);
+ let wrapped = parsed.events_between(900.0, 1_300.0);
+ assert_eq!(wrapped.len(), 1);
+ assert_eq!(wrapped[0].kind, AtmosphereKind::RainStart);
+ }
+
+ #[test]
+ fn samples_across_tracks_uses_integer_key_times_and_trailer_offset() {
+ let palette = [0xFF00_0000_u32; KEY_COLOR_COUNT];
+ let parsed = TypedAtmosphere::parse(&two_track_document(palette)).expect("parse");
+
+ assert_eq!(parsed.tracks[0].duration_seconds, 60);
+ assert_eq!(parsed.tracks[1].duration_seconds, 120);
+ assert_eq!(parsed.initial_offset_seconds(), 120);
+ assert_eq!(parsed.timeline()[1].real_seconds, 59.0);
+
+ let before_transition = parsed.sample(59.5);
+ assert_eq!(before_transition.track_index, 0);
+ assert!((before_transition.sun.intensity - 1.0).abs() < 0.01);
+
+ let active = parsed.sample_day(1, 6.0 * 3_600.0);
+ assert_eq!(active.real_seconds, 90.0);
+ assert!(active.rain.active);
+ assert_eq!(active.rain.resources.len(), 1);
+ assert_eq!(active.rain.resources[0].as_str(), "rain");
+ assert!(parsed
+ .events_between(59.0, 61.0)
+ .iter()
+ .any(|event| event.kind == AtmosphereKind::RainStart));
+ }
+
+ #[test]
+ fn repeating_schedule_shifts_celestial_birth_with_absolute_clock() {
+ let palette = [0xFF00_0000_u32; KEY_COLOR_COUNT];
+ let bytes = document(2, |out| {
+ key(out, 0, 0, 0, palette, [0.0, 0.0, 1.0, 0.0], &[]);
+ key(out, 12, 0, 1, palette, [0.0, 0.0, 1.0, 0.0], &[]);
+ });
+ let mut parsed = TypedAtmosphere::parse(&bytes).expect("parse");
+ for (key, name) in parsed.tracks[0].keys.iter_mut().zip(["sun", "sun"]) {
+ key.strings[0] = AtmosphereString {
+ raw: name.as_bytes().to_vec(),
+ text: name.to_owned(),
+ };
+ }
+
+ let total = parsed.total_duration_seconds();
+ let first = parsed.sample(300.0);
+ let later = parsed.sample(300.0 + total);
+ let first_birth = first.sun.birth.expect("sun birth");
+ let later_birth = later.sun.birth.expect("wrapped sun birth");
+ assert!((later_birth.start_seconds - first_birth.start_seconds - total).abs() < 1.0e-5);
+ assert_eq!(first_birth.lifetime_seconds, later_birth.lifetime_seconds);
+ assert_eq!(
+ first_birth.initial_angles_degrees,
+ later_birth.initial_angles_degrees
+ );
+ assert_eq!(first_birth.material_row, later_birth.material_row);
+
+ let first_frame = crate::sky::sun_frame(
+ &first.sun,
+ 300.0,
+ crate::sky::SunConfig::from_birth(first_birth),
+ );
+ let later_frame = crate::sky::sun_frame(
+ &later.sun,
+ 300.0 + total,
+ crate::sky::SunConfig::from_birth(later_birth),
+ );
+ assert!((first_frame.age - later_frame.age).abs() < 1.0e-5);
+ for (first, later) in first_frame.direction.iter().zip(later_frame.direction) {
+ assert!((*first - later).abs() < 1.0e-5);
+ }
+ }
+
+ #[test]
+ fn rejects_non_finite_key_values() {
+ let palette = [0xFF00_0000_u32; KEY_COLOR_COUNT];
+ let mut bytes = document(1, |out| {
+ key(out, 0, 0, 7, palette, [f32::NAN, 0.0, 0.0, 0.0], &[])
+ });
+ assert!(matches!(
+ TypedAtmosphere::parse(&bytes),
+ Err(AtmosphereError::NonFiniteValue {
+ field: "parameters",
+ index: 0,
+ ..
+ })
+ ));
+ bytes[0] = 0;
+ assert!(matches!(
+ TypedAtmosphere::parse(&bytes),
+ Err(AtmosphereError::InvalidHeader { .. })
+ ));
+ }
+
+ #[test]
+ #[ignore = "requires the original GOG installation"]
+ fn parses_all_original_gog_sky_files() {
+ let root = std::env::var_os("PARKAN_GOG_ROOT")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy"));
+ let mission_root = root.join("MISSIONS");
+ let mut files = Vec::new();
+ collect_ske(&mission_root, &mut files);
+ files.sort();
+ assert_eq!(files.len(), 29);
+ for path in files {
+ let bytes = std::fs::read(&path).expect("read sky.ske");
+ let parsed = TypedAtmosphere::parse(&bytes)
+ .unwrap_or_else(|error| panic!("{}: {error}", path.display()));
+ assert!(parsed.total_duration_seconds().is_finite());
+ assert!(!parsed.timeline().is_empty(), "{}", path.display());
+ let materials = std::fs::read(path.with_file_name("sky.wea"))
+ .ok()
+ .and_then(|bytes| crate::sky::SkyMaterials::parse(&bytes).ok())
+ .unwrap_or_default();
+ let mut sky = crate::sky::SkySystem::new(Default::default(), materials)
+ .unwrap_or_else(|error| panic!("{}: {error}", path.display()));
+ for second in [0.0, 0.5, 900.0, 86_399.0, 172_800.0] {
+ let frame = parsed.sample(second);
+ assert!(frame.sky.values.iter().all(|value| value.is_finite()));
+ assert!(frame.sun.rgb.iter().all(|value| value.is_finite()));
+ assert!(frame.rain.intensity.is_finite());
+ assert!(frame.snow.intensity.is_finite());
+ assert!(frame.lightning.intensity.is_finite());
+ let sky_frame = sky
+ .update(&frame, second)
+ .unwrap_or_else(|error| panic!("{} at {second}: {error}", path.display()));
+ assert_eq!(sky_frame.directional_lights.len(), 4);
+ let optics = sky_frame
+ .sun_optics(crate::sky::SunVisibility {
+ projected_sun: [640.0, 360.0],
+ viewport: [1280.0, 720.0],
+ view_forward: [0.0, 0.0, 1.0],
+ unoccluded: true,
+ })
+ .expect("native sky optics");
+ assert_eq!(optics.flares.len(), 12);
+ }
+ }
+ }
+
+ fn collect_ske(root: &Path, out: &mut Vec<PathBuf>) {
+ let Ok(entries) = std::fs::read_dir(root) else {
+ return;
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ collect_ske(&path, out);
+ } else if path.file_name().is_some_and(|name| name == "sky.ske") {
+ out.push(path);
+ }
+ }
+ }
+}
diff --git a/crates/fparkan-fx/src/environment.rs b/crates/fparkan-fx/src/environment.rs
new file mode 100644
index 0000000..babbd0f
--- /dev/null
+++ b/crates/fparkan-fx/src/environment.rs
@@ -0,0 +1,2803 @@
+//! CPU-side precipitation and lightning output.
+//!
+//! The original game keeps atmospheric objects close to the observer. This
+//! module follows that ownership boundary: it advances precipitation particles
+//! and native lightning effect lifetimes and returns renderer/audio records
+//! for the current frame. It does not invent a scene graph or hide resource
+//! loading behind the simulation.
+
+use crate::sky::SkyMaterials;
+use crate::{FxDocument, FxError, FxOpcode};
+use std::sync::Arc;
+
+// Terrain.dll writes these velocity vectors into the shared precipitation
+// particle state on every update (CSnow at 10045E27 and CRain at 10046555).
+// Keep the values together so a renderer cannot accidentally substitute the
+// old placeholder speeds.
+pub const RAIN_VELOCITY: [f32; 3] = [0.5, 0.0, -60.0];
+pub const SNOW_VELOCITY: [f32; 3] = [0.5, 0.0, -4.0];
+
+/// Near plane used by Terrain's precipitation volume constructor at
+/// `100444C0`.
+pub const PRECIPITATION_NEAR: f32 = 2.0;
+/// Far plane used by Terrain's precipitation volume constructor at
+/// `100444C0`.
+pub const PRECIPITATION_FAR: f32 = 50.0;
+/// Half horizontal angle used by Terrain's precipitation volume constructor.
+pub const PRECIPITATION_HALF_ANGLE_X: f32 = 1.3 * 0.5;
+/// Half vertical angle used by Terrain's precipitation volume constructor.
+pub const PRECIPITATION_HALF_ANGLE_Y: f32 = 0.975 * 0.5;
+/// Native rain size factor (`Terrain.dll!65E30`).
+pub const RAIN_SIZE_FACTOR: f32 = 0.0065;
+/// Native snow size factor (`Terrain.dll!65DE8`).
+pub const SNOW_SIZE_FACTOR: f32 = 0.0195;
+/// Native snow minimum depth fade (`Terrain.dll!65D98`).
+pub const SNOW_MIN_DEPTH_FADE: f32 = 0.1;
+/// Native precipitation particle-count multiplier (`Terrain.dll!65DE4`).
+pub const PARTICLE_COUNT_SCALE: f32 = 1_000.0;
+
+/// Native precipitation spawn volume. The fields mirror the descriptor
+/// built by Terrain at `100444C0`; particles are sampled from this box by
+/// `100445B0` using three 16-bit random values divided by 32768.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct PrecipitationVolume {
+ /// Local-space lower corner `[near, -tan(angle_x)*far, -tan(angle_y)*far]`.
+ pub origin: [f32; 3],
+ /// Local-space extent `[far-near, 2*tan(angle_x)*far, 2*tan(angle_y)*far]`.
+ pub extent: [f32; 3],
+ /// Reciprocals stored by the native descriptor for containment tests.
+ pub reciprocal_extent: [f32; 3],
+}
+
+impl PrecipitationVolume {
+ /// Reconstructs the descriptor constants used by Terrain.
+ #[must_use]
+ pub fn native() -> Self {
+ let width = PRECIPITATION_HALF_ANGLE_X.tan() * PRECIPITATION_FAR;
+ let height = PRECIPITATION_HALF_ANGLE_Y.tan() * PRECIPITATION_FAR;
+ let extent = [
+ PRECIPITATION_FAR - PRECIPITATION_NEAR,
+ 2.0 * width,
+ 2.0 * height,
+ ];
+ Self {
+ origin: [PRECIPITATION_NEAR, -width, -height],
+ extent,
+ reciprocal_extent: extent.map(|value| 1.0 / value),
+ }
+ }
+
+ /// Rebuilds the same depth box for a camera projection. The native
+ /// emitter uses the camera FOV and aspect ratio for this box while the
+ /// reference descriptor above keeps the original 1.3 by 0.975 angles.
+ #[must_use]
+ pub fn for_camera(self, camera: Camera) -> Self {
+ let near = self.origin[0];
+ let far = near + self.extent[0];
+ let half_y = (camera.vertical_fov * 0.5).tan() * far;
+ let half_x = (camera.vertical_fov * 0.5).tan() * camera.aspect_ratio * far;
+ let extent = [far - near, 2.0 * half_x, 2.0 * half_y];
+ Self {
+ origin: [near, -half_x, -half_y],
+ extent,
+ reciprocal_extent: extent.map(|value| 1.0 / value),
+ }
+ }
+
+ /// Returns an axis-aligned world-space bound around the camera-local
+ /// descriptor. The native emitter transforms each sampled local point by
+ /// the camera basis before handing it to the particle object.
+ #[must_use]
+ pub fn bounds(self, camera: Camera) -> PrecipitationBounds {
+ let corners: [[f32; 3]; 8] = std::array::from_fn(|index| {
+ let local = [
+ self.origin[0] + if index & 1 == 0 { 0.0 } else { self.extent[0] },
+ self.origin[1] + if index & 2 == 0 { 0.0 } else { self.extent[1] },
+ self.origin[2] + if index & 4 == 0 { 0.0 } else { self.extent[2] },
+ ];
+ camera.world_from_local(local)
+ });
+ let mut min = [f32::INFINITY; 3];
+ let mut max = [f32::NEG_INFINITY; 3];
+ for corner in corners {
+ for axis in 0..3 {
+ min[axis] = min[axis].min(corner[axis]);
+ max[axis] = max[axis].max(corner[axis]);
+ }
+ }
+ PrecipitationBounds { min, max }
+ }
+
+ /// Computes the native reference particle count for a weather intensity.
+ /// The camera-aware form below applies the current/reference volume ratio.
+ #[must_use]
+ pub fn particle_count(self, intensity: f32) -> usize {
+ self.particle_count_for(self, 1.0, intensity)
+ }
+
+ /// Computes the native count from the current/reference volume ratio and
+ /// the object's density scalar. The original uses FISTP's nearest-even
+ /// conversion after multiplying by 1000.
+ #[must_use]
+ pub fn particle_count_for(self, current: Self, density: f32, intensity: f32) -> usize {
+ if !density.is_finite() || !intensity.is_finite() || density <= 0.0 || intensity <= 0.0 {
+ return 0;
+ }
+ let reference_volume = volume_product(self.extent);
+ let current_volume = volume_product(current.extent);
+ if !reference_volume.is_finite() || reference_volume <= 0.0 || !current_volume.is_finite() {
+ return 0;
+ }
+ let ratio = (current_volume / reference_volume).clamp(0.0, 1.0);
+ let value = f64::from(ratio)
+ * f64::from(density)
+ * f64::from(intensity)
+ * f64::from(PARTICLE_COUNT_SCALE);
+ if !value.is_finite() || value <= 0.0 {
+ 0
+ } else if value >= usize::MAX as f64 {
+ usize::MAX
+ } else {
+ value.round_ties_even() as usize
+ }
+ }
+}
+
+impl Default for PrecipitationVolume {
+ fn default() -> Self {
+ Self::native()
+ }
+}
+
+/// World-space bounds for one observer-local precipitation volume.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct PrecipitationBounds {
+ /// Inclusive lower corner.
+ pub min: [f32; 3],
+ /// Inclusive upper corner.
+ pub max: [f32; 3],
+}
+
+impl PrecipitationBounds {
+ /// Checks whether a world-space point lies inside this bound.
+ #[must_use]
+ pub fn contains(self, point: [f32; 3]) -> bool {
+ point
+ .into_iter()
+ .zip(self.min.into_iter().zip(self.max))
+ .all(|(value, (min, max))| value >= min && value <= max)
+ }
+}
+
+// CLightning::Update (Terrain.dll!10043870) uses a DWORD game tick.
+const LIGHTNING_COOLDOWN_MS: u64 = 6_000;
+const LIGHTNING_DELAY_MS: f64 = 60_000.0;
+const LIGHTNING_INTENSITY_CAP: f32 = 0.95;
+// CLightning's effect descriptor stores a 600-unit vertical quad and
+// CLightning::CreateEffect (100439D0) adds half of that length to the
+// sampled Z coordinate before creating the visual effect.
+const LIGHTNING_NATIVE_WIDTH: f32 = 40.0;
+const LIGHTNING_NATIVE_LENGTH: f32 = 600.0;
+const LIGHTNING_NATIVE_ORIGIN_OFFSET: f32 = 300.0;
+/// Effective CLightning descriptor used by the renderer-facing quad.
+pub const LIGHTNING_QUAD_DIMENSIONS: [f32; 3] = [
+ LIGHTNING_NATIVE_WIDTH,
+ LIGHTNING_NATIVE_WIDTH,
+ LIGHTNING_NATIVE_LENGTH,
+];
+const NATIVE_RANDOM_SCALE: f32 = 1.0 / 32_768.0;
+
+/// The CRT linear congruential generator used by Terrain.dll and Effect.dll.
+/// Both modules read and update the same process-wide state, so the
+/// environment system intentionally owns one stream and passes it through
+/// each emitter in update order.
+#[derive(Clone, Copy, Debug)]
+struct NativeRng {
+ state: u32,
+}
+
+impl NativeRng {
+ fn new(seed: u64) -> Self {
+ Self { state: seed as u32 }
+ }
+
+ fn next_u15(&mut self) -> u16 {
+ self.state = self.state.wrapping_mul(0x343F_D).wrapping_add(0x269E_C);
+ ((self.state >> 16) & 0x7FFF) as u16
+ }
+
+ fn next_unit(&mut self) -> f32 {
+ f32::from(self.next_u15()) * NATIVE_RANDOM_SCALE
+ }
+}
+
+/// The two precipitation classes present in the original environment data.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum PrecipitationKind {
+ /// Rain drops using the `RAIN_DROP` material.
+ Rain,
+ /// Snow flakes using the `SNOWFLAKE` material.
+ Snow,
+}
+
+/// A sampled weather state supplied by the atmosphere schedule or a caller.
+#[derive(Clone, Debug, PartialEq)]
+pub struct WeatherState {
+ /// Whether this weather object is currently running.
+ pub active: bool,
+ /// Non-negative emission strength. Values above one are capped for the
+ /// bounded CPU emitter.
+ pub intensity: f32,
+ /// RGBA color passed to the renderer.
+ pub color: [f32; 4],
+ /// Resource names attached to the active interval in `sky.ske`.
+ /// Rain uses the first name as its loop sound; lightning uses the FXID
+ /// selected by the caller. Keeping these names here avoids silently
+ /// substituting a global weather asset.
+ pub resources: Vec<String>,
+ /// Material row resolved from the current `sky.wea` table.
+ pub material: Option<String>,
+}
+
+impl WeatherState {
+ /// Constructs a weather state.
+ #[must_use]
+ pub fn new(active: bool, intensity: f32, color: [f32; 4]) -> Self {
+ Self {
+ active,
+ intensity,
+ color,
+ resources: Vec::new(),
+ material: None,
+ }
+ }
+
+ /// Constructs a weather state with resource names from the atmosphere
+ /// key that started its active interval.
+ #[must_use]
+ pub fn with_resources<I, S>(active: bool, intensity: f32, color: [f32; 4], resources: I) -> Self
+ where
+ I: IntoIterator<Item = S>,
+ S: Into<String>,
+ {
+ Self {
+ active,
+ intensity,
+ color,
+ resources: resources.into_iter().map(Into::into).collect(),
+ material: None,
+ }
+ }
+
+ /// Constructs an inactive weather state.
+ #[must_use]
+ pub fn inactive() -> Self {
+ Self::new(false, 0.0, [0.0; 4])
+ }
+
+ fn sanitized(self) -> Self {
+ let color = self.color.map(|component| {
+ if component.is_finite() {
+ component.clamp(0.0, 1.0)
+ } else {
+ 0.0
+ }
+ });
+ let intensity = if self.intensity.is_finite() {
+ self.intensity.clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+ Self {
+ active: self.active,
+ intensity,
+ color,
+ resources: self
+ .resources
+ .into_iter()
+ .filter(|resource| !resource.is_empty())
+ .collect(),
+ material: self.material.filter(|material| !material.is_empty()),
+ }
+ }
+}
+
+impl Default for WeatherState {
+ fn default() -> Self {
+ Self::inactive()
+ }
+}
+
+/// Weather input for one environment update.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct WeatherInput {
+ /// Rain state.
+ pub rain: WeatherState,
+ /// Snow state.
+ pub snow: WeatherState,
+ /// Lightning state.
+ pub lightning: WeatherState,
+}
+
+impl WeatherInput {
+ /// Constructs a clear-weather input.
+ #[must_use]
+ pub fn clear() -> Self {
+ Self {
+ rain: WeatherState::inactive(),
+ snow: WeatherState::inactive(),
+ lightning: WeatherState::inactive(),
+ }
+ }
+
+ /// Converts a sampled `sky.ske` frame into environment input.
+ #[must_use]
+ pub fn from_atmosphere(frame: &crate::atmosphere::AtmosphereFrame) -> Self {
+ Self {
+ rain: from_atmosphere_weather(&frame.rain),
+ snow: from_atmosphere_weather(&frame.snow),
+ lightning: from_atmosphere_weather(&frame.lightning),
+ }
+ }
+
+ /// Converts a sampled atmosphere frame and the current `sky.wea` table
+ /// into environment input. Rows seven and eight are the native snow and
+ /// rain material slots; the table is resolved at runtime so missions can
+ /// replace their names.
+ #[must_use]
+ pub fn from_atmosphere_with_materials(
+ frame: &crate::atmosphere::AtmosphereFrame,
+ materials: &SkyMaterials,
+ ) -> Self {
+ let mut input = Self::from_atmosphere(frame);
+ input.rain.material = materials.material_name(8).map(str::to_owned);
+ input.snow.material = materials.material_name(7).map(str::to_owned);
+ input
+ }
+
+ fn sanitized(self) -> Self {
+ Self {
+ rain: self.rain.sanitized(),
+ snow: self.snow.sanitized(),
+ lightning: self.lightning.sanitized(),
+ }
+ }
+}
+
+/// Camera snapshot used to keep finite weather geometry around the observer.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Camera {
+ /// World-space camera position. The renderer uses +Z as up.
+ pub position: [f32; 3],
+ /// Unit world-space direction of camera-local depth.
+ pub forward: [f32; 3],
+ /// Unit world-space direction of camera-local horizontal offset.
+ pub right: [f32; 3],
+ /// Unit world-space direction of camera-local vertical offset.
+ pub up: [f32; 3],
+ /// Vertical camera field of view in radians.
+ pub vertical_fov: f32,
+ /// Horizontal-to-vertical projection aspect ratio.
+ pub aspect_ratio: f32,
+ /// Viewport width and height in pixels used by screen-space particle quads.
+ pub viewport: [f32; 2],
+}
+
+impl Camera {
+ /// Creates a camera snapshot.
+ #[must_use]
+ pub const fn new(position: [f32; 3]) -> Self {
+ Self {
+ position,
+ forward: [1.0, 0.0, 0.0],
+ right: [0.0, 1.0, 0.0],
+ up: [0.0, 0.0, 1.0],
+ vertical_fov: 0.975,
+ // tan(0.65) / tan(0.4875), the original reference box aspect.
+ aspect_ratio: 1.4338578,
+ viewport: [640.0, 480.0],
+ }
+ }
+
+ /// Creates a camera snapshot with the orientation used by the native
+ /// camera-local precipitation transform.
+ #[must_use]
+ pub const fn with_basis(
+ position: [f32; 3],
+ forward: [f32; 3],
+ right: [f32; 3],
+ up: [f32; 3],
+ ) -> Self {
+ Self {
+ position,
+ forward,
+ right,
+ up,
+ vertical_fov: 0.975,
+ aspect_ratio: 1.4338578,
+ viewport: [640.0, 480.0],
+ }
+ }
+
+ /// Creates a camera snapshot with orientation and projection data.
+ #[must_use]
+ pub const fn with_projection(
+ position: [f32; 3],
+ forward: [f32; 3],
+ right: [f32; 3],
+ up: [f32; 3],
+ vertical_fov: f32,
+ aspect_ratio: f32,
+ ) -> Self {
+ Self {
+ position,
+ forward,
+ right,
+ up,
+ vertical_fov,
+ aspect_ratio,
+ viewport: [640.0, 480.0],
+ }
+ }
+
+ /// Sets the pixel viewport used by the projected particle geometry.
+ #[must_use]
+ pub const fn with_viewport(self, viewport: [f32; 2]) -> Self {
+ Self { viewport, ..self }
+ }
+
+ /// Returns the horizontal field of view represented by this projection.
+ #[must_use]
+ pub fn horizontal_fov(self) -> f32 {
+ 2.0 * ((self.vertical_fov * 0.5).tan() * self.aspect_ratio).atan()
+ }
+
+ /// Returns Terrain's precipitation size query: viewport width divided by
+ /// the camera's horizontal FOV in radians.
+ #[must_use]
+ pub fn precipitation_size_scalar(self) -> f32 {
+ let fov = self.horizontal_fov();
+ if fov.is_finite() && fov > 0.0 {
+ self.viewport[0] / fov
+ } else {
+ 0.0
+ }
+ }
+
+ /// Transforms a camera-local point into world space.
+ #[must_use]
+ pub fn world_from_local(self, local: [f32; 3]) -> [f32; 3] {
+ [
+ self.position[0]
+ + self.forward[0] * local[0]
+ + self.right[0] * local[1]
+ + self.up[0] * local[2],
+ self.position[1]
+ + self.forward[1] * local[0]
+ + self.right[1] * local[1]
+ + self.up[1] * local[2],
+ self.position[2]
+ + self.forward[2] * local[0]
+ + self.right[2] * local[1]
+ + self.up[2] * local[2],
+ ]
+ }
+
+ /// Transforms a camera-local vector into world space.
+ #[must_use]
+ pub fn world_vector(self, local: [f32; 3]) -> [f32; 3] {
+ [
+ self.forward[0] * local[0] + self.right[0] * local[1] + self.up[0] * local[2],
+ self.forward[1] * local[0] + self.right[1] * local[1] + self.up[1] * local[2],
+ self.forward[2] * local[0] + self.right[2] * local[1] + self.up[2] * local[2],
+ ]
+ }
+
+ /// Transforms a world-space point into camera-local coordinates.
+ #[must_use]
+ pub fn local_from_world(self, world: [f32; 3]) -> [f32; 3] {
+ let delta = [
+ world[0] - self.position[0],
+ world[1] - self.position[1],
+ world[2] - self.position[2],
+ ];
+ [
+ dot3(self.forward, delta),
+ dot3(self.right, delta),
+ dot3(self.up, delta),
+ ]
+ }
+
+ fn sanitized(self) -> Self {
+ let mut camera = self;
+ camera.position = camera.position.map(|component| {
+ if component.is_finite() {
+ component
+ } else {
+ 0.0
+ }
+ });
+ camera.forward = finite_unit(camera.forward).unwrap_or([1.0, 0.0, 0.0]);
+ camera.right = finite_unit(camera.right).unwrap_or([0.0, 1.0, 0.0]);
+ camera.up = finite_unit(camera.up).unwrap_or([0.0, 0.0, 1.0]);
+ if !camera.vertical_fov.is_finite() || camera.vertical_fov <= 0.0 {
+ camera.vertical_fov = 0.975;
+ }
+ camera.vertical_fov = camera.vertical_fov.min(std::f32::consts::PI - 0.001);
+ if !camera.aspect_ratio.is_finite() || camera.aspect_ratio <= 0.0 {
+ camera.aspect_ratio = 1.4338578;
+ }
+ for value in &mut camera.viewport {
+ if !value.is_finite() || *value <= 0.0 {
+ *value = 1.0;
+ }
+ }
+ camera
+ }
+}
+
+impl Default for Camera {
+ fn default() -> Self {
+ Self::new([0.0; 3])
+ }
+}
+
+/// World-space range used by the native lightning position sampler.
+///
+/// `CLightning::Update` samples X from `[x_min, x_max]`, Y from
+/// `[y_min, y_max]`, and copies `z` unchanged. The atmosphere manager owns
+/// these values in the original engine, so the default environment system
+/// uses the camera position as a zero-size range until a caller supplies the
+/// current bounds.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct LightningBounds {
+ /// Lower and upper X bounds in world coordinates.
+ pub x: [f32; 2],
+ /// Lower and upper Y bounds in world coordinates.
+ pub y: [f32; 2],
+ /// Z coordinate copied to the effect origin.
+ pub z: f32,
+}
+
+impl LightningBounds {
+ /// Creates a world-space lightning sampling range.
+ #[must_use]
+ pub const fn new(x_min: f32, x_max: f32, y_min: f32, y_max: f32, z: f32) -> Self {
+ Self {
+ x: [x_min, x_max],
+ y: [y_min, y_max],
+ z,
+ }
+ }
+
+ fn sanitized(self) -> Option<Self> {
+ if self
+ .x
+ .into_iter()
+ .chain(self.y)
+ .chain([self.z])
+ .all(f32::is_finite)
+ {
+ Some(self)
+ } else {
+ None
+ }
+ }
+}
+
+/// Native point-light state produced by an Effect opcode 1 command.
+///
+/// Effect.dll's opcode 1 handler (`1000F6E0`) updates a LightManager record.
+/// The A and B groups are the submitted position and direction, C is the
+/// four-component HDR light color, the scalar pair at body offsets 108 and
+/// 112 is the light range, and body offsets 120, 124, and 128 are the native
+/// attenuation coefficients. These values do not describe lightning mesh
+/// geometry or texture coordinates.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct PointLightFrame {
+ /// Opcode 1 type word. `env_lightning` uses type 7.
+ pub effect_type: u32,
+ /// Effect age supplied to the native evaluator, in seconds.
+ pub elapsed_seconds: f32,
+ /// Interpolation parameter between the opcode 1 start/end times.
+ pub normalized_time: f32,
+ /// FXID lifetime in seconds.
+ pub duration_seconds: f32,
+ /// World-space LightManager position.
+ pub position: [f32; 3],
+ /// World-space direction submitted alongside the point-light record.
+ pub direction: [f32; 3],
+ /// HDR RGBA color submitted to LightManager slot +0C.
+ pub color: [f32; 4],
+ /// Light range submitted to LightManager slot +10.
+ pub range: f32,
+ /// Native attenuation coefficients `(a0, a1, a2)`.
+ pub attenuation: [f32; 3],
+ /// The scalar at body offset 116. Its native role is an effect factor,
+ /// not alpha or a UV parameter, so it is preserved without reinterpretation.
+ pub effect_factor: f32,
+ /// Whether the opcode 1 command is active at `elapsed_seconds`.
+ pub active: bool,
+}
+
+/// Native quad produced by the opcode 3 lightning visual.
+///
+/// CLightning creates this effect after moving the sampled position 300
+/// units upward. The owner descriptor is `[40, 40, 600]`; opcode 1 does not
+/// supply the quad vertices or texture coordinates.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct LightningQuadFrame {
+ /// Four world-space vertices in native perimeter order: bottom plus
+ /// horizontal, bottom minus horizontal, top minus horizontal, top plus
+ /// horizontal.
+ pub vertices: [[f32; 3]; 4],
+ /// Native UVs in the same order as `vertices`.
+ pub uv: [[f32; 2]; 4],
+ /// CLightning's width, height, and effective streak length.
+ pub dimensions: [f32; 3],
+ /// Opcode 3 animated local position from the native start/delta channels.
+ pub local_offset: [f32; 3],
+ /// Opcode 3 animated local scale from the native start/end channels.
+ pub local_scale: [f32; 3],
+ /// Native opcode 3 alpha channel. `env_lightning` fades from one to zero
+ /// over its active interval.
+ pub opacity: f32,
+ /// Effect age supplied to the native opcode 3 evaluator, in seconds.
+ pub elapsed_seconds: f32,
+ /// Interpolation parameter between the opcode 3 start/end times.
+ pub normalized_time: f32,
+ /// FXID lifetime in seconds.
+ pub duration_seconds: f32,
+ /// Whether the opcode 3 command is active at `elapsed_seconds`.
+ pub active: bool,
+}
+
+/// Screen-space quad data produced by the native rain/snow geometry paths.
+/// `head`, `tail`, and `corners` are normalized device coordinates. The native
+/// size remains in viewport pixels so a renderer can apply the same query
+/// without mixing pixel and NDC units.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ScreenBillboard {
+ /// World-space head point.
+ pub world_head: [f32; 3],
+ /// World-space tail point.
+ pub world_tail: [f32; 3],
+ /// Projected head point in NDC.
+ pub head: [f32; 2],
+ /// Projected tail point in NDC. Snow uses the same point as `head`.
+ pub tail: [f32; 2],
+ /// Four NDC corners in tail-left, tail-right, head-left, head-right order.
+ pub corners: [[f32; 2]; 4],
+ /// Texture coordinates for the four corners.
+ pub uv: [[f32; 2]; 4],
+ /// Native half-size argument in viewport pixels.
+ pub half_size: f32,
+ /// Camera-local depth of the projected head.
+ pub head_depth: f32,
+ /// Camera-local depth of the projected tail.
+ pub tail_depth: f32,
+}
+
+/// Geometry and light output decoded from the FXID.
+#[derive(Clone, Debug, PartialEq)]
+pub struct LightningBolt {
+ /// Material archive and name resolved from opcode 3.
+ pub material: EffectResource,
+ /// World-space sampled position supplied to CLightning.
+ pub position: [f32; 3],
+ /// Opcode 3 world-space quad and material state.
+ pub quad: LightningQuadFrame,
+ /// Current weather intensity used by the event scheduler.
+ pub intensity: f32,
+ /// Effect age in seconds from its FXID start time.
+ pub age_seconds: f32,
+ /// FXID lifetime in seconds used by the native evaluator.
+ pub duration_seconds: f32,
+}
+
+/// Native environment primitives consumed by the renderer.
+#[derive(Clone, Debug, PartialEq)]
+pub enum EnvironmentPrimitive {
+ /// A rain drop or snow flake billboard instance.
+ Particle {
+ /// Precipitation class.
+ kind: PrecipitationKind,
+ /// World-space position.
+ position: [f32; 3],
+ /// World-space velocity.
+ velocity: [f32; 3],
+ /// Native size argument after applying the rain/snow size factor.
+ size: f32,
+ /// RGBA color.
+ color: [f32; 4],
+ /// Material lookup name from `sky.wea`.
+ material: String,
+ /// Native projected quad geometry in viewport pixels.
+ screen: ScreenBillboard,
+ },
+ /// A lightning bolt with opcode 3 quad data.
+ Lightning(LightningBolt),
+}
+
+/// Action applied to a sound resource by the caller's audio backend.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum SoundAction {
+ /// Start a loop at the event's volume.
+ StartLoop,
+ /// Change the volume of an already-running loop.
+ SetLoopVolume,
+ /// Stop a previously started loop.
+ StopLoop,
+ /// Play one sample once.
+ OneShot,
+}
+
+/// One sound event generated by the environment simulation.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SoundEvent {
+ /// Archive to resolve through the resource repository. Empty means the
+ /// audio owner should use its mission-configured weather library.
+ pub archive: String,
+ /// Entry name in `archive`.
+ pub name: String,
+ /// World-space emitter position.
+ pub position: [f32; 3],
+ /// Linear volume multiplier.
+ pub volume: f32,
+ /// Native minimum distance in world units. A non-positive value disables
+ /// the native range clamp for callers such as atmosphere loops.
+ pub min_distance: f32,
+ /// Native maximum distance in world units. A non-positive or non-finite
+ /// value disables the native range clamp.
+ pub max_distance: f32,
+ /// Native playback-frequency ratio evaluated at event creation.
+ pub frequency_ratio: f32,
+ /// Playback action.
+ pub action: SoundAction,
+}
+
+/// Native opcode-2 sound parameters evaluated for one effect age.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SoundParameters {
+ /// DirectSound minimum distance in world units.
+ pub min_distance: f32,
+ /// DirectSound maximum distance in world units.
+ pub max_distance: f32,
+ /// Playback frequency ratio. The shipped thunder effect keeps this at 1.
+ pub frequency_ratio: f32,
+}
+
+impl Default for SoundParameters {
+ fn default() -> Self {
+ Self {
+ min_distance: 0.0,
+ max_distance: f32::INFINITY,
+ frequency_ratio: 1.0,
+ }
+ }
+}
+
+/// All visible and audible environment output for one frame.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct EnvironmentFrame {
+ /// Renderer primitives in stable emitter order.
+ pub primitives: Vec<EnvironmentPrimitive>,
+ /// Dynamic point lights emitted by native Effect opcode 1 commands.
+ pub point_lights: Vec<PointLightFrame>,
+ /// Sound transitions and one-shot events.
+ pub sounds: Vec<SoundEvent>,
+}
+
+/// A validated resource name from the `env_lightning` FXID.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct EffectResource {
+ /// Archive name.
+ pub archive: String,
+ /// Entry name.
+ pub name: String,
+}
+
+/// The real visual and sound contracts extracted from `env_lightning`.
+#[derive(Clone, Debug, PartialEq)]
+pub struct LightningEffect {
+ /// FXID header lifetime in seconds.
+ pub duration_seconds: f32,
+ /// Visual material resource from the opcode 3 body.
+ pub visual: EffectResource,
+ /// Complete opcode-3 body used by the native quad evaluator.
+ pub visual_body: Arc<[u8]>,
+ /// Complete opcode-1 body for the native Effect evaluator.
+ pub opcode1_body: Arc<[u8]>,
+ /// Command index containing those visual parameters.
+ pub opcode1_command_index: usize,
+ /// Sound resource from the opcode 2 body.
+ pub sound: EffectResource,
+ /// Complete opcode-2 body used to evaluate the native thunder gain and
+ /// active interval at the event origin.
+ pub sound_body: Arc<[u8]>,
+ /// Command index containing the visual material.
+ pub visual_command_index: usize,
+ /// Command index containing the sound resource.
+ pub sound_command_index: usize,
+}
+
+impl LightningEffect {
+ /// Extracts the visual, point-light, and sound contracts from a decoded
+ /// lightning FXID.
+ ///
+ /// The generic FXID parser intentionally preserves command bodies without
+ /// claiming field semantics. Opcode 3 has a material reference at body
+ /// offset 132, opcode 1 carries the LightManager record, and opcode 2
+ /// has a sound reference at body offsets 80 and 112.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the document does not contain those commands or
+ /// contains truncated command bodies.
+ pub fn from_fxid(document: &FxDocument) -> Result<Self, EnvironmentError> {
+ let duration_seconds = document.header().duration_seconds;
+ if !duration_seconds.is_finite() || duration_seconds <= 0.0 {
+ return Err(EnvironmentError::InvalidDuration { duration_seconds });
+ }
+
+ let mut visual = None;
+ let mut opcode1 = None;
+ let mut sound = None;
+ for (index, command) in document.commands().iter().enumerate() {
+ match command.opcode {
+ FxOpcode::Op3 if visual.is_none() => {
+ let resource = read_resource(index, command.opcode, &command.raw_body, 132)?;
+ validate_opcode3_body(index, &command.raw_body)?;
+ visual = Some((index, resource, Arc::clone(&command.raw_body)));
+ }
+ FxOpcode::Op1 if opcode1.is_none() => {
+ validate_opcode1_body(index, &command.raw_body)?;
+ opcode1 = Some((index, Arc::clone(&command.raw_body)));
+ }
+ FxOpcode::Op2 if sound.is_none() => {
+ validate_opcode2_body(index, &command.raw_body)?;
+ let resource = read_resource(index, command.opcode, &command.raw_body, 80)?;
+ let name = read_name(&command.raw_body, index, command.opcode, 112)?;
+ sound = Some((
+ index,
+ EffectResource {
+ archive: resource.archive,
+ name,
+ },
+ Arc::clone(&command.raw_body),
+ ));
+ }
+ _ => {}
+ }
+ }
+
+ let Some((visual_command_index, visual, visual_body)) = visual else {
+ return Err(EnvironmentError::MissingCommand {
+ opcode: FxOpcode::Op3,
+ });
+ };
+ let Some((opcode1_command_index, opcode1_body)) = opcode1 else {
+ return Err(EnvironmentError::MissingCommand {
+ opcode: FxOpcode::Op1,
+ });
+ };
+ let Some((sound_command_index, sound, sound_body)) = sound else {
+ return Err(EnvironmentError::MissingCommand {
+ opcode: FxOpcode::Op2,
+ });
+ };
+ Ok(Self {
+ duration_seconds,
+ visual,
+ visual_body,
+ opcode1_body,
+ opcode1_command_index,
+ sound,
+ sound_body,
+ visual_command_index,
+ sound_command_index,
+ })
+ }
+
+ /// Evaluates the native opcode-2 distance and playback parameters at an
+ /// effect age. The FX command stores minimum/maximum distance at body
+ /// offsets `0x3c`/`0x40` and interpolates the frequency ratio from
+ /// `0x44` to `0x48`; body offset `0x4c` remains in the preserved raw
+ /// command because its backend meaning is not established.
+ #[must_use]
+ pub fn sound_parameters(&self, elapsed_seconds: f32) -> Option<SoundParameters> {
+ if !elapsed_seconds.is_finite()
+ || !self.duration_seconds.is_finite()
+ || self.duration_seconds <= 0.0
+ {
+ return None;
+ }
+ let (normalized_time, _) = active_interval(
+ self.sound_body.as_ref(),
+ elapsed_seconds,
+ self.duration_seconds,
+ )?;
+ let min_distance = read_f32(self.sound_body.as_ref(), 60)?;
+ let max_distance = read_f32(self.sound_body.as_ref(), 64)?;
+ let frequency_ratio = lerp(
+ read_f32(self.sound_body.as_ref(), 68)?,
+ read_f32(self.sound_body.as_ref(), 72)?,
+ normalized_time,
+ );
+ if !min_distance.is_finite() || !max_distance.is_finite() || !frequency_ratio.is_finite() {
+ return None;
+ }
+ Some(SoundParameters {
+ min_distance,
+ max_distance,
+ frequency_ratio,
+ })
+ }
+
+ /// Evaluates the native opcode 1 point-light command at `elapsed_seconds`.
+ #[must_use]
+ pub fn evaluate_point_light(
+ &self,
+ elapsed_seconds: f32,
+ origin: [f32; 3],
+ ) -> Option<PointLightFrame> {
+ self.evaluate_point_light_with_basis(elapsed_seconds, origin, identity_basis())
+ }
+
+ /// Evaluates opcode 1 and applies a parent basis to its LightManager
+ /// position and direction. `basis` stores the world-space vectors
+ /// corresponding to local +X, +Y, and +Z.
+ #[must_use]
+ pub fn evaluate_point_light_with_basis(
+ &self,
+ elapsed_seconds: f32,
+ origin: [f32; 3],
+ basis: [[f32; 3]; 3],
+ ) -> Option<PointLightFrame> {
+ if !self.valid_effect_time(elapsed_seconds, origin, basis) {
+ return None;
+ }
+
+ let body = self.opcode1_body.as_ref();
+ let effect_type = read_u32(body, 0)?;
+ let (normalized_time, _) = active_interval(body, elapsed_seconds, self.duration_seconds)?;
+ let local_position = lerp3(read_vec3(body, 12)?, read_vec3(body, 24)?, normalized_time);
+ let local_direction = lerp3(read_vec3(body, 36)?, read_vec3(body, 48)?, normalized_time);
+ let color = lerp4(read_vec4(body, 60)?, read_vec4(body, 76)?, normalized_time);
+ let range = lerp(read_f32(body, 108)?, read_f32(body, 112)?, normalized_time);
+ let attenuation = [
+ read_f32(body, 120)?,
+ read_f32(body, 124)?,
+ read_f32(body, 128)?,
+ ];
+ let effect_factor = read_f32(body, 116)?;
+ let direction = finite_unit(transform3(basis, local_direction))
+ .unwrap_or_else(|| finite_unit(basis[0]).unwrap_or([1.0, 0.0, 0.0]));
+
+ Some(PointLightFrame {
+ effect_type,
+ elapsed_seconds,
+ normalized_time,
+ duration_seconds: self.duration_seconds,
+ position: add3(origin, transform3(basis, local_position)),
+ direction,
+ color: color.map(sanitize_float),
+ range: sanitize_float(range).max(0.0),
+ attenuation: attenuation.map(sanitize_float),
+ effect_factor: sanitize_float(effect_factor),
+ active: true,
+ })
+ }
+
+ /// Evaluates the native opcode 3 visual quad at `elapsed_seconds`.
+ ///
+ /// The native owner supplies the effective descriptor `[40, 40, 600]`
+ /// and the effect origin after adding 300 units to the sampled Z. Opcode
+ /// 3 contributes the material modifiers and active interval; its fixed
+ /// full-texture UVs are independent of opcode 1.
+ #[must_use]
+ pub fn evaluate_quad(
+ &self,
+ elapsed_seconds: f32,
+ origin: [f32; 3],
+ camera: Camera,
+ ) -> Option<LightningQuadFrame> {
+ self.evaluate_quad_with_mirror(elapsed_seconds, origin, camera, false)
+ }
+
+ /// Evaluates the native opcode 3 visual quad with the per-bolt mirrored
+ /// texture orientation selected by `CLightning::CreateEffect`.
+ #[must_use]
+ pub fn evaluate_quad_with_mirror(
+ &self,
+ elapsed_seconds: f32,
+ origin: [f32; 3],
+ camera: Camera,
+ mirrored: bool,
+ ) -> Option<LightningQuadFrame> {
+ let camera = camera.sanitized();
+ if !self.valid_effect_time(elapsed_seconds, origin, identity_basis()) {
+ return None;
+ }
+ let body = self.visual_body.as_ref();
+ let (normalized_time, _) = active_interval(body, elapsed_seconds, self.duration_seconds)?;
+ let local_offset = evaluate_position(body, normalized_time)?;
+ let local_scale = evaluate_scale(body, normalized_time)?;
+ // The owner matrix is diagonal. The mirrored path negates its local
+ // Y basis; that sign participates in both the inverse camera
+ // transform and the forward transform of the billboard frame.
+ // Effect.dll first computes `world_center = M_base * local_offset`,
+ // then builds `M_base * Diag(local_scale)`. It never applies the
+ // animated scale to the inverse camera vector.
+ let base_diag = lightning_parent_diagonal(mirrored);
+ let mut geometry_diag = base_diag.map(|value| value);
+ for (axis, scale) in geometry_diag.iter_mut().zip(local_scale) {
+ *axis *= scale;
+ }
+ let dimensions = [
+ LIGHTNING_QUAD_DIMENSIONS[0] * local_scale[0],
+ LIGHTNING_QUAD_DIMENSIONS[1] * local_scale[1],
+ LIGHTNING_QUAD_DIMENSIONS[2] * local_scale[2],
+ ];
+ let center = add3(
+ origin,
+ [
+ base_diag[0] * local_offset[0],
+ base_diag[1] * local_offset[1],
+ base_diag[2] * local_offset[2],
+ ],
+ );
+
+ // The shipped env_lightning command is type 1. Its native receiver
+ // builds a local frame with columns `(axis, U, W)`, where
+ // `U = normalize(V x axis)` and `W = axis x U`. The canonical quad
+ // uses the first two columns; the third column supplies the
+ // camera-facing normal to the native receiver. `V` is intentionally
+ // not normalized before the exact parallel-view test.
+ let axis = [0.0, 0.0, 1.0];
+ let camera_local = [
+ (camera.position[0] - origin[0]) / base_diag[0],
+ (camera.position[1] - origin[1]) / base_diag[1],
+ (camera.position[2] - origin[2]) / base_diag[2],
+ ];
+ let raw_view = sub3(camera_local, local_offset);
+ // Native type 1 compares the raw dot product against exactly +/-1.
+ // A near-parallel view therefore follows the cross-product path.
+ if dot3(raw_view, axis).abs() == 1.0 {
+ return None;
+ }
+ let horizontal_local = normalized_or_fallback(cross3(raw_view, axis), [1.0, 0.0, 0.0]);
+ let _normal_local = cross3(axis, horizontal_local);
+ let horizontal = transform_diagonal(geometry_diag, horizontal_local);
+ let vertical = transform_diagonal(geometry_diag, axis);
+ let bottom = sub3(center, scale3(vertical, 0.5));
+ let top = add3(center, scale3(vertical, 0.5));
+ let positive_horizontal = scale3(horizontal, 0.5);
+ let negative_horizontal = scale3(horizontal, -0.5);
+ Some(LightningQuadFrame {
+ vertices: [
+ add3(bottom, positive_horizontal),
+ add3(bottom, negative_horizontal),
+ add3(top, negative_horizontal),
+ add3(top, positive_horizontal),
+ ],
+ uv: [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]],
+ dimensions,
+ local_offset: local_offset.map(sanitize_float),
+ local_scale: local_scale.map(sanitize_float),
+ opacity: sanitize_float(1.0 - normalized_time).clamp(0.0, 1.0),
+ elapsed_seconds,
+ normalized_time,
+ duration_seconds: self.duration_seconds,
+ active: true,
+ })
+ }
+
+ fn valid_effect_time(
+ &self,
+ elapsed_seconds: f32,
+ origin: [f32; 3],
+ basis: [[f32; 3]; 3],
+ ) -> bool {
+ elapsed_seconds.is_finite()
+ && self.duration_seconds.is_finite()
+ && self.duration_seconds > 0.0
+ && elapsed_seconds >= 0.0
+ && elapsed_seconds <= self.duration_seconds
+ && origin.iter().all(|value| value.is_finite())
+ && basis.iter().flatten().all(|value| value.is_finite())
+ }
+}
+
+/// Decodes and validates the original `env_lightning` FXID payload.
+pub fn decode_env_lightning_fxid(bytes: Arc<[u8]>) -> Result<LightningEffect, EnvironmentError> {
+ let document = crate::decode_fxid(bytes)?;
+ LightningEffect::from_fxid(&document)
+}
+
+/// Environment simulation failure.
+#[derive(Debug)]
+pub enum EnvironmentError {
+ /// Generic FXID framing error.
+ Fx(FxError),
+ /// FXID lifetime is unusable for an active effect.
+ InvalidDuration {
+ /// Value from the FXID header.
+ duration_seconds: f32,
+ },
+ /// Required command is absent.
+ MissingCommand {
+ /// Required opcode.
+ opcode: FxOpcode,
+ },
+ /// A command body is too short for the established layout.
+ InvalidCommandLayout {
+ /// Command index.
+ command_index: usize,
+ /// Command opcode.
+ opcode: FxOpcode,
+ /// Required body length.
+ required: usize,
+ /// Actual body length.
+ actual: usize,
+ },
+ /// A known command points at an unexpected resource.
+ UnexpectedResource {
+ /// Command index.
+ command_index: usize,
+ /// Resource field being checked.
+ part: &'static str,
+ /// Decoded archive name.
+ archive: String,
+ /// Decoded resource name.
+ name: String,
+ },
+ /// Opcode 1 contains a non-finite visual parameter.
+ NonFiniteVisualValue {
+ /// Command index.
+ command_index: usize,
+ /// Value index in the decoded prefix.
+ index: usize,
+ /// Value read from the command body.
+ value: f32,
+ },
+ /// Opcode 2 contains a non-finite sound parameter.
+ NonFiniteSoundValue {
+ /// Command index.
+ command_index: usize,
+ /// Value index in the numeric sound prefix.
+ index: usize,
+ /// Value read from the command body.
+ value: f32,
+ },
+}
+
+impl From<FxError> for EnvironmentError {
+ fn from(value: FxError) -> Self {
+ Self::Fx(value)
+ }
+}
+
+impl std::fmt::Display for EnvironmentError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Fx(error) => write!(f, "{error}"),
+ Self::InvalidDuration { duration_seconds } => {
+ write!(f, "invalid env_lightning FXID duration {duration_seconds}")
+ }
+ Self::MissingCommand { opcode } => {
+ write!(f, "env_lightning is missing required {opcode:?} command")
+ }
+ Self::InvalidCommandLayout {
+ command_index,
+ opcode,
+ required,
+ actual,
+ } => write!(
+ f,
+ "env_lightning command {command_index} ({opcode:?}) needs {required} body bytes, found {actual}"
+ ),
+ Self::UnexpectedResource {
+ command_index,
+ part,
+ archive,
+ name,
+ } => write!(
+ f,
+ "unexpected env_lightning {part} at command {command_index}: {archive}/{name}"
+ ),
+ Self::NonFiniteVisualValue {
+ command_index,
+ index,
+ value,
+ } => write!(
+ f,
+ "env_lightning opcode 1 command {command_index} has non-finite visual value {index}: {value}"
+ ),
+ Self::NonFiniteSoundValue {
+ command_index,
+ index,
+ value,
+ } => write!(
+ f,
+ "env_lightning opcode 2 command {command_index} has non-finite sound value {index}: {value}"
+ ),
+ }
+ }
+}
+
+impl std::error::Error for EnvironmentError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Fx(error) => Some(error),
+ Self::InvalidDuration { .. }
+ | Self::MissingCommand { .. }
+ | Self::InvalidCommandLayout { .. }
+ | Self::UnexpectedResource { .. }
+ | Self::NonFiniteVisualValue { .. }
+ | Self::NonFiniteSoundValue { .. } => None,
+ }
+ }
+}
+
+/// Stateful CPU environment emitter.
+#[derive(Clone, Debug)]
+pub struct EnvironmentSystem {
+ time_seconds: f32,
+ time_millis: u64,
+ time_millis_fraction: f64,
+ /// Terrain and Effect share the CRT `rand` state. Keep one stream for
+ /// particle respawns, lightning scheduling, and lightning placement so
+ /// the native call order remains observable across subsystems.
+ rng: NativeRng,
+ rain: PrecipitationEmitter,
+ snow: PrecipitationEmitter,
+ materials: SkyMaterials,
+ lightning_effect: Option<LightningEffect>,
+ lightning_bounds: Option<LightningBounds>,
+ lightning: LightningState,
+ rain_loop_active: bool,
+ rain_loop_resource: Option<String>,
+}
+
+impl EnvironmentSystem {
+ /// Creates a deterministic environment system.
+ #[must_use]
+ pub fn new(seed: u64) -> Self {
+ Self {
+ time_seconds: 0.0,
+ time_millis: 0,
+ time_millis_fraction: 0.0,
+ rng: NativeRng::new(seed),
+ rain: PrecipitationEmitter::new(PrecipitationKind::Rain, PrecipitationVolume::native()),
+ snow: PrecipitationEmitter::new(PrecipitationKind::Snow, PrecipitationVolume::native()),
+ materials: SkyMaterials::default(),
+ lightning_effect: None,
+ lightning_bounds: None,
+ lightning: LightningState::new(),
+ rain_loop_active: false,
+ rain_loop_resource: None,
+ }
+ }
+
+ /// Installs the mission's `sky.wea` material table. The rain and snow
+ /// emitters resolve rows eight and seven from this table on every frame.
+ pub fn set_sky_materials(&mut self, materials: SkyMaterials) {
+ self.materials = materials;
+ }
+
+ /// Returns the active precipitation descriptor.
+ #[must_use]
+ pub fn precipitation_volume(&self) -> PrecipitationVolume {
+ self.rain.volume
+ }
+
+ /// Installs a validated `env_lightning` effect contract.
+ pub fn set_lightning_effect(&mut self, effect: LightningEffect) {
+ self.lightning_effect = effect
+ .duration_seconds
+ .is_finite()
+ .then_some(effect)
+ .filter(|effect| effect.duration_seconds > 0.0);
+ self.lightning.clear_visual();
+ }
+
+ /// Removes the installed lightning effect.
+ pub fn clear_lightning_effect(&mut self) {
+ self.lightning_effect = None;
+ self.lightning.clear_visual();
+ }
+
+ /// Supplies the current world-space bounds used by the native lightning
+ /// sampler. Passing non-finite bounds makes the system fall back to the
+ /// camera position for subsequent flashes.
+ pub fn set_lightning_bounds(&mut self, bounds: LightningBounds) {
+ self.lightning_bounds = bounds.sanitized();
+ }
+
+ /// Removes explicit lightning bounds. Subsequent effects use the camera
+ /// position as a zero-size range.
+ pub fn clear_lightning_bounds(&mut self) {
+ self.lightning_bounds = None;
+ }
+
+ /// Current simulation time in seconds.
+ #[must_use]
+ pub fn time_seconds(&self) -> f32 {
+ self.time_seconds
+ }
+
+ /// Current simulation time in whole native milliseconds.
+ #[must_use]
+ pub fn time_millis(&self) -> u64 {
+ self.time_millis
+ }
+
+ /// Advances precipitation and lightning and returns renderer/audio output.
+ ///
+ /// `dt_seconds` is sanitized to a finite non-negative value. Particle
+ /// positions are retained between frames and wrapped in the camera-local
+ /// descriptor using the native floor-based wrap operation, so a renderer
+ /// can consume this frame as a direct list of instances.
+ #[must_use]
+ pub fn update(
+ &mut self,
+ dt_seconds: f32,
+ weather: WeatherInput,
+ camera: Camera,
+ ) -> EnvironmentFrame {
+ let dt_seconds = if dt_seconds.is_finite() {
+ dt_seconds.max(0.0)
+ } else {
+ 0.0
+ };
+ let weather = weather.sanitized();
+ let camera = camera.sanitized();
+ self.time_seconds = self.time_seconds.max(0.0) + dt_seconds;
+ self.advance_millis(dt_seconds);
+
+ let mut frame = EnvironmentFrame::default();
+ self.rain.update(
+ dt_seconds,
+ &weather.rain,
+ camera,
+ self.materials.material_name(8),
+ &mut self.rng,
+ &mut frame.primitives,
+ );
+ self.snow.update(
+ dt_seconds,
+ &weather.snow,
+ camera,
+ self.materials.material_name(7),
+ &mut self.rng,
+ &mut frame.primitives,
+ );
+
+ let rain_resource = weather.rain.resources.first().cloned();
+ if weather.rain.active && self.rain_loop_active && rain_resource != self.rain_loop_resource
+ {
+ if let Some(name) = self.rain_loop_resource.clone() {
+ let (archive, name) = split_resource(&name);
+ frame.sounds.push(SoundEvent {
+ archive,
+ name,
+ position: camera.position,
+ volume: 0.0,
+ min_distance: 0.0,
+ max_distance: f32::INFINITY,
+ frequency_ratio: 1.0,
+ action: SoundAction::StopLoop,
+ });
+ }
+ }
+ if weather.rain.active != self.rain_loop_active
+ || (weather.rain.active && rain_resource != self.rain_loop_resource)
+ {
+ let (resource, action) = if weather.rain.active {
+ (rain_resource.clone(), SoundAction::StartLoop)
+ } else {
+ (self.rain_loop_resource.clone(), SoundAction::StopLoop)
+ };
+ if let Some(resource) = resource {
+ let (archive, name) = split_resource(&resource);
+ frame.sounds.push(SoundEvent {
+ archive,
+ name,
+ position: camera.position,
+ volume: weather.rain.intensity,
+ min_distance: 0.0,
+ max_distance: f32::INFINITY,
+ frequency_ratio: 1.0,
+ action,
+ });
+ }
+ } else if weather.rain.active {
+ // Atmosphere intensity is continuously interpolated between
+ // keys. The native rain object keeps its loop alive while that
+ // value changes, so the audio owner must receive the current
+ // volume even when the lifecycle state is unchanged.
+ if let Some(name) = rain_resource.clone() {
+ let (archive, name) = split_resource(&name);
+ frame.sounds.push(SoundEvent {
+ archive,
+ name,
+ position: camera.position,
+ volume: weather.rain.intensity,
+ min_distance: 0.0,
+ max_distance: f32::INFINITY,
+ frequency_ratio: 1.0,
+ action: SoundAction::SetLoopVolume,
+ });
+ }
+ }
+ self.rain_loop_active = weather.rain.active;
+ self.rain_loop_resource = weather.rain.active.then_some(rain_resource).flatten();
+
+ self.lightning.update(
+ self.time_millis,
+ weather.lightning,
+ camera,
+ self.lightning_effect.as_ref(),
+ self.lightning_bounds,
+ &mut self.rng,
+ &mut frame.primitives,
+ &mut frame.point_lights,
+ &mut frame.sounds,
+ );
+
+ frame
+ }
+
+ /// Advances from an `AtmosphereFrame` sampled by the `sky.ske` parser.
+ #[must_use]
+ pub fn update_atmosphere(
+ &mut self,
+ dt_seconds: f32,
+ frame: &crate::atmosphere::AtmosphereFrame,
+ camera: Camera,
+ ) -> EnvironmentFrame {
+ self.update(dt_seconds, WeatherInput::from_atmosphere(frame), camera)
+ }
+
+ /// Advances from a sampled `sky.ske` frame and its mission `sky.wea`
+ /// material table.
+ #[must_use]
+ pub fn update_atmosphere_with_materials(
+ &mut self,
+ dt_seconds: f32,
+ frame: &crate::atmosphere::AtmosphereFrame,
+ materials: &SkyMaterials,
+ camera: Camera,
+ ) -> EnvironmentFrame {
+ self.set_sky_materials(materials.clone());
+ self.update(
+ dt_seconds,
+ WeatherInput::from_atmosphere_with_materials(frame, materials),
+ camera,
+ )
+ }
+
+ fn advance_millis(&mut self, dt_seconds: f32) {
+ let millis = self.time_millis_fraction + f64::from(dt_seconds) * 1_000.0;
+ if !millis.is_finite() || millis <= 0.0 {
+ return;
+ }
+ let whole = millis.floor();
+ self.time_millis = self
+ .time_millis
+ .saturating_add(if whole >= u64::MAX as f64 {
+ u64::MAX
+ } else {
+ whole as u64
+ });
+ self.time_millis_fraction = millis - whole;
+ }
+}
+
+#[derive(Clone, Debug)]
+struct PrecipitationEmitter {
+ kind: PrecipitationKind,
+ volume: PrecipitationVolume,
+ particles: Vec<ParticleState>,
+}
+
+#[derive(Clone, Copy, Debug)]
+struct ParticleState {
+ /// World-space head position. Native integration advances this directly
+ /// before converting it to camera-local coordinates for wrapping.
+ position: [f32; 3],
+ /// Previous world-space head position, used as the rain tail.
+ previous_position: [f32; 3],
+ respawns: u64,
+}
+
+impl PrecipitationEmitter {
+ fn new(kind: PrecipitationKind, volume: PrecipitationVolume) -> Self {
+ Self {
+ kind,
+ volume,
+ particles: Vec::new(),
+ }
+ }
+
+ fn update(
+ &mut self,
+ dt_seconds: f32,
+ state: &WeatherState,
+ camera: Camera,
+ default_material: Option<&str>,
+ rng: &mut NativeRng,
+ out: &mut Vec<EnvironmentPrimitive>,
+ ) {
+ if !state.active || state.intensity <= 0.0 {
+ return;
+ }
+ let Some(material) = state
+ .material
+ .as_deref()
+ .or(default_material)
+ .filter(|material| !material.is_empty())
+ else {
+ return;
+ };
+ let current_volume = self.volume.for_camera(camera);
+ let desired = self
+ .volume
+ .particle_count_for(current_volume, 1.0, state.intensity);
+ self.particles.truncate(desired);
+ while self.particles.len() < desired {
+ let index = self.particles.len();
+ self.particles.push(ParticleState {
+ position: [0.0; 3],
+ previous_position: [0.0; 3],
+ respawns: 0,
+ });
+ self.respawn(index, camera, current_volume, rng);
+ }
+
+ let velocity = self.velocity();
+ let kind = self.kind;
+ let size_factor = match kind {
+ PrecipitationKind::Rain => RAIN_SIZE_FACTOR,
+ PrecipitationKind::Snow => SNOW_SIZE_FACTOR,
+ };
+ let size = camera.precipitation_size_scalar() * size_factor;
+ for index in 0..desired {
+ let particle = &mut self.particles[index];
+ particle.previous_position = particle.position;
+ particle.position[0] += velocity[0] * dt_seconds;
+ particle.position[1] += velocity[1] * dt_seconds;
+ particle.position[2] += velocity[2] * dt_seconds;
+ let local = camera.local_from_world(particle.position);
+ let mut wrapped_local = local;
+ let mut wrapped = false;
+ for axis in 0..3 {
+ let offset = local[axis] - current_volume.origin[axis];
+ let wrapped_offset = wrap_local(offset, current_volume.extent[axis]);
+ wrapped_local[axis] = current_volume.origin[axis] + wrapped_offset;
+ wrapped |= (wrapped_offset - offset).abs() > f32::EPSILON;
+ }
+ if wrapped {
+ // A wrapped particle has no continuous temporal tail across
+ // the opposite faces of the volume.
+ particle.position = camera.world_from_local(wrapped_local);
+ particle.previous_position = particle.position;
+ }
+ let position = particle.position;
+ let tail = particle.previous_position;
+ out.push(EnvironmentPrimitive::Particle {
+ kind,
+ position,
+ velocity,
+ size,
+ color: state.color,
+ material: material.to_owned(),
+ screen: screen_billboard(kind, camera, position, tail, size, index),
+ });
+ }
+ }
+
+ fn respawn(
+ &mut self,
+ index: usize,
+ camera: Camera,
+ volume: PrecipitationVolume,
+ rng: &mut NativeRng,
+ ) {
+ let particle = &mut self.particles[index];
+ let local = [
+ rng.next_unit() * volume.extent[0],
+ rng.next_unit() * volume.extent[1],
+ rng.next_unit() * volume.extent[2],
+ ];
+ particle.position = camera.world_from_local(add3(volume.origin, local));
+ particle.previous_position = particle.position;
+ particle.respawns = particle.respawns.wrapping_add(1);
+ }
+
+ fn velocity(&self) -> [f32; 3] {
+ match self.kind {
+ PrecipitationKind::Rain => RAIN_VELOCITY,
+ PrecipitationKind::Snow => SNOW_VELOCITY,
+ }
+ }
+}
+
+#[derive(Clone, Debug)]
+struct LightningState {
+ phase: u8,
+ last_time_ms: u64,
+ deadline_ms: u64,
+ active: bool,
+ visual: Option<LightningRuntime>,
+}
+
+#[derive(Clone, Debug)]
+struct LightningRuntime {
+ effect: LightningEffect,
+ position: [f32; 3],
+ intensity: f32,
+ mirrored: bool,
+ started_ms: u64,
+}
+
+impl LightningState {
+ fn new() -> Self {
+ Self {
+ // CLightning's constructor stores state 1, which means the first
+ // active update schedules a delay immediately.
+ phase: 1,
+ last_time_ms: 0,
+ deadline_ms: 0,
+ active: false,
+ visual: None,
+ }
+ }
+
+ fn clear_visual(&mut self) {
+ self.visual = None;
+ }
+
+ fn update(
+ &mut self,
+ now_ms: u64,
+ weather: WeatherState,
+ camera: Camera,
+ effect: Option<&LightningEffect>,
+ bounds: Option<LightningBounds>,
+ rng: &mut NativeRng,
+ primitives: &mut Vec<EnvironmentPrimitive>,
+ point_lights: &mut Vec<PointLightFrame>,
+ sounds: &mut Vec<SoundEvent>,
+ ) {
+ if !weather.active {
+ // The atmosphere destroys and recreates the lightning object on a
+ // stop/start transition. Reproduce that lifecycle so an old
+ // deadline cannot fire after clear weather.
+ self.active = false;
+ self.phase = 1;
+ self.last_time_ms = 0;
+ self.deadline_ms = 0;
+ self.visual = None;
+ return;
+ }
+ if !self.active {
+ self.active = true;
+ self.phase = 1;
+ self.last_time_ms = 0;
+ self.deadline_ms = 0;
+ }
+
+ self.emit_visual(now_ms, weather.color, camera, primitives, point_lights);
+
+ // Keep the state machine's one-transition-per-call behaviour. The
+ // original virtual update returns after changing state 0->1 or
+ // state 1->2; it does not catch up multiple lightning events after a
+ // long frame.
+ match self.phase {
+ 0 => {
+ if now_ms.saturating_sub(self.last_time_ms) >= LIGHTNING_COOLDOWN_MS {
+ self.phase = 1;
+ }
+ }
+ 1 => {
+ if weather.intensity <= 0.0 {
+ return;
+ }
+ let intensity = weather.intensity.min(LIGHTNING_INTENSITY_CAP);
+ let random = rng.next_unit();
+ let delay = ((1.0 - f64::from(intensity)) * LIGHTNING_DELAY_MS * f64::from(random))
+ .round_ties_even();
+ let delay = if delay.is_finite() && delay >= 0.0 {
+ delay.min(u64::MAX as f64) as u64
+ } else {
+ 0
+ };
+ self.deadline_ms = now_ms.saturating_add(delay);
+ self.phase = 2;
+ }
+ 2 => {
+ if now_ms < self.deadline_ms {
+ return;
+ }
+ let position = self.random_position(camera, bounds, rng);
+ if let Some(effect) = effect {
+ // CLightning consumes one CRT random value for the
+ // effect seed after sampling the X and Y coordinates.
+ // The seed has no renderer-facing field, but it remains
+ // part of the shared native stream.
+ let effect_seed = rng.next_unit();
+ self.visual = Some(LightningRuntime {
+ effect: effect.clone(),
+ position,
+ intensity: weather.intensity,
+ mirrored: effect_seed < 0.5,
+ started_ms: now_ms,
+ });
+ let parameters = effect.sound_parameters(0.0).unwrap_or_default();
+ sounds.push(SoundEvent {
+ archive: effect.sound.archive.clone(),
+ name: effect.sound.name.clone(),
+ position,
+ // The native opcode-2 command owns its frequency and
+ // range; weather intensity controls scheduling, not
+ // the thunder sample's source gain.
+ volume: 1.0,
+ min_distance: parameters.min_distance,
+ max_distance: parameters.max_distance,
+ frequency_ratio: parameters.frequency_ratio,
+ action: SoundAction::OneShot,
+ });
+ self.emit_visual(now_ms, weather.color, camera, primitives, point_lights);
+ }
+ self.last_time_ms = now_ms;
+ self.phase = 0;
+ }
+ _ => self.phase = 1,
+ }
+ }
+
+ fn emit_visual(
+ &mut self,
+ now_ms: u64,
+ _color: [f32; 4],
+ camera: Camera,
+ primitives: &mut Vec<EnvironmentPrimitive>,
+ point_lights: &mut Vec<PointLightFrame>,
+ ) {
+ let Some(visual) = self.visual.as_ref() else {
+ return;
+ };
+ let duration_ms = seconds_to_millis(visual.effect.duration_seconds);
+ let elapsed_ms = now_ms.saturating_sub(visual.started_ms);
+ if elapsed_ms >= duration_ms.max(1) {
+ self.visual = None;
+ return;
+ }
+ // CLightning creates the Effect object 300 world units above the
+ // sampled ground point. Its opcode 3 type-1 quad keeps a 600-unit
+ // world-Z streak axis and billboards only its horizontal axis.
+ let origin = [
+ visual.position[0],
+ visual.position[1],
+ visual.position[2] + LIGHTNING_NATIVE_ORIGIN_OFFSET,
+ ];
+ let basis = diagonal_basis(lightning_parent_diagonal(visual.mirrored));
+ let elapsed_seconds = elapsed_ms as f32 * 0.001;
+ if let Some(point_light) =
+ visual
+ .effect
+ .evaluate_point_light_with_basis(elapsed_seconds, origin, basis)
+ {
+ point_lights.push(point_light);
+ }
+ if let Some(quad) = visual.effect.evaluate_quad_with_mirror(
+ elapsed_seconds,
+ origin,
+ camera,
+ visual.mirrored,
+ ) {
+ primitives.push(EnvironmentPrimitive::Lightning(LightningBolt {
+ material: visual.effect.visual.clone(),
+ position: visual.position,
+ quad,
+ intensity: visual.intensity,
+ age_seconds: elapsed_seconds,
+ duration_seconds: visual.effect.duration_seconds,
+ }));
+ }
+ }
+
+ fn random_position(
+ &mut self,
+ camera: Camera,
+ bounds: Option<LightningBounds>,
+ rng: &mut NativeRng,
+ ) -> [f32; 3] {
+ let x_random = rng.next_unit();
+ let y_random = rng.next_unit();
+ let bounds = bounds.unwrap_or_else(|| {
+ LightningBounds::new(
+ camera.position[0],
+ camera.position[0],
+ camera.position[1],
+ camera.position[1],
+ camera.position[2],
+ )
+ });
+ [
+ bounds.x[0] + (bounds.x[1] - bounds.x[0]) * x_random,
+ bounds.y[0] + (bounds.y[1] - bounds.y[0]) * y_random,
+ bounds.z,
+ ]
+ }
+}
+
+fn from_atmosphere_weather(sample: &crate::atmosphere::WeatherSample) -> WeatherState {
+ WeatherState::with_resources(
+ sample.active,
+ sample.intensity,
+ sample.color,
+ sample.resources.iter().map(|resource| resource.as_str()),
+ )
+}
+
+fn split_resource(resource: &str) -> (String, String) {
+ resource.split_once('/').map_or_else(
+ || (String::new(), resource.to_owned()),
+ |(archive, name)| (archive.to_owned(), name.to_owned()),
+ )
+}
+
+fn seconds_to_millis(seconds: f32) -> u64 {
+ if !seconds.is_finite() || seconds <= 0.0 {
+ return 0;
+ }
+ let millis = f64::from(seconds) * 1_000.0;
+ if millis >= u64::MAX as f64 {
+ u64::MAX
+ } else {
+ millis.round() as u64
+ }
+}
+
+fn read_resource(
+ command_index: usize,
+ opcode: FxOpcode,
+ body: &[u8],
+ archive_offset: usize,
+) -> Result<EffectResource, EnvironmentError> {
+ let archive = read_name(body, command_index, opcode, archive_offset)?;
+ let name = read_name(body, command_index, opcode, archive_offset + 32)?;
+ Ok(EffectResource { archive, name })
+}
+
+fn validate_opcode1_body(command_index: usize, body: &[u8]) -> Result<(), EnvironmentError> {
+ let required = 132;
+ body.get(..required)
+ .ok_or(EnvironmentError::InvalidCommandLayout {
+ command_index,
+ opcode: FxOpcode::Op1,
+ required,
+ actual: body.len(),
+ })?;
+ for (index, offset) in (4..required).step_by(4).enumerate() {
+ let value = read_f32(body, offset).expect("validated opcode 1 layout");
+ if !value.is_finite() {
+ return Err(EnvironmentError::NonFiniteVisualValue {
+ command_index,
+ index,
+ value,
+ });
+ }
+ }
+ Ok(())
+}
+
+fn validate_opcode3_body(command_index: usize, body: &[u8]) -> Result<(), EnvironmentError> {
+ let required = 132;
+ body.get(..required)
+ .ok_or(EnvironmentError::InvalidCommandLayout {
+ command_index,
+ opcode: FxOpcode::Op3,
+ required,
+ actual: body.len(),
+ })?;
+ for (index, offset) in (4..required).step_by(4).enumerate() {
+ let value = read_f32(body, offset).expect("validated opcode 3 layout");
+ if !value.is_finite() {
+ return Err(EnvironmentError::NonFiniteVisualValue {
+ command_index,
+ index,
+ value,
+ });
+ }
+ }
+ Ok(())
+}
+
+fn validate_opcode2_body(command_index: usize, body: &[u8]) -> Result<(), EnvironmentError> {
+ let required = 144;
+ body.get(..required)
+ .ok_or(EnvironmentError::InvalidCommandLayout {
+ command_index,
+ opcode: FxOpcode::Op2,
+ required,
+ actual: body.len(),
+ })?;
+ for (index, offset) in (4..80).step_by(4).enumerate() {
+ let value = read_f32(body, offset).expect("validated opcode 2 layout");
+ if !value.is_finite() {
+ return Err(EnvironmentError::NonFiniteSoundValue {
+ command_index,
+ index,
+ value,
+ });
+ }
+ }
+ Ok(())
+}
+
+fn active_interval(body: &[u8], elapsed: f32, duration: f32) -> Option<(f32, f32)> {
+ let start = read_f32(body, 4)?;
+ let end = read_f32(body, 8)?;
+ if !start.is_finite()
+ || !end.is_finite()
+ || !duration.is_finite()
+ || duration <= 0.0
+ || elapsed < start
+ || elapsed > end
+ {
+ return None;
+ }
+ let span = end - start;
+ let normalized = if span.abs() > f32::EPSILON {
+ ((elapsed - start) / span).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+ Some((normalized, end.min(duration)))
+}
+
+fn evaluate_position(body: &[u8], normalized_time: f32) -> Option<[f32; 3]> {
+ let start = read_vec3(body, 36)?;
+ let delta = read_vec3(body, 48)?;
+ let exponent = read_vec3(body, 60)?;
+ Some(std::array::from_fn(|axis| {
+ let input = normalized_time.powf(exponent[axis]);
+ start[axis] + delta[axis] * input
+ }))
+}
+
+fn evaluate_scale(body: &[u8], normalized_time: f32) -> Option<[f32; 3]> {
+ let start = read_vec3(body, 96)?;
+ let end = read_vec3(body, 108)?;
+ let exponent = read_vec3(body, 120)?;
+ Some(std::array::from_fn(|axis| {
+ let input = normalized_time.powf(exponent[axis]);
+ lerp(start[axis], end[axis], input)
+ }))
+}
+
+fn read_name(
+ body: &[u8],
+ command_index: usize,
+ opcode: FxOpcode,
+ offset: usize,
+) -> Result<String, EnvironmentError> {
+ let end = offset
+ .checked_add(32)
+ .ok_or(EnvironmentError::InvalidCommandLayout {
+ command_index,
+ opcode,
+ required: usize::MAX,
+ actual: body.len(),
+ })?;
+ let bytes = body
+ .get(offset..end)
+ .ok_or(EnvironmentError::InvalidCommandLayout {
+ command_index,
+ opcode,
+ required: end,
+ actual: body.len(),
+ })?;
+ let end = bytes
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(bytes.len());
+ Ok(String::from_utf8_lossy(&bytes[..end]).into_owned())
+}
+
+fn volume_product(extent: [f32; 3]) -> f32 {
+ extent[0] * extent[1] * extent[2]
+}
+
+fn finite_unit(vector: [f32; 3]) -> Option<[f32; 3]> {
+ let length = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt();
+ if length.is_finite() && length > f32::EPSILON {
+ Some(vector.map(|component| component / length))
+ } else {
+ None
+ }
+}
+
+fn wrap_local(value: f32, extent: f32) -> f32 {
+ if !value.is_finite() || !extent.is_finite() || extent <= 0.0 {
+ return 0.0;
+ }
+ value - (value / extent).floor() * extent
+}
+
+fn add3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [left[0] + right[0], left[1] + right[1], left[2] + right[2]]
+}
+
+fn scale3(value: [f32; 3], scale: f32) -> [f32; 3] {
+ [value[0] * scale, value[1] * scale, value[2] * scale]
+}
+
+fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 {
+ left[0].mul_add(right[0], left[1].mul_add(right[1], left[2] * right[2]))
+}
+
+fn cross3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [
+ left[1].mul_add(right[2], -(left[2] * right[1])),
+ left[2].mul_add(right[0], -(left[0] * right[2])),
+ left[0].mul_add(right[1], -(left[1] * right[0])),
+ ]
+}
+
+fn lightning_parent_diagonal(mirrored: bool) -> [f32; 3] {
+ [
+ LIGHTNING_QUAD_DIMENSIONS[0],
+ if mirrored {
+ -LIGHTNING_QUAD_DIMENSIONS[1]
+ } else {
+ LIGHTNING_QUAD_DIMENSIONS[1]
+ },
+ LIGHTNING_QUAD_DIMENSIONS[2],
+ ]
+}
+
+fn normalized_or_fallback(vector: [f32; 3], fallback: [f32; 3]) -> [f32; 3] {
+ finite_unit(vector).unwrap_or(fallback)
+}
+
+fn transform_diagonal(diagonal: [f32; 3], vector: [f32; 3]) -> [f32; 3] {
+ [
+ diagonal[0] * vector[0],
+ diagonal[1] * vector[1],
+ diagonal[2] * vector[2],
+ ]
+}
+
+fn diagonal_basis(diagonal: [f32; 3]) -> [[f32; 3]; 3] {
+ [
+ [diagonal[0], 0.0, 0.0],
+ [0.0, diagonal[1], 0.0],
+ [0.0, 0.0, diagonal[2]],
+ ]
+}
+
+fn identity_basis() -> [[f32; 3]; 3] {
+ [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
+}
+
+fn read_u32(body: &[u8], offset: usize) -> Option<u32> {
+ let bytes = body.get(offset..offset.checked_add(4)?)?;
+ Some(u32::from_le_bytes(bytes.try_into().ok()?))
+}
+
+fn read_f32(body: &[u8], offset: usize) -> Option<f32> {
+ Some(f32::from_bits(read_u32(body, offset)?))
+}
+
+fn read_vec3(body: &[u8], offset: usize) -> Option<[f32; 3]> {
+ Some([
+ read_f32(body, offset)?,
+ read_f32(body, offset.checked_add(4)?)?,
+ read_f32(body, offset.checked_add(8)?)?,
+ ])
+}
+
+fn read_vec4(body: &[u8], offset: usize) -> Option<[f32; 4]> {
+ Some([
+ read_f32(body, offset)?,
+ read_f32(body, offset.checked_add(4)?)?,
+ read_f32(body, offset.checked_add(8)?)?,
+ read_f32(body, offset.checked_add(12)?)?,
+ ])
+}
+
+fn lerp(start: f32, end: f32, t: f32) -> f32 {
+ start + (end - start) * t
+}
+
+fn lerp3(start: [f32; 3], end: [f32; 3], t: f32) -> [f32; 3] {
+ [
+ lerp(start[0], end[0], t),
+ lerp(start[1], end[1], t),
+ lerp(start[2], end[2], t),
+ ]
+}
+
+fn lerp4(start: [f32; 4], end: [f32; 4], t: f32) -> [f32; 4] {
+ [
+ lerp(start[0], end[0], t),
+ lerp(start[1], end[1], t),
+ lerp(start[2], end[2], t),
+ lerp(start[3], end[3], t),
+ ]
+}
+
+fn transform3(basis: [[f32; 3]; 3], local: [f32; 3]) -> [f32; 3] {
+ [
+ basis[0][0].mul_add(
+ local[0],
+ basis[1][0].mul_add(local[1], basis[2][0] * local[2]),
+ ),
+ basis[0][1].mul_add(
+ local[0],
+ basis[1][1].mul_add(local[1], basis[2][1] * local[2]),
+ ),
+ basis[0][2].mul_add(
+ local[0],
+ basis[1][2].mul_add(local[1], basis[2][2] * local[2]),
+ ),
+ ]
+}
+
+fn sanitize_float(value: f32) -> f32 {
+ if value.is_finite() {
+ value
+ } else {
+ 0.0
+ }
+}
+
+fn sub3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [left[0] - right[0], left[1] - right[1], left[2] - right[2]]
+}
+
+fn screen_point(camera: Camera, point: [f32; 3]) -> ([f32; 2], f32) {
+ let delta = sub3(point, camera.position);
+ let depth = dot3(camera.forward, delta);
+ let depth_for_projection = depth.max(f32::EPSILON);
+ let half_vertical = (camera.vertical_fov * 0.5).tan();
+ let half_horizontal = half_vertical * camera.aspect_ratio;
+ let ndc_x = dot3(camera.right, delta) / (depth_for_projection * half_horizontal);
+ let ndc_y = dot3(camera.up, delta) / (depth_for_projection * half_vertical);
+ let x = (ndc_x + 1.0) * 0.5 * camera.viewport[0];
+ let y = (1.0 - ndc_y) * 0.5 * camera.viewport[1];
+ ([x, y], depth)
+}
+
+fn pixel_to_ndc(camera: Camera, point: [f32; 2]) -> [f32; 2] {
+ [
+ point[0] * 2.0 / camera.viewport[0] - 1.0,
+ 1.0 - point[1] * 2.0 / camera.viewport[1],
+ ]
+}
+
+fn screen_billboard(
+ kind: PrecipitationKind,
+ camera: Camera,
+ head_world: [f32; 3],
+ tail_world: [f32; 3],
+ size: f32,
+ index: usize,
+) -> ScreenBillboard {
+ let (head_pixels, depth) = screen_point(camera, head_world);
+ let (tail_pixels, tail_depth) = screen_point(camera, tail_world);
+ let near = PRECIPITATION_NEAR;
+ let extent_x = PRECIPITATION_FAR - PRECIPITATION_NEAR;
+ let depth_fade = (1.0 - (depth - near) / extent_x).max(0.0);
+ let half_size = match kind {
+ PrecipitationKind::Rain => size * 0.5 * depth_fade,
+ PrecipitationKind::Snow => size * 0.5 * depth_fade.max(SNOW_MIN_DEPTH_FADE),
+ };
+ let (corners_pixels, uv) = match kind {
+ PrecipitationKind::Rain => {
+ let direction = [
+ head_pixels[0] - tail_pixels[0],
+ head_pixels[1] - tail_pixels[1],
+ ];
+ let direction_length =
+ (direction[0] * direction[0] + direction[1] * direction[1]).sqrt();
+ let perpendicular = if direction_length.is_finite() && direction_length > f32::EPSILON {
+ [
+ -direction[1] / direction_length,
+ direction[0] / direction_length,
+ ]
+ } else {
+ [1.0, 0.0]
+ };
+ let offset = [perpendicular[0] * half_size, perpendicular[1] * half_size];
+ (
+ [
+ [tail_pixels[0] - offset[0], tail_pixels[1] - offset[1]],
+ [tail_pixels[0] + offset[0], tail_pixels[1] + offset[1]],
+ [head_pixels[0] - offset[0], head_pixels[1] - offset[1]],
+ [head_pixels[0] + offset[0], head_pixels[1] + offset[1]],
+ ],
+ [[0.0, 1.0], [1.0, 1.0], [0.0, 0.0], [1.0, 0.0]],
+ )
+ }
+ PrecipitationKind::Snow => {
+ let signs = [[1.0, -1.0], [1.0, 1.0], [-1.0, -1.0], [-1.0, 1.0]];
+ let mirror = index & 0x10 != 0;
+ let corners = std::array::from_fn(|corner| {
+ let [mut x, mut y] = signs[corner];
+ if mirror {
+ std::mem::swap(&mut x, &mut y);
+ }
+ [
+ head_pixels[0] + x * half_size,
+ head_pixels[1] + y * half_size,
+ ]
+ });
+ let uv = if mirror {
+ [[1.0, 0.0], [1.0, 1.0], [0.0, 0.0], [0.0, 1.0]]
+ } else {
+ [[1.0, 0.0], [1.0, 1.0], [0.0, 0.0], [0.0, 1.0]]
+ };
+ (corners, uv)
+ }
+ };
+ let head = pixel_to_ndc(camera, head_pixels);
+ let tail = pixel_to_ndc(camera, tail_pixels);
+ let corners = corners_pixels.map(|corner| pixel_to_ndc(camera, corner));
+ ScreenBillboard {
+ world_head: head_world,
+ world_tail: tail_world,
+ head,
+ tail,
+ corners,
+ uv,
+ half_size,
+ head_depth: depth,
+ tail_depth,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn active_rain_is_camera_local_and_uses_sky_material() {
+ let mut system = EnvironmentSystem::new(7);
+ let weather = WeatherInput {
+ rain: WeatherState::with_resources(true, 0.5, [0.7, 0.8, 1.0, 0.6], ["atm_rain1.wav"]),
+ ..WeatherInput::clear()
+ };
+ let frame = system.update(1.0, weather.clone(), Camera::new([10.0, -2.0, 4.0]));
+ let particles = frame
+ .primitives
+ .iter()
+ .filter_map(|primitive| match primitive {
+ EnvironmentPrimitive::Particle {
+ kind,
+ position,
+ velocity,
+ material,
+ ..
+ } => Some((*kind, *position, *velocity, material.as_str())),
+ _ => None,
+ })
+ .collect::<Vec<_>>();
+
+ assert_eq!(
+ particles.len(),
+ system.precipitation_volume().particle_count(0.5)
+ );
+ assert!(particles
+ .iter()
+ .all(|(kind, position, velocity, material)| {
+ *kind == PrecipitationKind::Rain
+ && *material == system.materials.material_name(8).unwrap()
+ && *velocity == RAIN_VELOCITY
+ && system
+ .precipitation_volume()
+ .bounds(Camera::new([10.0, -2.0, 4.0]))
+ .contains(*position)
+ }));
+ assert_eq!(frame.sounds.len(), 1);
+ assert_eq!(frame.sounds[0].name, "atm_rain1.wav");
+ assert_eq!(frame.sounds[0].action, SoundAction::StartLoop);
+
+ let update = system.update(0.1, weather, Camera::new([10.0, -2.0, 4.0]));
+ assert_eq!(update.sounds.len(), 1);
+ assert_eq!(update.sounds[0].action, SoundAction::SetLoopVolume);
+ assert_eq!(update.sounds[0].volume, 0.5);
+ }
+
+ #[test]
+ fn precipitation_uses_native_volume_ratio_and_nearest_even_count() {
+ let reference = PrecipitationVolume::native();
+ assert_eq!(reference.particle_count_for(reference, 1.0, 0.5004), 500);
+ assert_eq!(reference.particle_count_for(reference, 1.0, 0.5016), 502);
+
+ let narrow_camera = Camera::with_projection(
+ [0.0; 3],
+ [1.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0],
+ [0.0, 0.0, 1.0],
+ 0.4875,
+ 1.0,
+ );
+ let current = reference.for_camera(narrow_camera);
+ assert!(reference.particle_count_for(current, 1.0, 1.0) < reference.particle_count(1.0));
+ }
+
+ #[test]
+ fn precipitation_keeps_world_velocity_when_camera_turns_and_wraps() {
+ let mut system = EnvironmentSystem::new(3);
+ let camera = Camera::new([10.0, 20.0, 30.0]);
+ let weather = WeatherInput {
+ rain: WeatherState::new(true, 0.01, [1.0; 4]),
+ ..WeatherInput::clear()
+ };
+ let initial = system.update(0.0, weather.clone(), camera);
+ let initial_position = initial
+ .primitives
+ .iter()
+ .find_map(|primitive| match primitive {
+ EnvironmentPrimitive::Particle { position, .. } => Some(*position),
+ _ => None,
+ })
+ .expect("initial rain particle");
+ let turned_camera = Camera::with_basis(
+ [10.0, 20.0, 30.0],
+ [0.0, 1.0, 0.0],
+ [-1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0],
+ );
+ let turned = system.update(0.0, weather.clone(), turned_camera);
+ let turned_position = turned
+ .primitives
+ .iter()
+ .find_map(|primitive| match primitive {
+ EnvironmentPrimitive::Particle { position, .. } => Some(*position),
+ _ => None,
+ })
+ .expect("turned rain particle");
+ assert!(sub3(initial_position, turned_position)
+ .into_iter()
+ .all(|component| component.abs() < 1.0e-5));
+
+ let frame = system.update(10.0, weather, camera);
+ let particle = frame
+ .primitives
+ .iter()
+ .find_map(|primitive| match primitive {
+ EnvironmentPrimitive::Particle {
+ kind,
+ position,
+ velocity,
+ size,
+ screen,
+ ..
+ } => Some((*kind, *position, *velocity, *size, *screen)),
+ _ => None,
+ })
+ .expect("rain particle");
+ assert_eq!(particle.0, PrecipitationKind::Rain);
+ assert_eq!(particle.2, RAIN_VELOCITY);
+ assert_eq!(
+ particle.3,
+ camera.precipitation_size_scalar() * RAIN_SIZE_FACTOR
+ );
+ assert!(system
+ .precipitation_volume()
+ .bounds(camera)
+ .contains(particle.1));
+ assert!(particle
+ .4
+ .corners
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite()));
+ }
+
+ #[test]
+ fn precipitation_size_uses_viewport_width_and_horizontal_fov() {
+ let camera = Camera::with_projection(
+ [0.0; 3],
+ [1.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0],
+ [0.0, 0.0, 1.0],
+ 0.975,
+ 16.0 / 9.0,
+ )
+ .with_viewport([1920.0, 1080.0]);
+ let expected_fov = 2.0 * ((0.975_f32 * 0.5).tan() * (16.0_f32 / 9.0)).atan();
+ assert!((camera.horizontal_fov() - expected_fov).abs() < 1.0e-6);
+ assert!((camera.precipitation_size_scalar() - 1920.0 / expected_fov).abs() < 1.0e-4);
+ }
+
+ #[test]
+ fn lightning_fixture_extracts_real_material_and_sound_offsets() {
+ let mut bytes = header(3, 0.75);
+ // The shipped env_lightning FXID uses opcode words 3, 1, and 2 with
+ // bit 8 clear. Bit 8 is a native mode flag, not command enablement.
+ let mut visual = command(0x0003, 200);
+ visual[4..8].copy_from_slice(&1_u32.to_le_bytes());
+ copy_name(&mut visual[4 + 132..4 + 164], b"material.lib");
+ copy_name(&mut visual[4 + 164..4 + 196], b"env_lightning");
+ bytes.extend_from_slice(&visual);
+ let mut opcode1 = command(0x0001, 224);
+ opcode1[4..8].copy_from_slice(&7_u32.to_le_bytes());
+ opcode1[12..16].copy_from_slice(&0.99_f32.to_le_bytes());
+ bytes.extend_from_slice(&opcode1);
+ let mut sound = command(0x0002, 148);
+ copy_name(&mut sound[4 + 80..4 + 112], b"sounds.lib");
+ copy_name(&mut sound[4 + 112..4 + 144], b"atm_light1.wav");
+ bytes.extend_from_slice(&sound);
+
+ let effect = decode_env_lightning_fxid(Arc::from(bytes.into_boxed_slice()))
+ .expect("env_lightning fixture");
+ assert_eq!(effect.duration_seconds, 0.75);
+ assert_eq!(
+ effect.visual,
+ EffectResource {
+ archive: "material.lib".to_owned(),
+ name: "env_lightning".to_owned()
+ }
+ );
+ assert_eq!(
+ effect.sound,
+ EffectResource {
+ archive: "sounds.lib".to_owned(),
+ name: "atm_light1.wav".to_owned()
+ }
+ );
+ }
+
+ #[test]
+ fn opcode2_sound_parameters_use_native_distance_and_frequency_offsets() {
+ let mut bytes = header(3, 1.0);
+ let mut visual = command(0x0003, 200);
+ visual[4..8].copy_from_slice(&1_u32.to_le_bytes());
+ copy_name(&mut visual[4 + 132..4 + 164], b"material.lib");
+ copy_name(&mut visual[4 + 164..4 + 196], b"env_lightning");
+ bytes.extend_from_slice(&visual);
+ let mut opcode1 = command(0x0001, 224);
+ opcode1[4..8].copy_from_slice(&7_u32.to_le_bytes());
+ bytes.extend_from_slice(&opcode1);
+ let mut sound = command(0x0002, 148);
+ let body = &mut sound[4..];
+ body[4..8].copy_from_slice(&0.0_f32.to_le_bytes());
+ body[8..12].copy_from_slice(&1.0_f32.to_le_bytes());
+ body[60..64].copy_from_slice(&100.0_f32.to_le_bytes());
+ body[64..68].copy_from_slice(&1_500.0_f32.to_le_bytes());
+ body[68..72].copy_from_slice(&0.75_f32.to_le_bytes());
+ body[72..76].copy_from_slice(&1.25_f32.to_le_bytes());
+ body[76..80].copy_from_slice(&0_u32.to_le_bytes());
+ copy_name(&mut body[80..112], b"sounds.lib");
+ copy_name(&mut body[112..144], b"atm_light1.wav");
+ bytes.extend_from_slice(&sound);
+
+ let effect = decode_env_lightning_fxid(Arc::from(bytes.into_boxed_slice()))
+ .expect("env_lightning sound fixture");
+ assert_eq!(
+ effect.sound_parameters(0.5),
+ Some(SoundParameters {
+ min_distance: 100.0,
+ max_distance: 1_500.0,
+ frequency_ratio: 1.0,
+ })
+ );
+ }
+
+ #[test]
+ fn opcode1_evaluator_emits_native_point_light_groups_and_maps_parent_axis() {
+ let mut bytes = header(3, 0.75);
+ let mut visual = command(0x0003, 200);
+ visual[4..8].copy_from_slice(&1_u32.to_le_bytes());
+ copy_name(&mut visual[4 + 132..4 + 164], b"material.lib");
+ copy_name(&mut visual[4 + 164..4 + 196], b"env_lightning");
+ visual[4 + 4..4 + 8].copy_from_slice(&(-1.0_f32).to_le_bytes());
+ visual[4 + 8..4 + 12].copy_from_slice(&1.0_f32.to_le_bytes());
+ for offset in [96_usize, 100, 104, 108, 112, 116, 120, 124, 128] {
+ visual[4 + offset..4 + offset + 4].copy_from_slice(&1.0_f32.to_le_bytes());
+ }
+ bytes.extend_from_slice(&visual);
+ let mut opcode1 = command(0x0001, 224);
+ let body = &mut opcode1[4..];
+ body[0..4].copy_from_slice(&7_u32.to_le_bytes());
+ body[4..8].copy_from_slice(&0.0_f32.to_le_bytes());
+ body[8..12].copy_from_slice(&1.0_f32.to_le_bytes());
+ body[12..24].copy_from_slice(
+ &[0.0_f32; 3]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[24..36].copy_from_slice(
+ &[2.0_f32, 4.0, 6.0]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[36..48].copy_from_slice(
+ &[1.0_f32, 0.0, 0.0]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[48..60].copy_from_slice(
+ &[0.0_f32, 1.0, 0.0]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[60..76].copy_from_slice(
+ &[7.0_f32, 7.0, 10.0, 0.0]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[76..92].copy_from_slice(
+ &[0.0_f32, 0.0, 0.0, 0.0]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[108..112].copy_from_slice(&100.0_f32.to_le_bytes());
+ body[112..116].copy_from_slice(&0.0_f32.to_le_bytes());
+ body[116..120].copy_from_slice(&0.25_f32.to_le_bytes());
+ body[120..124].copy_from_slice(&0.25_f32.to_le_bytes());
+ body[124..128].copy_from_slice(&0.75_f32.to_le_bytes());
+ body[128..132].copy_from_slice(&0.75_f32.to_le_bytes());
+ bytes.extend_from_slice(&opcode1);
+ let mut sound = command(0x0002, 148);
+ copy_name(&mut sound[4 + 80..4 + 112], b"sounds.lib");
+ copy_name(&mut sound[4 + 112..4 + 144], b"atm_light1.wav");
+ bytes.extend_from_slice(&sound);
+
+ let effect = decode_env_lightning_fxid(Arc::from(bytes.into_boxed_slice()))
+ .expect("env_lightning evaluator fixture");
+ let basis = [[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
+ let frame = effect
+ .evaluate_point_light_with_basis(0.5, [10.0, 20.0, 30.0], basis)
+ .expect("active opcode 1 sample");
+ assert_eq!(frame.effect_type, 7);
+ assert_eq!(frame.position, [12.0, 23.0, 31.0]);
+ let diagonal = 2.0_f32.sqrt().recip();
+ assert!((frame.direction[0] - diagonal).abs() < 1.0e-6);
+ assert_eq!(frame.direction[1], 0.0);
+ assert!((frame.direction[2] - diagonal).abs() < 1.0e-6);
+ assert_eq!(frame.color, [3.5, 3.5, 5.0, 0.0]);
+ assert_eq!(frame.range, 50.0);
+ assert_eq!(frame.attenuation, [0.25, 0.75, 0.75]);
+ assert_eq!(frame.effect_factor, 0.25);
+ assert!(frame.active);
+ let quad = effect
+ .evaluate_quad(0.5, [10.0, 20.0, 330.0], Camera::new([0.0, 0.0, 0.0]))
+ .expect("active opcode 3 sample");
+ assert_eq!(quad.dimensions, LIGHTNING_QUAD_DIMENSIONS);
+ assert_eq!(
+ quad.uv,
+ [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]]
+ );
+ assert_eq!(quad.opacity, 0.25);
+ assert!(quad
+ .vertices
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite()));
+ assert!(effect.evaluate_point_light(1.01, [0.0; 3]).is_none());
+ }
+
+ #[test]
+ fn opcode3_quad_applies_parent_diagonal_before_offset_and_scale() {
+ let mut body = vec![0; 196];
+ body[0..4].copy_from_slice(&1_u32.to_le_bytes());
+ body[4..8].copy_from_slice(&0.0_f32.to_le_bytes());
+ body[8..12].copy_from_slice(&1.0_f32.to_le_bytes());
+ body[36..48].copy_from_slice(
+ &[1.0_f32, 2.0, 0.0]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[48..60].copy_from_slice(
+ &[1.0_f32, 0.0, 0.0]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[60..72].copy_from_slice(
+ &[1.0_f32; 3]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[96..108].copy_from_slice(
+ &[2.0_f32, 3.0, 1.0]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[108..120].copy_from_slice(
+ &[4.0_f32, 5.0, 2.0]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[120..132].copy_from_slice(
+ &[1.0_f32; 3]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ let effect = LightningEffect {
+ duration_seconds: 1.0,
+ visual: EffectResource {
+ archive: "material.lib".to_owned(),
+ name: "env_lightning".to_owned(),
+ },
+ visual_body: Arc::from(body.into_boxed_slice()),
+ opcode1_body: Arc::from(vec![0; 132].into_boxed_slice()),
+ opcode1_command_index: 1,
+ sound: EffectResource {
+ archive: "sounds.lib".to_owned(),
+ name: "atm_light1.wav".to_owned(),
+ },
+ sound_body: Arc::from(vec![0; 144].into_boxed_slice()),
+ visual_command_index: 0,
+ sound_command_index: 2,
+ };
+ let quad = effect
+ .evaluate_quad(0.5, [10.0, 20.0, 30.0], Camera::new([130.0, 80.0, 100.0]))
+ .expect("synthetic opcode 3 sample");
+ assert_eq!(quad.local_offset, [1.5, 2.0, 0.0]);
+ assert_eq!(quad.local_scale, [3.0, 4.0, 1.5]);
+ assert_eq!(quad.dimensions, [120.0, 160.0, 900.0]);
+ let center = quad
+ .vertices
+ .iter()
+ .fold([0.0; 3], |sum, vertex| add3(sum, *vertex))
+ .map(|component| component * 0.25);
+ assert_eq!(center, [70.0, 100.0, 30.0]);
+ assert!((quad.vertices[2][2] - quad.vertices[0][2] - 900.0).abs() < 1.0e-5);
+
+ let parallel_camera = Camera::new([70.0, 100.0, 630.0]);
+ assert!(effect
+ .evaluate_quad(0.5, [10.0, 20.0, 30.0], parallel_camera)
+ .is_none());
+ let fallback_camera = Camera::new([70.0, 100.0, 1_230.0]);
+ let fallback = effect
+ .evaluate_quad(0.5, [10.0, 20.0, 30.0], fallback_camera)
+ .expect("non-unit parallel view uses native fallback axis");
+ assert_eq!(fallback.vertices[0][1], fallback.vertices[1][1]);
+ assert!((fallback.vertices[0][0] - fallback.vertices[1][0] - 120.0).abs() < 1.0e-5);
+ }
+
+ #[test]
+ fn lightning_uses_native_delay_cooldown_and_fx_lifetime() {
+ let effect = LightningEffect {
+ duration_seconds: 0.75,
+ visual: EffectResource {
+ archive: "material.lib".to_owned(),
+ name: "env_lightning".to_owned(),
+ },
+ visual_body: {
+ let mut body = vec![0; 196];
+ body[0..4].copy_from_slice(&1_u32.to_le_bytes());
+ body[4..8].copy_from_slice(&(-1.0_f32).to_le_bytes());
+ body[8..12].copy_from_slice(&1.0_f32.to_le_bytes());
+ for offset in [96_usize, 100, 104, 108, 112, 116, 120, 124, 128] {
+ body[offset..offset + 4].copy_from_slice(&1.0_f32.to_le_bytes());
+ }
+ Arc::from(body.into_boxed_slice())
+ },
+ opcode1_body: {
+ let mut body = vec![0; 220];
+ body[8..12].copy_from_slice(&0.75_f32.to_le_bytes());
+ body[36..40].copy_from_slice(&1.0_f32.to_le_bytes());
+ body[128..132].copy_from_slice(&1.0_f32.to_le_bytes());
+ Arc::from(body.into_boxed_slice())
+ },
+ opcode1_command_index: 1,
+ sound: EffectResource {
+ archive: "sounds.lib".to_owned(),
+ name: "atm_light1.wav".to_owned(),
+ },
+ sound_body: Arc::from(vec![0; 144].into_boxed_slice()),
+ visual_command_index: 0,
+ sound_command_index: 2,
+ };
+ let mut system = EnvironmentSystem::new(11);
+ system.set_lightning_effect(effect);
+ let weather = WeatherInput {
+ lightning: WeatherState::new(true, 1.0, [1.0; 4]),
+ ..WeatherInput::clear()
+ };
+ let camera = Camera::new([50.0, 0.0, 0.0]);
+ system.set_lightning_bounds(LightningBounds::new(0.0, 0.0, 0.0, 0.0, 0.0));
+ // The constructor starts in native state 1, which schedules a random
+ // delay. No external trigger sequence is needed.
+ let first = system.update(0.1, weather.clone(), camera);
+ assert!(first.sounds.is_empty());
+ assert_eq!(system.lightning.phase, 2);
+
+ let mut one_shots = 0;
+ let mut effect_frames = 0;
+ let mut point_light_frames = 0;
+ let mut inspected_shape = false;
+ for _ in 0..200 {
+ let frame = system.update(0.1, weather.clone(), camera);
+ point_light_frames += frame.point_lights.len();
+ one_shots += frame
+ .sounds
+ .iter()
+ .filter(|sound| sound.action == SoundAction::OneShot)
+ .count();
+ for primitive in &frame.primitives {
+ if let EnvironmentPrimitive::Lightning(bolt) = primitive {
+ inspected_shape = true;
+ assert_eq!(bolt.quad.dimensions, LIGHTNING_QUAD_DIMENSIONS);
+ assert!(bolt
+ .quad
+ .vertices
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite()));
+ assert_eq!(
+ bolt.quad.uv,
+ [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]]
+ );
+ assert!(bolt.age_seconds >= 0.0 && bolt.age_seconds < bolt.duration_seconds);
+ assert_eq!(bolt.duration_seconds, 0.75);
+ effect_frames += 1;
+ }
+ }
+ }
+ assert!(one_shots >= 2, "native timer must repeat lightning");
+ assert!(effect_frames > 0);
+ assert!(inspected_shape);
+ assert!(point_light_frames > 0);
+
+ let clear = system.update(0.1, WeatherInput::clear(), camera);
+ assert!(clear.primitives.is_empty());
+ assert!(clear.point_lights.is_empty());
+ assert!(clear.sounds.is_empty());
+ }
+
+ #[test]
+ fn fractional_milliseconds_do_not_drift_at_120_fps() {
+ let mut system = EnvironmentSystem::new(1);
+ for _ in 0..120 {
+ let _ = system.update(1.0 / 120.0, WeatherInput::clear(), Camera::default());
+ }
+ assert_eq!(system.time_millis(), 1_000);
+ }
+
+ fn header(command_count: u32, duration: f32) -> Vec<u8> {
+ let mut out = Vec::with_capacity(60);
+ out.extend_from_slice(&command_count.to_le_bytes());
+ out.extend_from_slice(&1_u32.to_le_bytes());
+ out.extend_from_slice(&duration.to_bits().to_le_bytes());
+ out.extend_from_slice(&0.0_f32.to_bits().to_le_bytes());
+ out.extend_from_slice(&0_u32.to_le_bytes());
+ out.extend_from_slice(&0_u32.to_le_bytes());
+ for _ in 0..9 {
+ out.extend_from_slice(&0.0_f32.to_bits().to_le_bytes());
+ }
+ out
+ }
+
+ fn command(word: u32, size: usize) -> Vec<u8> {
+ let mut out = vec![0; size];
+ out[..4].copy_from_slice(&word.to_le_bytes());
+ out
+ }
+
+ fn copy_name(dst: &mut [u8], name: &[u8]) {
+ dst[..name.len()].copy_from_slice(name);
+ }
+}
diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs
index 3fa4aae..437ae5c 100644
--- a/crates/fparkan-fx/src/lib.rs
+++ b/crates/fparkan-fx/src/lib.rs
@@ -6,6 +6,11 @@
//! reference stub until opcode timing, gates, RNG, and command-body semantics
//! are backed by runtime-captured evidence.
+pub mod atmosphere;
+pub mod environment;
+pub mod shadow;
+pub mod sky;
+
use fparkan_binary::{Cursor, DecodeError};
use std::sync::Arc;
@@ -85,8 +90,13 @@ pub struct FxCommand {
pub word: u32,
/// Decoded opcode.
pub opcode: FxOpcode,
- /// Enabled bit.
- pub enabled: bool,
+ /// Native command flag stored in bit 8 of the command word.
+ ///
+ /// This flag is passed to the native command object as a mode/feature
+ /// flag. It does not disable the command: real effects such as
+ /// `env_lightning` use zero for all three command flags and still execute
+ /// opcode 3, opcode 1, and opcode 2.
+ pub native_flag: bool,
/// Command body after the word.
pub raw_body: Arc<[u8]>,
/// Resource references discovered in known command layouts.
@@ -314,7 +324,7 @@ pub fn decode_fxid(bytes: Arc<[u8]>) -> Result<FxDocument, FxError> {
commands.push(FxCommand {
word,
opcode,
- enabled: ((word >> 8) & 1) != 0,
+ native_flag: ((word >> 8) & 1) != 0,
raw_body,
resource_refs,
});
@@ -369,9 +379,6 @@ pub fn emit(state: &FxState, out: &mut Vec<FxEmission>) -> Result<(), FxError> {
return Ok(());
}
for (index, command) in state.document.commands.iter().enumerate() {
- if !command.enabled {
- continue;
- }
let command_index = u32::try_from(index).map_err(|_| DecodeError::IntegerOverflow)?;
if command.opcode == FxOpcode::Op2 {
out.push(FxEmission::Sound(FxSoundEvent { command_index }));
@@ -605,7 +612,7 @@ mod tests {
assert_eq!(document.header().command_count, 2);
assert_eq!(document.commands()[0].opcode, FxOpcode::Op2);
- assert!(document.commands()[0].enabled);
+ assert!(document.commands()[0].native_flag);
assert_eq!(
document.commands()[0].resource_refs[0].archive_name(),
b"sounds.lib"
@@ -641,6 +648,25 @@ mod tests {
}
#[test]
+ fn zero_native_flags_still_emit_every_command() {
+ let mut bytes = header(3);
+ bytes.extend_from_slice(&command(0x0003, 200));
+ bytes.extend_from_slice(&command(0x0001, 224));
+ bytes.extend_from_slice(&command(0x0002, 148));
+ let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"));
+
+ assert!(document
+ .commands()
+ .iter()
+ .all(|command| !command.native_flag));
+ let state = create_instance(document, FxSeed(9), Transform::default()).expect("state");
+ assert_eq!(
+ canonical_emission_capture(&state).expect("capture"),
+ b"P,0, Op3\nP,1, Op1\nS,2\n"
+ );
+ }
+
+ #[test]
fn rejects_unknown_opcode_at_command_index() {
let mut bytes = header(1);
bytes.extend_from_slice(&99_u32.to_le_bytes());
diff --git a/crates/fparkan-fx/src/shadow.rs b/crates/fparkan-fx/src/shadow.rs
new file mode 100644
index 0000000..64e2b8e
--- /dev/null
+++ b/crates/fparkan-fx/src/shadow.rs
@@ -0,0 +1,1785 @@
+//! CPU reference implementation of the native projected shadow path.
+//!
+//! The original renderer keeps shadow pages in one 256 by 256 ARGB4444
+//! texture. This module deliberately keeps that work independent from a
+//! graphics backend: callers provide world-space caster and receiver
+//! triangles, and receive an RGBA8 atlas plus ordinary projected geometry.
+
+use std::error::Error;
+use std::fmt;
+
+/// Native shadow atlas width in texels.
+pub const SHADOW_ATLAS_WIDTH: usize = 256;
+/// Native shadow atlas height in texels.
+pub const SHADOW_ATLAS_HEIGHT: usize = 256;
+/// Number of native page slots.
+pub const SHADOW_PAGE_COUNT: usize = 28;
+/// Maximum number of shadow casters considered by the native frame builder.
+///
+/// The atlas has 28 physical slots, but the native `CShade` object allocates
+/// only 20 live shadow records per frame.
+pub const SHADOW_MAX_ACTIVE_CASTERS: usize = 20;
+/// Native primary directional-light flag.
+pub const SHADOW_PRIMARY_DIRECTIONAL_FLAG: u32 = 0x0800_0000;
+/// Full receiver flag used by the ordinary shadow query.
+///
+/// `ShadowTriangle::mask` contains the full decoded face flags. The native
+/// general query excludes a triangle when this bit is present.
+pub const SHADOW_RECEIVER_MASK: u32 = 0x2000;
+/// Compact query mask passed to the native four-ray world call.
+pub const SHADOW_FOUR_RAY_QUERY_MASK_COMPACT: u32 = 0x8008;
+/// Full receiver flags produced by expanding [`SHADOW_FOUR_RAY_QUERY_MASK_COMPACT`].
+///
+/// Terrain expands the compact descriptor before testing a receiver triangle,
+/// so this is the mask that belongs in `ShadowTriangle::mask` comparisons.
+pub const SHADOW_FOUR_RAY_MASK: u32 = 0x0020_0020;
+const SHADOW_PROJECTION_EPSILON: f32 = -1.175_494_4e-37;
+const SHADOW_CLIP_EPSILON: f32 = f32::MIN_POSITIVE;
+const SHADOW_QUAD_UV_MAX: f32 = 0.99;
+
+/// Bounding sphere used by native light selection and page projection.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ShadowSphere {
+ /// World-space center.
+ pub center: [f32; 3],
+ /// Radius in world units.
+ pub radius: f32,
+}
+
+/// World-space triangle used by a caster or receiver.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ShadowTriangle {
+ /// Triangle positions in world space.
+ pub positions: [[f32; 3]; 3],
+ /// World-space normal. `new` derives this value when the caller does not
+ /// already have the native normal.
+ pub normal: [f32; 3],
+ /// Full decoded native face flags used as receiver exclusion bits.
+ pub mask: u32,
+}
+
+/// A world-space shadow caster.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ShadowCaster {
+ /// Stable caster id from the caller's object table.
+ pub id: u32,
+ /// Native object axes. Each entry is one world-space axis.
+ pub world_axes: [[f32; 3]; 3],
+ /// Native bounding sphere.
+ pub sphere: ShadowSphere,
+ /// Native object kind (`3` is the building path and `4` is the four-ray
+ /// actor path).
+ pub native_kind: u32,
+ /// Entire visible UNIT triangle set in world space.
+ pub triangles: Vec<ShadowTriangle>,
+}
+
+/// Camera values consumed by the native projected-size shadow LOD test.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ShadowCamera {
+ /// World-space camera position.
+ pub position: [f32; 3],
+ /// Viewport width in pixels.
+ pub viewport_width: f32,
+ /// Horizontal field of view in radians.
+ pub horizontal_fov: f32,
+}
+
+/// Native shadow quality controls used by the page allocator and LOD test.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ShadowLodSettings {
+ /// Minimum building LOD (`kind == 3`).
+ pub building_min_lod: u8,
+ /// Building screen-size multiplier.
+ pub building_detail: f32,
+ /// Minimum BTLU/robot LOD (`kind == 4`).
+ pub robot_min_lod: u8,
+ /// Robot screen-size multiplier.
+ pub robot_detail: f32,
+}
+
+impl Default for ShadowLodSettings {
+ fn default() -> Self {
+ Self {
+ building_min_lod: 1,
+ building_detail: 1.0,
+ robot_min_lod: 1,
+ robot_detail: 1.0,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct ShadowCacheSlot {
+ caster: u32,
+ age: u32,
+}
+
+/// Reusable native page ownership state.
+///
+/// The original allocator keeps page ownership and an age counter between
+/// frames. Keeping that small piece of state separate from the CPU raster
+/// output lets callers rebuild the atlas without changing a caster's UV page
+/// whenever the camera moves.
+#[derive(Clone, Debug)]
+pub struct ShadowPageCache {
+ slots: [Option<ShadowCacheSlot>; SHADOW_PAGE_COUNT],
+ /// Slots claimed while building the current frame. A slot already used
+ /// by an earlier candidate must not be evicted again by a later one.
+ frame_used: [bool; SHADOW_PAGE_COUNT],
+}
+
+impl Default for ShadowPageCache {
+ fn default() -> Self {
+ Self {
+ slots: [None; SHADOW_PAGE_COUNT],
+ frame_used: [false; SHADOW_PAGE_COUNT],
+ }
+ }
+}
+
+impl ShadowPageCache {
+ /// Builds a frame while preserving page ownership across calls.
+ pub fn build(&mut self, scene: ShadowScene<'_>) -> Result<ShadowFrame, ShadowError> {
+ self.begin_frame();
+ build_shadow_frame_inner(scene, Some(self))
+ }
+
+ /// Clears all page ownership. The next build will allocate from the
+ /// native preferred page groups again.
+ pub fn clear(&mut self) {
+ self.slots = [None; SHADOW_PAGE_COUNT];
+ self.frame_used = [false; SHADOW_PAGE_COUNT];
+ }
+
+ /// Returns the cached page slot for a caster, if it currently owns one.
+ #[must_use]
+ pub fn page_slot(&self, caster: u32) -> Option<usize> {
+ self.slots
+ .iter()
+ .enumerate()
+ .filter_map(|(index, slot)| {
+ slot.filter(|slot| slot.caster == caster)
+ .map(|slot| (index, slot.age))
+ })
+ .min_by_key(|&(_, age)| age)
+ .map(|(index, _)| index)
+ }
+
+ fn begin_frame(&mut self) {
+ self.frame_used = [false; SHADOW_PAGE_COUNT];
+ for slot in &mut self.slots {
+ if let Some(slot) = slot {
+ slot.age = slot.age.saturating_add(1);
+ }
+ }
+ }
+
+ fn assign(&mut self, caster: u32, lod: u8) -> Option<usize> {
+ // A native candidate is submitted at most once per frame. Preserve
+ // that invariant even when a caller repeats an id: falling through
+ // from the preferred group to the fallback group must not allocate a
+ // second page for the same caster.
+ if self.slots.iter().enumerate().any(|(index, slot)| {
+ self.frame_used[index] && slot.is_some_and(|slot| slot.caster == caster)
+ }) {
+ return None;
+ }
+ let large = lod <= 1;
+ let preferred = if large { 0..12 } else { 12..28 };
+ let fallback = if large { 12..28 } else { 0..28 };
+ let threshold = if large { 1 } else { 6 };
+ self.assign_range(caster, preferred, 6)
+ .or_else(|| self.assign_range(caster, fallback, threshold))
+ }
+
+ /// Applies the native allocator's inclusive-range search:
+ /// existing owner, first free slot, then the oldest eligible slot. A
+ /// current-frame slot is never eligible for reuse.
+ fn assign_range(
+ &mut self,
+ caster: u32,
+ range: std::ops::Range<usize>,
+ threshold: u32,
+ ) -> Option<usize> {
+ let mut oldest = None;
+ // 30BE0 scans the inclusive page range once. An existing owner or
+ // the first free page wins immediately; only an occupied page past
+ // the age threshold is deferred until the complete scan.
+ for index in range {
+ if self.frame_used[index] {
+ continue;
+ }
+ match self.slots[index] {
+ Some(slot) if slot.caster == caster => return self.claim(index, caster),
+ None => return self.claim(index, caster),
+ Some(slot) if slot.age >= threshold => {
+ // Native max-age selection keeps the later physical page
+ // on ties.
+ if oldest.is_none_or(|(age, old_index)| {
+ slot.age > age || (slot.age == age && index > old_index)
+ }) {
+ oldest = Some((slot.age, index));
+ }
+ }
+ Some(_) => {}
+ }
+ }
+ oldest.and_then(|(_, index)| self.claim(index, caster))
+ }
+
+ fn claim(&mut self, index: usize, caster: u32) -> Option<usize> {
+ if self.frame_used.get(index).copied().unwrap_or(true) {
+ return None;
+ }
+ self.frame_used[index] = true;
+ self.slots[index] = Some(ShadowCacheSlot { caster, age: 0 });
+ Some(index)
+ }
+}
+
+/// Native light records that can contribute to projected shadows.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum ShadowLight {
+ /// A directional sun or moon light.
+ Directional {
+ /// Native world-space direction.
+ direction: [f32; 3],
+ /// RGB intensity.
+ rgb: [f32; 3],
+ /// Whether the native slot is live.
+ active: bool,
+ /// Native light flags.
+ flags: u32,
+ },
+ /// A native point light.
+ Point {
+ /// World-space point-light position.
+ position: [f32; 3],
+ /// RGB intensity.
+ rgb: [f32; 3],
+ /// Light range.
+ range: f32,
+ /// Native attenuation coefficients.
+ coefficients: [f32; 3],
+ /// Whether the native slot is live.
+ active: bool,
+ /// Native light flags.
+ flags: u32,
+ },
+}
+
+/// The projection transform and opacity used by one atlas page.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct ShadowProjection {
+ /// Row-major matrix from shadow-local coordinates to world space.
+ pub world_from_shadow: [f32; 16],
+ /// Row-major matrix from world space to shadow-local coordinates.
+ pub shadow_from_world: [f32; 16],
+ /// Shadow axis (`M.Z`) used by the native receiver test.
+ pub shadow_axis: [f32; 3],
+ /// Shadow opacity before the receiver normal term.
+ pub opacity: f32,
+ /// Upper and lower native extrusion distances along `shadow_axis`.
+ pub upper_distance: f32,
+ pub lower_distance: f32,
+ /// Maximum light strength used by receiver opacity.
+ light_strength: f32,
+}
+
+/// One occupied native atlas page.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct ShadowPage {
+ /// Top-left atlas origin.
+ pub origin: [u32; 2],
+ /// Page side in texels, either 64 or 32.
+ pub size: u32,
+ /// Caster assigned to this page.
+ caster: u32,
+ /// Native projection transform for this page.
+ pub projection: ShadowProjection,
+}
+
+/// One vertex of a projected receiver shadow.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ShadowVertex {
+ /// World-space receiver position.
+ pub position: [f32; 3],
+ /// Normalized UV into the 256 by 256 atlas.
+ pub uv: [f32; 2],
+ /// Receiver alpha after native light and normal terms.
+ pub alpha: f32,
+ /// Receiver normal passed to the backend.
+ pub normal: [f32; 3],
+}
+
+/// CPU-produced shadow frame.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ShadowFrame {
+ /// RGBA8 atlas. Native `F000` texels become `[0, 0, 0, 255]`.
+ pub atlas_rgba8: Vec<u8>,
+ /// Projected receiver vertices.
+ pub vertices: Vec<ShadowVertex>,
+ /// Triangle-list indices into [`ShadowFrame::vertices`].
+ pub indices: Vec<u32>,
+}
+
+/// Inputs for one shadow build.
+pub struct ShadowScene<'a> {
+ /// Active light records.
+ pub lights: &'a [ShadowLight],
+ /// Visible world-space casters.
+ pub casters: &'a [ShadowCaster],
+ /// World-space receiver triangles.
+ pub receivers: &'a [ShadowTriangle],
+ /// Native fade multiplier before the `0.2` opacity scale.
+ pub fade: f32,
+ /// Native blur passes: zero, forward once, or forward then reverse.
+ pub smooth_passes: u32,
+ /// Camera used for native projected-size LOD selection. `None` keeps the
+ /// CPU reference useful for callers that already performed visibility and
+ /// LOD selection upstream.
+ pub camera: Option<ShadowCamera>,
+ /// Native shadow quality controls.
+ pub lod: ShadowLodSettings,
+}
+
+/// Shadow build failures at the renderer trust boundary.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum ShadowError {
+ /// A caller supplied a non-finite or otherwise unusable value.
+ InvalidInput(&'static str),
+}
+
+impl fmt::Display for ShadowError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::InvalidInput(field) => write!(f, "invalid shadow input: {field}"),
+ }
+ }
+}
+
+impl Error for ShadowError {}
+
+/// Builds one native-style projected shadow frame.
+pub fn build_shadow_frame(scene: ShadowScene<'_>) -> Result<ShadowFrame, ShadowError> {
+ build_shadow_frame_inner(scene, None)
+}
+
+fn build_shadow_frame_inner(
+ scene: ShadowScene<'_>,
+ mut cache: Option<&mut ShadowPageCache>,
+) -> Result<ShadowFrame, ShadowError> {
+ if !scene.fade.is_finite() || scene.fade < 0.0 {
+ return Err(ShadowError::InvalidInput("fade"));
+ }
+ if let Some(camera) = scene.camera {
+ if !camera.position.iter().all(|value| value.is_finite())
+ || !camera.viewport_width.is_finite()
+ || camera.viewport_width <= 0.0
+ || !camera.horizontal_fov.is_finite()
+ || camera.horizontal_fov <= 0.0
+ {
+ return Err(ShadowError::InvalidInput("shadow camera"));
+ }
+ }
+ if !scene.lod.building_detail.is_finite()
+ || scene.lod.building_detail <= 0.0
+ || !scene.lod.robot_detail.is_finite()
+ || scene.lod.robot_detail <= 0.0
+ {
+ return Err(ShadowError::InvalidInput("shadow LOD settings"));
+ }
+ for light in scene.lights {
+ validate_light(light)?;
+ }
+ for receiver in scene.receivers {
+ validate_triangle(receiver, "receiver")?;
+ }
+
+ let mut frame = ShadowFrame {
+ atlas_rgba8: vec![0; SHADOW_ATLAS_WIDTH * SHADOW_ATLAS_HEIGHT * 4],
+ vertices: Vec::new(),
+ indices: Vec::new(),
+ };
+ let mut pages = Vec::new();
+
+ let mut candidates = Vec::with_capacity(scene.casters.len().min(SHADOW_MAX_ACTIVE_CASTERS));
+ for caster in scene.casters {
+ validate_caster(caster)?;
+ let lod = scene
+ .camera
+ .map(|camera| projected_shadow_lod(caster, camera, scene.lod))
+ .unwrap_or(0);
+ if lod >= 8 {
+ continue;
+ }
+ candidates.push((caster, lod));
+ if candidates.len() == SHADOW_MAX_ACTIVE_CASTERS {
+ break;
+ }
+ }
+
+ let mut assignments = Vec::with_capacity(candidates.len());
+ for (caster, lod) in candidates {
+ let slot = match cache.as_deref_mut() {
+ Some(cache) => {
+ let Some(slot) = cache.assign(caster.id, lod) else {
+ // A page that is still too young, or a duplicate caster
+ // in this frame, is omitted by the native allocator. Do
+ // not invent a stateless slot: it could overwrite a page
+ // already assigned to an earlier candidate.
+ continue;
+ };
+ slot
+ }
+ None => assignments.len(),
+ };
+ assignments.push((slot, caster, lod));
+ }
+
+ // Terrain owns the candidate list and performs native radius/distance
+ // selection. The CPU contract therefore consumes the visible list in
+ // caller order while preserving the native 20-record frame budget. A
+ // cache supplies stable physical page slots; the stateless wrapper above
+ // retains the original deterministic slot order for simple callers.
+ for (slot, caster, _lod) in assignments {
+ let Some(contribution) = select_light(caster, scene.lights) else {
+ continue;
+ };
+ let (origin, size) = native_page_layout(slot);
+ let projection = make_projection(caster, contribution, scene.fade)?;
+ rasterize_page(
+ &mut frame.atlas_rgba8,
+ origin,
+ size,
+ caster,
+ projection,
+ scene.smooth_passes,
+ );
+ pages.push(ShadowPage {
+ origin: [origin[0] as u32, origin[1] as u32],
+ size: size as u32,
+ caster: caster.id,
+ projection,
+ });
+ }
+
+ // Page blur happens after all page rasterization and before receiver UVs
+ // are emitted, matching the native texture upload order.
+ for page in &pages {
+ project_receivers(&mut frame, page, scene.receivers, scene.casters);
+ }
+ Ok(frame)
+}
+
+/// Returns the fixed native page layout, including unused slots.
+#[cfg(test)]
+fn native_shadow_pages() -> Vec<([u32; 2], u32)> {
+ (0..SHADOW_PAGE_COUNT)
+ .map(native_page_layout)
+ .map(|(origin, size)| ([origin[0] as u32, origin[1] as u32], size as u32))
+ .collect()
+}
+
+#[derive(Clone, Copy, Debug)]
+struct LightContribution {
+ direction: [f32; 3],
+ strength: f32,
+}
+
+fn select_light(caster: &ShadowCaster, lights: &[ShadowLight]) -> Option<LightContribution> {
+ let center = caster.sphere.center;
+ let radius = caster.sphere.radius;
+ if caster.native_kind == 3 {
+ for light in lights {
+ if let ShadowLight::Directional {
+ direction,
+ rgb,
+ active: true,
+ flags,
+ } = *light
+ {
+ if flags & SHADOW_PRIMARY_DIRECTIONAL_FLAG == 0 {
+ continue;
+ }
+ let direction = normalize([direction[0], direction[1], direction[2] - 0.3])?;
+ let strength = length(rgb);
+ if strength > 0.0 && strength.is_finite() {
+ return Some(LightContribution {
+ direction,
+ strength,
+ });
+ }
+ }
+ }
+ return None;
+ }
+
+ let mut direction_sum = [0.0; 3];
+ let mut max_strength: f32 = 0.0;
+ for light in lights {
+ match *light {
+ ShadowLight::Directional {
+ direction,
+ rgb,
+ active: true,
+ flags,
+ } if flags & SHADOW_PRIMARY_DIRECTIONAL_FLAG != 0 => {
+ let direction = [direction[0], direction[1], direction[2] - 0.3];
+ let strength = length(rgb);
+ if strength > 0.0 && strength.is_finite() {
+ direction_sum = add(direction_sum, direction);
+ max_strength = max_strength.max(strength);
+ }
+ }
+ ShadowLight::Point {
+ position,
+ rgb,
+ range,
+ coefficients,
+ active: true,
+ flags,
+ } if flags & 0x8000_0000 == 0 && range >= 10.0 => {
+ let to_center = sub(center, position);
+ let distance = length(to_center);
+ if !(distance < range) || !distance.is_finite() {
+ continue;
+ }
+ let t = (range - distance) / range;
+ let strength = length(rgb)
+ * (coefficients[0] + coefficients[1] * t + coefficients[2] * t * t)
+ * 1.5;
+ if !(strength > 0.0) || !strength.is_finite() {
+ continue;
+ }
+ let Some(direction) = normalize(to_center) else {
+ continue;
+ };
+ let Some(direction) = normalize([direction[0], direction[1], -0.3]) else {
+ continue;
+ };
+ let contribution = (strength / 15.0 * range / (2.0 * radius)).min(2.0);
+ direction_sum = add(direction_sum, scale(direction, contribution));
+ max_strength = max_strength.max(strength);
+ }
+ _ => {}
+ }
+ }
+ // The ordinary-object path retains the magnitude of the accumulated
+ // vector; native uses it again in the receiver normal term.
+ if length(direction_sum) <= 1.0e-6 || !length(direction_sum).is_finite() {
+ return None;
+ }
+ Some(LightContribution {
+ direction: direction_sum,
+ strength: max_strength,
+ })
+}
+
+fn make_projection(
+ caster: &ShadowCaster,
+ light: LightContribution,
+ fade: f32,
+) -> Result<ShadowProjection, ShadowError> {
+ let center = caster.sphere.center;
+ let radius = caster.sphere.radius;
+ let light_direction =
+ normalize(light.direction).ok_or(ShadowError::InvalidInput("light direction"))?;
+ // Kind 3 arrives unit length. Other kinds intentionally keep the
+ // accumulated native vector magnitude in M.Z.
+ let shadow_axis = scale(light.direction, -1.0);
+ let mut axis = caster.world_axes[0];
+ let mut best = f32::INFINITY;
+ for candidate in caster.world_axes {
+ let Some(candidate) = normalize(candidate) else {
+ continue;
+ };
+ let score = dot(candidate, light_direction).abs();
+ if score < best {
+ best = score;
+ axis = candidate;
+ }
+ }
+ let mut u = normalize(cross(axis, shadow_axis));
+ if u.is_none() {
+ for candidate in identity_axes() {
+ u = normalize(cross(candidate, shadow_axis));
+ if u.is_some() {
+ break;
+ }
+ }
+ }
+ let u = u.ok_or(ShadowError::InvalidInput("world axes"))?;
+ let v = normalize(cross(shadow_axis, u)).ok_or(ShadowError::InvalidInput("shadow basis"))?;
+ let x_column = scale(v, 2.0 * radius);
+ let y_column = scale(u, -2.0 * radius);
+ let z_column = shadow_axis;
+ let world_from_shadow = matrix_from_columns(x_column, y_column, z_column, center);
+ let shadow_from_world = inverse_shadow_matrix(v, u, shadow_axis, center, radius);
+ Ok(ShadowProjection {
+ world_from_shadow,
+ shadow_from_world,
+ shadow_axis,
+ opacity: (fade * light.strength * 0.2).min(1.0),
+ upper_distance: 4.0 * radius,
+ lower_distance: -100.0,
+ light_strength: light.strength,
+ })
+}
+
+fn projected_shadow_lod(
+ caster: &ShadowCaster,
+ camera: ShadowCamera,
+ settings: ShadowLodSettings,
+) -> u8 {
+ let offset = sub(camera.position, caster.sphere.center);
+ let distance_squared = dot(offset, offset);
+ let radius_squared = caster.sphere.radius * caster.sphere.radius;
+ let measure = if distance_squared < radius_squared {
+ camera.viewport_width
+ } else {
+ let tangent_distance = (distance_squared - radius_squared).max(0.0).sqrt();
+ caster.sphere.radius.atan2(tangent_distance) * camera.viewport_width / camera.horizontal_fov
+ };
+ if !measure.is_finite() {
+ return 8;
+ }
+ match caster.native_kind {
+ 4 => {
+ let computed = if measure < 4.0 {
+ 8
+ } else if measure >= 70.0 {
+ 0
+ } else {
+ let scaled = measure * settings.robot_detail;
+ if scaled < 8.0 {
+ 3
+ } else if scaled < 30.0 {
+ 2
+ } else {
+ 1
+ }
+ };
+ computed.max(settings.robot_min_lod)
+ }
+ 3 => {
+ let computed = if measure < 10.0 {
+ 8
+ } else if measure >= 120.0 {
+ 1
+ } else {
+ let scaled = measure * settings.building_detail;
+ if scaled < 40.0 {
+ 3
+ } else if scaled < 80.0 {
+ 2
+ } else {
+ 1
+ }
+ };
+ computed.max(settings.building_min_lod)
+ }
+ _ => 8,
+ }
+}
+
+fn native_page_layout(index: usize) -> ([usize; 2], usize) {
+ // This is the physical order emitted by Terrain's page allocator, not a
+ // row-major packing approximation. The non-monotonic order is observable
+ // in the native page table and is part of the texture contract.
+ const LARGE: [[usize; 2]; 12] = [
+ [0, 0],
+ [0, 64],
+ [64, 64],
+ [64, 0],
+ [0, 128],
+ [0, 192],
+ [64, 192],
+ [64, 128],
+ [128, 0],
+ [128, 64],
+ [192, 64],
+ [192, 0],
+ ];
+ const SMALL: [[usize; 2]; 16] = [
+ [128, 128],
+ [128, 160],
+ [160, 160],
+ [160, 128],
+ [128, 192],
+ [128, 224],
+ [160, 224],
+ [160, 192],
+ [192, 192],
+ [192, 224],
+ [224, 224],
+ [224, 192],
+ [192, 128],
+ [192, 160],
+ [224, 160],
+ [224, 128],
+ ];
+ if index < LARGE.len() {
+ (LARGE[index], 64)
+ } else {
+ (SMALL[index - LARGE.len()], 32)
+ }
+}
+
+/// Matches the x86 `cvtt` helper used by Ngi103F0 with the renderer's
+/// default nearest-even rounding mode. `f32::round` is ties-away-from-zero,
+/// so it cannot be used for the native half-pixel boundaries.
+fn native_round(value: f32) -> isize {
+ debug_assert!(value.is_finite());
+ let lower = value.floor();
+ let fraction = value - lower;
+ let rounded = if fraction < 0.5 {
+ lower
+ } else if fraction > 0.5 {
+ lower + 1.0
+ } else if (lower as i64) & 1 == 0 {
+ lower
+ } else {
+ lower + 1.0
+ };
+ rounded as isize
+}
+
+fn rasterize_page(
+ atlas: &mut [u8],
+ origin: [usize; 2],
+ size: usize,
+ caster: &ShadowCaster,
+ projection: ShadowProjection,
+ smooth_passes: u32,
+) {
+ for triangle in &caster.triangles {
+ let points = triangle
+ .positions
+ .map(|position| transform_point(projection.shadow_from_world, position));
+ let area = cross2(sub2(points[1], points[0]), sub2(points[2], points[0]));
+ if !area.is_finite() || area < 0.0 {
+ continue;
+ }
+
+ // Ngi103F0 converts the projected endpoints with the active nearest,
+ // ties-to-even rounding mode. The raster loop visits rows below the
+ // rounded upper endpoint; evaluating the edge slopes at `y + 0.5`
+ // reproduces the native pixel-center scanline.
+ let min_y = points
+ .iter()
+ .map(|point| point[1])
+ .fold(f32::INFINITY, f32::min);
+ let max_y = points
+ .iter()
+ .map(|point| point[1])
+ .fold(f32::NEG_INFINITY, f32::max);
+ let first_y = native_round((min_y + 0.5) * size as f32);
+ let last_y = native_round((max_y + 0.5) * size as f32);
+ let first_y = first_y.max(0);
+ let last_y = last_y.min(size as isize);
+ if first_y >= last_y {
+ continue;
+ }
+ for y in first_y as usize..last_y as usize {
+ let local_y = (y as f32 + 0.5) / size as f32 - 0.5;
+ let mut intersections = [0.0_f32; 3];
+ let mut count = 0;
+ for edge in [(0usize, 1usize), (1, 2), (2, 0)] {
+ let a = points[edge.0];
+ let b = points[edge.1];
+ if ((a[1] <= local_y && b[1] > local_y) || (b[1] <= local_y && a[1] > local_y))
+ && count < intersections.len()
+ {
+ let t = (local_y - a[1]) / (b[1] - a[1]);
+ intersections[count] = a[0] + (b[0] - a[0]) * t;
+ count += 1;
+ }
+ }
+ if count < 2 {
+ continue;
+ }
+ let min_x = intersections[..count]
+ .iter()
+ .copied()
+ .fold(f32::INFINITY, f32::min);
+ let max_x = intersections[..count]
+ .iter()
+ .copied()
+ .fold(f32::NEG_INFINITY, f32::max);
+
+ // Ngi103F0 is also used for both X intersections. The native
+ // fill includes the rounded right endpoint, which matters for
+ // half-pixel and zero-width scanline cases.
+ let first = native_round((min_x + 0.5) * size as f32);
+ let last = native_round((max_x + 0.5) * size as f32);
+ let first = first.max(0);
+ let last = last.min(size as isize - 1);
+ if first > last {
+ continue;
+ }
+ for x in first as usize..=last as usize {
+ write_alpha_nibble(atlas, origin, size, x, y, 15);
+ }
+ }
+ }
+ if smooth_passes > 0 {
+ blur_page(atlas, origin, size, false);
+ }
+ if smooth_passes > 1 {
+ blur_page(atlas, origin, size, true);
+ }
+}
+
+fn blur_page(atlas: &mut [u8], origin: [usize; 2], size: usize, reverse: bool) {
+ if size < 2 {
+ return;
+ }
+
+ // FastProc3A368/24510 operates in place. The forward pass writes the
+ // top-left texel of each 2x2 window in row-major order; the last row and
+ // column are never visited. The optional reverse pass mirrors that
+ // traversal and writes the bottom-right texel, leaving the first row and
+ // column untouched. Native integer division always uses four samples,
+ // including at the page boundary.
+ if reverse {
+ for y in (1..size).rev() {
+ for x in (1..size).rev() {
+ let total = u32::from(read_alpha_nibble(atlas, origin, size, x - 1, y - 1))
+ + u32::from(read_alpha_nibble(atlas, origin, size, x, y - 1))
+ + u32::from(read_alpha_nibble(atlas, origin, size, x - 1, y))
+ + u32::from(read_alpha_nibble(atlas, origin, size, x, y));
+ write_alpha_nibble(atlas, origin, size, x, y, (total / 4) as u8);
+ }
+ }
+ } else {
+ for y in 0..size - 1 {
+ for x in 0..size - 1 {
+ let total = u32::from(read_alpha_nibble(atlas, origin, size, x, y))
+ + u32::from(read_alpha_nibble(atlas, origin, size, x + 1, y))
+ + u32::from(read_alpha_nibble(atlas, origin, size, x, y + 1))
+ + u32::from(read_alpha_nibble(atlas, origin, size, x + 1, y + 1));
+ write_alpha_nibble(atlas, origin, size, x, y, (total / 4) as u8);
+ }
+ }
+ }
+}
+
+#[derive(Clone, Copy)]
+struct ClipVertex {
+ position: [f32; 3],
+ uv: [f32; 2],
+}
+
+fn project_receivers(
+ frame: &mut ShadowFrame,
+ page: &ShadowPage,
+ receivers: &[ShadowTriangle],
+ casters: &[ShadowCaster],
+) {
+ let Some(caster) = casters.iter().find(|caster| caster.id == page.caster) else {
+ return;
+ };
+ // Kind-4 small actors select the four-ray receiver path once for the
+ // caster. The native query descriptor is compact 0x8008, but Landscape
+ // expands it to full face flags 0x00200020 before filtering triangles;
+ // all other casters use the ordinary full 0x2000 exclusion bit.
+ let ray_path = caster.native_kind == 4 && caster.sphere.radius < 10.0;
+ let ray_fade = ray_path.then(|| four_ray_fade(page, receivers));
+ for receiver in receivers {
+ let use_rays = ray_path && receiver.mask & SHADOW_FOUR_RAY_MASK == 0;
+ let use_general = !ray_path && receiver.mask & SHADOW_RECEIVER_MASK == 0;
+ if !use_rays && !use_general {
+ continue;
+ }
+ let normal = normalize(receiver.normal).unwrap_or_else(|| {
+ normalize(cross(
+ sub(receiver.positions[1], receiver.positions[0]),
+ sub(receiver.positions[2], receiver.positions[0]),
+ ))
+ .unwrap_or([0.0, 0.0, 0.0])
+ });
+ let normal_term = dot(page.projection.shadow_axis, normal);
+ if normal_term <= 0.0 || !normal_term.is_finite() {
+ continue;
+ }
+
+ let plane_distance = dot(normal, receiver.positions[0]);
+ let corners = shadow_quad(&page.projection);
+ let mut projected = Vec::with_capacity(4);
+ for (upper, lower, uv) in corners.iter() {
+ let delta = sub(*lower, *upper);
+ if dot(normal, delta) >= SHADOW_PROJECTION_EPSILON {
+ projected.clear();
+ break;
+ }
+ let upper_distance = dot(normal, *upper) - plane_distance;
+ let lower_distance = dot(normal, *lower) - plane_distance;
+ if upper_distance < 0.0 || lower_distance >= 0.0 {
+ projected.clear();
+ break;
+ }
+ let denominator = upper_distance - lower_distance;
+ if denominator <= SHADOW_CLIP_EPSILON {
+ projected.clear();
+ break;
+ }
+ let t = (upper_distance / denominator).clamp(0.0, 1.0);
+ projected.push(ClipVertex {
+ position: lerp(*upper, *lower, t),
+ uv: *uv,
+ });
+ }
+ if projected.len() != 4 {
+ continue;
+ }
+
+ // Terrain's receiver routine clips the projected four-corner quad
+ // against the three receiver edge planes. Keeping UV with the point
+ // makes the later fan exactly the native interpolated quad.
+ for edge in 0..3 {
+ let a = receiver.positions[edge];
+ let b = receiver.positions[(edge + 1) % 3];
+ projected = clip_polygon(projected, |vertex| {
+ dot(cross(sub(b, a), sub(vertex.position, a)), normal)
+ });
+ }
+ projected.truncate(9);
+ if projected.len() < 3 {
+ continue;
+ }
+ let opacity = if use_rays {
+ (ray_fade.unwrap_or(0.0) * page.projection.light_strength * 0.2).min(1.0)
+ } else {
+ page.projection.opacity
+ };
+ let alpha = opacity * normal_term.sqrt();
+ if alpha <= 0.0 || !alpha.is_finite() {
+ continue;
+ }
+ let start = frame.vertices.len() as u32;
+ for vertex in &projected {
+ frame.vertices.push(ShadowVertex {
+ position: vertex.position,
+ uv: [
+ (page.origin[0] as f32 + vertex.uv[0] * page.size as f32)
+ / SHADOW_ATLAS_WIDTH as f32,
+ (page.origin[1] as f32 + vertex.uv[1] * page.size as f32)
+ / SHADOW_ATLAS_HEIGHT as f32,
+ ],
+ alpha,
+ normal,
+ });
+ }
+ for index in 1..projected.len() - 1 {
+ frame.indices.extend_from_slice(&[
+ start,
+ start + index as u32,
+ start + index as u32 + 1,
+ ]);
+ }
+ }
+}
+
+fn shadow_quad(projection: &ShadowProjection) -> [([f32; 3], [f32; 3], [f32; 2]); 4] {
+ let uv_max = SHADOW_QUAD_UV_MAX;
+ let corners = [[0.0, 0.0], [uv_max, 0.0], [uv_max, uv_max], [0.0, uv_max]];
+ corners.map(|corner| {
+ let local = [corner[0] - 0.5, corner[1] - 0.5];
+ let uv = corner;
+ (
+ transform_point(
+ projection.world_from_shadow,
+ [local[0], local[1], projection.upper_distance],
+ ),
+ transform_point(
+ projection.world_from_shadow,
+ [local[0], local[1], projection.lower_distance],
+ ),
+ uv,
+ )
+ })
+}
+
+/// Casts the four native shadow-quad segments against receiver query
+/// triangles. A miss contributes nothing; the native average divides by the
+/// number of successful ray hits rather than by four.
+fn four_ray_fade(page: &ShadowPage, receivers: &[ShadowTriangle]) -> f32 {
+ let corners = shadow_quad(&page.projection);
+ let mut sum = 0.0;
+ let mut hits = 0_u32;
+ for (upper, lower, _) in corners {
+ let delta = sub(lower, upper);
+ let mut nearest = f32::INFINITY;
+ for receiver in receivers {
+ if receiver.mask & SHADOW_FOUR_RAY_MASK != 0 {
+ continue;
+ }
+ if let Some(parameter) = segment_triangle_parameter(upper, delta, receiver) {
+ let hit = add(upper, scale(delta, parameter));
+ let offset = sub(hit, upper);
+ let distance_squared = dot(offset, offset);
+ nearest = nearest.min(distance_squared);
+ }
+ }
+ if nearest.is_finite() {
+ sum += (1.0 - nearest * 0.0001).clamp(0.0, 1.0);
+ hits += 1;
+ }
+ }
+ if hits == 0 {
+ 0.0
+ } else {
+ sum / hits as f32
+ }
+}
+
+fn segment_triangle_parameter(
+ origin: [f32; 3],
+ delta: [f32; 3],
+ triangle: &ShadowTriangle,
+) -> Option<f32> {
+ let edge1 = sub(triangle.positions[1], triangle.positions[0]);
+ let edge2 = sub(triangle.positions[2], triangle.positions[0]);
+ let p = cross(delta, edge2);
+ let determinant = dot(edge1, p);
+ if determinant.abs() <= SHADOW_CLIP_EPSILON {
+ return None;
+ }
+ let inverse = 1.0 / determinant;
+ let tvec = sub(origin, triangle.positions[0]);
+ let u = dot(tvec, p) * inverse;
+ if !(0.0..=1.0).contains(&u) {
+ return None;
+ }
+ let q = cross(tvec, edge1);
+ let v = dot(delta, q) * inverse;
+ if v < 0.0 || u + v > 1.0 {
+ return None;
+ }
+ let parameter = dot(edge2, q) * inverse;
+ (parameter >= 0.0 && parameter <= 1.0).then_some(parameter)
+}
+
+fn clip_polygon<F>(polygon: Vec<ClipVertex>, signed_distance: F) -> Vec<ClipVertex>
+where
+ F: Fn(ClipVertex) -> f32,
+{
+ if polygon.is_empty() {
+ return polygon;
+ }
+ let mut output = Vec::with_capacity(polygon.len() + 1);
+ let mut previous = *polygon.last().expect("non-empty polygon");
+ let mut previous_distance = signed_distance(previous);
+ for &current in &polygon {
+ let current_distance = signed_distance(current);
+ let previous_inside = previous_distance >= 0.0;
+ let current_inside = current_distance >= 0.0;
+ if previous_inside != current_inside {
+ let denominator = previous_distance - current_distance;
+ let t = if denominator.abs() > SHADOW_CLIP_EPSILON {
+ previous_distance / denominator
+ } else {
+ 0.0
+ };
+ output.push(interpolate_clip(previous, current, t));
+ }
+ if current_inside {
+ output.push(current);
+ }
+ previous = current;
+ previous_distance = current_distance;
+ }
+ output
+}
+
+fn interpolate_clip(a: ClipVertex, b: ClipVertex, t: f32) -> ClipVertex {
+ ClipVertex {
+ position: lerp(a.position, b.position, t),
+ uv: [
+ a.uv[0] + (b.uv[0] - a.uv[0]) * t,
+ a.uv[1] + (b.uv[1] - a.uv[1]) * t,
+ ],
+ }
+}
+
+fn validate_caster(caster: &ShadowCaster) -> Result<(), ShadowError> {
+ if !caster.sphere.radius.is_finite() || caster.sphere.radius <= 0.0 {
+ return Err(ShadowError::InvalidInput("caster radius"));
+ }
+ if !caster.sphere.center.iter().all(|value| value.is_finite())
+ || !caster
+ .world_axes
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ {
+ return Err(ShadowError::InvalidInput("caster transform"));
+ }
+ for triangle in &caster.triangles {
+ validate_triangle(triangle, "caster")?;
+ }
+ Ok(())
+}
+
+fn validate_triangle(triangle: &ShadowTriangle, field: &'static str) -> Result<(), ShadowError> {
+ if !triangle
+ .positions
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ || !triangle.normal.iter().all(|value| value.is_finite())
+ {
+ return Err(ShadowError::InvalidInput(field));
+ }
+ Ok(())
+}
+
+fn validate_light(light: &ShadowLight) -> Result<(), ShadowError> {
+ match *light {
+ ShadowLight::Directional { direction, rgb, .. }
+ if direction.iter().all(|value| value.is_finite())
+ && rgb.iter().all(|value| value.is_finite()) =>
+ {
+ Ok(())
+ }
+ ShadowLight::Point {
+ position,
+ rgb,
+ range,
+ coefficients,
+ ..
+ } if position.iter().all(|value| value.is_finite())
+ && rgb.iter().all(|value| value.is_finite())
+ && range.is_finite()
+ && coefficients.iter().all(|value| value.is_finite()) =>
+ {
+ Ok(())
+ }
+ _ => Err(ShadowError::InvalidInput("light")),
+ }
+}
+
+fn write_alpha_nibble(
+ atlas: &mut [u8],
+ origin: [usize; 2],
+ size: usize,
+ x: usize,
+ y: usize,
+ nibble: u8,
+) {
+ let index = ((origin[1] + y) * SHADOW_ATLAS_WIDTH + origin[0] + x) * 4;
+ if index + 3 < atlas.len() {
+ atlas[index] = 0;
+ atlas[index + 1] = 0;
+ atlas[index + 2] = 0;
+ atlas[index + 3] = nibble.saturating_mul(17);
+ }
+ let _ = size;
+}
+
+fn read_alpha_nibble(atlas: &[u8], origin: [usize; 2], _size: usize, x: usize, y: usize) -> u8 {
+ let index = ((origin[1] + y) * SHADOW_ATLAS_WIDTH + origin[0] + x) * 4;
+ atlas.get(index + 3).copied().unwrap_or(0) / 17
+}
+
+fn matrix_from_columns(x: [f32; 3], y: [f32; 3], z: [f32; 3], translation: [f32; 3]) -> [f32; 16] {
+ [
+ x[0],
+ y[0],
+ z[0],
+ translation[0],
+ x[1],
+ y[1],
+ z[1],
+ translation[1],
+ x[2],
+ y[2],
+ z[2],
+ translation[2],
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ ]
+}
+
+fn inverse_shadow_matrix(
+ v: [f32; 3],
+ u: [f32; 3],
+ shadow_axis: [f32; 3],
+ center: [f32; 3],
+ radius: f32,
+) -> [f32; 16] {
+ let x_scale = scale(v, 1.0 / (2.0 * radius));
+ let y_scale = scale(u, -1.0 / (2.0 * radius));
+ let z_scale = scale(shadow_axis, 1.0 / dot(shadow_axis, shadow_axis));
+ [
+ x_scale[0],
+ x_scale[1],
+ x_scale[2],
+ -dot(x_scale, center),
+ y_scale[0],
+ y_scale[1],
+ y_scale[2],
+ -dot(y_scale, center),
+ z_scale[0],
+ z_scale[1],
+ z_scale[2],
+ -dot(z_scale, center),
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ ]
+}
+
+fn transform_point(matrix: [f32; 16], point: [f32; 3]) -> [f32; 3] {
+ [
+ matrix[0] * point[0] + matrix[1] * point[1] + matrix[2] * point[2] + matrix[3],
+ matrix[4] * point[0] + matrix[5] * point[1] + matrix[6] * point[2] + matrix[7],
+ matrix[8] * point[0] + matrix[9] * point[1] + matrix[10] * point[2] + matrix[11],
+ ]
+}
+
+fn identity_axes() -> [[f32; 3]; 3] {
+ [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
+}
+
+fn add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
+ [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
+}
+
+fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
+ [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
+}
+
+fn scale(a: [f32; 3], scalar: f32) -> [f32; 3] {
+ [a[0] * scalar, a[1] * scalar, a[2] * scalar]
+}
+
+fn lerp(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
+ add(a, scale(sub(b, a), t))
+}
+
+fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
+ a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
+}
+
+fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
+ [
+ a[1] * b[2] - a[2] * b[1],
+ a[2] * b[0] - a[0] * b[2],
+ a[0] * b[1] - a[1] * b[0],
+ ]
+}
+
+fn cross2(a: [f32; 2], b: [f32; 2]) -> f32 {
+ a[0] * b[1] - a[1] * b[0]
+}
+
+fn sub2(a: [f32; 3], b: [f32; 3]) -> [f32; 2] {
+ [a[0] - b[0], a[1] - b[1]]
+}
+
+fn length(a: [f32; 3]) -> f32 {
+ dot(a, a).sqrt()
+}
+
+fn normalize(a: [f32; 3]) -> Option<[f32; 3]> {
+ let length = length(a);
+ (length > 1.0e-6 && length.is_finite()).then(|| scale(a, 1.0 / length))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn native_layout_has_twelve_large_and_sixteen_small_pages() {
+ let pages = native_shadow_pages();
+ assert_eq!(pages.len(), 28);
+ assert_eq!(
+ pages[..12].iter().map(|(_, size)| *size).sum::<u32>(),
+ 12 * 64
+ );
+ assert!(pages[..12].iter().all(|(_, size)| *size == 64));
+ assert!(pages[12..].iter().all(|(_, size)| *size == 32));
+ assert_eq!(
+ pages.iter().map(|(origin, _)| *origin).collect::<Vec<_>>(),
+ vec![
+ [0, 0],
+ [0, 64],
+ [64, 64],
+ [64, 0],
+ [0, 128],
+ [0, 192],
+ [64, 192],
+ [64, 128],
+ [128, 0],
+ [128, 64],
+ [192, 64],
+ [192, 0],
+ [128, 128],
+ [128, 160],
+ [160, 160],
+ [160, 128],
+ [128, 192],
+ [128, 224],
+ [160, 224],
+ [160, 192],
+ [192, 192],
+ [192, 224],
+ [224, 224],
+ [224, 192],
+ [192, 128],
+ [192, 160],
+ [224, 160],
+ [224, 128],
+ ]
+ );
+ }
+
+ #[test]
+ fn directional_shadow_raster_and_receiver_projection_are_concrete() {
+ let caster_triangle = triangle([[-1.0, -1.0, 0.0], [0.0, 1.0, 0.0], [1.0, -1.0, 0.0]], 0);
+ let caster = caster(7, 2.0, 1, vec![caster_triangle]);
+ let receiver = triangle([[-1.0, -1.0, 1.0], [0.0, 1.0, 1.0], [1.0, -1.0, 1.0]], 0);
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 1.0],
+ rgb: [1.0, 1.0, 1.0],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let frame = build_shadow_frame(ShadowScene {
+ lights: &[light],
+ casters: &[caster],
+ receivers: &[receiver],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("shadow frame");
+ assert_eq!(frame.atlas_rgba8.len(), 256 * 256 * 4);
+ assert!(frame.atlas_rgba8.chunks_exact(4).any(|rgba| rgba[3] == 255));
+ assert!(!frame.vertices.is_empty());
+ assert_eq!(frame.indices.len() % 3, 0);
+ assert!(frame.vertices.iter().all(|vertex| vertex.alpha > 0.0));
+ }
+
+ #[test]
+ fn point_light_range_and_native_flags_gate_pages() {
+ let caster = caster(1, 1.0, 1, Vec::new());
+ let inactive = ShadowLight::Point {
+ position: [0.0, 0.0, -1.0],
+ rgb: [1.0; 3],
+ range: 20.0,
+ coefficients: [1.0, 0.0, 0.0],
+ active: true,
+ flags: 0x8000_0000,
+ };
+ let frame = build_shadow_frame(ShadowScene {
+ lights: &[inactive],
+ casters: &[caster],
+ receivers: &[],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("valid empty frame");
+ assert!(frame.atlas_rgba8.iter().all(|byte| *byte == 0));
+ }
+
+ #[test]
+ fn ordinary_directional_uses_primary_raw_direction_and_max_strength_only() {
+ let caster = caster(1, 1.0, 1, Vec::new());
+ let lights = [
+ ShadowLight::Directional {
+ direction: [2.0, 0.0, 4.0],
+ rgb: [0.0, 0.0, 3.0],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ },
+ ShadowLight::Directional {
+ direction: [100.0, 0.0, 100.0],
+ rgb: [100.0; 3],
+ active: true,
+ flags: 0x1000_0000,
+ },
+ ];
+ let contribution = select_light(&caster, &lights).expect("primary directional");
+ assert_eq!(contribution.direction, [2.0, 0.0, 3.7]);
+ assert!((contribution.strength - 3.0).abs() < 1.0e-6);
+ }
+
+ #[test]
+ fn point_direction_overwrites_normalized_z_before_second_normalize() {
+ let caster = caster(1, 1.0, 1, Vec::new());
+ let light = ShadowLight::Point {
+ position: [0.0, 0.0, -5.0],
+ rgb: [1.0; 3],
+ range: 20.0,
+ coefficients: [1.0, 0.0, 0.0],
+ active: true,
+ flags: 0,
+ };
+ let contribution = select_light(&caster, &[light]).expect("point light");
+ let expected_strength = 3.0_f32.sqrt() * 1.5;
+ let expected_contribution = expected_strength / 15.0 * 20.0 / 2.0;
+ assert!((contribution.direction[2] + expected_contribution).abs() < 1.0e-6);
+ assert!(contribution.direction[0].abs() < 1.0e-6);
+ assert!((contribution.strength - expected_strength).abs() < 1.0e-6);
+ }
+
+ #[test]
+ fn four_ray_fade_averages_successes_and_zeroes_all_misses() {
+ let caster = caster(1, 1.0, 4, Vec::new());
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 1.0],
+ rgb: [1.0; 3],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let projection = make_projection(
+ &caster,
+ select_light(&caster, &[light]).expect("light"),
+ 1.0,
+ )
+ .expect("projection");
+ let page = ShadowPage {
+ origin: [0, 0],
+ size: 64,
+ caster: 1,
+ projection,
+ };
+ let partial = triangle([[-1.0, 1.0, 1.0], [0.98, 1.0, 1.0], [-1.0, -0.98, 1.0]], 0);
+ let fade = four_ray_fade(&page, &[partial]);
+ assert!(
+ fade > 0.9,
+ "three successful rays must not be divided by four"
+ );
+ let fifty_world_units = triangle(
+ [
+ [-100.0, -100.0, 47.2],
+ [100.0, -100.0, 47.2],
+ [0.0, 100.0, 47.2],
+ ],
+ 0,
+ );
+ let fade_at_fifty = four_ray_fade(&page, &[fifty_world_units]);
+ assert!(
+ (fade_at_fifty - 0.75).abs() < 1.0e-6,
+ "fade at 50 world units: {fade_at_fifty}"
+ );
+ let miss = triangle([[10.0, 10.0, 1.0], [11.0, 10.0, 1.0], [10.0, 11.0, 1.0]], 0);
+ assert_eq!(four_ray_fade(&page, &[miss]), 0.0);
+ let excluded = triangle(
+ [[-1.0, 1.0, 1.0], [0.98, 1.0, 1.0], [-1.0, -0.98, 1.0]],
+ SHADOW_FOUR_RAY_MASK,
+ );
+ assert_eq!(four_ray_fade(&page, &[excluded]), 0.0);
+ }
+
+ #[test]
+ fn ordinary_alpha_keeps_native_hdr_normal_term_after_opacity_clamp() {
+ let caster = caster(1, 1.0, 1, Vec::new());
+ let receiver = triangle([[-1.0, -1.0, 1.0], [0.0, 1.0, 1.0], [1.0, -1.0, 1.0]], 0);
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 10.0],
+ rgb: [100.0; 3],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let frame = build_shadow_frame(ShadowScene {
+ lights: &[light],
+ casters: &[caster],
+ receivers: &[receiver],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("HDR shadow frame");
+ assert!(frame.vertices.iter().any(|vertex| vertex.alpha > 1.0));
+ }
+
+ #[test]
+ fn smoothing_uses_nibble_quantization() {
+ let caster_triangle = triangle([[-1.0, -1.0, 0.0], [0.0, 1.0, 0.0], [1.0, -1.0, 0.0]], 0);
+ let caster = caster(1, 2.0, 1, vec![caster_triangle]);
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 1.0],
+ rgb: [1.0; 3],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let frame = build_shadow_frame(ShadowScene {
+ lights: &[light],
+ casters: &[caster],
+ receivers: &[],
+ fade: 1.0,
+ smooth_passes: 2,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("smooth frame");
+ assert!(frame
+ .atlas_rgba8
+ .chunks_exact(4)
+ .any(|rgba| rgba[3] > 0 && rgba[3] < 255 && rgba[3] % 17 == 0));
+ }
+
+ #[test]
+ fn native_rounding_and_blur_preserve_asymmetric_page_edges() {
+ assert_eq!(native_round(1.5), 2);
+ assert_eq!(native_round(2.5), 2);
+ assert_eq!(native_round(-0.5), 0);
+ assert_eq!(native_round(-1.5), -2);
+
+ let size = 4;
+ let origin = [0, 0];
+ let mut atlas = vec![0; SHADOW_ATLAS_WIDTH * SHADOW_ATLAS_HEIGHT * 4];
+ for y in 0..size {
+ for x in 0..size {
+ write_alpha_nibble(&mut atlas, origin, size, x, y, (y * size + x) as u8);
+ }
+ }
+ blur_page(&mut atlas, origin, size, false);
+ let expected_forward = [
+ [2, 3, 4, 3],
+ [6, 7, 8, 7],
+ [10, 11, 12, 11],
+ [12, 13, 14, 15],
+ ];
+ for (y, row) in expected_forward.iter().enumerate() {
+ for (x, expected) in row.iter().enumerate() {
+ assert_eq!(
+ read_alpha_nibble(&atlas, origin, size, x, y),
+ *expected,
+ "forward blur at ({x},{y})"
+ );
+ }
+ }
+
+ let mut reverse_atlas = vec![0; SHADOW_ATLAS_WIDTH * SHADOW_ATLAS_HEIGHT * 4];
+ for y in 0..size {
+ for x in 0..size {
+ write_alpha_nibble(&mut reverse_atlas, origin, size, x, y, (y * size + x) as u8);
+ }
+ }
+ blur_page(&mut reverse_atlas, origin, size, true);
+ let expected_reverse = [[0, 1, 2, 3], [4, 2, 3, 4], [8, 6, 7, 8], [12, 10, 11, 12]];
+ for (y, row) in expected_reverse.iter().enumerate() {
+ for (x, expected) in row.iter().enumerate() {
+ assert_eq!(
+ read_alpha_nibble(&reverse_atlas, origin, size, x, y),
+ *expected,
+ "reverse blur at ({x},{y})"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn native_lod_uses_projected_measure_and_reuses_page_slots() {
+ let mut building = caster(41, 1.0, 3, Vec::new());
+ building.sphere.center = [0.0, 0.0, 1.05];
+ let camera = ShadowCamera {
+ position: [0.0, 0.0, 0.0],
+ viewport_width: 100.0,
+ horizontal_fov: std::f32::consts::FRAC_PI_2,
+ };
+ let close_lod = projected_shadow_lod(&building, camera, ShadowLodSettings::default());
+ assert_eq!(close_lod, 1);
+
+ let mut cache = ShadowPageCache::default();
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 1.0],
+ rgb: [1.0; 3],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let first = cache
+ .build(ShadowScene {
+ lights: &[light],
+ casters: &[building.clone()],
+ receivers: &[],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: Some(camera),
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("first cached frame");
+ assert_eq!(
+ first.atlas_rgba8.len(),
+ SHADOW_ATLAS_WIDTH * SHADOW_ATLAS_HEIGHT * 4
+ );
+ let slot = cache.page_slot(building.id).expect("cached page");
+ let second = cache
+ .build(ShadowScene {
+ lights: &[light],
+ casters: &[building],
+ receivers: &[],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: Some(camera),
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("reused cached frame");
+ assert_eq!(cache.page_slot(41), Some(slot));
+ assert_eq!(second.atlas_rgba8.len(), first.atlas_rgba8.len());
+ }
+
+ #[test]
+ fn native_lod_rejects_small_and_unknown_shadow_objects() {
+ let camera = ShadowCamera {
+ position: [0.0, 0.0, 0.0],
+ viewport_width: 100.0,
+ horizontal_fov: std::f32::consts::FRAC_PI_2,
+ };
+ let mut robot = caster(1, 1.0, 4, Vec::new());
+ robot.sphere.center = [0.0, 0.0, 100.0];
+ assert_eq!(
+ projected_shadow_lod(&robot, camera, ShadowLodSettings::default()),
+ 8
+ );
+ let unknown = caster(2, 100.0, 10, Vec::new());
+ assert_eq!(
+ projected_shadow_lod(&unknown, camera, ShadowLodSettings::default()),
+ 8
+ );
+ }
+
+ #[test]
+ fn page_cache_keeps_current_frame_slots_unique_and_rejects_stale_overflow() {
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 1.0],
+ rgb: [1.0; 3],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let casters = (0..40)
+ .map(|id| caster(id, 1.0, 3, Vec::new()))
+ .collect::<Vec<_>>();
+ let mut cache = ShadowPageCache::default();
+
+ for (start, expected_used) in [(0, 20), (20, 16), (20, 16)] {
+ let selected = casters[start..start + 20].to_vec();
+ let _frame = cache
+ .build(ShadowScene {
+ lights: &[light],
+ casters: &selected,
+ receivers: &[],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("cached multi-frame shadow build");
+
+ let used = cache
+ .frame_used
+ .iter()
+ .enumerate()
+ .filter_map(|(index, used)| used.then_some(index))
+ .collect::<Vec<_>>();
+ assert_eq!(used.len(), expected_used);
+ let mut owners = used
+ .iter()
+ .map(|&index| cache.slots[index].expect("used slot owner").caster)
+ .collect::<Vec<_>>();
+ owners.sort_unstable();
+ owners.dedup();
+ assert_eq!(owners.len(), used.len(), "two current casters share a page");
+
+ for id in 0..40 {
+ let owner_count = cache
+ .slots
+ .iter()
+ .flatten()
+ .filter(|slot| slot.caster == id)
+ .count();
+ assert!(owner_count <= 1, "caster {id} owns duplicate page slots");
+ }
+ }
+
+ // The second frame has more new casters than the aged fallback range
+ // can accept. Rejected records must remain unowned rather than being
+ // assigned an occupied stateless index.
+ assert!(cache.page_slot(36).is_none());
+ assert!(cache.page_slot(37).is_none());
+ assert!(cache.page_slot(38).is_none());
+ assert!(cache.page_slot(39).is_none());
+ }
+
+ #[test]
+ fn receiver_masks_are_full_exclusions() {
+ let caster = caster(1, 1.0, 4, Vec::new());
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 1.0],
+ rgb: [1.0; 3],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let ground = triangle([[-1.0, -1.0, 1.0], [0.0, 1.0, 1.0], [1.0, -1.0, 1.0]], 0);
+ let excluded = triangle(
+ [[-1.0, -1.0, 1.0], [0.0, 1.0, 1.0], [1.0, -1.0, 1.0]],
+ SHADOW_FOUR_RAY_MASK,
+ );
+ let accepted = build_shadow_frame(ShadowScene {
+ lights: &[light],
+ casters: &[caster.clone()],
+ receivers: &[ground],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("unmasked ground receiver");
+ let filtered = build_shadow_frame(ShadowScene {
+ lights: &[light],
+ casters: &[caster],
+ receivers: &[ground, excluded],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("masked receiver");
+ assert_eq!(filtered.vertices.len(), accepted.vertices.len());
+ assert_eq!(filtered.indices.len(), accepted.indices.len());
+ }
+
+ fn triangle(positions: [[f32; 3]; 3], mask: u32) -> ShadowTriangle {
+ ShadowTriangle {
+ positions,
+ normal: normalize(cross(
+ sub(positions[1], positions[0]),
+ sub(positions[2], positions[0]),
+ ))
+ .unwrap_or([0.0; 3]),
+ mask,
+ }
+ }
+
+ fn caster(
+ id: u32,
+ radius: f32,
+ native_kind: u32,
+ triangles: Vec<ShadowTriangle>,
+ ) -> ShadowCaster {
+ ShadowCaster {
+ id,
+ world_axes: identity_axes(),
+ sphere: ShadowSphere {
+ center: [0.0; 3],
+ radius,
+ },
+ native_kind,
+ triangles,
+ }
+ }
+}
diff --git a/crates/fparkan-fx/src/sky.rs b/crates/fparkan-fx/src/sky.rs
new file mode 100644
index 0000000..f41e920
--- /dev/null
+++ b/crates/fparkan-fx/src/sky.rs
@@ -0,0 +1,2079 @@
+//! CPU-side sky dome, sky materials, fog, and sun output.
+//!
+//! The original `CSky` object is a finite dome rather than an equirectangular
+//! skybox. This module keeps that geometry and the interpolated
+//! `sky.ske`/`sky.wea` data independent of any graphics backend. A renderer
+//! can upload [`SkyMesh`] once, update its vertex colors, and resolve the
+//! material names in [`SkyMaterials`] through its normal resource path.
+
+use crate::atmosphere::{AtmosphereFrame, PackedColor, SkySample, SunBirth, SunSample};
+use std::fmt;
+
+/// Number of rows used by the original environment `sky.wea` table.
+pub const SKY_MATERIAL_COUNT: usize = 9;
+
+/// Texture-coordinate scales used by the three original sky vertex stages.
+///
+/// The values are the constants at PE `65E94`, `65E98`, and `65E9C` in the
+/// original renderer. They are applied to `position.x / radius` and
+/// `position.y / radius`.
+pub const SKY_UV_SCALES: [f32; 3] = [1.0, 15.0, 3.0];
+
+/// Material names observed in the AutoDemo `sky.wea` table.
+///
+/// Missions are allowed to replace these names. Code should use the rows
+/// returned by [`SkyMaterials::parse`] when resolving a real mission.
+pub const OBSERVED_SKY_MATERIAL_NAMES: [&str; SKY_MATERIAL_COUNT] = [
+ "ENV_NEBULA_0",
+ "ENV_STARS",
+ "ENV_CLOUDS",
+ "ENV_SUN_3",
+ "ENV_MOON",
+ "ENV_FLARE_00",
+ "ENV_FLARE_01",
+ "SNOWFLAKE",
+ "RAIN_DROP",
+];
+
+const TAU: f32 = std::f32::consts::TAU;
+// These are the values written by Terrain's atmosphere-manager constructor:
+// 0x6581C = 10000, and 0x6580C = 3.1415901184 is halved twice before the
+// CSky constructor receives it.
+const NATIVE_PI: f32 = 3.1415901184;
+const DEFAULT_HEIGHT: f32 = 10_000.0;
+const DEFAULT_THETA_MAX: f32 = NATIVE_PI * 0.25;
+const DEFAULT_Z_SCALE: f32 = 1.0;
+const DEFAULT_AZIMUTH_COUNT: usize = 16;
+const DEFAULT_RINGS: usize = 5;
+const FOG_DISTANCE_SCALE: f32 = 700.0;
+const MAX_GEOMETRY_VERTICES: usize = 1_000_000;
+// CSky::Render (481C5) uses these data constants when it converts the camera
+// yaw to a four-color, ninety-degree horizon sector.
+const NATIVE_GRADIENT_SEGMENT_DEGREES: i64 = 90;
+const NATIVE_GRADIENT_FRACTION_SCALE: f32 = f32::from_bits(0x3C36_0B61);
+const NATIVE_GRADIENT_ZERO: f32 = 0.0;
+const NATIVE_GRADIENT_ONE: f32 = 1.0;
+const NATIVE_GRADIENT_THREE: f32 = 3.0;
+const NATIVE_GRADIENT_SHORT_SCALE: f32 = 0.8;
+const NATIVE_GRADIENT_LONG_SCALE: f32 = 0.1;
+const NATIVE_GRADIENT_LONG_OFFSET: f32 = 0.7;
+
+/// Configuration passed to the original `CSky` dome constructor.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SkyGeometryConfig {
+ /// Dome height before the z scale is applied.
+ pub height: f32,
+ /// Maximum polar angle in radians.
+ pub theta_max: f32,
+ /// Vertical scale applied to the generated positions.
+ pub z_scale: f32,
+ /// Number of azimuth sectors around the dome.
+ pub azimuth_count: usize,
+ /// Number of vertices in each azimuth sector after the zenith vertex.
+ pub rings: usize,
+}
+
+impl SkyGeometryConfig {
+ /// Creates a geometry configuration.
+ #[must_use]
+ pub const fn new(
+ height: f32,
+ theta_max: f32,
+ z_scale: f32,
+ azimuth_count: usize,
+ rings: usize,
+ ) -> Self {
+ Self {
+ height,
+ theta_max,
+ z_scale,
+ azimuth_count,
+ rings,
+ }
+ }
+
+ /// Returns the radius used by the original constructor.
+ #[must_use]
+ pub fn radius(self) -> f32 {
+ let half_theta = self.theta_max * 0.5;
+ self.height * 0.5 / half_theta.sin().powi(2)
+ }
+
+ /// Builds a colored sky mesh and validates all counts and floating point
+ /// inputs.
+ pub fn mesh(self, sample: &SkySample) -> Result<SkyMesh, SkyGeometryError> {
+ self.try_mesh(sample)
+ }
+
+ /// Builds a colored sky mesh and validates all counts and floating point
+ /// inputs.
+ pub fn try_mesh(self, sample: &SkySample) -> Result<SkyMesh, SkyGeometryError> {
+ let mut mesh = self.try_topology()?;
+ mesh.update_colors(sample);
+ Ok(mesh)
+ }
+
+ /// Builds the immutable dome topology.
+ ///
+ /// Vertex colors are initialized to the packed zero value and are filled
+ /// by [`SkyMesh::update_colors`] when the first atmosphere sample arrives.
+ /// Keeping topology construction separate lets a renderer upload the
+ /// position/normal/UV/index buffers once.
+ pub fn try_topology(self) -> Result<SkyMesh, SkyGeometryError> {
+ validate_geometry(self)?;
+ let vertex_count = self
+ .azimuth_count
+ .checked_mul(self.rings)
+ .and_then(|count| count.checked_add(1))
+ .ok_or(SkyGeometryError::CountOverflow)?;
+ if vertex_count > MAX_GEOMETRY_VERTICES {
+ return Err(SkyGeometryError::TooManyVertices { vertex_count });
+ }
+ let triangle_count = self
+ .azimuth_count
+ .checked_mul(
+ self.rings
+ .checked_mul(2)
+ .and_then(|count| count.checked_sub(1))
+ .ok_or(SkyGeometryError::CountOverflow)?,
+ )
+ .ok_or(SkyGeometryError::CountOverflow)?;
+ let index_count = triangle_count
+ .checked_mul(3)
+ .ok_or(SkyGeometryError::CountOverflow)?;
+ let radius = self.radius();
+ if !radius.is_finite() || radius <= 0.0 {
+ return Err(SkyGeometryError::NonFiniteRadius);
+ }
+
+ let mut vertices = Vec::with_capacity(vertex_count);
+ vertices.push(SkyVertex {
+ position: [0.0, 0.0, self.height * self.z_scale],
+ normal: [0, 0, 127],
+ uv: [[0.0, 0.0]; 3],
+ color: PackedColor::default(),
+ });
+
+ for azimuth in 0..self.azimuth_count {
+ let phi = azimuth as f32 / self.azimuth_count as f32 * TAU;
+ let sin_phi = phi.sin();
+ let cos_phi = phi.cos();
+ for ring in 0..self.rings {
+ let theta = (ring + 1) as f32 / self.rings as f32 * self.theta_max;
+ let sin_theta = theta.sin();
+ let cos_theta = theta.cos();
+ let position = [
+ radius * sin_theta * sin_phi,
+ radius * sin_theta * cos_phi,
+ (radius * cos_theta + self.height - radius) * self.z_scale,
+ ];
+ let normal = [
+ normal_byte(sin_theta * sin_phi),
+ normal_byte(sin_theta * cos_phi),
+ normal_byte(cos_theta),
+ ];
+ let uv = std::array::from_fn(|stage| {
+ [
+ position[0] / radius * SKY_UV_SCALES[stage],
+ position[1] / radius * SKY_UV_SCALES[stage],
+ ]
+ });
+ vertices.push(SkyVertex {
+ position,
+ normal,
+ uv,
+ color: PackedColor::default(),
+ });
+ }
+ }
+
+ let mut indices = Vec::with_capacity(index_count);
+ for azimuth in 0..self.azimuth_count {
+ let base = 1 + azimuth * self.rings;
+ let next = 1 + ((azimuth + 1) % self.azimuth_count) * self.rings;
+ indices.push(0);
+ indices.extend_from_slice(&[
+ u32::try_from(base).map_err(|_| SkyGeometryError::IndexOverflow)?,
+ u32::try_from(next).map_err(|_| SkyGeometryError::IndexOverflow)?,
+ ]);
+ for ring in 0..self.rings - 1 {
+ let a = base + ring;
+ let b = base + ring + 1;
+ let c = next + ring;
+ let d = next + ring + 1;
+ indices.extend_from_slice(&[
+ u32::try_from(a).map_err(|_| SkyGeometryError::IndexOverflow)?,
+ u32::try_from(b).map_err(|_| SkyGeometryError::IndexOverflow)?,
+ u32::try_from(c).map_err(|_| SkyGeometryError::IndexOverflow)?,
+ u32::try_from(c).map_err(|_| SkyGeometryError::IndexOverflow)?,
+ u32::try_from(b).map_err(|_| SkyGeometryError::IndexOverflow)?,
+ u32::try_from(d).map_err(|_| SkyGeometryError::IndexOverflow)?,
+ ]);
+ }
+ }
+
+ Ok(SkyMesh {
+ config: self,
+ radius,
+ vertices,
+ indices,
+ })
+ }
+}
+
+impl Default for SkyGeometryConfig {
+ fn default() -> Self {
+ Self::new(
+ DEFAULT_HEIGHT,
+ DEFAULT_THETA_MAX,
+ DEFAULT_Z_SCALE,
+ DEFAULT_AZIMUTH_COUNT,
+ DEFAULT_RINGS,
+ )
+ }
+}
+
+fn validate_geometry(config: SkyGeometryConfig) -> Result<(), SkyGeometryError> {
+ if !config.height.is_finite() || config.height <= 0.0 {
+ return Err(SkyGeometryError::InvalidHeight(config.height));
+ }
+ if !config.theta_max.is_finite() || config.theta_max <= 0.0 || config.theta_max >= TAU {
+ return Err(SkyGeometryError::InvalidTheta(config.theta_max));
+ }
+ if !config.z_scale.is_finite() {
+ return Err(SkyGeometryError::InvalidZScale(config.z_scale));
+ }
+ if config.azimuth_count == 0 || config.rings == 0 {
+ return Err(SkyGeometryError::ZeroCount);
+ }
+ Ok(())
+}
+
+fn normal_byte(component: f32) -> i8 {
+ // Terrain's native sky constructor writes signed normal components with
+ // x87 nearest-even rounding and a 127 scale. Keep the signed type here;
+ // a renderer that uploads the legacy byte stream can cast it to u8.
+ (component * 127.0).round_ties_even().clamp(-128.0, 127.0) as i8
+}
+
+fn color_for_vertex(
+ sample: &SkySample,
+ ring: usize,
+ azimuth: usize,
+ azimuth_count: usize,
+) -> PackedColor {
+ match ring {
+ 0 => sample.colors[12],
+ 1 => belt_color(sample, 8, azimuth, azimuth_count),
+ 2 => belt_color(sample, 4, azimuth, azimuth_count),
+ _ => belt_color(sample, 0, azimuth, azimuth_count),
+ }
+}
+
+fn belt_color(
+ sample: &SkySample,
+ offset: usize,
+ azimuth: usize,
+ azimuth_count: usize,
+) -> PackedColor {
+ let group_width = azimuth_count.saturating_add(3) / 4;
+ if group_width == 0 {
+ return sample.colors[offset];
+ }
+ let anchor = (azimuth / group_width).min(3);
+ let next = (anchor + 1) & 3;
+ let factor = (azimuth % group_width) as f32 / group_width as f32;
+ sample.colors[offset + anchor].lerp(sample.colors[offset + next], factor)
+}
+
+/// One CPU vertex in the original sky buffer layout.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SkyVertex {
+ /// World-space position.
+ pub position: [f32; 3],
+ /// Signed 8-bit normal components written by the original constructor.
+ pub normal: [i8; 3],
+ /// Three texture-coordinate stages, in original stage order.
+ pub uv: [[f32; 2]; 3],
+ /// Current ARGB vertex color.
+ pub color: PackedColor,
+}
+
+impl SkyVertex {
+ /// Returns the packed color as normalized RGBA.
+ #[must_use]
+ pub fn color_rgba(self) -> [f32; 4] {
+ self.color.rgba()
+ }
+
+ /// Decodes the native signed normal bytes to normalized components.
+ #[must_use]
+ pub fn normal_vector(self) -> [f32; 3] {
+ self.normal.map(|component| f32::from(component) / 127.0)
+ }
+}
+
+/// Generated sky dome geometry and indices.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SkyMesh {
+ /// Configuration used to generate this mesh.
+ pub config: SkyGeometryConfig,
+ /// Radius from the original constructor formula.
+ pub radius: f32,
+ /// Zenith followed by azimuth-major ring vertices.
+ pub vertices: Vec<SkyVertex>,
+ /// Triangle-list indices: one fan triangle and `rings - 1` quads per
+ /// azimuth sector.
+ pub indices: Vec<u32>,
+}
+
+impl SkyMesh {
+ /// Number of triangles in the mesh.
+ #[must_use]
+ pub fn triangle_count(&self) -> usize {
+ self.indices.len() / 3
+ }
+
+ /// Updates only the dynamic vertex colors for an atmosphere sample.
+ ///
+ /// The vertex order is fixed by [`SkyGeometryConfig::try_topology`], so
+ /// this operation never reallocates the topology or index buffer. The
+ /// renderer can upload the changed color fields in place.
+ pub fn update_colors(&mut self, sample: &SkySample) {
+ let Some(zenith) = self.vertices.first_mut() else {
+ return;
+ };
+ zenith.color = sample.colors[12];
+ for azimuth in 0..self.config.azimuth_count {
+ let base = 1 + azimuth * self.config.rings;
+ for ring in 0..self.config.rings {
+ let Some(vertex) = self.vertices.get_mut(base + ring) else {
+ return;
+ };
+ vertex.color = color_for_vertex(sample, ring, azimuth, self.config.azimuth_count);
+ }
+ }
+ }
+}
+
+/// Errors found while constructing a sky dome.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum SkyGeometryError {
+ /// Height must be finite and positive.
+ InvalidHeight(f32),
+ /// Polar angle must be finite and in `(0, TAU)`.
+ InvalidTheta(f32),
+ /// Vertical scale must be finite.
+ InvalidZScale(f32),
+ /// A sector or ring count was zero.
+ ZeroCount,
+ /// A count calculation overflowed.
+ CountOverflow,
+ /// Mesh allocation would exceed the bounded CPU contract.
+ TooManyVertices { vertex_count: usize },
+ /// The radius formula did not produce a finite positive value.
+ NonFiniteRadius,
+ /// An index could not fit in the renderer-facing u32 index type.
+ IndexOverflow,
+}
+
+impl fmt::Display for SkyGeometryError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::InvalidHeight(value) => {
+ write!(f, "sky height must be finite and positive: {value}")
+ }
+ Self::InvalidTheta(value) => write!(f, "sky theta_max must be in (0, TAU): {value}"),
+ Self::InvalidZScale(value) => write!(f, "sky z_scale must be finite: {value}"),
+ Self::ZeroCount => write!(f, "sky azimuth and ring counts must be non-zero"),
+ Self::CountOverflow => write!(f, "sky geometry count overflow"),
+ Self::TooManyVertices { vertex_count } => {
+ write!(f, "sky geometry has too many vertices: {vertex_count}")
+ }
+ Self::NonFiniteRadius => write!(f, "sky geometry radius is not finite and positive"),
+ Self::IndexOverflow => write!(f, "sky geometry index does not fit in u32"),
+ }
+ }
+}
+
+impl std::error::Error for SkyGeometryError {}
+
+/// One row from a `sky.wea` material table.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SkyMaterialRow {
+ /// Legacy numeric id preserved from the source row.
+ pub legacy_id: i32,
+ /// Material lookup name.
+ pub name: String,
+}
+
+/// Parsed dynamic material names from `sky.wea`.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SkyMaterials {
+ /// Main material rows in file order.
+ pub rows: Vec<SkyMaterialRow>,
+ /// Optional lightmap rows after the `LIGHTMAPS` section.
+ pub lightmaps: Vec<SkyMaterialRow>,
+}
+
+impl SkyMaterials {
+ /// Parses a text `sky.wea` payload.
+ pub fn parse(bytes: &[u8]) -> Result<Self, SkyWeaError> {
+ let text = String::from_utf8_lossy(bytes);
+ let mut lines = text.lines().enumerate();
+ let (count_line, count_text) = lines
+ .find_map(|(line, text)| (!text.trim().is_empty()).then_some((line, text.trim())))
+ .ok_or(SkyWeaError::Empty)?;
+ let count = count_text
+ .parse::<usize>()
+ .map_err(|_| SkyWeaError::InvalidCount {
+ line: count_line,
+ text: count_text.to_string(),
+ })?;
+ if count == 0 {
+ return Err(SkyWeaError::ZeroCount);
+ }
+
+ let mut rows = Vec::with_capacity(count);
+ for index in 0..count {
+ let Some((line, text)) = lines.next() else {
+ return Err(SkyWeaError::MissingRow { index, count });
+ };
+ let text = text.trim();
+ if text.is_empty() {
+ return Err(SkyWeaError::InvalidRow {
+ index,
+ line,
+ text: text.to_string(),
+ });
+ }
+ rows.push(parse_material_row(index, line, text)?);
+ }
+
+ let mut lightmaps = Vec::new();
+ let mut rest = lines.filter_map(|(line, text)| {
+ let trimmed = text.trim();
+ (!trimmed.is_empty()).then_some((line, trimmed))
+ });
+ if let Some((line, marker)) = rest.next() {
+ if !marker.eq_ignore_ascii_case("LIGHTMAPS") {
+ return Err(SkyWeaError::UnexpectedTrailingLine {
+ line,
+ text: marker.to_string(),
+ });
+ }
+ let Some((count_line, count_text)) = rest.next() else {
+ return Err(SkyWeaError::InvalidLightmapCount {
+ line,
+ text: String::new(),
+ });
+ };
+ let lightmap_count =
+ count_text
+ .parse::<usize>()
+ .map_err(|_| SkyWeaError::InvalidLightmapCount {
+ line: count_line,
+ text: count_text.to_string(),
+ })?;
+ lightmaps.reserve(lightmap_count);
+ for index in 0..lightmap_count {
+ let Some((row_line, row_text)) = rest.next() else {
+ return Err(SkyWeaError::MissingLightmapRow {
+ index,
+ count: lightmap_count,
+ });
+ };
+ lightmaps.push(parse_material_row(index, row_line, row_text)?);
+ }
+ if let Some((extra_line, extra_text)) = rest.next() {
+ return Err(SkyWeaError::UnexpectedTrailingLine {
+ line: extra_line,
+ text: extra_text.to_string(),
+ });
+ }
+ }
+
+ Ok(Self { rows, lightmaps })
+ }
+
+ /// Creates a table from caller-provided names, assigning positional ids.
+ #[must_use]
+ pub fn from_names<I, S>(names: I) -> Self
+ where
+ I: IntoIterator<Item = S>,
+ S: Into<String>,
+ {
+ Self {
+ rows: names
+ .into_iter()
+ .enumerate()
+ .map(|(index, name)| SkyMaterialRow {
+ legacy_id: index as i32,
+ name: name.into(),
+ })
+ .collect(),
+ lightmaps: Vec::new(),
+ }
+ }
+
+ /// Returns the positional material row.
+ #[must_use]
+ pub fn row(&self, index: usize) -> Option<&SkyMaterialRow> {
+ self.rows.get(index)
+ }
+
+ /// Returns the material name at a positional row.
+ #[must_use]
+ pub fn material_name(&self, index: usize) -> Option<&str> {
+ self.row(index).map(|row| row.name.as_str())
+ }
+
+ /// Returns all main material names in file order.
+ #[must_use]
+ pub fn names(&self) -> Vec<&str> {
+ self.rows.iter().map(|row| row.name.as_str()).collect()
+ }
+}
+
+impl From<Vec<String>> for SkyMaterials {
+ fn from(names: Vec<String>) -> Self {
+ Self::from_names(names)
+ }
+}
+
+impl Default for SkyMaterials {
+ fn default() -> Self {
+ Self::from_names(OBSERVED_SKY_MATERIAL_NAMES)
+ }
+}
+
+fn parse_material_row(
+ index: usize,
+ line: usize,
+ text: &str,
+) -> Result<SkyMaterialRow, SkyWeaError> {
+ let mut parts = text.splitn(2, char::is_whitespace);
+ let legacy_text = parts.next().unwrap_or_default();
+ let name = parts.next().unwrap_or_default().trim();
+ let legacy_id = legacy_text
+ .parse::<i32>()
+ .map_err(|_| SkyWeaError::InvalidRow {
+ index,
+ line,
+ text: text.to_string(),
+ })?;
+ if name.is_empty() {
+ return Err(SkyWeaError::InvalidRow {
+ index,
+ line,
+ text: text.to_string(),
+ });
+ }
+ Ok(SkyMaterialRow {
+ legacy_id,
+ name: name.to_string(),
+ })
+}
+
+/// Errors returned while parsing `sky.wea`.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum SkyWeaError {
+ /// Payload has no non-empty lines.
+ Empty,
+ /// Main row count is malformed.
+ InvalidCount { line: usize, text: String },
+ /// Main table declared no rows.
+ ZeroCount,
+ /// A main row is missing.
+ MissingRow { index: usize, count: usize },
+ /// A main or lightmap row is malformed.
+ InvalidRow {
+ index: usize,
+ line: usize,
+ text: String,
+ },
+ /// Lightmap count is malformed.
+ InvalidLightmapCount { line: usize, text: String },
+ /// A lightmap row is missing.
+ MissingLightmapRow { index: usize, count: usize },
+ /// Non-empty data followed the main rows without a known section marker.
+ UnexpectedTrailingLine { line: usize, text: String },
+}
+
+impl fmt::Display for SkyWeaError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Empty => write!(f, "sky.wea payload is empty"),
+ Self::InvalidCount { line, text } => {
+ write!(f, "invalid sky.wea count at line {line}: {text}")
+ }
+ Self::ZeroCount => write!(f, "sky.wea material count must be positive"),
+ Self::MissingRow { index, count } => {
+ write!(f, "missing sky.wea row {index} of {count}")
+ }
+ Self::InvalidRow { index, line, text } => {
+ write!(f, "invalid sky.wea row {index} at line {line}: {text}")
+ }
+ Self::InvalidLightmapCount { line, text } => {
+ write!(f, "invalid sky.wea lightmap count at line {line}: {text}")
+ }
+ Self::MissingLightmapRow { index, count } => {
+ write!(f, "missing sky.wea lightmap row {index} of {count}")
+ }
+ Self::UnexpectedTrailingLine { line, text } => {
+ write!(f, "unexpected sky.wea line {line}: {text}")
+ }
+ }
+ }
+}
+
+impl std::error::Error for SkyWeaError {}
+
+/// Parses a `sky.wea` payload without requiring callers to name the parser
+/// type.
+pub fn parse_sky_wea(bytes: &[u8]) -> Result<SkyMaterials, SkyWeaError> {
+ SkyMaterials::parse(bytes)
+}
+
+/// The nine positional material roles used by the original sky renderer.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum SkyLayerKind {
+ /// Nebula/background row zero.
+ Nebula,
+ /// Stars row one.
+ Stars,
+ /// Clouds row two.
+ Clouds,
+ /// Sun sprite row three.
+ Sun,
+ /// Moon sprite row four.
+ Moon,
+ /// First flare row five.
+ Flare0,
+ /// Second flare row six.
+ Flare1,
+ /// Snow row seven.
+ Snow,
+ /// Rain row eight.
+ Rain,
+ /// A mission-specific row after the known roles.
+ Other(usize),
+}
+
+impl SkyLayerKind {
+ /// Maps a positional WEA row to its known role.
+ #[must_use]
+ pub const fn from_row(row: usize) -> Self {
+ match row {
+ 0 => Self::Nebula,
+ 1 => Self::Stars,
+ 2 => Self::Clouds,
+ 3 => Self::Sun,
+ 4 => Self::Moon,
+ 5 => Self::Flare0,
+ 6 => Self::Flare1,
+ 7 => Self::Snow,
+ 8 => Self::Rain,
+ other => Self::Other(other),
+ }
+ }
+}
+
+/// Native draw order for the dome and its full-screen gradient.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum SkyPassKind {
+ /// Untextured full-screen gradient quad.
+ ScreenGradient,
+ /// Row zero nebula with row one stars bound as its secondary texture.
+ NebulaStars,
+ /// Untextured colored gradient over the dome.
+ DomeGradient,
+ /// Row two cloud dome translated below the camera.
+ Clouds,
+}
+
+/// Renderer contract for one native sky pass.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SkyPassFrame<'a> {
+ /// Pass order/role.
+ pub kind: SkyPassKind,
+ /// Primary positional WEA material row, if textured.
+ pub material_row: Option<usize>,
+ /// Secondary positional WEA row for the stars texture.
+ pub secondary_material_row: Option<usize>,
+ /// Primary material name.
+ pub material: Option<&'a str>,
+ /// Secondary material name.
+ pub secondary_material: Option<&'a str>,
+ /// Primary vertex UV stage.
+ pub uv_stage: Option<usize>,
+ /// Secondary vertex UV stage.
+ pub secondary_uv_stage: Option<usize>,
+ /// World-space Z offset relative to camera translation.
+ pub translation_z: f32,
+ /// Native blend mode of the pass.
+ pub blend: SkyBlendMode,
+ /// Native clouds directional RGB, when the pass uses it.
+ pub material_color: Option<[f32; 3]>,
+}
+
+/// Lighting-dependent colors produced by the native screen-gradient update.
+///
+/// `color` is the packed ARGB value sent to the full-screen gradient and to
+/// the last dome ring. `horizon_floor` is the independently computed RGB
+/// floor from the light-manager glare delta; native CSky clamps every stored
+/// dome palette color up to these three channels.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SkyGradientFrame {
+ /// Packed ARGB screen-gradient and last-ring color.
+ pub color: PackedColor,
+ /// Lower bound applied to dome palette RGB channels, normalized to 0..1.
+ pub horizon_floor: [f32; 3],
+}
+
+impl SkyGradientFrame {
+ /// Raises a packed dome color to the native lighting-dependent RGB floor.
+ #[must_use]
+ pub fn clamp_dome_color(self, color: PackedColor) -> PackedColor {
+ let rgba = color.rgba();
+ PackedColor::from_rgba([
+ rgba[0].max(self.horizon_floor[0]),
+ rgba[1].max(self.horizon_floor[1]),
+ rgba[2].max(self.horizon_floor[2]),
+ rgba[3],
+ ])
+ }
+}
+
+/// Per-row state supplied alongside [`SkyMesh`].
+#[derive(Clone, Debug, PartialEq)]
+pub struct SkyLayerFrame<'a> {
+ /// Positional row in `sky.wea`.
+ pub row: usize,
+ /// Interpreted role for the row.
+ pub kind: SkyLayerKind,
+ /// Dynamic mission material name, if that row exists.
+ pub material: Option<&'a str>,
+ /// Whether the row is currently visible according to atmosphere state.
+ pub active: bool,
+ /// Vertex UV stage used by the native sky material pass.
+ pub uv_stage: usize,
+ /// Native UV scale for this layer's stage.
+ pub uv_scale: f32,
+ /// Normalized row intensity where the atmosphere supplies one.
+ pub intensity: f32,
+ /// Monotonic elapsed time for material UV animation.
+ pub phase_seconds: f32,
+}
+
+/// Blend operation used by a directional sky sprite pass.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum SkyBlendMode {
+ /// Use the sprite's alpha channel over the current sky.
+ Alpha,
+ /// Add the sprite contribution to the current sky.
+ Additive,
+}
+
+/// The fixed four-corner UV order used by native sun, moon, and flare quads.
+///
+/// `CSun` leaves a half-percent border around the source texture. The order
+/// is left-top, left-bottom, right-bottom, right-top in the native quad.
+pub const SKY_SPRITE_UV: [[f32; 2]; 4] = [
+ [0.005, 0.005],
+ [0.005, 0.995],
+ [0.995, 0.995],
+ [0.995, 0.005],
+];
+
+/// Positional flare rows emitted by the original sun object.
+pub const SKY_FLARE_ROWS: [usize; 12] = [5, 5, 6, 6, 5, 6, 5, 5, 6, 5, 5, 5];
+
+const SKY_FLARE_SIZES: [f32; 12] = [0.2, 0.3, 0.2, 0.1, 0.1, 0.3, 0.3, 0.7, 0.4, 1.0, 0.3, 0.2];
+const SKY_FLARE_OFFSETS: [f32; 12] = [
+ 1.2, 0.7, 0.5, 0.2, 0.0, -0.2, -0.3, -0.5, -0.6, -0.8, -1.0, -1.1,
+];
+const SKY_FLARE_COLORS: [u32; 12] = [
+ 0xFFB0_90A3,
+ 0xFF5A_58BB,
+ 0x9630_BE52,
+ 0x96C9_3432,
+ 0xFF30_BE52,
+ 0x9696_9664,
+ 0xFFB0_90A3,
+ 0xFF7C_6BC9,
+ 0x9630_6452,
+ 0xFF0B_17B9,
+ 0xFFB6_26B1,
+ 0xFF7C_C5C9,
+];
+
+const GLARE_COS_15: f32 = 0.965_925_8;
+const GLARE_COS_30: f32 = 0.866_025_4;
+const GLARE_COS_60: f32 = 0.5;
+const SUN_SPRITE_DISTANCE: f32 = 0.325;
+
+/// Renderer-facing state for one directional sun, moon or flare sprite.
+///
+/// The CPU side provides the native material row, direction, sampled RGB and
+/// unit-quad UVs. A renderer projects the direction through its active camera
+/// and applies the material's size/alpha state. Flare rows additionally need a
+/// camera visibility or occlusion test before drawing.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SkySpriteFrame<'a> {
+ /// Positional role in `sky.wea`.
+ pub kind: SkyLayerKind,
+ /// Positional material row.
+ pub row: usize,
+ /// Dynamic mission material name.
+ pub material: Option<&'a str>,
+ /// Whether the source celestial interval is active.
+ pub active: bool,
+ /// Direction in world space.
+ pub direction: [f32; 3],
+ /// Native packed sprite RGB (`SunSample::packed[0]`).
+ pub color: [f32; 3],
+ /// Native packed sprite alpha (`SunSample::packed[0]`).
+ pub alpha: f32,
+ /// The two native half-size sample values used by sun/moon quads.
+ pub size_values: [f32; 2],
+ /// Sampled intensity.
+ pub intensity: f32,
+ /// Unit quad texture coordinates in native corner order.
+ pub uv: [[f32; 2]; 4],
+ /// Blend operation for the sprite pass.
+ pub blend: SkyBlendMode,
+ /// Flares require camera-space visibility testing.
+ pub requires_occlusion: bool,
+}
+
+/// One native directional source submitted for a celestial object.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct DirectionalLightFrame {
+ /// False means the light slot must be removed from the light manager.
+ pub active: bool,
+ /// Direction used by the native light manager.
+ pub direction: [f32; 3],
+ /// RGB value passed to the native light manager without another intensity
+ /// multiplication.
+ pub color: [f32; 3],
+}
+
+impl DirectionalLightFrame {
+ const INACTIVE: Self = Self {
+ active: false,
+ direction: [0.0; 3],
+ color: [0.0; 3],
+ };
+}
+
+/// Camera data needed by the native screen-space sun and flare path.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SunVisibility {
+ /// Projected sun center in viewport pixels.
+ pub projected_sun: [f32; 2],
+ /// Viewport width and height in pixels.
+ pub viewport: [f32; 2],
+ /// Camera forward vector in world space.
+ pub view_forward: [f32; 3],
+ /// Result of the renderer's world-ray visibility query.
+ pub unoccluded: bool,
+}
+
+/// One screen-space flare generated from the native flare tables.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct FlareQuad {
+ /// Native positional material row (5 or 6).
+ pub row: usize,
+ /// Whether this quad should be submitted this frame.
+ pub active: bool,
+ /// Final RGBA color after the packed sun alpha multiplication.
+ pub color: [f32; 4],
+ /// Center in viewport pixels.
+ pub center_pixels: [f32; 2],
+ /// Equal X/Y half-size in viewport pixels.
+ pub half_size_pixels: f32,
+ /// Native UV order for the quad.
+ pub uv: [[f32; 2]; 4],
+}
+
+/// Result of the native sun glare/flare calculation.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SunOptics {
+ /// RGB submitted to the primary directional light after the glare boost.
+ pub primary_color: [f32; 3],
+ /// Squared 15-degree cone amount before the horizon factor.
+ pub glare_amount: f32,
+ /// Twelve fixed-topology flare slots.
+ pub flares: [FlareQuad; 12],
+}
+
+/// Errors returned when a camera projection cannot supply native sprite data.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum SkyProjectionError {
+ /// Viewport dimensions must be finite and positive.
+ InvalidViewport([f32; 2]),
+ /// Horizontal FOV must be finite and in `(0, pi)`.
+ InvalidHorizontalFov(f32),
+ /// A camera forward vector could not be normalized.
+ InvalidViewForward([f32; 3]),
+}
+
+impl fmt::Display for SkyProjectionError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::InvalidViewport(value) => write!(f, "invalid sky viewport: {value:?}"),
+ Self::InvalidHorizontalFov(value) => {
+ write!(f, "invalid sky horizontal FOV: {value}")
+ }
+ Self::InvalidViewForward(value) => {
+ write!(f, "invalid sky view forward vector: {value:?}")
+ }
+ }
+ }
+}
+
+impl std::error::Error for SkyProjectionError {}
+
+/// Errors returned while updating a persistent sky owner.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum SkyUpdateError {
+ /// An externally supplied atmosphere sample contains a non-finite value.
+ NonFiniteAtmosphere,
+ /// The persistent mesh no longer matches its immutable configuration.
+ TopologyChanged,
+}
+
+impl fmt::Display for SkyUpdateError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::NonFiniteAtmosphere => {
+ write!(f, "sky atmosphere sample contains a non-finite value")
+ }
+ Self::TopologyChanged => write!(f, "sky topology changed after construction"),
+ }
+ }
+}
+
+impl std::error::Error for SkyUpdateError {}
+
+/// Fog values sent by the original `CSky` update.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct FogFrame {
+ /// Normalized RGB fog color from `SkySample::packed[1]`.
+ pub color: [f32; 3],
+ /// Native global lighting floor from the same sampled RGB state.
+ ///
+ /// `CShade` merges this vector with the material lighting accumulator by
+ /// taking the per-channel maximum before the shader receives the result.
+ pub lighting_floor: [f32; 3],
+ /// Fog start distance (`sky.values[0] * 700`).
+ pub start: f32,
+ /// Fog end distance (`sky.values[1] * 700`).
+ pub end: f32,
+}
+
+impl FogFrame {
+ /// Converts an interpolated sky sample to fog parameters.
+ #[must_use]
+ pub fn from_sample(sample: &SkySample) -> Self {
+ let start = finite_or_zero(sample.values[0]) * FOG_DISTANCE_SCALE;
+ let end = finite_or_zero(sample.values[1]) * FOG_DISTANCE_SCALE;
+ let rgba = sample.packed[1].rgba();
+ let color = [rgba[0], rgba[1], rgba[2]];
+ Self {
+ color,
+ lighting_floor: color,
+ start,
+ end,
+ }
+ }
+}
+
+/// Converts an interpolated sky sample to fog parameters.
+#[must_use]
+pub fn fog_frame(sample: &SkySample) -> FogFrame {
+ FogFrame::from_sample(sample)
+}
+
+/// Birth and orbit settings for a sun object.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SunConfig {
+ /// Time at which the stateless sun sample starts, in seconds.
+ pub start_seconds: f32,
+ /// Lifetime used by the original `(now - start) / life` age calculation.
+ pub lifetime_seconds: f32,
+ /// Two integer birth angles from the sun birth payload, in degrees.
+ pub initial_angles_degrees: [f32; 2],
+}
+
+impl SunConfig {
+ /// Creates sun settings.
+ #[must_use]
+ pub const fn new(
+ start_seconds: f32,
+ lifetime_seconds: f32,
+ initial_angles_degrees: [f32; 2],
+ ) -> Self {
+ Self {
+ start_seconds,
+ lifetime_seconds,
+ initial_angles_degrees,
+ }
+ }
+
+ fn angles_radians(self) -> [f32; 2] {
+ [
+ finite_or_zero(self.initial_angles_degrees[0]).to_radians(),
+ finite_or_zero(self.initial_angles_degrees[1]).to_radians(),
+ ]
+ }
+
+ /// Converts the native birth payload emitted by the atmosphere sampler.
+ #[must_use]
+ pub const fn from_birth(birth: SunBirth) -> Self {
+ Self::new(
+ birth.start_seconds,
+ birth.lifetime_seconds,
+ birth.initial_angles_degrees,
+ )
+ }
+}
+
+impl Default for SunConfig {
+ fn default() -> Self {
+ Self::new(0.0, 1.0, [0.0; 2])
+ }
+}
+
+/// A renderer-facing sun sample.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SunFrame {
+ /// Whether the latest SunStart/SunStop interval is active.
+ pub active: bool,
+ /// Final world-space direction after the original birth-angle matrix.
+ pub direction: [f32; 3],
+ /// Intermediate orbit direction before the birth-angle matrix.
+ pub orbit_direction: [f32; 3],
+ /// RGB light color from `SunSample::rgb`.
+ pub color: [f32; 3],
+ /// RGBA color stored in the native sprite descriptor.
+ pub sprite_color: [f32; 4],
+ /// RGB color of the opposite directional light from packed parameter 1.
+ pub secondary_color: [f32; 3],
+ /// The two native screen-quad size values from the SKE tail.
+ pub size_values: [f32; 2],
+ /// Material row selected by the native birth dispatcher.
+ pub material_row: Option<usize>,
+ /// Atmosphere intensity from trailing key parameter two.
+ pub intensity: f32,
+ /// Clamped normalized lifecycle age.
+ pub age: f32,
+ /// Untransformed orbit angle in radians.
+ pub theta: f32,
+}
+
+impl SunFrame {
+ /// Converts a sampled sun and stateless clock into a frame.
+ #[must_use]
+ pub fn from_sample(sample: &SunSample, now_seconds: f32, config: SunConfig) -> Self {
+ sun_frame(sample, now_seconds, config)
+ }
+
+ /// Returns an inactive frame without inventing lifecycle timing.
+ #[must_use]
+ pub fn inactive(sample: &SunSample) -> Self {
+ let sprite_color = sample.packed[0].rgba();
+ let secondary_color = sample.packed[1].rgba();
+ Self {
+ active: false,
+ direction: [0.0; 3],
+ orbit_direction: [0.0; 3],
+ color: sample.rgb.map(finite_or_zero),
+ sprite_color,
+ secondary_color: [secondary_color[0], secondary_color[1], secondary_color[2]],
+ size_values: sample.values.map(finite_or_zero),
+ material_row: None,
+ intensity: finite_or_zero(sample.intensity).max(0.0),
+ age: 0.0,
+ theta: 0.0,
+ }
+ }
+
+ /// Direction from the camera toward the native celestial geometry.
+ ///
+ /// The light manager stores the opposite vector, so the screen/world
+ /// sprite path negates [`Self::direction`].
+ #[must_use]
+ pub fn geometry_direction(self) -> [f32; 3] {
+ self.direction.map(|component| -component)
+ }
+
+ /// Places the native celestial object at half the camera far plane.
+ /// This is `camera_translation - light_direction * far / 2`, with the
+ /// geometry direction exposed by [`Self::geometry_direction`].
+ #[must_use]
+ pub fn world_center(self, camera_translation: [f32; 3], camera_far: f32) -> [f32; 3] {
+ let distance = finite_or_zero(camera_far) * 0.5;
+ let direction = self.geometry_direction();
+ std::array::from_fn(|index| camera_translation[index] + direction[index] * distance)
+ }
+
+ /// Returns primary and opposite directional lights in native order.
+ #[must_use]
+ pub fn directional_lights(self) -> [DirectionalLightFrame; 2] {
+ if !self.active {
+ return [DirectionalLightFrame::INACTIVE; 2];
+ }
+ [
+ DirectionalLightFrame {
+ active: true,
+ direction: self.direction,
+ color: self.color,
+ },
+ DirectionalLightFrame {
+ active: true,
+ direction: self.direction.map(|component| -component),
+ color: self.secondary_color,
+ },
+ ]
+ }
+}
+
+/// Converts an interpolated atmosphere sun sample to a renderer-facing frame.
+///
+/// The original update computes `orbit_direction = [cos(theta), 0,
+/// -sin(theta)]`, then applies the row-vector product of the two birth-angle
+/// matrices. With `A` and `B` in radians, the resulting direction is:
+///
+/// ```text
+/// [x*cos(A), -x*sin(A)*cos(B) + z*sin(B),
+/// x*sin(A)*sin(B) + z*cos(B)]
+/// ```
+///
+/// where `x` and `z` are the two non-zero orbit components.
+#[must_use]
+pub fn sun_frame(sample: &SunSample, now_seconds: f32, config: SunConfig) -> SunFrame {
+ let color = sample.rgb.map(finite_or_zero);
+ let sprite_color = sample.packed[0].rgba();
+ let secondary = sample.packed[1].rgba();
+ let intensity = finite_or_zero(sample.intensity).max(0.0);
+ if !sample.active {
+ return SunFrame::inactive(sample);
+ }
+
+ let now_seconds = finite_or_zero(now_seconds);
+ let start_seconds = finite_or_zero(config.start_seconds);
+ let lifetime = finite_or_zero(config.lifetime_seconds);
+ let age = if lifetime > 0.0 {
+ ((now_seconds - start_seconds) / lifetime).clamp(0.0, 1.0)
+ } else {
+ 1.0
+ };
+ let theta = (age * 1.2 - 0.1) * std::f32::consts::PI;
+ let orbit_direction = [theta.cos(), 0.0, -theta.sin()];
+ let [angle_a, angle_b] = config.angles_radians();
+ let (sin_a, cos_a) = angle_a.sin_cos();
+ let (sin_b, cos_b) = angle_b.sin_cos();
+ let x = orbit_direction[0];
+ let z = orbit_direction[2];
+ let direction = normalize3([
+ x * cos_a,
+ -x * sin_a * cos_b + z * sin_b,
+ x * sin_a * sin_b + z * cos_b,
+ ]);
+ SunFrame {
+ active: true,
+ direction,
+ orbit_direction,
+ color,
+ sprite_color,
+ secondary_color: [secondary[0], secondary[1], secondary[2]],
+ size_values: sample.values.map(finite_or_zero),
+ material_row: sample.birth.map(|birth| birth.material_row),
+ intensity,
+ age,
+ theta,
+ }
+}
+
+/// Converts a sampled celestial object using the birth payload generated by
+/// the atmosphere dispatcher.
+///
+/// The schedule sampler carries the absolute start time, lifetime, angles and
+/// material row for the active interval. Keeping that payload in the sample
+/// means a renderer does not need a stateful wrapper or a caller-provided
+/// placeholder configuration.
+#[must_use]
+pub fn celestial_frame(sample: &SunSample, now_seconds: f32) -> SunFrame {
+ sample
+ .birth
+ .map(|birth| sun_frame(sample, now_seconds, SunConfig::from_birth(birth)))
+ .unwrap_or_else(|| SunFrame::inactive(sample))
+}
+
+/// Computes the native half-size of a sun or moon screen quad in pixels.
+///
+/// The SKE tail values are the first two fields of `SunSample::values`; they
+/// are deliberately not read from the material descriptor. The renderer
+/// supplies the real viewport and horizontal FOV for the current swapchain.
+pub fn sprite_half_size_pixels(
+ values: [f32; 2],
+ viewport: [f32; 2],
+ horizontal_fov: f32,
+) -> Result<[f32; 2], SkyProjectionError> {
+ validate_viewport(viewport)?;
+ if !horizontal_fov.is_finite()
+ || horizontal_fov <= 0.0
+ || horizontal_fov >= std::f32::consts::PI
+ {
+ return Err(SkyProjectionError::InvalidHorizontalFov(horizontal_fov));
+ }
+ let scale = viewport[0] / horizontal_fov * SUN_SPRITE_DISTANCE * 0.5;
+ Ok([
+ scale * finite_or_zero(values[0]),
+ scale * finite_or_zero(values[1]),
+ ])
+}
+
+/// Computes native glare and flare quads for the current camera.
+pub fn sun_optics(
+ sun: SunFrame,
+ visibility: SunVisibility,
+) -> Result<SunOptics, SkyProjectionError> {
+ validate_viewport(visibility.viewport)?;
+ let view_forward = normalize3_checked(visibility.view_forward)?;
+ let light_direction = sun.direction;
+ let to_sun = light_direction.map(|component| -component);
+ let cone = dot3(view_forward, to_sun);
+ let cone_amount = ((cone - GLARE_COS_15) / (1.0 - GLARE_COS_15)).max(0.0);
+ let glare_amount = cone_amount * cone_amount;
+ let projected_inside = visibility.projected_sun[0].is_finite()
+ && visibility.projected_sun[1].is_finite()
+ && visibility.projected_sun[0] >= 0.0
+ && visibility.projected_sun[0] <= visibility.viewport[0]
+ && visibility.projected_sun[1] >= 0.0
+ && visibility.projected_sun[1] <= visibility.viewport[1];
+ let primary_length = length3(sun.color);
+ let visible = sun.active && projected_inside && visibility.unoccluded && primary_length > 1.1;
+
+ let mut primary_color = sun.color;
+ if visible {
+ let height =
+ ((-light_direction[2] - GLARE_COS_60) / (GLARE_COS_30 - GLARE_COS_60)).clamp(0.0, 1.0);
+ let boost = 1.0 + 4.0 * sun.sprite_color[3].clamp(0.0, 1.0) * height * glare_amount;
+ primary_color = primary_color.map(|component| component * boost);
+ }
+
+ let flare_active = visible && glare_amount <= 0.1;
+ let viewport_center = [visibility.viewport[0] * 0.5, visibility.viewport[1] * 0.5];
+ let flares = std::array::from_fn(|index| {
+ let base = PackedColor(SKY_FLARE_COLORS[index]);
+ let rgba = base.rgba();
+ let base_alpha = ((SKY_FLARE_COLORS[index] >> 24) & 0xFF) as f32;
+ let alpha = (base_alpha * sun.sprite_color[3].clamp(0.0, 1.0)).round_ties_even() / 255.0;
+ let offset = SKY_FLARE_OFFSETS[index];
+ let center = [
+ viewport_center[0] + (visibility.projected_sun[0] - viewport_center[0]) * offset,
+ viewport_center[1] + (visibility.projected_sun[1] - viewport_center[1]) * offset,
+ ];
+ FlareQuad {
+ row: SKY_FLARE_ROWS[index],
+ active: flare_active,
+ color: [rgba[0], rgba[1], rgba[2], alpha],
+ center_pixels: center,
+ half_size_pixels: visibility.viewport[0] * SKY_FLARE_SIZES[index] / 8.0,
+ uv: SKY_SPRITE_UV,
+ }
+ });
+ Ok(SunOptics {
+ primary_color,
+ glare_amount,
+ flares,
+ })
+}
+
+fn validate_viewport(viewport: [f32; 2]) -> Result<(), SkyProjectionError> {
+ if viewport[0].is_finite() && viewport[1].is_finite() && viewport[0] > 0.0 && viewport[1] > 0.0
+ {
+ Ok(())
+ } else {
+ Err(SkyProjectionError::InvalidViewport(viewport))
+ }
+}
+
+fn normalize3_checked(vector: [f32; 3]) -> Result<[f32; 3], SkyProjectionError> {
+ let length = length3(vector);
+ if length.is_finite() && length > 0.0 {
+ Ok(vector.map(|component| component / length))
+ } else {
+ Err(SkyProjectionError::InvalidViewForward(vector))
+ }
+}
+
+fn length3(vector: [f32; 3]) -> f32 {
+ (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt()
+}
+
+fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 {
+ left[0] * right[0] + left[1] * right[1] + left[2] * right[2]
+}
+
+/// Samples the native full-screen sky gradient and lighting-dependent horizon
+/// floor for one view.
+///
+/// The original `CSky::Render` code at `481C5` converts `(yaw + pi)` to
+/// degrees with the x87 nearest-even conversion, chooses one of four
+/// ninety-degree sectors, and interpolates the corresponding two entries of
+/// `SkySample::colors[0..4]`. The later `483A3..485EE` block derives a
+/// non-negative light-manager glare RGB delta and raises the stored dome
+/// palette to its piecewise RGB floor. The renderer supplies
+/// `glare_rgb_delta = max(current_light_rgb - base_light_rgb, 0)` componentwise;
+/// it is not a camera or projection vector.
+#[must_use]
+pub fn screen_gradient_frame(
+ sample: &SkySample,
+ yaw_radians: f32,
+ glare_rgb_delta: [f32; 3],
+) -> SkyGradientFrame {
+ let yaw_radians = finite_or_zero(yaw_radians);
+ let phase_degrees = ((yaw_radians + NATIVE_PI) * (180.0 / NATIVE_PI)).round_ties_even();
+ let phase_degrees = phase_degrees as i64;
+ let segment = phase_degrees.div_euclid(NATIVE_GRADIENT_SEGMENT_DEGREES);
+ let remainder = phase_degrees.rem_euclid(NATIVE_GRADIENT_SEGMENT_DEGREES);
+ let palette_index = (segment - 2).rem_euclid(4) as usize;
+ let next_palette_index = (palette_index + 1) & 3;
+ let palette_fraction = remainder as f32 * NATIVE_GRADIENT_FRACTION_SCALE;
+ let color = interpolate_gradient_color(
+ sample.colors[palette_index],
+ sample.colors[next_palette_index],
+ palette_fraction,
+ );
+ let glare_rgb_delta = glare_rgb_delta.map(|component| finite_or_zero(component).max(0.0));
+ let horizon_floor = glare_rgb_delta.map(native_horizon_floor_component);
+ SkyGradientFrame {
+ color,
+ horizon_floor,
+ }
+}
+
+fn interpolate_gradient_color(
+ first: PackedColor,
+ second: PackedColor,
+ fraction: f32,
+) -> PackedColor {
+ let channel = |shift: u32| {
+ let a = ((first.raw() >> shift) & 0xFF) as f32;
+ let b = ((second.raw() >> shift) & 0xFF) as f32;
+ (a + (b - a) * fraction).clamp(0.0, 255.0).round_ties_even() as u32
+ };
+ PackedColor(0xFF00_0000 | (channel(16) << 16) | (channel(8) << 8) | channel(0))
+}
+
+fn native_horizon_floor_component(delta: f32) -> f32 {
+ let mapped = if delta <= NATIVE_GRADIENT_ONE {
+ delta * NATIVE_GRADIENT_SHORT_SCALE
+ } else if delta <= NATIVE_GRADIENT_THREE {
+ delta * NATIVE_GRADIENT_LONG_SCALE + NATIVE_GRADIENT_LONG_OFFSET
+ } else {
+ NATIVE_GRADIENT_ONE
+ };
+ // Keep the normalized API bounded even if a caller supplies a malformed
+ // negative or non-finite value after the input sanitization above.
+ mapped.clamp(NATIVE_GRADIENT_ZERO, NATIVE_GRADIENT_ONE)
+}
+
+/// Complete CPU output for one sky frame.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SkyFrame<'a> {
+ /// Interpolated atmosphere sky payload retained for renderer extensions.
+ pub sky: SkySample,
+ /// Sun and light-manager state.
+ pub sun: SunFrame,
+ /// Independent moon object state from the moon SunStart/SunStop interval.
+ pub moon: SunFrame,
+ /// Primary and opposite light for the sun, followed by the moon pair.
+ pub directional_lights: [DirectionalLightFrame; 4],
+ /// Native directional sprite data for sun, moon and both flares.
+ pub sprites: [SkySpriteFrame<'a>; 4],
+ /// Native sky draw order. The renderer submits these four passes as a
+ /// stable topology and changes only sampled uniforms/material dynamics.
+ pub passes: [SkyPassFrame<'a>; 4],
+ /// Fog state.
+ pub fog: FogFrame,
+ /// Native global lighting floor copied from the sampled sky state.
+ pub lighting_floor: [f32; 3],
+ /// Per-row cloud/star/sprite state.
+ pub layers: Vec<SkyLayerFrame<'a>>,
+ /// Clock value supplied by the caller.
+ pub time_seconds: f32,
+}
+
+impl<'a> SkyFrame<'a> {
+ /// Builds a frame from one sampled atmosphere snapshot.
+ #[must_use]
+ pub fn from_atmosphere(
+ atmosphere: &AtmosphereFrame,
+ materials: &'a SkyMaterials,
+ now_seconds: f32,
+ ) -> SkyFrame<'a> {
+ let sky = atmosphere.sky.clone();
+ let sun = celestial_frame(&atmosphere.sun, now_seconds);
+ let moon = celestial_frame(&atmosphere.moon, now_seconds);
+ let time_seconds = finite_or_zero(now_seconds);
+ let fog = fog_frame(&sky);
+ let passes = build_passes(materials, &sky);
+ Self {
+ sky,
+ sun,
+ moon,
+ directional_lights: celestial_lights(&sun, &moon),
+ sprites: build_sprites(materials, &sun, &moon),
+ passes,
+ lighting_floor: fog.lighting_floor,
+ fog,
+ layers: build_layers(materials, &sun, atmosphere, time_seconds),
+ time_seconds,
+ }
+ }
+
+ /// Computes camera-dependent sun glare and the fixed twelve flare slots.
+ pub fn sun_optics(&self, visibility: SunVisibility) -> Result<SunOptics, SkyProjectionError> {
+ sun_optics(self.sun, visibility)
+ }
+
+ /// Computes the lighting-dependent native screen-gradient and horizon floor.
+ #[must_use]
+ pub fn screen_gradient(&self, yaw_radians: f32, glare_rgb_delta: [f32; 3]) -> SkyGradientFrame {
+ screen_gradient_frame(&self.sky, yaw_radians, glare_rgb_delta)
+ }
+}
+
+/// Stateful CPU sky owner suitable for a renderer's per-frame update.
+#[derive(Clone, Debug)]
+pub struct SkySystem {
+ /// Dome geometry configuration.
+ pub geometry: SkyGeometryConfig,
+ /// Mission material rows.
+ pub materials: SkyMaterials,
+ /// Static dome topology with mutable per-frame colors.
+ mesh: SkyMesh,
+}
+
+impl SkySystem {
+ /// Creates a sky system.
+ pub fn new(
+ geometry: SkyGeometryConfig,
+ materials: SkyMaterials,
+ ) -> Result<Self, SkyGeometryError> {
+ Ok(Self {
+ geometry,
+ materials,
+ mesh: geometry.try_topology()?,
+ })
+ }
+
+ /// Returns the persistent dome mesh.
+ #[must_use]
+ pub const fn mesh(&self) -> &SkyMesh {
+ &self.mesh
+ }
+
+ /// Updates only dynamic colors and frame state on the persistent topology.
+ pub fn update(
+ &mut self,
+ atmosphere: &AtmosphereFrame,
+ now_seconds: f32,
+ ) -> Result<SkyFrame<'_>, SkyUpdateError> {
+ validate_atmosphere(atmosphere)?;
+ let expected_vertices = self
+ .geometry
+ .azimuth_count
+ .checked_mul(self.geometry.rings)
+ .and_then(|count| count.checked_add(1));
+ let expected_indices = self
+ .geometry
+ .azimuth_count
+ .checked_mul(self.geometry.rings.saturating_mul(2).saturating_sub(1))
+ .and_then(|count| count.checked_mul(3));
+ if expected_vertices != Some(self.mesh.vertices.len())
+ || expected_indices != Some(self.mesh.indices.len())
+ {
+ return Err(SkyUpdateError::TopologyChanged);
+ }
+ let sky = atmosphere.sky.clone();
+ self.mesh.update_colors(&sky);
+ let sun = celestial_frame(&atmosphere.sun, now_seconds);
+ let moon = celestial_frame(&atmosphere.moon, now_seconds);
+ let time_seconds = finite_or_zero(now_seconds);
+ let fog = fog_frame(&sky);
+ let passes = build_passes(&self.materials, &sky);
+ Ok(SkyFrame {
+ sky,
+ sun,
+ moon,
+ directional_lights: celestial_lights(&sun, &moon),
+ sprites: build_sprites(&self.materials, &sun, &moon),
+ passes,
+ lighting_floor: fog.lighting_floor,
+ fog,
+ layers: build_layers(&self.materials, &sun, atmosphere, time_seconds),
+ time_seconds,
+ })
+ }
+}
+
+fn build_sprites<'a>(
+ materials: &'a SkyMaterials,
+ sun: &SunFrame,
+ moon: &SunFrame,
+) -> [SkySpriteFrame<'a>; 4] {
+ [
+ sprite_frame(
+ materials,
+ SkyLayerKind::Sun,
+ 3,
+ sun,
+ false,
+ SkyBlendMode::Alpha,
+ ),
+ sprite_frame(
+ materials,
+ SkyLayerKind::Moon,
+ 4,
+ moon,
+ false,
+ SkyBlendMode::Alpha,
+ ),
+ sprite_frame(
+ materials,
+ SkyLayerKind::Flare0,
+ 5,
+ sun,
+ true,
+ SkyBlendMode::Additive,
+ ),
+ sprite_frame(
+ materials,
+ SkyLayerKind::Flare1,
+ 6,
+ sun,
+ true,
+ SkyBlendMode::Additive,
+ ),
+ ]
+}
+
+fn build_passes<'a>(materials: &'a SkyMaterials, sky: &SkySample) -> [SkyPassFrame<'a>; 4] {
+ [
+ SkyPassFrame {
+ kind: SkyPassKind::ScreenGradient,
+ material_row: None,
+ secondary_material_row: None,
+ material: None,
+ secondary_material: None,
+ uv_stage: None,
+ secondary_uv_stage: None,
+ translation_z: 0.0,
+ blend: SkyBlendMode::Alpha,
+ material_color: None,
+ },
+ SkyPassFrame {
+ kind: SkyPassKind::NebulaStars,
+ material_row: Some(0),
+ secondary_material_row: Some(1),
+ material: materials.material_name(0),
+ secondary_material: materials.material_name(1),
+ uv_stage: Some(0),
+ secondary_uv_stage: Some(1),
+ translation_z: 0.0,
+ blend: SkyBlendMode::Alpha,
+ material_color: None,
+ },
+ SkyPassFrame {
+ kind: SkyPassKind::DomeGradient,
+ material_row: None,
+ secondary_material_row: None,
+ material: None,
+ secondary_material: None,
+ uv_stage: None,
+ secondary_uv_stage: None,
+ translation_z: 0.0,
+ blend: SkyBlendMode::Alpha,
+ material_color: None,
+ },
+ SkyPassFrame {
+ kind: SkyPassKind::Clouds,
+ material_row: Some(2),
+ secondary_material_row: None,
+ material: materials.material_name(2),
+ secondary_material: None,
+ uv_stage: Some(2),
+ secondary_uv_stage: None,
+ translation_z: -5000.0,
+ blend: SkyBlendMode::Alpha,
+ material_color: {
+ let rgba = sky.packed[0].rgba();
+ Some([rgba[0], rgba[1], rgba[2]])
+ },
+ },
+ ]
+}
+
+fn sprite_frame<'a>(
+ materials: &'a SkyMaterials,
+ kind: SkyLayerKind,
+ row: usize,
+ source: &SunFrame,
+ requires_occlusion: bool,
+ blend: SkyBlendMode,
+) -> SkySpriteFrame<'a> {
+ SkySpriteFrame {
+ kind,
+ row,
+ material: materials.material_name(row),
+ active: source.active,
+ direction: source.geometry_direction(),
+ color: [
+ source.sprite_color[0],
+ source.sprite_color[1],
+ source.sprite_color[2],
+ ],
+ alpha: source.sprite_color[3],
+ size_values: source.size_values,
+ intensity: source.intensity,
+ uv: SKY_SPRITE_UV,
+ blend,
+ requires_occlusion,
+ }
+}
+
+fn build_layers<'a>(
+ materials: &'a SkyMaterials,
+ sun: &SunFrame,
+ atmosphere: &AtmosphereFrame,
+ phase_seconds: f32,
+) -> Vec<SkyLayerFrame<'a>> {
+ let count = materials.rows.len().max(SKY_MATERIAL_COUNT);
+ (0..count)
+ .map(|row| {
+ let kind = SkyLayerKind::from_row(row);
+ let (active, intensity, uv_stage) = match kind {
+ SkyLayerKind::Nebula => (true, 1.0, 0),
+ SkyLayerKind::Stars => (true, 1.0, 1),
+ SkyLayerKind::Clouds => (true, 1.0, 2),
+ SkyLayerKind::Sun | SkyLayerKind::Flare0 | SkyLayerKind::Flare1 => {
+ (sun.active, sun.intensity, 0)
+ }
+ SkyLayerKind::Moon => (atmosphere.moon.active, atmosphere.moon.intensity, 0),
+ SkyLayerKind::Snow => (atmosphere.snow.active, atmosphere.snow.intensity, 0),
+ SkyLayerKind::Rain => (atmosphere.rain.active, atmosphere.rain.intensity, 0),
+ SkyLayerKind::Other(_) => (true, 1.0, 0),
+ };
+ SkyLayerFrame {
+ row,
+ kind,
+ material: materials.material_name(row),
+ active,
+ uv_stage,
+ uv_scale: SKY_UV_SCALES[uv_stage],
+ intensity: finite_or_zero(intensity).max(0.0),
+ phase_seconds,
+ }
+ })
+ .collect()
+}
+
+fn celestial_lights(sun: &SunFrame, moon: &SunFrame) -> [DirectionalLightFrame; 4] {
+ let sun_lights = sun.directional_lights();
+ let moon_lights = moon.directional_lights();
+ [sun_lights[0], sun_lights[1], moon_lights[0], moon_lights[1]]
+}
+
+fn validate_atmosphere(atmosphere: &AtmosphereFrame) -> Result<(), SkyUpdateError> {
+ let finite = |values: &[f32]| values.iter().all(|value| value.is_finite());
+ if finite(&atmosphere.sky.values)
+ && finite(&atmosphere.sun.rgb)
+ && finite(&atmosphere.sun.values)
+ && atmosphere.sun.intensity.is_finite()
+ && finite(&atmosphere.moon.rgb)
+ && finite(&atmosphere.moon.values)
+ && atmosphere.moon.intensity.is_finite()
+ && atmosphere.rain.intensity.is_finite()
+ && atmosphere.snow.intensity.is_finite()
+ && atmosphere.lightning.intensity.is_finite()
+ && atmosphere.real_seconds.is_finite()
+ && atmosphere.track_seconds.is_finite()
+ && atmosphere.day_seconds.is_finite()
+ {
+ Ok(())
+ } else {
+ Err(SkyUpdateError::NonFiniteAtmosphere)
+ }
+}
+
+fn finite_or_zero(value: f32) -> f32 {
+ if value.is_finite() {
+ value
+ } else {
+ 0.0
+ }
+}
+
+fn normalize3(vector: [f32; 3]) -> [f32; 3] {
+ let length = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt();
+ if length.is_finite() && length > 0.0 {
+ vector.map(|component| component / length)
+ } else {
+ [0.0; 3]
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::atmosphere::{AtmosphereFrame, WeatherSample};
+
+ fn sample() -> SkySample {
+ let colors = std::array::from_fn(|index| PackedColor(0xFF00_0000 | index as u32));
+ SkySample {
+ colors,
+ values: [1.0, 2.0],
+ packed: [PackedColor(0), PackedColor(0xFF11_2233)],
+ }
+ }
+
+ fn sun(active: bool) -> SunSample {
+ SunSample {
+ active,
+ rgb: [0.5, 0.25, 0.125],
+ values: [0.0; 2],
+ packed: [PackedColor::default(); 2],
+ intensity: 0.5,
+ birth: None,
+ }
+ }
+
+ fn atmosphere(active: bool) -> AtmosphereFrame {
+ AtmosphereFrame {
+ real_seconds: 0.0,
+ track_index: 0,
+ track_seconds: 0.0,
+ day_seconds: 0.0,
+ sky: sample(),
+ sun: sun(active),
+ moon: sun(false),
+ rain: WeatherSample {
+ active: false,
+ intensity: 0.0,
+ color: [0.0; 4],
+ raw_color: [0.0; 4],
+ resources: Vec::new(),
+ },
+ snow: WeatherSample {
+ active: false,
+ intensity: 0.0,
+ color: [0.0; 4],
+ raw_color: [0.0; 4],
+ resources: Vec::new(),
+ },
+ lightning: WeatherSample {
+ active: false,
+ intensity: 0.0,
+ color: [0.0; 4],
+ raw_color: [0.0; 4],
+ resources: Vec::new(),
+ },
+ }
+ }
+
+ #[test]
+ fn geometry_matches_original_counts_positions_uv_and_winding() {
+ let config = SkyGeometryConfig::new(10.0, std::f32::consts::FRAC_PI_2, 2.0, 4, 3);
+ let mesh = config.try_mesh(&sample()).expect("valid mesh");
+ assert_eq!(mesh.vertices.len(), 13);
+ assert_eq!(mesh.indices.len(), 4 * (6 * 3 - 3));
+ assert_eq!(mesh.indices[0..3], [0, 1, 4]);
+ assert_eq!(mesh.indices[3..9], [1, 2, 4, 4, 2, 5]);
+ assert_eq!(mesh.vertices[0].position, [0.0, 0.0, 20.0]);
+ assert!((mesh.vertices[1].position[0]).abs() < 1.0e-5);
+ assert!((mesh.vertices[1].position[1] - 5.0).abs() < 1.0e-5);
+ assert!((mesh.vertices[1].position[2] - 17.320_508).abs() < 1.0e-5);
+ assert!((mesh.vertices[1].uv[1][1] - 7.5).abs() < 1.0e-5);
+ assert_eq!(mesh.vertices[0].normal, [0, 0, 127]);
+ assert_eq!(mesh.vertices[0].normal_vector(), [0.0, 0.0, 1.0]);
+ }
+
+ #[test]
+ fn normals_use_signed_127_nearest_even_encoding() {
+ assert_eq!(normal_byte(0.5), 64);
+ assert_eq!(normal_byte(-0.5), -64);
+ assert_eq!(normal_byte(-1.0), -127);
+ }
+
+ #[test]
+ fn colors_use_zenith_and_three_interpolated_quadrant_belts() {
+ let mut sky = sample();
+ sky.colors[12] = PackedColor(0xFFAA_BBCC);
+ sky.colors[8] = PackedColor(0xFF00_0000);
+ sky.colors[9] = PackedColor(0xFF00_FF00);
+ sky.colors[10] = PackedColor(0xFF00_00FF);
+ sky.colors[11] = PackedColor(0xFFFF_FFFF);
+ let config = SkyGeometryConfig::new(10.0, 1.0, 1.0, 12, 4);
+ let mesh = config.try_mesh(&sky).expect("valid mesh");
+ assert_eq!(mesh.vertices[0].color.raw(), 0xFFAA_BBCC);
+ assert_eq!(mesh.vertices[1].color.raw(), 0xFFAA_BBCC);
+ // First belt, azimuth one: one third from colors 8 to 9.
+ assert_eq!(mesh.vertices[6].color.raw(), PackedColor(0xFF00_5500).raw());
+ // The next belt starts at the fourth ring, and the last belt is used
+ // for every ring after the three original color bands.
+ let ring_two = 1 + 2;
+ let ring_three = 1 + 3;
+ assert_eq!(mesh.vertices[ring_two].color.raw(), sky.colors[4].raw());
+ assert_eq!(mesh.vertices[ring_three].color.raw(), sky.colors[0].raw());
+ }
+
+ #[test]
+ fn wea_parser_keeps_dynamic_names_and_optional_lightmaps() {
+ let table = SkyMaterials::parse(
+ b"2\r\n7 custom_snow\r\n8 custom_rain\r\n\r\nLIGHTMAPS\r\n1\r\n0 lm\r\n",
+ )
+ .expect("sky.wea");
+ assert_eq!(table.material_name(0), Some("custom_snow"));
+ assert_eq!(table.rows[1].legacy_id, 8);
+ assert_eq!(table.lightmaps[0].name, "lm");
+ }
+
+ #[test]
+ fn fog_uses_original_scale_and_packed_rgb() {
+ let frame = fog_frame(&sample());
+ assert_eq!(frame.start, 700.0);
+ assert_eq!(frame.end, 1_400.0);
+ assert_eq!(
+ frame.color,
+ [
+ 0x11 as f32 / 255.0,
+ 0x22 as f32 / 255.0,
+ 0x33 as f32 / 255.0
+ ]
+ );
+ }
+
+ #[test]
+ fn screen_gradient_uses_native_yaw_and_lighting_floor() {
+ let mut sky = sample();
+ sky.colors[0] = PackedColor(0xFF00_0000);
+ sky.colors[1] = PackedColor(0xFF00_FF00);
+ sky.colors[2] = PackedColor(0xFF00_00FF);
+ sky.colors[3] = PackedColor(0xFFFF_0000);
+
+ let at_sector_start = screen_gradient_frame(&sky, 0.0, [0.0; 3]);
+ assert_eq!(at_sector_start.color, sky.colors[0]);
+
+ let halfway = screen_gradient_frame(&sky, std::f32::consts::FRAC_PI_4, [0.5, 2.0, 4.0]);
+ assert_eq!(halfway.color, PackedColor(0xFF00_8000));
+ assert_eq!(halfway.horizon_floor, [0.4, 0.9, 1.0]);
+ assert_eq!(
+ halfway.clamp_dome_color(PackedColor(0xFF10_2030)),
+ PackedColor(0xFF66_E6_FF)
+ );
+ }
+
+ #[test]
+ fn lighting_floor_maps_native_boundaries() {
+ for (delta, expected) in [(0.5, 0.4), (1.0, 0.8), (2.0, 0.9), (3.0, 1.0), (4.0, 1.0)] {
+ let frame = screen_gradient_frame(&sample(), 0.0, [delta; 3]);
+ assert_eq!(frame.horizon_floor, [expected; 3], "delta={delta}");
+ }
+ }
+
+ #[test]
+ fn sun_orientation_applies_birth_matrix_after_orbit_vector() {
+ let sample = sun(true);
+ let at_start = sun_frame(&sample, 0.0, SunConfig::new(0.0, 1.0, [90.0, 0.0]));
+ assert!((at_start.direction[0]).abs() < 1.0e-5);
+ assert!((at_start.direction[1] + at_start.orbit_direction[0]).abs() < 1.0e-5);
+ assert!((at_start.direction[2] - at_start.orbit_direction[2]).abs() < 1.0e-5);
+
+ let side = sun_frame(&sample, 0.0, SunConfig::new(0.0, 1.0, [0.0, 90.0]));
+ assert!((side.direction[0] - side.orbit_direction[0]).abs() < 1.0e-5);
+ assert!((side.direction[1] - side.orbit_direction[2]).abs() < 1.0e-5);
+ assert!(side.direction[2].abs() < 1.0e-5);
+ }
+
+ #[test]
+ fn native_birth_keeps_the_real_start_time() {
+ assert!(!celestial_frame(&sun(false), 100.0).active);
+ let birth = SunBirth {
+ start_seconds: 90.0,
+ lifetime_seconds: 20.0,
+ initial_angles_degrees: [0.0; 2],
+ material_row: 3,
+ };
+ let mut active = sun(true);
+ active.birth = Some(birth);
+ let first = celestial_frame(&active, 100.0);
+ let later = celestial_frame(&active, 105.0);
+ assert!((first.age - 0.5).abs() < 1.0e-5);
+ assert!((later.age - 0.75).abs() < 1.0e-5);
+ assert!(!celestial_frame(&sun(false), 106.0).active);
+ }
+
+ #[test]
+ fn frame_exposes_dynamic_layers_and_moon_direction() {
+ let atmosphere = atmosphere(true);
+ let materials = SkyMaterials::from_names(["nebula", "stars", "clouds", "sun", "moon"]);
+ let frame = SkyFrame::from_atmosphere(&atmosphere, &materials, 0.0);
+ assert_eq!(frame.layers[3].material.as_deref(), Some("sun"));
+ assert_eq!(frame.layers[8].material, None);
+ assert_eq!(frame.moon.direction, [0.0; 3]);
+ assert_eq!(frame.sprites[0].material, Some("sun"));
+ }
+
+ #[test]
+ fn system_reuses_topology_and_updates_only_colors() {
+ let materials = SkyMaterials::from_names(["nebula", "stars", "clouds"]);
+ let mut system = SkySystem::new(SkyGeometryConfig::default(), materials).expect("geometry");
+ let vertex_ptr = system.mesh().vertices.as_ptr();
+ let index_ptr = system.mesh().indices.as_ptr();
+ let positions = system
+ .mesh()
+ .vertices
+ .iter()
+ .map(|vertex| vertex.position)
+ .collect::<Vec<_>>();
+
+ let mut atmosphere = atmosphere(true);
+ atmosphere.sky.colors[12] = PackedColor(0xFF12_3456);
+ let (lighting_floor, fog_color, sprites_have_native_uv) = {
+ let frame = system.update(&atmosphere, 10.0).expect("sky update");
+ (
+ frame.lighting_floor,
+ frame.fog.color,
+ frame
+ .sprites
+ .iter()
+ .all(|sprite| sprite.uv == SKY_SPRITE_UV),
+ )
+ };
+
+ assert_eq!(system.mesh().vertices.as_ptr(), vertex_ptr);
+ assert_eq!(system.mesh().indices.as_ptr(), index_ptr);
+ assert_eq!(
+ system
+ .mesh()
+ .vertices
+ .iter()
+ .map(|vertex| vertex.position)
+ .collect::<Vec<_>>(),
+ positions
+ );
+ assert_eq!(system.mesh().vertices[0].color, PackedColor(0xFF12_3456));
+ assert_eq!(lighting_floor, fog_color);
+ assert!(sprites_have_native_uv);
+ }
+
+ #[test]
+ fn native_sprite_and_light_contract_uses_packed_secondary_values() {
+ let mut source = sun(true);
+ source.packed = [PackedColor(0x8011_2233), PackedColor(0xFF44_5566)];
+ source.values = [2.0, 3.0];
+ let frame = sun_frame(&source, 0.5, SunConfig::new(0.0, 1.0, [0.0, 0.0]));
+ assert_eq!(
+ frame.sprite_color,
+ [
+ 0x11 as f32 / 255.0,
+ 0x22 as f32 / 255.0,
+ 0x33 as f32 / 255.0,
+ 0x80 as f32 / 255.0,
+ ]
+ );
+ let lights = frame.directional_lights();
+ assert_eq!(lights[0].color, frame.color);
+ assert_eq!(lights[1].direction, frame.direction.map(|value| -value));
+ assert_eq!(
+ lights[1].color,
+ [
+ 0x44 as f32 / 255.0,
+ 0x55 as f32 / 255.0,
+ 0x66 as f32 / 255.0,
+ ]
+ );
+ assert_eq!(
+ frame.geometry_direction(),
+ frame.direction.map(|value| -value)
+ );
+ let center = frame.world_center([1.0, 2.0, 3.0], 100.0);
+ assert!((center[0] - 1.0).abs() < 1.0e-4);
+ assert!((center[1] - 2.0).abs() < 1.0e-4);
+ assert!((center[2] - 53.0).abs() < 1.0e-4);
+ assert_eq!(
+ sprite_half_size_pixels(frame.size_values, [1000.0, 500.0], 2.0).expect("projection"),
+ [162.5, 243.75]
+ );
+ }
+
+ #[test]
+ fn glare_boost_and_flares_follow_native_visibility_gates() {
+ let mut source = sun(true);
+ source.rgb = [1.0, 1.0, 1.0];
+ source.packed[0] = PackedColor(0xFF11_2233);
+ let sun = sun_frame(&source, 0.5, SunConfig::new(0.0, 1.0, [0.0, 0.0]));
+ let visibility = SunVisibility {
+ projected_sun: [50.0, 50.0],
+ viewport: [100.0, 100.0],
+ view_forward: sun.geometry_direction(),
+ unoccluded: true,
+ };
+ let glare = sun_optics(sun, visibility).expect("optics");
+ assert_eq!(glare.glare_amount, 1.0);
+ assert!(glare.primary_color[0] > sun.color[0]);
+ assert!(glare.flares.iter().all(|flare| !flare.active));
+
+ let mut visibility = visibility;
+ visibility.view_forward = sun.direction;
+ let flare = sun_optics(sun, visibility).expect("optics");
+ assert!(flare.glare_amount < 0.1);
+ assert!(flare.flares.iter().all(|quad| quad.active));
+ assert_eq!(flare.flares[0].row, 5);
+ assert_eq!(flare.flares[0].uv, SKY_SPRITE_UV);
+ assert_eq!(flare.flares[0].center_pixels, [50.0, 50.0]);
+ }
+
+ #[test]
+ fn native_passes_preserve_nebula_stars_gradient_cloud_order() {
+ let materials = SkyMaterials::from_names([
+ "nebula", "stars", "clouds", "sun", "moon", "flare0", "flare1", "snow", "rain",
+ ]);
+ let sky = sample();
+ let frame = SkyFrame::from_atmosphere(&atmosphere(false), &materials, 0.0);
+ assert_eq!(frame.passes.len(), 4);
+ assert_eq!(frame.passes[0].kind, SkyPassKind::ScreenGradient);
+ assert_eq!(frame.passes[1].kind, SkyPassKind::NebulaStars);
+ assert_eq!(frame.passes[1].material_row, Some(0));
+ assert_eq!(frame.passes[1].secondary_material_row, Some(1));
+ assert_eq!(frame.passes[1].uv_stage, Some(0));
+ assert_eq!(frame.passes[1].secondary_uv_stage, Some(1));
+ assert_eq!(frame.passes[2].kind, SkyPassKind::DomeGradient);
+ assert_eq!(frame.passes[3].kind, SkyPassKind::Clouds);
+ assert_eq!(frame.passes[3].uv_stage, Some(2));
+ assert_eq!(frame.passes[3].translation_z, -5000.0);
+ let rgba = sky.packed[0].rgba();
+ assert_eq!(
+ frame.passes[3].material_color,
+ Some([rgba[0], rgba[1], rgba[2]])
+ );
+ }
+
+ #[test]
+ fn sky_update_rejects_non_finite_input() {
+ let mut system = SkySystem::new(SkyGeometryConfig::default(), SkyMaterials::default())
+ .expect("geometry");
+ let mut frame = atmosphere(false);
+ frame.sky.values[0] = f32::NAN;
+ assert_eq!(
+ system.update(&frame, 0.0),
+ Err(SkyUpdateError::NonFiniteAtmosphere)
+ );
+ }
+}
diff --git a/crates/fparkan-material/src/lib.rs b/crates/fparkan-material/src/lib.rs
index 32d48a1..da0b716 100644
--- a/crates/fparkan-material/src/lib.rs
+++ b/crates/fparkan-material/src/lib.rs
@@ -67,6 +67,26 @@ pub struct MaterialPhase {
pub texture_raw: [u8; 16],
}
+impl MaterialPhase {
+ /// Decodes the native floating-point material coefficients.
+ #[must_use]
+ pub fn coefficients(&self) -> MaterialCoefficients {
+ let normalized = |index: usize| f32::from(self.parameters[index]) / 255.0;
+ MaterialCoefficients {
+ additive_rgb: [normalized(0), normalized(1), normalized(2)],
+ opacity: f32::from(self.parameters[3]) * 0.01,
+ directional_rgb: [normalized(4), normalized(5), normalized(6)],
+ directional_extra: normalized(7),
+ specular_rgb: [normalized(8), normalized(9), normalized(10)],
+ specular_extra: normalized(11),
+ extra_rgb: [normalized(12), normalized(13), normalized(14)],
+ extra_alpha: normalized(15),
+ power: self.parameters[16],
+ page_index: i8::from_ne_bytes([self.parameters[17]]),
+ }
+ }
+}
+
/// Material animation block.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MaterialAnimationBlock {
@@ -100,38 +120,88 @@ pub enum MaterialFallback {
FirstEntry,
}
-/// Material timeline mode.
+/// Native MAT0 animation mode encoded by `header_raw & 7`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum MaterialTimelineMode {
- /// Play once from phase zero.
- OneShot,
- /// Clamp frame to the last phase.
- Clamp,
- /// Loop over all phases.
+pub enum MaterialAnimationMode {
+ /// Repeat the timeline from its first key.
Loop,
- /// Ping-pong over all phases.
+ /// Repeat the timeline in alternating forward and reverse directions.
PingPong,
+ /// Hold the final key after the timeline duration.
+ Clamp,
+ /// Choose a fresh time from the caller-provided random value per query.
+ RandomPerQuery,
+ /// Preserve an unknown native mode without making the sampler panic.
+ Unknown(u8),
}
-/// Material runtime sampling profile.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct MaterialTimelineProfile {
- /// Timeline mode.
- pub mode: MaterialTimelineMode,
- /// Apply deterministic material-only random offset.
- pub random_offset: bool,
+impl MaterialAnimationMode {
+ /// Decodes the low three mode bits from a native block header.
+ #[must_use]
+ pub const fn from_header(header_raw: u32) -> Self {
+ match (header_raw & 7) as u8 {
+ 0 => Self::Loop,
+ 1 => Self::PingPong,
+ 2 => Self::Clamp,
+ 3 => Self::RandomPerQuery,
+ other => Self::Unknown(other),
+ }
+ }
}
-/// Sampled material phase.
-#[derive(Clone, Debug, Eq, PartialEq)]
+impl MaterialAnimationBlock {
+ /// Returns the native timeline mode from this block's header.
+ #[must_use]
+ pub const fn mode(&self) -> MaterialAnimationMode {
+ MaterialAnimationMode::from_header(self.header_raw)
+ }
+
+ /// Returns the native interpolation bit mask from `header_raw >> 3`.
+ #[must_use]
+ pub const fn interpolation_mask(&self) -> u32 {
+ self.header_raw >> 3
+ }
+
+ /// Returns the final key end time in milliseconds, if a key exists.
+ #[must_use]
+ pub fn duration_ms(&self) -> Option<u32> {
+ self.keys.last().map(|key| u32::from(key.k1))
+ }
+}
+
+/// Coefficients produced by the native MAT0 phase loader.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct MaterialCoefficients {
+ /// Parameters 0..2, normalized additive RGB.
+ pub additive_rgb: [f32; 3],
+ /// Parameter 3, stored as a percentage (`raw * 0.01`).
+ pub opacity: f32,
+ /// Parameters 4..6, normalized directional RGB.
+ pub directional_rgb: [f32; 3],
+ /// Parameter 7, normalized and held when the animation mask is bit 2.
+ pub directional_extra: f32,
+ /// Parameters 8..10, normalized specular RGB.
+ pub specular_rgb: [f32; 3],
+ /// Parameter 11, normalized and held when the animation mask is bit 4.
+ pub specular_extra: f32,
+ /// Parameters 12..14, normalized extra RGB.
+ pub extra_rgb: [f32; 3],
+ /// Parameter 15, normalized and held when the animation mask is bit 8.
+ pub extra_alpha: f32,
+ /// Parameter 16, an integer material power value.
+ pub power: u8,
+ /// Parameter 17, a signed TEXM page selector.
+ pub page_index: i8,
+}
+
+/// Sampled native material phase.
+#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MaterialPhaseSample {
/// Selected phase index.
pub phase_index: usize,
- /// Effective frame after mode and random offset.
- pub effective_frame: u32,
- /// Sampled parameter bytes.
- pub parameters: [u8; 18],
- /// Sampled texture bytes.
+ /// Native floating-point coefficients after optional interpolation.
+ pub coefficients: MaterialCoefficients,
+ /// Texture name bytes from the selected phase; never interpolated.
pub texture_raw: [u8; 16],
}
@@ -142,6 +212,8 @@ pub struct ResolvedMaterial {
pub name: ResourceName,
/// Fallback path.
pub fallback: MaterialFallback,
+ /// Raw NRes `attr1`, retained for renderer blend/category selection.
+ pub attr1: u32,
/// Decoded document.
pub document: Mat0Document,
}
@@ -221,6 +293,29 @@ pub enum MaterialError {
MissingMaterial(String),
/// A material document has no phases for runtime sampling.
EmptyMaterial,
+ /// An animation block has no positive duration for a multi-key timeline.
+ InvalidAnimationDuration {
+ /// Selected animation block.
+ block_index: usize,
+ },
+ /// An animation key points outside the decoded phase table.
+ InvalidAnimationPhaseIndex {
+ /// Selected animation block.
+ block_index: usize,
+ /// Key index within the block.
+ key_index: usize,
+ /// Raw phase index from the key.
+ phase_index: u16,
+ /// Number of decoded phases.
+ phase_count: usize,
+ },
+ /// Animation key end times are not strictly increasing.
+ InvalidAnimationKeyTimes {
+ /// Selected animation block.
+ block_index: usize,
+ /// Key index whose end time is invalid.
+ key_index: usize,
+ },
}
impl From<ResourceError> for MaterialError {
@@ -271,6 +366,25 @@ impl std::fmt::Display for MaterialError {
Self::Resource(message) => write!(f, "{message}"),
Self::MissingMaterial(name) => write!(f, "missing material: {name}"),
Self::EmptyMaterial => write!(f, "material has no phases"),
+ Self::InvalidAnimationDuration { block_index } => {
+ write!(f, "MAT0 animation block {block_index} has zero duration")
+ }
+ Self::InvalidAnimationPhaseIndex {
+ block_index,
+ key_index,
+ phase_index,
+ phase_count,
+ } => write!(
+ f,
+ "MAT0 animation block {block_index} key {key_index} phase {phase_index} outside {phase_count} phases"
+ ),
+ Self::InvalidAnimationKeyTimes {
+ block_index,
+ key_index,
+ } => write!(
+ f,
+ "MAT0 animation block {block_index} key {key_index} has non-increasing end time"
+ ),
}
}
}
@@ -294,7 +408,8 @@ pub fn decode_wear(bytes: &[u8]) -> Result<WearTable, MaterialError> {
return Err(MaterialError::ZeroWearCount);
}
- let mut entries = Vec::with_capacity(count);
+ let available_lines = lines.clone().count();
+ let mut entries = Vec::with_capacity(count.min(available_lines));
for index in 0..count {
let line = lines
.next()
@@ -425,97 +540,214 @@ pub fn resolve_material(
))
}
-/// Samples a material phase with deterministic runtime timeline semantics.
+/// Samples one native MAT0 animation block.
+///
+/// `animation_block_index` selects a block from the already-resolved material
+/// table. The packed WEAR handle (table and row) is resolved by the caller;
+/// its high half is not an animation-block selector. An out-of-range block
+/// uses block zero, matching `GetPhase`. `clock_ms` is the paused-compensated
+/// `timeGetTime` value and `wear_row_start_ms` is the start timestamp captured
+/// for the owning WEA row; the sampler subtracts the latter with wrapping
+/// `u32` arithmetic. For mode [`MaterialAnimationMode::RandomPerQuery`],
+/// `random_value` is the caller's fresh native-style random value for this
+/// query. The sampler does not own an RNG.
///
/// # Errors
///
-/// Returns [`MaterialError::EmptyMaterial`] when the MAT0 document has no
-/// phases.
+/// Returns an error for an empty material, a malformed multi-key duration,
+/// non-increasing key end times, or a key that references no phase.
pub fn sample_material_phase(
document: &Mat0Document,
- profile: MaterialTimelineProfile,
- frame: u32,
- seed: u64,
+ animation_block_index: usize,
+ clock_ms: u32,
+ wear_row_start_ms: u32,
+ random_value: u32,
) -> Result<MaterialPhaseSample, MaterialError> {
- if document.phases.is_empty() {
+ let Some(first_phase) = document.phases.first() else {
return Err(MaterialError::EmptyMaterial);
+ };
+
+ let Some((selected_block_index, block)) = document
+ .animation_blocks
+ .get(animation_block_index)
+ .map(|block| (animation_block_index, block))
+ .or_else(|| document.animation_blocks.first().map(|block| (0, block)))
+ else {
+ return Ok(sample_static_phase(0, first_phase));
+ };
+
+ if block.keys.is_empty() {
+ return Ok(sample_static_phase(0, first_phase));
+ }
+
+ let keys = &block.keys;
+ let resolve_phase = |key_index: usize, key: &MaterialKey| {
+ let phase_index = usize::from(key.k0);
+ document
+ .phases
+ .get(phase_index)
+ .map(|phase| (phase_index, phase))
+ .ok_or(MaterialError::InvalidAnimationPhaseIndex {
+ block_index: selected_block_index,
+ key_index,
+ phase_index: key.k0,
+ phase_count: document.phases.len(),
+ })
+ };
+
+ if keys.len() == 1 {
+ let (phase_index, phase) = resolve_phase(0, &keys[0])?;
+ return Ok(sample_static_phase(phase_index, phase));
}
- let phase_count = document.phases.len();
- let offset = if profile.random_offset {
- material_random_offset(seed, phase_count)
+
+ let duration_ms = u32::from(keys[keys.len() - 1].k1);
+ if duration_ms == 0 {
+ return Err(MaterialError::InvalidAnimationDuration {
+ block_index: selected_block_index,
+ });
+ }
+ validate_key_times(keys, selected_block_index)?;
+ for (key_index, key) in keys.iter().enumerate() {
+ resolve_phase(key_index, key)?;
+ }
+
+ let elapsed_ms = clock_ms.wrapping_sub(wear_row_start_ms);
+ let mode = block.mode();
+ let (local_time_ms, forced_last_key) = match mode {
+ MaterialAnimationMode::Loop | MaterialAnimationMode::Unknown(_) => {
+ (elapsed_ms % duration_ms, false)
+ }
+ MaterialAnimationMode::PingPong => {
+ let cycle = elapsed_ms / duration_ms;
+ let remainder = elapsed_ms % duration_ms;
+ let local_time = if cycle & 1 == 0 {
+ remainder
+ } else {
+ duration_ms - remainder
+ };
+ (local_time, false)
+ }
+ MaterialAnimationMode::Clamp => {
+ if elapsed_ms >= duration_ms {
+ // Native clamp selects the last key and leaves the local
+ // remainder at zero before entering the common interpolation
+ // path, preserving its unsigned endpoint arithmetic.
+ (0, true)
+ } else {
+ (elapsed_ms, false)
+ }
+ }
+ MaterialAnimationMode::RandomPerQuery => (random_value % duration_ms, false),
+ };
+
+ let (key_index, previous_end_ms) = if forced_last_key {
+ (keys.len() - 1, u32::from(keys[keys.len() - 2].k1))
} else {
- 0
+ select_key(keys, local_time_ms)
};
- let effective_frame = frame.wrapping_add(offset);
- let phase_index = select_phase_index(profile.mode, effective_frame, phase_count);
- let phase = &document.phases[phase_index];
+ let key = &keys[key_index];
+ let next_key_index = (key_index + 1) % keys.len();
+ let next_key = &keys[next_key_index];
+ let (phase_index, phase) = resolve_phase(key_index, key)?;
+ let (_, next_phase) = resolve_phase(next_key_index, next_key)?;
+ let span_ms = u32::from(key.k1).wrapping_sub(previous_end_ms);
+ if span_ms == 0 {
+ return Err(MaterialError::InvalidAnimationKeyTimes {
+ block_index: selected_block_index,
+ key_index,
+ });
+ }
+ // The original routine subtracts unsigned dwords before converting both
+ // values to floating point. Clamp's post-duration endpoint intentionally
+ // retains this wraparound behavior.
+ let fraction = local_time_ms.wrapping_sub(previous_end_ms) as f32 / span_ms as f32;
+ let coefficients =
+ interpolate_coefficients(phase, next_phase, block.interpolation_mask(), fraction);
Ok(MaterialPhaseSample {
phase_index,
- effective_frame,
- parameters: phase.parameters,
+ coefficients,
texture_raw: phase.texture_raw,
})
}
-/// Interpolates selected parameter bytes according to a bit mask.
-///
-/// Unmasked fields are copied from `left`; masked fields are linearly blended
-/// and rounded to nearest integer.
-#[must_use]
-pub fn interpolate_parameter_bytes(
- left: [u8; 18],
- right: [u8; 18],
- interpolation_mask: u32,
- t: f32,
-) -> [u8; 18] {
- let mut out = left;
- for (index, value) in out.iter_mut().enumerate() {
- if interpolation_mask & (1_u32 << index) == 0 {
- continue;
+fn sample_static_phase(phase_index: usize, phase: &MaterialPhase) -> MaterialPhaseSample {
+ MaterialPhaseSample {
+ phase_index,
+ coefficients: phase.coefficients(),
+ texture_raw: phase.texture_raw,
+ }
+}
+
+fn validate_key_times(keys: &[MaterialKey], block_index: usize) -> Result<(), MaterialError> {
+ let mut previous_end = 0_u32;
+ for (key_index, key) in keys.iter().enumerate() {
+ let end = u32::from(key.k1);
+ if end <= previous_end {
+ return Err(MaterialError::InvalidAnimationKeyTimes {
+ block_index,
+ key_index,
+ });
}
- let blended =
- f32::from(left[index]) + (f32::from(right[index]) - f32::from(left[index])) * t;
- *value = rounded_clamped_byte(blended);
+ previous_end = end;
}
- out
+ Ok(())
}
-fn rounded_clamped_byte(value: f32) -> u8 {
- let rounded = value.round();
- if !rounded.is_finite() || rounded <= 0.0 {
- return 0;
+fn select_key(keys: &[MaterialKey], local_time_ms: u32) -> (usize, u32) {
+ let mut previous_end = 0_u32;
+ for (key_index, key) in keys.iter().enumerate() {
+ let end = u32::from(key.k1);
+ if local_time_ms < end {
+ return (key_index, previous_end);
+ }
+ previous_end = end;
}
- if rounded >= f32::from(u8::MAX) {
- return u8::MAX;
+ // The native backward search falls through to key zero when the local
+ // value is exactly the duration (the ping-pong turnaround endpoint).
+ if local_time_ms >= u32::from(keys[keys.len() - 1].k1) {
+ (0, 0)
+ } else {
+ (keys.len() - 1, previous_end)
}
- (0_u8..=u8::MAX)
- .find(|candidate| f32::from(*candidate) >= rounded)
- .unwrap_or(u8::MAX)
}
-/// Builds a deterministic capture for material phase sampling.
-///
-/// # Errors
-///
-/// Returns [`MaterialError`] when sampling fails.
-pub fn material_phase_capture(
- document: &Mat0Document,
- profile: MaterialTimelineProfile,
- frames: &[u32],
- seed: u64,
-) -> Result<Vec<u8>, MaterialError> {
- let mut out = Vec::new();
- for frame in frames {
- let sample = sample_material_phase(document, profile, *frame, seed)?;
- out.extend_from_slice(
- format!(
- "M,{},{},{}\n",
- frame, sample.effective_frame, sample.phase_index
- )
- .as_bytes(),
- );
+fn interpolate_coefficients(
+ left: &MaterialPhase,
+ right: &MaterialPhase,
+ mask: u32,
+ fraction: f32,
+) -> MaterialCoefficients {
+ let left = left.coefficients();
+ let right = right.coefficients();
+ let mut output = left;
+ if mask & 1 != 0 {
+ output.additive_rgb = lerp3(left.additive_rgb, right.additive_rgb, fraction);
}
- Ok(out)
+ if mask & 2 != 0 {
+ output.directional_rgb = lerp3(left.directional_rgb, right.directional_rgb, fraction);
+ }
+ if mask & 4 != 0 {
+ output.specular_rgb = lerp3(left.specular_rgb, right.specular_rgb, fraction);
+ }
+ if mask & 8 != 0 {
+ output.extra_rgb = lerp3(left.extra_rgb, right.extra_rgb, fraction);
+ }
+ if mask & 16 != 0 {
+ output.opacity = lerp(left.opacity, right.opacity, fraction);
+ }
+ output
+}
+
+fn lerp(left: f32, right: f32, fraction: f32) -> f32 {
+ left + (right - left) * fraction
+}
+
+fn lerp3(left: [f32; 3], right: [f32; 3], fraction: f32) -> [f32; 3] {
+ [
+ lerp(left[0], right[0], fraction),
+ lerp(left[1], right[1], fraction),
+ lerp(left[2], right[2], fraction),
+ ]
}
impl Mat0Document {
@@ -541,46 +773,6 @@ impl Mat0Document {
}
}
-fn select_phase_index(mode: MaterialTimelineMode, frame: u32, phase_count: usize) -> usize {
- let count = u32::try_from(phase_count).unwrap_or(u32::MAX).max(1);
- let index = match mode {
- MaterialTimelineMode::OneShot | MaterialTimelineMode::Clamp => frame.min(count - 1),
- MaterialTimelineMode::Loop => frame % count,
- MaterialTimelineMode::PingPong => {
- if count == 1 {
- 0
- } else {
- let period = count.saturating_mul(2).saturating_sub(2);
- let local = frame % period;
- if local < count {
- local
- } else {
- period - local
- }
- }
- }
- };
- usize::try_from(index).unwrap_or(phase_count.saturating_sub(1))
-}
-
-fn material_random_offset(seed: u64, phase_count: usize) -> u32 {
- let count = u64::try_from(phase_count).unwrap_or(u64::MAX).max(1);
- let mut state = 0xa076_1d64_78bd_642f_u64 ^ seed;
- for byte in b"material" {
- state ^= u64::from(*byte);
- state = splitmix64(state);
- }
- u32::try_from(splitmix64(state) % count).unwrap_or(0)
-}
-
-fn splitmix64(mut value: u64) -> u64 {
- value = value.wrapping_add(0x9e37_79b9_7f4a_7c15);
- let mut mixed = value;
- mixed = (mixed ^ (mixed >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
- mixed = (mixed ^ (mixed >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
- mixed ^ (mixed >> 31)
-}
-
fn load_material_entry(
repository: &dyn ResourceRepository,
archive: fparkan_resource::ArchiveId,
@@ -599,6 +791,7 @@ fn load_material_entry(
Ok(Some(ResolvedMaterial {
name: info.key.name,
fallback,
+ attr1: info.attr1,
document,
}))
}
@@ -619,6 +812,7 @@ fn load_first_material_entry(
Ok(Some(ResolvedMaterial {
name: info.key.name,
fallback: MaterialFallback::FirstEntry,
+ attr1: info.attr1,
document,
}))
}
@@ -642,7 +836,8 @@ fn parse_lightmaps(lines: &[&str]) -> Result<Vec<LightmapEntry>, MaterialError>
let count = parse_count(count_line)
.map_err(|_| MaterialError::InvalidLightmapCount((*count_line).to_string()))?;
cursor += 1;
- let mut lightmaps = Vec::with_capacity(count);
+ let available_lines = lines.len().saturating_sub(cursor);
+ let mut lightmaps = Vec::with_capacity(count.min(available_lines));
for index in 0..count {
let line = lines
.get(cursor)
@@ -783,6 +978,7 @@ mod tests {
use fparkan_nres::ReadProfile;
use fparkan_resource::CachedResourceRepository;
use fparkan_vfs::MemoryVfs;
+ use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -814,6 +1010,27 @@ mod tests {
}
#[test]
+ fn wear_and_lightmap_counts_do_not_reserve_beyond_available_lines() {
+ let wear = format!("{}\n", usize::MAX);
+ assert!(matches!(
+ decode_wear(wear.as_bytes()),
+ Err(MaterialError::MissingWearRow {
+ index: 0,
+ count: usize::MAX
+ })
+ ));
+
+ let lightmaps = format!("1\n0 MAT\n\nLIGHTMAPS\n{}\n", usize::MAX);
+ assert!(matches!(
+ decode_wear(lightmaps.as_bytes()),
+ Err(MaterialError::MissingLightmapRow {
+ index: 0,
+ count: usize::MAX
+ })
+ ));
+ }
+
+ #[test]
fn mat0_version_prefix_and_primary_texture() {
let mut bytes = vec![0; 4 + 10 + 68];
bytes[0..2].copy_from_slice(&2_u16.to_le_bytes());
@@ -899,7 +1116,7 @@ mod tests {
#[test]
fn resolve_material_uses_exact_match() {
let repo = material_repo(&[
- material_entry(b"MAT_A", &mat0_with_texture(b"TEX_A")),
+ material_entry_with_attr1(b"MAT_A", &mat0_with_texture(b"TEX_A"), 0x1234_5678),
material_entry(b"DEFAULT", &mat0_with_texture(b"TEX_DEFAULT")),
]);
let table = decode_wear(b"1\n0 MAT_A\n").expect("wear");
@@ -908,6 +1125,7 @@ mod tests {
assert_eq!(resolved.name.0, b"MAT_A");
assert_eq!(resolved.fallback, MaterialFallback::Exact);
+ assert_eq!(resolved.attr1, 0x1234_5678);
assert_eq!(
resolved.document.primary_texture().expect("texture").0,
b"TEX_A"
@@ -980,146 +1198,303 @@ mod tests {
}
#[test]
- fn material_modes_zero_to_three_choose_stable_phases() {
- let document =
- decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0");
-
- let cases = [
- (MaterialTimelineMode::OneShot, 9, 2),
- (MaterialTimelineMode::Clamp, 9, 2),
- (MaterialTimelineMode::Loop, 4, 1),
- (MaterialTimelineMode::PingPong, 3, 1),
- ];
- for (mode, frame, expected_phase) in cases {
- let sample = sample_material_phase(
- &document,
- MaterialTimelineProfile {
- mode,
- random_offset: false,
- },
- frame,
- 0,
- )
- .expect("sample");
- assert_eq!(sample.phase_index, expected_phase, "{mode:?}");
- }
+ fn material_animation_header_decodes_native_mode_and_mask() {
+ let block = MaterialAnimationBlock {
+ header_raw: (0x1f_u32 << 3) | 3,
+ keys: Vec::new(),
+ bytes: Vec::new(),
+ };
+
+ assert_eq!(block.mode(), MaterialAnimationMode::RandomPerQuery);
+ assert_eq!(block.interpolation_mask(), 0x1f);
+ assert_eq!(
+ MaterialAnimationMode::from_header(4),
+ MaterialAnimationMode::Unknown(4)
+ );
}
#[test]
- fn material_exact_key_boundary_selects_exact_phase() {
- let document =
- decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0");
-
- let sample = sample_material_phase(
- &document,
- MaterialTimelineProfile {
- mode: MaterialTimelineMode::Clamp,
- random_offset: false,
- },
- 1,
- 0,
- )
- .expect("sample");
+ fn material_phase_coefficients_decode_native_parameter_slots() {
+ let mut parameters = [0_u8; 18];
+ parameters.copy_from_slice(&[
+ 0, 64, 128, 25, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 9, 0xfe,
+ ]);
+ let phase = MaterialPhase {
+ parameters,
+ texture_raw: [0; 16],
+ };
- assert_eq!(sample.phase_index, 1);
- assert_eq!(&sample.texture_raw[..1], b"B");
+ let coefficients = phase.coefficients();
+ assert_eq!(
+ coefficients.additive_rgb,
+ [0.0, 64.0 / 255.0, 128.0 / 255.0]
+ );
+ assert!((coefficients.opacity - 0.25).abs() < f32::EPSILON);
+ assert_eq!(
+ coefficients.directional_rgb,
+ [10.0 / 255.0, 20.0 / 255.0, 30.0 / 255.0]
+ );
+ assert_eq!(coefficients.directional_extra, 40.0 / 255.0);
+ assert_eq!(
+ coefficients.specular_rgb,
+ [50.0 / 255.0, 60.0 / 255.0, 70.0 / 255.0]
+ );
+ assert_eq!(coefficients.specular_extra, 80.0 / 255.0);
+ assert_eq!(
+ coefficients.extra_rgb,
+ [90.0 / 255.0, 100.0 / 255.0, 110.0 / 255.0]
+ );
+ assert_eq!(coefficients.extra_alpha, 120.0 / 255.0);
+ assert_eq!(coefficients.power, 9);
+ assert_eq!(coefficients.page_index, -2);
}
#[test]
- fn material_interpolation_mask_affects_only_selected_fields() {
- let mut left = [10_u8; 18];
- let mut right = [20_u8; 18];
- left[1] = 100;
- right[1] = 200;
+ fn material_loop_interpolates_native_groups_and_selects_current_texture() {
+ let left = phase_with_parameters(
+ [
+ 0, 10, 20, 25, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 7, 1,
+ ],
+ b"LEFT",
+ );
+ let right = phase_with_parameters(
+ [
+ 255, 110, 220, 75, 230, 240, 250, 160, 170, 180, 190, 200, 210, 220, 230, 240, 9, 2,
+ ],
+ b"RIGHT",
+ );
+ let document = animation_document(
+ 0x1f_u32 << 3,
+ vec![left, right],
+ vec![
+ MaterialKey {
+ k0: 0,
+ k1: 100,
+ k2: 0,
+ },
+ MaterialKey {
+ k0: 1,
+ k1: 200,
+ k2: 0,
+ },
+ ],
+ );
- let out = interpolate_parameter_bytes(left, right, 0b101, 0.5);
+ let sample = sample_material_phase(&document, 0, 150, 0, 0).expect("sample");
- assert_eq!(out[0], 15);
- assert_eq!(out[1], 100);
- assert_eq!(out[2], 15);
- assert_eq!(out[3], 10);
+ assert_eq!(sample.phase_index, 1);
+ assert_eq!(&sample.texture_raw[..5], b"RIGHT");
+ assert!((sample.coefficients.additive_rgb[0] - (127.5 / 255.0)).abs() < 1e-6);
+ assert!((sample.coefficients.opacity - 0.5).abs() < 1e-6);
+ assert!((sample.coefficients.directional_rgb[1] - (140.0 / 255.0)).abs() < 1e-6);
+ assert_eq!(sample.coefficients.directional_extra, 160.0 / 255.0);
+ assert!((sample.coefficients.specular_rgb[2] - (140.0 / 255.0)).abs() < 1e-6);
+ assert_eq!(sample.coefficients.specular_extra, 200.0 / 255.0);
+ assert!((sample.coefficients.extra_rgb[0] - (160.0 / 255.0)).abs() < 1e-6);
+ assert_eq!(sample.coefficients.extra_alpha, 240.0 / 255.0);
+ assert_eq!(sample.coefficients.power, 9);
+ assert_eq!(sample.coefficients.page_index, 2);
+
+ let boundary = sample_material_phase(&document, 0, 100, 0, 0).expect("boundary");
+ assert_eq!(boundary.phase_index, 1);
+ assert_eq!(&boundary.texture_raw[..5], b"RIGHT");
+ assert_eq!(boundary.coefficients.additive_rgb[0], 1.0);
+
+ let wrapped = sample_material_phase(&document, 0, 200, 0, 0).expect("wrapped");
+ assert_eq!(wrapped.phase_index, 0);
+ assert_eq!(&wrapped.texture_raw[..4], b"LEFT");
}
#[test]
- fn material_timeline_profile_cases_are_evidence_labeled() {
- let document =
- decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0");
-
+ fn material_ping_pong_reverses_local_time_and_clamp_holds_last_phase() {
+ let phases = vec![
+ phase_with_parameters([0; 18], b"A"),
+ phase_with_parameters([255; 18], b"B"),
+ ];
+ let keys = vec![
+ MaterialKey {
+ k0: 0,
+ k1: 100,
+ k2: 0,
+ },
+ MaterialKey {
+ k0: 1,
+ k1: 200,
+ k2: 0,
+ },
+ ];
+ let ping_pong = animation_document((0x1_u32) | (1 << 3), phases.clone(), keys.clone());
assert_eq!(
- material_phase_capture(
- &document,
- MaterialTimelineProfile {
- mode: MaterialTimelineMode::OneShot,
- random_offset: false,
- },
- &[0, 1, 4],
- 0,
- )
- .expect("one-shot"),
- b"M,0,0,0\nM,1,1,1\nM,4,4,2\n"
+ sample_material_phase(&ping_pong, 0, 100, 0, 0)
+ .expect("turn")
+ .phase_index,
+ 1
);
assert_eq!(
- material_phase_capture(
- &document,
- MaterialTimelineProfile {
- mode: MaterialTimelineMode::Loop,
- random_offset: false,
- },
- &[0, 1, 4],
- 0,
- )
- .expect("loop"),
- b"M,0,0,0\nM,1,1,1\nM,4,4,1\n"
+ sample_material_phase(&ping_pong, 0, 150, 0, 0)
+ .expect("reverse")
+ .phase_index,
+ 1
);
assert_eq!(
- material_phase_capture(
- &document,
- MaterialTimelineProfile {
- mode: MaterialTimelineMode::PingPong,
- random_offset: false,
- },
- &[0, 1, 3],
- 0,
- )
- .expect("ping-pong"),
- b"M,0,0,0\nM,1,1,1\nM,3,3,1\n"
+ sample_material_phase(&ping_pong, 0, 200, 0, 0)
+ .expect("restart")
+ .phase_index,
+ 0
);
+ let turnaround = sample_material_phase(&ping_pong, 0, 200, 0, 0).expect("turnaround");
+ assert!((turnaround.coefficients.additive_rgb[0] - 2.0).abs() < 1e-6);
+
+ let clamp = animation_document((2_u32) | (1 << 3), phases, keys);
+ let sample = sample_material_phase(&clamp, 0, 500, 0, 0).expect("clamp");
+ assert_eq!(sample.phase_index, 1);
+ assert!(sample.coefficients.additive_rgb[0] < 0.0);
}
#[test]
- fn material_random_offset_uses_material_stream_only() {
- let document =
- decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0");
- let profile = MaterialTimelineProfile {
- mode: MaterialTimelineMode::Loop,
- random_offset: true,
+ fn material_random_mode_uses_caller_value_and_invalid_block_uses_block_zero() {
+ let phases = vec![
+ phase_with_parameters([0; 18], b"A"),
+ phase_with_parameters([255; 18], b"B"),
+ ];
+ let keys = vec![
+ MaterialKey {
+ k0: 0,
+ k1: 100,
+ k2: 0,
+ },
+ MaterialKey {
+ k0: 1,
+ k1: 200,
+ k2: 0,
+ },
+ ];
+ let document = Mat0Document {
+ version: 0,
+ animation_block_count: 2,
+ phases,
+ prefix: Vec::new(),
+ header_opaque: [2, 0],
+ animation_blocks: vec![
+ MaterialAnimationBlock {
+ header_raw: 3,
+ keys: keys.clone(),
+ bytes: Vec::new(),
+ },
+ MaterialAnimationBlock {
+ header_raw: 2,
+ keys,
+ bytes: Vec::new(),
+ },
+ ],
};
- let before = material_phase_capture(&document, profile, &[0, 1, 2], 99).expect("capture");
- let mut unrelated = 0x5555_u64;
- for _ in 0..16 {
- unrelated = unrelated.rotate_left(11).wrapping_mul(31);
- }
- assert_ne!(unrelated, 0);
+ let sample = sample_material_phase(&document, 0, 0, 0, 150).expect("random");
+ assert_eq!(sample.phase_index, 1);
+ let fallback = sample_material_phase(&document, 99, 500, 0, 0).expect("fallback");
+ assert_eq!(fallback.phase_index, 0);
+ }
+
+ #[test]
+ fn material_static_and_single_key_sampling_uses_phase_zero_or_key_phase() {
+ let phases = vec![
+ phase_with_parameters([1; 18], b"A"),
+ phase_with_parameters([2; 18], b"B"),
+ ];
+ let static_document = Mat0Document {
+ version: 0,
+ animation_block_count: 0,
+ phases: phases.clone(),
+ prefix: Vec::new(),
+ header_opaque: [0, 0],
+ animation_blocks: Vec::new(),
+ };
assert_eq!(
- material_phase_capture(&document, profile, &[0, 1, 2], 99).expect("capture"),
- before
+ sample_material_phase(&static_document, 0, 123, 50, 0)
+ .expect("static")
+ .phase_index,
+ 0
+ );
+
+ let single_document = animation_document(
+ 0,
+ phases,
+ vec![MaterialKey {
+ k0: 1,
+ k1: 0,
+ k2: 0,
+ }],
);
+ let sample = sample_material_phase(&single_document, 0, 123, 50, 0).expect("single");
+ assert_eq!(sample.phase_index, 1);
+ assert_eq!(&sample.texture_raw[..1], b"B");
}
#[test]
- fn material_same_seed_and_timeline_produces_same_phase_capture() {
- let document =
- decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0");
- let profile = MaterialTimelineProfile {
- mode: MaterialTimelineMode::Loop,
- random_offset: true,
- };
+ fn material_sampling_rejects_bad_timeline_data() {
+ let phase = phase_with_parameters([0; 18], b"A");
+ let invalid_ref = animation_document(
+ 0,
+ vec![phase.clone()],
+ vec![
+ MaterialKey {
+ k0: 0,
+ k1: 10,
+ k2: 0,
+ },
+ MaterialKey {
+ k0: 4,
+ k1: 20,
+ k2: 0,
+ },
+ ],
+ );
+ assert!(matches!(
+ sample_material_phase(&invalid_ref, 0, 0, 0, 0),
+ Err(MaterialError::InvalidAnimationPhaseIndex { .. })
+ ));
- assert_eq!(
- material_phase_capture(&document, profile, &[0, 4, 7], 123).expect("first"),
- material_phase_capture(&document, profile, &[0, 4, 7], 123).expect("second")
+ let invalid_times = animation_document(
+ 0,
+ vec![phase.clone()],
+ vec![
+ MaterialKey {
+ k0: 0,
+ k1: 20,
+ k2: 0,
+ },
+ MaterialKey {
+ k0: 0,
+ k1: 10,
+ k2: 0,
+ },
+ ],
+ );
+ assert!(matches!(
+ sample_material_phase(&invalid_times, 0, 0, 0, 0),
+ Err(MaterialError::InvalidAnimationKeyTimes { .. })
+ ));
+
+ let zero_duration = animation_document(
+ 0,
+ vec![phase],
+ vec![
+ MaterialKey {
+ k0: 0,
+ k1: 0,
+ k2: 0,
+ },
+ MaterialKey {
+ k0: 0,
+ k1: 0,
+ k2: 0,
+ },
+ ],
);
+ assert!(matches!(
+ sample_material_phase(&zero_duration, 0, 0, 0, 0),
+ Err(MaterialError::InvalidAnimationDuration { .. })
+ ));
}
#[test]
@@ -1133,6 +1508,8 @@ mod tests {
let mut mat0_count = 0usize;
let mut archive_wear_count = 0usize;
let mut standalone_wear_count = 0usize;
+ let mut animation_modes = BTreeMap::<u8, usize>::new();
+ let mut interpolation_masks = BTreeSet::<u32>::new();
for path in files_under(&root) {
let Ok(bytes) = std::fs::read(&path) else {
continue;
@@ -1156,9 +1533,16 @@ mod tests {
let payload = archive.payload(entry.id()).expect("payload");
match entry.meta().type_id {
MAT0_KIND => {
- decode_mat0(payload, entry.meta().attr2).unwrap_or_else(|err| {
- panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
- });
+ let document =
+ decode_mat0(payload, entry.meta().attr2).unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
+ });
+ for block in &document.animation_blocks {
+ *animation_modes
+ .entry((block.header_raw & 7) as u8)
+ .or_default() += 1;
+ interpolation_masks.insert(block.interpolation_mask());
+ }
mat0_count += 1;
}
WEAR_KIND => {
@@ -1180,6 +1564,9 @@ mod tests {
standalone_wear_count, expected_standalone_wear,
"{corpus} standalone WEAR count"
);
+ eprintln!(
+ "{corpus} MAT0 animation inventory: modes={animation_modes:?}, masks={interpolation_masks:?}"
+ );
}
}
@@ -1223,6 +1610,7 @@ mod tests {
struct TestMaterialEntry<'a> {
name: &'a [u8],
type_id: u32,
+ attr1: u32,
attr2: u32,
payload: &'a [u8],
}
@@ -1231,6 +1619,21 @@ mod tests {
TestMaterialEntry {
name,
type_id: MAT0_KIND,
+ attr1: 0,
+ attr2: 0,
+ payload,
+ }
+ }
+
+ fn material_entry_with_attr1<'a>(
+ name: &'a [u8],
+ payload: &'a [u8],
+ attr1: u32,
+ ) -> TestMaterialEntry<'a> {
+ TestMaterialEntry {
+ name,
+ type_id: MAT0_KIND,
+ attr1,
attr2: 0,
payload,
}
@@ -1254,20 +1657,33 @@ mod tests {
bytes
}
- fn mat0_with_phase_textures(textures: &[&[u8]]) -> Vec<u8> {
- let mut bytes = vec![0; 4 + textures.len() * 34];
- bytes[0..2].copy_from_slice(
- &u16::try_from(textures.len())
- .expect("phase count")
- .to_le_bytes(),
- );
- for (index, texture) in textures.iter().enumerate() {
- let offset = 4 + index * 34;
- bytes[offset] = u8::try_from(index).expect("index");
- let len = texture.len().min(16);
- bytes[offset + 18..offset + 18 + len].copy_from_slice(&texture[..len]);
+ fn phase_with_parameters(parameters: [u8; 18], texture: &[u8]) -> MaterialPhase {
+ let mut texture_raw = [0_u8; 16];
+ let len = texture.len().min(texture_raw.len());
+ texture_raw[..len].copy_from_slice(&texture[..len]);
+ MaterialPhase {
+ parameters,
+ texture_raw,
+ }
+ }
+
+ fn animation_document(
+ header_raw: u32,
+ phases: Vec<MaterialPhase>,
+ keys: Vec<MaterialKey>,
+ ) -> Mat0Document {
+ Mat0Document {
+ version: 0,
+ animation_block_count: 1,
+ phases,
+ prefix: Vec::new(),
+ header_opaque: [1, 0],
+ animation_blocks: vec![MaterialAnimationBlock {
+ header_raw,
+ keys,
+ bytes: Vec::new(),
+ }],
}
- bytes
}
fn build_material_nres(entries: &[TestMaterialEntry<'_>]) -> Vec<u8> {
@@ -1283,7 +1699,7 @@ mod tests {
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.attr1);
push_u32(&mut out, entry.attr2);
push_u32(
&mut out,
diff --git a/crates/fparkan-msh/src/lib.rs b/crates/fparkan-msh/src/lib.rs
index 2e38082..b6459ab 100644
--- a/crates/fparkan-msh/src/lib.rs
+++ b/crates/fparkan-msh/src/lib.rs
@@ -18,6 +18,9 @@ pub const STREAM_POSITIONS: u32 = 3;
pub const STREAM_NORMALS: u32 = 4;
/// Texture coordinate stream.
pub const STREAM_UV0: u32 = 5;
+/// Optional secondary texture coordinate stream used by native lightmap/detail
+/// stages. MSH stores it as the type-18 four-byte packed UV stream.
+pub const STREAM_UV1: u32 = 18;
/// Triangle index stream.
pub const STREAM_INDICES: u32 = 6;
/// Animation key stream.
@@ -81,14 +84,20 @@ pub struct ModelAsset {
pub node_count: usize,
/// Raw node table.
pub nodes_raw: Vec<u8>,
+ /// Model-level bounding sphere center from the Res2 header.
+ pub bounding_sphere_center: [f32; 3],
+ /// Model-level bounding sphere radius from the Res2 header.
+ pub bounding_sphere_radius: f32,
/// Slot table.
pub slots: Vec<Slot>,
/// Vertex positions.
pub positions: Vec<[f32; 3]>,
/// Optional normals.
pub normals: Option<Vec<[i8; 4]>>,
- /// Optional texture coordinates.
- pub uv0: Option<Vec<[i16; 2]>>,
+ /// Optional primary packed texture coordinates from type 5.
+ pub uv0: Option<Vec<[u16; 2]>>,
+ /// Optional secondary packed texture coordinates from type 18.
+ pub uv1: Option<Vec<[u16; 2]>>,
/// Triangle indices.
pub indices: Vec<u16>,
/// Draw batches.
@@ -167,18 +176,24 @@ pub struct Slot {
pub struct Batch {
/// Batch flags.
pub batch_flags: u16,
- /// Material index.
- pub material_index: u16,
- /// Opaque field.
- pub opaque4: u16,
- /// Opaque field.
- pub opaque6: u16,
+ /// High material selector/slot field at byte offset `+0x02`.
+ ///
+ /// The ordinary WEAR material selector is the byte at `+0x04`. This
+ /// field is retained separately because the native path can override it
+ /// with a forced material index.
+ pub material_index_hi: u16,
+ /// WEAR material selector at byte offset `+0x04`.
+ pub material_index: u8,
+ /// Lightmap selector at byte offset `+0x05`.
+ pub lightmap_index: u8,
+ /// Local batch index at byte offset `+0x06`.
+ pub local_batch_index: u16,
/// Index count.
pub index_count: u16,
/// First index offset.
pub index_start: u32,
- /// Opaque field.
- pub opaque14: u16,
+ /// Vertex count at byte offset `+0x0E`.
+ pub vertex_count: u16,
/// Base vertex.
pub base_vertex: u32,
}
@@ -194,8 +209,10 @@ pub struct VertexStreams {
pub positions: Vec<[f32; 3]>,
/// Optional normals.
pub normals: Option<Vec<[i8; 4]>>,
- /// Optional texture coordinates.
- pub uv0: Option<Vec<[i16; 2]>>,
+ /// Optional primary packed texture coordinates from type 5.
+ pub uv0: Option<Vec<[u16; 2]>>,
+ /// Optional secondary packed texture coordinates from type 18.
+ pub uv1: Option<Vec<[u16; 2]>>,
}
/// Preserved non-core stream.
@@ -209,11 +226,11 @@ pub struct PreservedStream {
pub bytes: std::sync::Arc<[u8]>,
}
-/// LOD id.
+/// Native state id kept under the historical `Lod` API name.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Lod(pub u8);
-/// Group id.
+/// Native LOD id kept under the historical `Group` API name.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Group(pub u8);
@@ -377,7 +394,10 @@ pub fn validate_msh(document: &MshDocument) -> Result<ModelAsset, MshError> {
Ok(model)
}
-/// Returns the selected slot for a node/lod/group tuple.
+/// Returns the selected slot for a node/native-state/native-LOD tuple.
+///
+/// The argument names are retained for API compatibility: `Lod` is the
+/// three-value native state and `Group` is the five-value native LOD.
#[must_use]
pub fn selected_slot(model: &ModelAsset, node: NodeId, lod: Lod, group: Group) -> Option<SlotId> {
if model.node_stride != 38 || lod.0 >= 3 || group.0 >= 5 {
@@ -417,12 +437,13 @@ pub fn node38_metadata(model: &ModelAsset, node: NodeId) -> Option<Node38Metadat
})
}
-/// Returns the source fallback pose of a standard node.
+/// Returns the fallback key as a native Node38 source-local pose.
///
/// The legacy sampler uses this key when a node has no frame map or its current
/// frame falls outside that map. It is a static-pose input only; callers must
/// still recover animation state and the meaning of `parent_or_link_raw` before
-/// assembling a runtime hierarchy.
+/// assembling a runtime hierarchy. Its quaternion is converted to match native
+/// AniMesh's quaternion-to-matrix convention.
#[must_use]
pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
let metadata = node38_metadata(model, node)?;
@@ -432,6 +453,18 @@ pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
.keys
.get(usize::from(metadata.fallback_key))
.map(AnimKey24::sampling_pose)
+ .map(node38_native_local_pose)
+}
+
+/// Converts a sampled MSH source-local quaternion into the native AniMesh
+/// quaternion-matrix convention before Node38 hierarchy composition. Native
+/// AniMesh's matrix coefficients are the transpose of the portable `Pose`
+/// basis.
+fn node38_native_local_pose(mut pose: Pose) -> Pose {
+ pose.rotation[0] = -pose.rotation[0];
+ pose.rotation[1] = -pose.rotation[1];
+ pose.rotation[2] = -pose.rotation[2];
+ pose
}
/// Evaluates the static fallback pose hierarchy of a standard `Node38` model.
@@ -462,8 +495,9 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
evaluate_hierarchy(&parents, &poses).ok()
}
-/// Evaluates the portable-reference pose hierarchy of a standard `Node38`
-/// model at one explicitly supplied logical frame.
+/// Evaluates a standard `Node38` hierarchy at one explicitly supplied logical
+/// frame, using portable key sampling and native AniMesh local-matrix
+/// convention.
///
/// A node without a usable type-19 map, or a requested frame outside the
/// declared map length, retains its exact fallback key. Mapped keys are
@@ -475,6 +509,59 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
/// representation or the map cannot be safely resolved.
#[must_use]
pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePoseBuffer> {
+ node38_sampled_hierarchy_with_selector(model, |_| {
+ Some((u32::from(frame), AnimationTime(f32::from(frame))))
+ })
+}
+
+/// Evaluates a standard `Node38` hierarchy with an independent floating-point
+/// sample time for each node.
+///
+/// `Some(time)` uses the nearest-even integer to `time - 0.5` as the type-19
+/// frame-map selector, then samples the selected key and its immediate
+/// successor at the original fractional time. This matches the native
+/// sampler's selector calculation while keeping interpolation time fractional.
+/// The selector uses Rust's default-nearest, ties-to-even rounding. This is
+/// the portable counterpart of the native x87 conversion under its default
+/// rounding mode; it does not claim parity under every x87 control-word mode.
+/// A missing time uses that node's exact fallback key. Supplied times must be
+/// finite and non-negative; the caller owns choosing the per-node times.
+///
+/// Returns `None` when the model is not a complete standard-node animation,
+/// the slice does not match the node count, any supplied time is invalid, or a
+/// frame-map entry cannot be safely resolved.
+#[must_use]
+pub fn node38_sampled_hierarchy_at_times(
+ model: &ModelAsset,
+ node_times: &[Option<AnimationTime>],
+) -> Option<NodePoseBuffer> {
+ if node_times.len() != model.node_count
+ || node_times
+ .iter()
+ .flatten()
+ .any(|time| !time.0.is_finite() || time.0 < 0.0)
+ {
+ return None;
+ }
+ node38_sampled_hierarchy_with_selector(model, |index| {
+ let time = node_times[index]?;
+ let selector = (time.0 - 0.5).round_ties_even();
+ // A finite, non-negative time can still be beyond the representable
+ // map range. Saturate to a frame that will resolve to the node's
+ // fallback key rather than allowing a float-to-index wraparound.
+ let map_frame = if selector < 0.0 || selector >= u32::MAX as f32 {
+ u32::MAX
+ } else {
+ selector as u32
+ };
+ Some((map_frame, time))
+ })
+}
+
+fn node38_sampled_hierarchy_with_selector(
+ model: &ModelAsset,
+ mut selector_for_node: impl FnMut(usize) -> Option<(u32, AnimationTime)>,
+) -> Option<NodePoseBuffer> {
if model.node_stride != 38 || model.node_count == 0 {
return None;
}
@@ -492,21 +579,36 @@ pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePo
};
let fallback_index = usize::from(metadata.fallback_key);
let _fallback = animation.keys.get(fallback_index)?;
- let key_index =
- if metadata.anim_map_start == u16::MAX || u32::from(frame) >= animation.frame_count {
+ let sample = selector_for_node(index);
+ let key_index = if let Some((map_frame, _)) = sample {
+ if metadata.anim_map_start == u16::MAX
+ || map_frame == u32::MAX
+ || map_frame >= animation.frame_count
+ {
fallback_index
} else {
- let mapped_index =
- usize::from(*animation.frame_map.get(
- usize::from(metadata.anim_map_start).checked_add(usize::from(frame))?,
- )?);
+ let mapped_index = usize::from(
+ *animation.frame_map.get(
+ usize::from(metadata.anim_map_start)
+ .checked_add(usize::try_from(map_frame).ok()?)?,
+ )?,
+ );
if mapped_index < fallback_index {
mapped_index
} else {
fallback_index
}
- };
- let pose = sample_node38_key_pair(&animation.keys, key_index, fallback_index, frame)?;
+ }
+ } else {
+ fallback_index
+ };
+ let sample_time = sample.map_or(AnimationTime(0.0), |(_, time)| time);
+ let pose = node38_native_local_pose(sample_node38_key_pair(
+ &animation.keys,
+ key_index,
+ fallback_index,
+ sample_time,
+ )?);
parents.push(parent);
poses.push(pose);
}
@@ -517,7 +619,7 @@ fn sample_node38_key_pair(
keys: &[AnimKey24],
key_index: usize,
fallback_index: usize,
- frame: u16,
+ sample_time: AnimationTime,
) -> Option<Pose> {
let key = *keys.get(key_index)?;
if key_index == fallback_index {
@@ -541,7 +643,7 @@ fn sample_node38_key_pair(
],
)
.ok()?;
- track.sample(AnimationTime(f32::from(frame))).ok()
+ track.sample(sample_time).ok()
}
/// Returns draw batches for a validated slot.
@@ -667,6 +769,8 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
}
let node_count = nodes_stream.bytes.len() / node_stride;
+ let (bounding_sphere_center, bounding_sphere_radius) =
+ parse_res2_bounding_sphere(&slots_stream.bytes)?;
let slots = parse_slots(&slots_stream.bytes)?;
let positions = parse_positions(&positions_stream.bytes)?;
let indices = parse_u16_array(&indices_stream.bytes, "Res6")?;
@@ -678,7 +782,10 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
.map(|raw| parse_i8x4_array(&raw.bytes, "Res4"))
.transpose()?;
let uv0 = read_optional_stream(document, STREAM_UV0)?
- .map(|raw| parse_i16x2_array(&raw.bytes, "Res5"))
+ .map(|raw| parse_u16x2_array(&raw.bytes, "Res5"))
+ .transpose()?;
+ let uv1 = read_optional_stream(document, STREAM_UV1)?
+ .map(|raw| parse_secondary_uv_stream(raw, positions.len()))
.transpose()?;
let node_names = read_optional_stream(document, STREAM_NAMES)?
.map(|raw| parse_res10_names(&raw.bytes, node_count))
@@ -689,10 +796,13 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
node_stride,
node_count,
nodes_raw: nodes_stream.bytes,
+ bounding_sphere_center,
+ bounding_sphere_radius,
slots,
positions,
normals,
uv0,
+ uv1,
indices,
batches,
node_names,
@@ -822,6 +932,27 @@ fn parse_slots(data: &[u8]) -> Result<Vec<Slot>, MshError> {
Ok(slots)
}
+fn parse_res2_bounding_sphere(data: &[u8]) -> Result<([f32; 3], f32), MshError> {
+ if data.len() < 0x8C {
+ return Err(MshError::InvalidGeometry(format!(
+ "invalid Res2 size: {}",
+ data.len()
+ )));
+ }
+ let center = [
+ read_f32(data, 0x60)?,
+ read_f32(data, 0x64)?,
+ read_f32(data, 0x68)?,
+ ];
+ let radius = read_f32(data, 0x6C)?;
+ if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() || radius < 0.0 {
+ return Err(MshError::InvalidGeometry(
+ "invalid Res2 bounding sphere".to_string(),
+ ));
+ }
+ Ok((center, radius))
+}
+
fn parse_positions(data: &[u8]) -> Result<Vec<[f32; 3]>, MshError> {
if !data.len().is_multiple_of(12) {
return Err(invalid_resource_size("Res3", data.len(), 12));
@@ -845,12 +976,17 @@ fn parse_batches(data: &[u8]) -> Result<Vec<Batch>, MshError> {
for offset in (0..data.len()).step_by(20) {
out.push(Batch {
batch_flags: read_u16_required(data, offset)?,
- material_index: read_u16_required(data, offset + 2)?,
- opaque4: read_u16_required(data, offset + 4)?,
- opaque6: read_u16_required(data, offset + 6)?,
+ material_index_hi: read_u16_required(data, offset + 2)?,
+ material_index: *data
+ .get(offset + 4)
+ .ok_or_else(|| MshError::InvalidGeometry("batch material selector".to_string()))?,
+ lightmap_index: *data
+ .get(offset + 5)
+ .ok_or_else(|| MshError::InvalidGeometry("batch lightmap selector".to_string()))?,
+ local_batch_index: read_u16_required(data, offset + 6)?,
index_count: read_u16_required(data, offset + 8)?,
index_start: read_u32(data, offset + 10)?,
- opaque14: read_u16_required(data, offset + 14)?,
+ vertex_count: read_u16_required(data, offset + 14)?,
base_vertex: read_u32(data, offset + 16)?,
});
}
@@ -884,17 +1020,51 @@ fn parse_i8x4_array(data: &[u8], label: &'static str) -> Result<Vec<[i8; 4]>, Ms
Ok(out)
}
-fn parse_i16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[i16; 2]>, MshError> {
+fn parse_u16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[u16; 2]>, MshError> {
if !data.len().is_multiple_of(4) {
return Err(invalid_resource_size(label, data.len(), 4));
}
let mut out = Vec::with_capacity(data.len() / 4);
for offset in (0..data.len()).step_by(4) {
- out.push([read_i16(data, offset)?, read_i16(data, offset + 2)?]);
+ out.push([
+ read_u16_required(data, offset)?,
+ read_u16_required(data, offset + 2)?,
+ ]);
}
Ok(out)
}
+fn parse_secondary_uv_stream(
+ stream: RawStream,
+ position_count: usize,
+) -> Result<Vec<[u16; 2]>, MshError> {
+ if stream.attributes.attr3 != 4 {
+ return Err(MshError::InvalidGeometry(format!(
+ "invalid Res18 stride: expected 4, got {}",
+ stream.attributes.attr3
+ )));
+ }
+ let values = parse_u16x2_array(&stream.bytes, "Res18")?;
+ let declared_count = usize::try_from(stream.attributes.attr1).map_err(|_| {
+ MshError::InvalidGeometry("Res18 vertex count does not fit usize".to_string())
+ })?;
+ if declared_count != values.len() {
+ return Err(MshError::InvalidGeometry(format!(
+ "Res18 vertex count does not match payload: declared={}, decoded={}",
+ declared_count,
+ values.len()
+ )));
+ }
+ if values.len() != position_count {
+ return Err(MshError::InvalidGeometry(format!(
+ "Res18 vertex count does not match Res3: uv1={}, positions={}",
+ values.len(),
+ position_count
+ )));
+ }
+ Ok(values)
+}
+
fn parse_res10_names(data: &[u8], node_count: usize) -> Result<Vec<Option<String>>, MshError> {
let mut out = Vec::with_capacity(node_count);
let mut offset = 0usize;
@@ -1038,16 +1208,6 @@ fn read_u16_required(bytes: &[u8], offset: usize) -> Result<u16, MshError> {
Ok(u16::from_le_bytes(arr))
}
-fn read_i16(bytes: &[u8], offset: usize) -> Result<i16, MshError> {
- let raw = bytes
- .get(offset..offset.saturating_add(2))
- .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?;
- let arr: [u8; 2] = raw
- .try_into()
- .map_err(|_| MshError::InvalidGeometry("integer overflow".to_string()))?;
- Ok(i16::from_le_bytes(arr))
-}
-
fn read_i8(bytes: &[u8], offset: usize) -> Result<i8, MshError> {
let byte = bytes
.get(offset)
@@ -1309,18 +1469,21 @@ mod tests {
let mut nodes = root;
nodes.extend(child);
let mut keys = Vec::new();
- for (x, y, z, qz, qw) in [
+ // Type 8 stores WXYZ, which `AnimKey24::decode` reorders to XYZW.
+ // The native Node38 local-basis conversion happens before hierarchy
+ // evaluation, not in the generic animation decoder.
+ for (x, y, z, qw, qz) in [
(1.0, 0.0, 0.0, 23_170_i16, 23_170_i16),
- (2.0, 0.0, 0.0, 0_i16, 32_767_i16),
+ (2.0, 0.0, 0.0, 32_767_i16, 0_i16),
] {
push_f32(&mut keys, x);
push_f32(&mut keys, y);
push_f32(&mut keys, z);
push_f32(&mut keys, 0.0);
+ push_u16(&mut keys, qw.cast_unsigned());
push_u16(&mut keys, 0);
push_u16(&mut keys, 0);
push_u16(&mut keys, qz.cast_unsigned());
- push_u16(&mut keys, qw.cast_unsigned());
}
let document = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &nodes),
@@ -1336,7 +1499,78 @@ mod tests {
validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset");
let hierarchy = node38_fallback_hierarchy(&model).expect("valid node hierarchy");
assert!((hierarchy.poses[1].translation[0] - 1.0).abs() < 0.001);
- assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001);
+ assert!((hierarchy.poses[1].translation[1] + 2.0).abs() < 0.001);
+ }
+
+ #[test]
+ fn node38_native_x_rotation_moves_child_translation_for_fallback_and_sampled_paths() {
+ let mut root = node38([u16::MAX; 15]);
+ root[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
+ root[4..6].copy_from_slice(&0_u16.to_le_bytes());
+ root[6..8].copy_from_slice(&1_u16.to_le_bytes());
+ let mut child = node38([u16::MAX; 15]);
+ child[2..4].copy_from_slice(&0_u16.to_le_bytes());
+ child[4..6].copy_from_slice(&u16::MAX.to_le_bytes());
+ child[6..8].copy_from_slice(&2_u16.to_le_bytes());
+ let mut nodes = root;
+ nodes.extend(child);
+ let half_sqrt_two = std::f32::consts::FRAC_1_SQRT_2;
+ let root_rotation = [half_sqrt_two, 0.0, 0.0, half_sqrt_two];
+ let identity = [0.0, 0.0, 0.0, 1.0];
+ let child_translation = [0.0, -1.01209, -0.001792];
+ let model = ModelAsset {
+ node_stride: 38,
+ node_count: 2,
+ nodes_raw: nodes,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 1.0,
+ slots: Vec::new(),
+ positions: Vec::new(),
+ normals: None,
+ uv0: None,
+ uv1: None,
+ indices: Vec::new(),
+ batches: Vec::new(),
+ node_names: None,
+ animation: Some(ModelAnimation {
+ keys: vec![
+ AnimKey24 {
+ time: AnimationTime(0.0),
+ pose: Pose {
+ translation: [0.0; 3],
+ rotation: root_rotation,
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(1.0),
+ pose: Pose {
+ translation: [0.0; 3],
+ rotation: root_rotation,
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(0.0),
+ pose: Pose {
+ translation: child_translation,
+ rotation: identity,
+ },
+ },
+ ],
+ frame_map: vec![0],
+ frame_count: 1,
+ }),
+ };
+
+ let fallback_root = node38_fallback_pose(&model, NodeId(0)).expect("fallback root pose");
+ assert!(fallback_root.rotation[0] < -0.7);
+ let fallback = node38_fallback_hierarchy(&model).expect("fallback hierarchy");
+ let sampled = node38_sampled_hierarchy(&model, 0).expect("sampled hierarchy");
+ for hierarchy in [&fallback, &sampled] {
+ let child_translation = hierarchy.poses[1].translation;
+ assert!(child_translation[0].abs() < 0.0001);
+ assert!((child_translation[1] + 0.001792).abs() < 0.0001);
+ assert!((child_translation[2] - 1.01209).abs() < 0.0001);
+ }
}
#[test]
@@ -1349,10 +1583,13 @@ mod tests {
node_stride: 38,
node_count: 1,
nodes_raw: node,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 1.0,
slots: Vec::new(),
positions: Vec::new(),
normals: None,
uv0: None,
+ uv1: None,
indices: Vec::new(),
batches: Vec::new(),
node_names: None,
@@ -1395,6 +1632,105 @@ mod tests {
}
#[test]
+ fn standard_nodes_sample_independent_fractional_times_and_selector_rounding() {
+ let mut root = node38([u16::MAX; 15]);
+ root[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
+ root[4..6].copy_from_slice(&0_u16.to_le_bytes());
+ root[6..8].copy_from_slice(&4_u16.to_le_bytes());
+ let mut child = node38([u16::MAX; 15]);
+ child[2..4].copy_from_slice(&0_u16.to_le_bytes());
+ child[4..6].copy_from_slice(&2_u16.to_le_bytes());
+ child[6..8].copy_from_slice(&5_u16.to_le_bytes());
+ let mut nodes = root;
+ nodes.extend(child);
+ let model = ModelAsset {
+ node_stride: 38,
+ node_count: 2,
+ nodes_raw: nodes,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 1.0,
+ slots: Vec::new(),
+ positions: Vec::new(),
+ normals: None,
+ uv0: None,
+ uv1: None,
+ indices: Vec::new(),
+ batches: Vec::new(),
+ node_names: None,
+ animation: Some(ModelAnimation {
+ keys: vec![
+ AnimKey24 {
+ time: AnimationTime(0.0),
+ pose: Pose {
+ translation: [4.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(2.0),
+ pose: Pose {
+ translation: [8.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(1.0),
+ pose: Pose {
+ translation: [20.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(2.0),
+ pose: Pose {
+ translation: [30.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(9.0),
+ pose: Pose {
+ translation: [100.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(9.0),
+ pose: Pose {
+ translation: [300.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ ],
+ frame_map: vec![0, 1, 0, 2],
+ frame_count: 4,
+ }),
+ };
+
+ let hierarchy = node38_sampled_hierarchy_at_times(
+ &model,
+ &[Some(AnimationTime(1.0)), Some(AnimationTime(1.55))],
+ )
+ .expect("per-node fractional hierarchy");
+ // The root's tie selects map frame zero and interpolates to x=6. The
+ // child independently selects frame one and samples its own 1.55 time;
+ // its global pose includes the root translation.
+ assert!((hierarchy.poses[0].translation[0] - 6.0).abs() < f32::EPSILON);
+ assert!((hierarchy.poses[1].translation[0] - 31.5).abs() < 0.001);
+ let fallback = node38_sampled_hierarchy_at_times(&model, &[Some(AnimationTime(1.0)), None])
+ .expect("per-node fallback hierarchy");
+ assert_eq!(fallback.poses[1].translation[0], 306.0);
+ assert!(node38_sampled_hierarchy_at_times(&model, &[Some(AnimationTime(1.0))]).is_none());
+ for invalid in [-1.0, f32::NAN, f32::INFINITY] {
+ assert!(node38_sampled_hierarchy_at_times(
+ &model,
+ &[Some(AnimationTime(invalid)), None]
+ )
+ .is_none());
+ }
+ }
+
+ #[test]
fn type2_header_and_slot_tail_framing_are_exact() {
let too_small = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
@@ -1421,6 +1757,44 @@ mod tests {
}
#[test]
+ fn type2_header_bounding_sphere_is_preserved_and_rejects_invalid_values() {
+ let valid_slots = slots_payload_with_bounds([1.25, -2.5, 3.75], 4.5, &[]);
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &valid_slots),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ ]))
+ .expect("nested");
+ let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
+ assert_eq!(model.bounding_sphere_center, [1.25, -2.5, 3.75]);
+ assert_eq!(model.bounding_sphere_radius, 4.5);
+
+ for (offset, value) in [
+ (0x60, f32::NAN),
+ (0x68, f32::INFINITY),
+ (0x6C, f32::NAN),
+ (0x6C, -1.0),
+ ] {
+ let mut slots = slots_payload(&[]);
+ slots[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ ]))
+ .expect("nested");
+ assert!(matches!(
+ validate_msh(&decode_msh(&document).expect("msh")),
+ Err(MshError::InvalidGeometry(_))
+ ));
+ }
+ }
+
+ #[test]
fn slot_batch_range_out_of_bounds_is_error() {
let slots = slots_payload(&[slot_record(1, 1, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0], 1.0)]);
let document = decode_nested(&build_nres(&[
@@ -1448,6 +1822,7 @@ mod tests {
(STREAM_POSITIONS, b"Res3".as_slice(), vec![0; 11]),
(STREAM_NORMALS, b"Res4".as_slice(), vec![0; 3]),
(STREAM_UV0, b"Res5".as_slice(), vec![0; 3]),
+ (STREAM_UV1, b"Res18".as_slice(), vec![0; 3]),
(STREAM_INDICES, b"Res6".as_slice(), vec![0; 1]),
] {
let slots = slots_payload(&[]);
@@ -1472,6 +1847,127 @@ mod tests {
}
#[test]
+ fn type18_secondary_uv_decodes_unsigned_pairs_for_each_source_vertex() {
+ let positions = positions_payload(&[[0.0, 0.0, 0.0]; 4]);
+ let mut uv1 = Vec::new();
+ for pair in [[2_u16, 5_u16], [12, 8], [16, 30], [34, 2]] {
+ push_u16(&mut uv1, pair[0]);
+ push_u16(&mut uv1, pair[1]);
+ }
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
+ stream(STREAM_POSITIONS, 12, b"Res3", &positions),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ stream_with_attr1(STREAM_UV1, 4, 4, b"Res18", &uv1),
+ ]))
+ .expect("nested");
+ let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
+
+ assert_eq!(model.positions.len(), 4);
+ assert_eq!(model.uv1, Some(vec![[2, 5], [12, 8], [16, 30], [34, 2]]));
+ }
+
+ #[test]
+ fn type18_secondary_uv_rejects_untrusted_count_or_stride() {
+ let positions = positions_payload(&[[0.0, 0.0, 0.0]; 1]);
+ for (attr1, attr3, expected) in [(1, 8, "stride"), (2, 4, "count")] {
+ let mut uv1 = Vec::new();
+ push_u16(&mut uv1, 2);
+ push_u16(&mut uv1, 5);
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
+ stream(STREAM_POSITIONS, 12, b"Res3", &positions),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ stream_with_attr1(STREAM_UV1, attr1, attr3, b"Res18", &uv1),
+ ]))
+ .expect("nested");
+ let err = validate_msh(&decode_msh(&document).expect("msh")).expect_err("invalid");
+ let message = err.to_string();
+ assert!(message.contains(expected), "{message}");
+ }
+ }
+
+ #[test]
+ fn installed_gog_secondary_uv_fixture_is_aligned_and_real() {
+ let Some(root) = std::env::var_os("FPARKAN_GAME_ROOT") else {
+ return;
+ };
+ let root = PathBuf::from(root);
+ assert!(
+ root.is_dir(),
+ "FPARKAN_GAME_ROOT is missing: {}",
+ root.display()
+ );
+
+ let mut models_with_uv1 = 0usize;
+ let mut fixture_found = false;
+ for path in files_under(&root) {
+ if !path
+ .extension()
+ .is_some_and(|extension| extension.eq_ignore_ascii_case("rlb"))
+ {
+ continue;
+ }
+ let Ok(bytes) = std::fs::read(&path) else {
+ continue;
+ };
+ let Ok(archive) =
+ fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
+ else {
+ continue;
+ };
+ for entry in archive
+ .entries()
+ .iter()
+ .filter(|entry| has_msh_extension(entry.name_bytes()))
+ {
+ let payload = archive.payload(entry.id()).expect("payload");
+ let nested = fparkan_nres::decode(
+ Arc::from(payload.to_vec().into_boxed_slice()),
+ ReadProfile::Compatible,
+ )
+ .unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes()));
+ let msh = decode_msh(&nested)
+ .unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes()));
+ let model = validate_msh(&msh)
+ .unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes()));
+ let Some(uv1) = model.uv1.as_ref() else {
+ continue;
+ };
+ models_with_uv1 += 1;
+ assert_eq!(
+ uv1.len(),
+ model.positions.len(),
+ "{path:?} {:?}",
+ entry.name_bytes()
+ );
+ let descriptor = msh
+ .streams()
+ .iter()
+ .find(|stream| stream.type_id == STREAM_UV1)
+ .expect("Res18 descriptor");
+ assert_eq!(descriptor.attributes.attr1 as usize, uv1.len());
+ assert_eq!(descriptor.attributes.attr3, 4);
+
+ if entry.name_bytes().eq_ignore_ascii_case(b"fr_l_gener.msh") {
+ assert_eq!(&uv1[..4], &[[2, 5], [12, 8], [16, 30], [34, 2]]);
+ fixture_found = true;
+ }
+ }
+ }
+
+ assert!(models_with_uv1 > 0, "FPARKAN_GAME_ROOT has no type18 MSH");
+ assert!(
+ fixture_found,
+ "FPARKAN_GAME_ROOT lacks fr_l_gener.msh fixture"
+ );
+ }
+
+ #[test]
fn batch20_uses_unaligned_field_offsets() {
let positions = positions_payload(&[[0.0, 0.0, 0.0]]);
let mut indices = Vec::new();
@@ -1488,12 +1984,13 @@ mod tests {
let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
assert_eq!(model.batches[0].batch_flags, 0x1100);
- assert_eq!(model.batches[0].material_index, 0x2200);
- assert_eq!(model.batches[0].opaque4, 0x3300);
- assert_eq!(model.batches[0].opaque6, 0x4400);
+ assert_eq!(model.batches[0].material_index_hi, 0x2200);
+ assert_eq!(model.batches[0].material_index, 0x00);
+ assert_eq!(model.batches[0].lightmap_index, 0x33);
+ assert_eq!(model.batches[0].local_batch_index, 0x4400);
assert_eq!(model.batches[0].index_count, 1);
assert_eq!(model.batches[0].index_start, 0);
- assert_eq!(model.batches[0].opaque14, 0x5500);
+ assert_eq!(model.batches[0].vertex_count, 0x5500);
assert_eq!(model.batches[0].base_vertex, 0);
}
@@ -1587,44 +2084,49 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
- fn licensed_corpus_msh_assets_validate() {
- for (corpus, expected) in [("IS", 435_usize), ("IS2", 511_usize)] {
- let root = corpus_root(corpus);
- let mut count = 0usize;
- for path in files_under(&root) {
- let Ok(bytes) = std::fs::read(&path) else {
- continue;
- };
- let Ok(archive) = fparkan_nres::decode(
- Arc::from(bytes.into_boxed_slice()),
+ fn licensed_corpus_part1_msh_assets_validate() {
+ validate_licensed_corpus_msh_assets("IS", 435);
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_corpus_part2_msh_assets_validate() {
+ validate_licensed_corpus_msh_assets("IS2", 511);
+ }
+
+ fn validate_licensed_corpus_msh_assets(corpus: &str, expected: usize) {
+ let root = corpus_root(corpus);
+ let mut count = 0usize;
+ for path in files_under(&root) {
+ let Ok(bytes) = std::fs::read(&path) else {
+ continue;
+ };
+ let Ok(archive) =
+ fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
+ else {
+ continue;
+ };
+ for entry in archive
+ .entries()
+ .iter()
+ .filter(|entry| has_msh_extension(entry.name_bytes()))
+ {
+ let payload = archive.payload(entry.id()).expect("payload");
+ let nested = fparkan_nres::decode(
+ Arc::from(payload.to_vec().into_boxed_slice()),
ReadProfile::Compatible,
- ) else {
- continue;
- };
- for entry in archive
- .entries()
- .iter()
- .filter(|entry| has_msh_extension(entry.name_bytes()))
- {
- let payload = archive.payload(entry.id()).expect("payload");
- let nested = fparkan_nres::decode(
- Arc::from(payload.to_vec().into_boxed_slice()),
- ReadProfile::Compatible,
- )
- .unwrap_or_else(|err| {
- panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
- });
- let msh = decode_msh(&nested).unwrap_or_else(|err| {
- panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
- });
- validate_msh(&msh).unwrap_or_else(|err| {
- panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
- });
- count += 1;
- }
+ )
+ .unwrap_or_else(|err| panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()));
+ let msh = decode_msh(&nested).unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
+ });
+ validate_msh(&msh).unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
+ });
+ count += 1;
}
- assert_eq!(count, expected, "{corpus} MSH count");
}
+ assert_eq!(count, expected, "{corpus} MSH count");
}
#[test]
@@ -1932,6 +2434,23 @@ mod tests {
fn stream<'a>(type_id: u32, attr3: u32, name: &'a [u8], payload: &'a [u8]) -> TestEntry<'a> {
TestEntry {
type_id,
+ attr1: 0,
+ attr3,
+ name,
+ payload,
+ }
+ }
+
+ fn stream_with_attr1<'a>(
+ type_id: u32,
+ attr1: u32,
+ attr3: u32,
+ name: &'a [u8],
+ payload: &'a [u8],
+ ) -> TestEntry<'a> {
+ TestEntry {
+ type_id,
+ attr1,
attr3,
name,
payload,
@@ -1940,6 +2459,7 @@ mod tests {
struct TestEntry<'a> {
type_id: u32,
+ attr1: u32,
attr3: u32,
name: &'a [u8],
payload: &'a [u8],
@@ -1958,7 +2478,7 @@ mod tests {
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.attr1);
push_u32(&mut out, 0);
push_u32(
&mut out,
@@ -2000,7 +2520,19 @@ mod tests {
}
fn slots_payload(records: &[Vec<u8>]) -> Vec<u8> {
+ slots_payload_with_bounds([0.0; 3], 1.0, records)
+ }
+
+ fn slots_payload_with_bounds(center: [f32; 3], radius: f32, records: &[Vec<u8>]) -> Vec<u8> {
let mut out = vec![0; 0x8c];
+ for (offset, value) in [
+ (0x60, center[0]),
+ (0x64, center[1]),
+ (0x68, center[2]),
+ (0x6C, radius),
+ ] {
+ out[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ }
for record in records {
assert_eq!(record.len(), 68);
out.extend_from_slice(record);
diff --git a/crates/fparkan-prototype/src/lib.rs b/crates/fparkan-prototype/src/lib.rs
index 5023df4..dece041 100644
--- a/crates/fparkan-prototype/src/lib.rs
+++ b/crates/fparkan-prototype/src/lib.rs
@@ -15,6 +15,9 @@ const UNIT_DAT_MIN_SIZE: usize = 0x48;
const UNIT_DAT_MAGIC: u32 = 0x0000_F0F1;
const PROTOTYPE_INHERITANCE_DEPTH_LIMIT: usize = 32;
+/// `objects.rlb` external geometry entry type (`EXTO`).
+pub const PROTOTYPE_TYPE_EXTO: u32 = u32::from_le_bytes(*b"EXTO");
+
/// Prototype key.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct PrototypeKey(pub ResourceName);
@@ -61,10 +64,21 @@ pub struct UnitComponentRecord {
pub parent_or_link: i32,
/// Description raw bytes.
pub description_raw: [u8; 32],
- /// Opaque tail.
+ /// Opaque record word at offset `0x68`.
pub tail0: u32,
- /// Opaque tail.
- pub tail1: u32,
+ /// Number of immediate children in the preorder unit tree.
+ pub immediate_child_count: u32,
+}
+
+/// One node in a decoded Unit DAT preorder tree.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct UnitComponentTreeNode {
+ /// Original preorder record index.
+ pub record_index: usize,
+ /// Parent record index, or `None` for the root.
+ pub parent_index: Option<usize>,
+ /// Direct child record indices in source order.
+ pub children: Vec<usize>,
}
/// Prototype geometry.
@@ -85,8 +99,18 @@ pub struct EffectivePrototype {
pub geometry: PrototypeGeometry,
/// Resolution source.
pub source: PrototypeSource,
+ /// Source registry entry type, when the prototype came from an NRes
+ /// object registry (for example `EXTO`, `INTO`, or `BTLU`).
+ pub source_type: Option<u32>,
/// Resource dependencies discovered while resolving this prototype.
pub dependencies: Vec<ResourceKey>,
+ /// Explicit control-data resource referenced by this prototype's resolved
+ /// `objects.rlb` registry inheritance chain, when present.
+ ///
+ /// This is deliberately separate from visual dependencies: controller
+ /// state belongs to each component instance and must not alter shared
+ /// mesh/material cache identity.
+ pub control_resource: Option<ResourceKey>,
}
/// Prototype resolution source.
@@ -401,6 +425,8 @@ pub enum PrototypeError {
InvalidSize,
/// Invalid unit DAT magic.
InvalidUnitDatMagic(u32),
+ /// Unit DAT child counts do not describe one complete preorder tree.
+ InvalidUnitHierarchy(String),
/// Invalid path.
InvalidPath(String),
/// VFS error.
@@ -435,6 +461,9 @@ impl std::fmt::Display for PrototypeError {
Self::InvalidUnitDatMagic(magic) => {
write!(f, "invalid unit DAT magic: {magic:#010X}")
}
+ Self::InvalidUnitHierarchy(message) => {
+ write!(f, "invalid unit DAT preorder hierarchy: {message}")
+ }
Self::InvalidPath(value) => write!(f, "invalid path: {value}"),
Self::Vfs(source) => write!(f, "vfs error: {source}"),
Self::Resource(source) => write!(f, "resource error: {source}"),
@@ -446,7 +475,10 @@ impl std::error::Error for PrototypeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Decode(source) => Some(source),
- Self::InvalidSize | Self::InvalidUnitDatMagic(_) | Self::InvalidPath(_) => None,
+ Self::InvalidSize
+ | Self::InvalidUnitDatMagic(_)
+ | Self::InvalidUnitHierarchy(_)
+ | Self::InvalidPath(_) => None,
Self::Vfs(source) => Some(source),
Self::Resource(source) => Some(source),
}
@@ -512,7 +544,7 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> {
let parent_or_link = cursor.read_i32_le()?;
description_raw.copy_from_slice(cursor.read_exact(32)?);
let tail0 = cursor.read_u32_le()?;
- let tail1 = cursor.read_u32_le()?;
+ let immediate_child_count = cursor.read_u32_le()?;
records.push(UnitComponentRecord {
archive_raw,
resource_raw,
@@ -520,7 +552,7 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> {
parent_or_link,
description_raw,
tail0,
- tail1,
+ immediate_child_count,
});
}
cursor.require_eof()?;
@@ -530,6 +562,86 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> {
})
}
+/// Builds the immediate-child tree encoded by Unit DAT record counts.
+///
+/// Records are stored in preorder. The final word of each 112-byte record is
+/// the number of immediate children, so the tree can be reconstructed without
+/// treating `parent_or_link` as a parent index. That field is retained as the
+/// native mount-socket value for attachment consumers.
+///
+/// # Errors
+///
+/// Returns [`PrototypeError::InvalidUnitHierarchy`] when child counts consume
+/// too few or too many records.
+pub fn unit_component_tree(
+ records: &[UnitComponentRecord],
+) -> Result<Vec<UnitComponentTreeNode>, PrototypeError> {
+ if records.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let root_child_count = usize::try_from(records[0].immediate_child_count).map_err(|_| {
+ PrototypeError::InvalidUnitHierarchy("record 0 child count does not fit usize".to_string())
+ })?;
+ let mut nodes = Vec::with_capacity(records.len());
+ nodes.push(UnitComponentTreeNode {
+ record_index: 0,
+ parent_index: None,
+ children: Vec::new(),
+ });
+ // Each open frame holds a preorder node and the direct children still due.
+ // This keeps hostile deep chains off the call stack.
+ let mut open_parents = vec![(0_usize, root_child_count)];
+ let mut cursor = 1_usize;
+ while cursor < records.len() {
+ while open_parents
+ .last()
+ .is_some_and(|(_, remaining_children)| *remaining_children == 0)
+ {
+ open_parents.pop();
+ }
+ let Some((parent_node_index, remaining_children)) = open_parents.last_mut() else {
+ return Err(PrototypeError::InvalidUnitHierarchy(format!(
+ "root tree consumed {cursor} records, but {} were encoded",
+ records.len()
+ )));
+ };
+ let parent_node_index = *parent_node_index;
+ *remaining_children -= 1;
+
+ let record_index = cursor;
+ let record = &records[record_index];
+ let child_count = usize::try_from(record.immediate_child_count).map_err(|_| {
+ PrototypeError::InvalidUnitHierarchy(format!(
+ "record {record_index} child count does not fit usize"
+ ))
+ })?;
+ let node_index = nodes.len();
+ nodes.push(UnitComponentTreeNode {
+ record_index,
+ parent_index: Some(nodes[parent_node_index].record_index),
+ children: Vec::new(),
+ });
+ nodes[parent_node_index].children.push(node_index);
+ cursor += 1;
+ open_parents.push((node_index, child_count));
+ }
+
+ while open_parents
+ .last()
+ .is_some_and(|(_, remaining_children)| *remaining_children == 0)
+ {
+ open_parents.pop();
+ }
+ if !open_parents.is_empty() {
+ return Err(PrototypeError::InvalidUnitHierarchy(format!(
+ "child count reached record {cursor}, but only {} records exist",
+ records.len()
+ )));
+ }
+ Ok(nodes)
+}
+
/// Decodes a mission unit DAT binding.
///
/// # Errors
@@ -1029,7 +1141,7 @@ fn resolve_archive_model(
let Some(mesh) = find_mesh_resource(repository, archive, model_key)? else {
return Ok(None);
};
- Ok(Some(effective(model_key.clone(), mesh, source)))
+ Ok(Some(effective(model_key.clone(), mesh, source, None, None)))
}
fn resolve_objects_registry_model(
@@ -1037,17 +1149,33 @@ fn resolve_objects_registry_model(
registry_archive: &NormalizedPath,
object_key: &ResourceName,
) -> Result<Option<EffectivePrototype>, PrototypeError> {
+ let source_type = match repository.open_archive(registry_archive) {
+ Ok(archive_id) => {
+ find_any_candidate(repository, archive_id, &mesh_name_candidates(&object_key.0))?
+ .map(|(entry, _)| repository.entry_info(entry))
+ .transpose()?
+ .and_then(|info| info.key.type_id)
+ }
+ Err(ResourceError::MissingArchive { .. }) => return Ok(None),
+ Err(error) => return Err(error.into()),
+ };
let Some(refs) =
collect_registry_refs(repository, registry_archive, object_key, &mut Vec::new(), 0)?
else {
return Ok(None);
};
+ let control_resource = resolve_explicit_control_resource(object_key, &refs)?;
let mut missing_mesh_refs = Vec::new();
for item in refs.iter().filter(|item| is_explicit_mesh_ref(item)) {
- if let Some(prototype) =
- resolve_object_ref_model(repository, object_key, item, cstr_bytes(&item.resource_raw))?
- {
+ if let Some(prototype) = resolve_object_ref_model(
+ repository,
+ object_key,
+ item,
+ cstr_bytes(&item.resource_raw),
+ source_type,
+ control_resource.clone(),
+ )? {
return Ok(Some(prototype));
}
missing_mesh_refs.push(describe_object_ref(item));
@@ -1064,7 +1192,9 @@ fn resolve_objects_registry_model(
key: PrototypeKey(object_key.clone()),
geometry: PrototypeGeometry::NonGeometric,
source: PrototypeSource::ObjectsRegistry,
+ source_type,
dependencies: Vec::new(),
+ control_resource,
}))
}
@@ -1130,6 +1260,8 @@ fn resolve_object_ref_model(
requested: &ResourceName,
item: &ObjectRefRecord,
model_name: &[u8],
+ source_type: Option<u32>,
+ control_resource: Option<ResourceKey>,
) -> Result<Option<EffectivePrototype>, PrototypeError> {
let archive = normalized_path_from_name(&ResourceName(cstr_bytes(&item.archive_raw).to_vec()))?;
let Some(mesh) = find_mesh_resource(repository, &archive, &ResourceName(model_name.to_vec()))?
@@ -1140,6 +1272,8 @@ fn resolve_object_ref_model(
requested.clone(),
mesh,
PrototypeSource::ObjectsRegistry,
+ source_type,
+ control_resource,
)))
}
@@ -1147,6 +1281,61 @@ fn is_explicit_mesh_ref(item: &ObjectRefRecord) -> bool {
has_extension_bytes(cstr_bytes(&item.resource_raw), b"msh")
}
+fn is_explicit_control_ref(item: &ObjectRefRecord) -> bool {
+ has_extension_bytes(cstr_bytes(&item.resource_raw), b"ctl")
+}
+
+fn resolve_explicit_control_resource(
+ requested: &ResourceName,
+ refs: &[ObjectRefRecord],
+) -> Result<Option<ResourceKey>, PrototypeError> {
+ let mut resolved = Vec::new();
+ for item in refs.iter().filter(|item| is_explicit_control_ref(item)) {
+ let archive =
+ normalized_path_from_name(&ResourceName(cstr_bytes(&item.archive_raw).to_vec()))?;
+ let name = resource_name(cstr_bytes(&item.resource_raw));
+ // CTLD is an optional pose sidecar. Keep the exact registry reference
+ // here, then let asset preparation treat a missing archive/entry as an
+ // absent override while still rejecting a present malformed payload.
+ let key = ResourceKey {
+ archive,
+ name,
+ type_id: None,
+ };
+ if !resolved
+ .iter()
+ .any(|existing| same_resource_key(existing, &key))
+ {
+ resolved.push(key);
+ }
+ }
+ match resolved.len() {
+ 0 => Ok(None),
+ 1 => Ok(resolved.pop()),
+ _ => Err(PrototypeError::Resource(ResourceError::Format(format!(
+ "prototype {} has ambiguous explicit control resources: {}",
+ String::from_utf8_lossy(&requested.0),
+ resolved
+ .iter()
+ .map(|key| format!(
+ "{}:{}",
+ key.archive.as_str(),
+ String::from_utf8_lossy(&key.name.0)
+ ))
+ .collect::<Vec<_>>()
+ .join(", "),
+ )))),
+ }
+}
+
+fn same_resource_key(left: &ResourceKey, right: &ResourceKey) -> bool {
+ left.archive
+ .as_str()
+ .eq_ignore_ascii_case(right.archive.as_str())
+ && eq_ignore_ascii_case(&left.name.0, &right.name.0)
+ && left.type_id == right.type_id
+}
+
fn describe_object_ref(item: &ObjectRefRecord) -> String {
format!(
"{}:{}",
@@ -1195,12 +1384,16 @@ fn effective(
requested: ResourceName,
mesh: ResourceKey,
source: PrototypeSource,
+ source_type: Option<u32>,
+ control_resource: Option<ResourceKey>,
) -> EffectivePrototype {
EffectivePrototype {
key: PrototypeKey(requested),
geometry: PrototypeGeometry::Mesh(mesh.clone()),
source,
+ source_type,
dependencies: vec![mesh],
+ control_resource,
}
}
@@ -1380,7 +1573,7 @@ mod tests {
assert_eq!(record.parent_or_link, -7);
assert_eq!(&record.description_raw[..description.len()], description);
assert_eq!(record.tail0, 0x1122_3344);
- assert_eq!(record.tail1, 0x5566_7788);
+ assert_eq!(record.immediate_child_count, 0x5566_7788);
}
#[test]
@@ -1407,6 +1600,88 @@ mod tests {
}
#[test]
+ fn unit_component_tree_follows_preorder_child_counts_and_keeps_mount_links() {
+ let mut bytes = build_unit_dat(&[
+ (b"objects.rlb".as_slice(), b"root".as_slice()),
+ (b"objects.rlb".as_slice(), b"child".as_slice()),
+ (b"objects.rlb".as_slice(), b"grandchild".as_slice()),
+ ]);
+ bytes[8 + 68..8 + 72].copy_from_slice(&(-1_i32).to_le_bytes());
+ bytes[8 + 108..8 + 112].copy_from_slice(&1_u32.to_le_bytes());
+ bytes[8 + 112 + 68..8 + 112 + 72].copy_from_slice(&7_i32.to_le_bytes());
+ bytes[8 + 112 + 108..8 + 112 + 112].copy_from_slice(&1_u32.to_le_bytes());
+ bytes[8 + 224 + 68..8 + 224 + 72].copy_from_slice(&3_i32.to_le_bytes());
+
+ let unit = decode_unit_dat(&bytes).expect("unit");
+ let tree = unit_component_tree(&unit.records).expect("preorder tree");
+ assert_eq!(tree.len(), 3);
+ assert_eq!(tree[0].record_index, 0);
+ assert_eq!(tree[0].parent_index, None);
+ assert_eq!(tree[0].children, vec![1]);
+ assert_eq!(tree[1].parent_index, Some(0));
+ assert_eq!(tree[1].children, vec![2]);
+ assert_eq!(tree[2].parent_index, Some(1));
+ assert_eq!(tree[2].children, Vec::<usize>::new());
+ assert_eq!(unit.records[1].parent_or_link, 7);
+ assert_eq!(unit.records[2].parent_or_link, 3);
+ }
+
+ #[test]
+ fn unit_component_tree_handles_deep_preorder_chains_iteratively() {
+ const DEPTH: usize = 32_768;
+ let records: Vec<_> = (0..DEPTH)
+ .map(|index| UnitComponentRecord {
+ archive_raw: [0; 32],
+ resource_raw: [0; 32],
+ kind: 0,
+ parent_or_link: 0,
+ description_raw: [0; 32],
+ tail0: 0,
+ immediate_child_count: u32::from(index + 1 < DEPTH),
+ })
+ .collect();
+
+ let tree = unit_component_tree(&records).expect("deep preorder chain");
+
+ assert_eq!(tree.len(), DEPTH);
+ assert_eq!(tree[0].parent_index, None);
+ assert_eq!(tree[0].children, vec![1]);
+ assert_eq!(tree[DEPTH / 2].parent_index, Some(DEPTH / 2 - 1));
+ assert_eq!(tree[DEPTH - 2].children, vec![DEPTH - 1]);
+ assert_eq!(tree[DEPTH - 1].parent_index, Some(DEPTH - 2));
+ assert!(tree[DEPTH - 1].children.is_empty());
+ }
+
+ #[test]
+ fn unit_component_tree_rejects_missing_child_records() {
+ let bytes = build_unit_dat(&[
+ (b"objects.rlb".as_slice(), b"root".as_slice()),
+ (b"objects.rlb".as_slice(), b"child".as_slice()),
+ ]);
+ let mut unit = decode_unit_dat(&bytes).expect("unit");
+ unit.records[0].immediate_child_count = 2;
+
+ assert!(matches!(
+ unit_component_tree(&unit.records),
+ Err(PrototypeError::InvalidUnitHierarchy(message))
+ if message.contains("child count reached record 2")
+ ));
+ }
+
+ #[test]
+ fn unit_component_tree_rejects_unconsumed_preorder_records() {
+ let bytes = build_unit_dat(&[
+ (b"objects.rlb".as_slice(), b"root".as_slice()),
+ (b"objects.rlb".as_slice(), b"child".as_slice()),
+ ]);
+ let unit = decode_unit_dat(&bytes).expect("unit");
+ assert!(matches!(
+ unit_component_tree(&unit.records),
+ Err(PrototypeError::InvalidUnitHierarchy(_))
+ ));
+ }
+
+ #[test]
fn resolves_synthetic_objects_registry_model() {
let mut vfs = MemoryVfs::default();
let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
@@ -1436,6 +1711,7 @@ mod tests {
.expect("prototype");
assert_eq!(resolved.source, PrototypeSource::ObjectsRegistry);
+ assert!(resolved.control_resource.is_none());
let PrototypeGeometry::Mesh(mesh) = resolved.geometry else {
panic!("expected mesh");
};
@@ -1855,10 +2131,10 @@ mod tests {
build_nres(&[
(
b"parent_proto".as_slice(),
- build_object_refs(&[(
- b"static.rlb".as_slice(),
- b"parent_proto.msh".as_slice(),
- )])
+ build_object_refs(&[
+ (b"static.rlb".as_slice(), b"parent_proto.msh".as_slice()),
+ (b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()),
+ ])
.as_slice(),
),
(
@@ -1866,6 +2142,7 @@ mod tests {
build_object_refs(&[
(b"objects.rlb".as_slice(), b"parent_proto".as_slice()),
(b"fortif.rlb".as_slice(), b"child_proto.bas".as_slice()),
+ (b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()),
])
.as_slice(),
),
@@ -1881,7 +2158,13 @@ mod tests {
);
vfs.insert(
fortif_path,
- Arc::from(build_nres(&[(b"child_proto.bas".as_slice(), b"base")]).into_boxed_slice()),
+ Arc::from(
+ build_nres(&[
+ (b"child_proto.bas".as_slice(), b"base"),
+ (b"parent_proto.ctl".as_slice(), b"control"),
+ ])
+ .into_boxed_slice(),
+ ),
);
let vfs = Arc::new(vfs);
let repo = CachedResourceRepository::new(vfs.clone());
@@ -1895,6 +2178,108 @@ mod tests {
};
assert_eq!(mesh.archive.as_str(), "static.rlb");
assert!(mesh.name.0.eq_ignore_ascii_case(b"parent_proto.msh"));
+ let control = resolved.control_resource.expect("inherited explicit CTL");
+ assert_eq!(control.archive.as_str(), "fortif.rlb");
+ assert!(control.name.0.eq_ignore_ascii_case(b"parent_proto.ctl"));
+ }
+
+ #[test]
+ fn objects_registry_retains_missing_explicit_control_metadata() {
+ let mut vfs = MemoryVfs::default();
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ let static_path = resource_archive_path(b"static.rlb").expect("static path");
+ let mesh = minimal_msh_payload();
+ vfs.insert(
+ objects_path,
+ Arc::from(
+ build_nres(&[(
+ b"optional_control".as_slice(),
+ build_object_refs(&[
+ (b"static.rlb".as_slice(), b"optional_control.msh".as_slice()),
+ (
+ b"missing.rlb".as_slice(),
+ b"optional_control.ctl".as_slice(),
+ ),
+ ])
+ .as_slice(),
+ )])
+ .into_boxed_slice(),
+ ),
+ );
+ vfs.insert(
+ static_path,
+ Arc::from(
+ build_nres(&[(b"optional_control.msh".as_slice(), mesh.as_slice())])
+ .into_boxed_slice(),
+ ),
+ );
+
+ let vfs = Arc::new(vfs);
+ let repository = CachedResourceRepository::new(vfs.clone());
+ let resolved = resolve_prototype_single(
+ &repository,
+ vfs.as_ref(),
+ &resource_name(b"optional_control"),
+ )
+ .expect("resolve despite absent optional CTL")
+ .expect("prototype");
+
+ let control = resolved
+ .control_resource
+ .expect("preserve explicit missing CTL reference");
+ assert_eq!(control.archive.as_str(), "missing.rlb");
+ assert_eq!(control.name, resource_name(b"optional_control.ctl"));
+ assert_eq!(control.type_id, None);
+ }
+
+ #[test]
+ fn objects_registry_rejects_distinct_explicit_controls() {
+ let mut vfs = MemoryVfs::default();
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ let static_path = resource_archive_path(b"static.rlb").expect("static path");
+ let controls_path = resource_archive_path(b"controls.rlb").expect("controls path");
+ let mesh = minimal_msh_payload();
+ vfs.insert(
+ objects_path,
+ Arc::from(
+ build_nres(&[(
+ b"prototype".as_slice(),
+ build_object_refs(&[
+ (b"static.rlb".as_slice(), b"prototype.msh".as_slice()),
+ (b"controls.rlb".as_slice(), b"first.ctl".as_slice()),
+ (b"controls.rlb".as_slice(), b"second.ctl".as_slice()),
+ ])
+ .as_slice(),
+ )])
+ .into_boxed_slice(),
+ ),
+ );
+ vfs.insert(
+ static_path,
+ Arc::from(
+ build_nres(&[(b"prototype.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(),
+ ),
+ );
+ vfs.insert(
+ controls_path,
+ Arc::from(
+ build_nres(&[
+ (b"first.ctl".as_slice(), b"first control"),
+ (b"second.ctl".as_slice(), b"second control"),
+ ])
+ .into_boxed_slice(),
+ ),
+ );
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+ let error = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"prototype"))
+ .expect_err("distinct explicit controls are ambiguous");
+
+ assert!(matches!(
+ error,
+ PrototypeError::Resource(ResourceError::Format(message))
+ if message.contains("ambiguous explicit control resources")
+ ));
}
#[test]
diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs
index 7e3c1f8..08a285d 100644
--- a/crates/fparkan-render/src/lib.rs
+++ b/crates/fparkan-render/src/lib.rs
@@ -319,7 +319,7 @@ impl LegacyIron3dEulerTransform {
/// Fixed-function blend behaviour represented without a graphics API type.
///
-/// This is a compatibility contract, not yet a decoded MAT0 mapping.
+/// This is the compatibility contract for the native MAT0 pipeline category.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum LegacyBlendMode {
/// Do not blend the fragment with the existing colour.
@@ -327,6 +327,14 @@ pub enum LegacyBlendMode {
Opaque,
/// Blend using source alpha.
SourceAlpha,
+ /// Add the source-alpha-scaled fragment to the existing colour.
+ Additive,
+ /// Replace the source factor with zero and use source colour as the
+ /// destination factor.
+ ZeroSourceColor,
+ /// Multiply the source by destination colour and destination by source
+ /// colour, matching the native two-colour blend mode.
+ DestColorSourceColor,
}
/// Depth-buffer behaviour represented without a graphics API type.
@@ -369,10 +377,36 @@ pub struct LegacyPipelineState {
pub alpha_test: bool,
}
+impl LegacyPipelineState {
+ /// Applies the native MAT0 pipeline category while retaining the caller's
+ /// depth and cull state.
+ ///
+ /// Categories 0..4 are the complete table recovered from the native
+ /// material path. The native table enables `GREATER_EQUAL` alpha testing
+ /// for categories 1..4; the alpha reference remains per draw range.
+ #[must_use]
+ pub const fn with_material_category(self, category: u8) -> Option<Self> {
+ let (blend, alpha_test) = match category {
+ 0 => (LegacyBlendMode::Opaque, false),
+ 1 => (LegacyBlendMode::SourceAlpha, true),
+ 2 => (LegacyBlendMode::Additive, true),
+ 3 => (LegacyBlendMode::ZeroSourceColor, true),
+ 4 => (LegacyBlendMode::DestColorSourceColor, true),
+ _ => return None,
+ };
+ Some(Self {
+ blend,
+ alpha_test,
+ ..self
+ })
+ }
+}
+
/// Canonical, backend-neutral key for a graphics-pipeline variant.
///
/// The value is explicitly packed rather than hashed, so captures and caches
-/// remain stable across processes and Rust toolchain updates.
+/// remain stable across processes and Rust toolchain updates. Three bits are
+/// reserved for the five native material blend modes.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PipelineKey(u8);
@@ -389,6 +423,9 @@ impl From<LegacyPipelineState> for PipelineKey {
let blend = match state.blend {
LegacyBlendMode::Opaque => 0,
LegacyBlendMode::SourceAlpha => 1,
+ LegacyBlendMode::Additive => 2,
+ LegacyBlendMode::ZeroSourceColor => 3,
+ LegacyBlendMode::DestColorSourceColor => 4,
};
let depth = match state.depth {
LegacyDepthMode::Disabled => 0,
@@ -400,7 +437,7 @@ impl From<LegacyPipelineState> for PipelineKey {
LegacyCullMode::BackFace => 1,
LegacyCullMode::FrontFace => 2,
};
- Self(blend | (depth << 1) | (cull << 3) | (u8::from(state.alpha_test) << 5))
+ Self(blend | (depth << 3) | (cull << 5) | (u8::from(state.alpha_test) << 7))
}
}
@@ -549,4 +586,49 @@ mod tests {
None
);
}
+
+ #[test]
+ fn native_material_categories_preserve_depth_and_cull_state() {
+ let base = LegacyPipelineState {
+ depth: LegacyDepthMode::TestWrite,
+ cull: LegacyCullMode::BackFace,
+ ..LegacyPipelineState::default()
+ };
+ let expected = [
+ (LegacyBlendMode::Opaque, false),
+ (LegacyBlendMode::SourceAlpha, true),
+ (LegacyBlendMode::Additive, true),
+ (LegacyBlendMode::ZeroSourceColor, true),
+ (LegacyBlendMode::DestColorSourceColor, true),
+ ];
+ for (category, (blend, alpha_test)) in expected.into_iter().enumerate() {
+ let state = base
+ .with_material_category(category as u8)
+ .expect("native category is represented");
+ assert_eq!(state.blend, blend);
+ assert_eq!(state.alpha_test, alpha_test);
+ assert_eq!(state.depth, base.depth);
+ assert_eq!(state.cull, base.cull);
+ }
+ assert_eq!(base.with_material_category(5), None);
+ }
+
+ #[test]
+ fn native_material_categories_have_distinct_pipeline_keys() {
+ let base = LegacyPipelineState {
+ depth: LegacyDepthMode::TestWrite,
+ ..LegacyPipelineState::default()
+ };
+ let keys: Vec<_> = (0..=4)
+ .map(|category| {
+ PipelineKey::from(
+ base.with_material_category(category)
+ .expect("native category is represented"),
+ )
+ })
+ .collect();
+ for (index, key) in keys.iter().enumerate() {
+ assert!(keys[index + 1..].iter().all(|other| other != key));
+ }
+ }
}
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
index 2fead30..e0088a3 100644
--- a/crates/fparkan-runtime/src/lib.rs
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -1761,7 +1761,7 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
- fn licensed_corpora_load_all_mission_foundations() {
+ fn licensed_corpus_part1_loads_all_mission_foundations() {
let part1 = load_all(&licensed_root("IS"));
assert_eq!(part1.missions, 29);
assert_eq!(part1.paths, 34);
@@ -1781,7 +1781,11 @@ mod tests {
assert_eq!(part1.material_slots, part1.material_resolved);
assert_eq!(part1.texture_requests, part1.texture_resolved);
assert_eq!(part1.lightmap_requests, part1.lightmap_resolved);
+ }
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_corpus_part2_loads_all_mission_foundations() {
let part2 = load_all(&licensed_root("IS2"));
assert_eq!(part2.missions, 31);
assert_eq!(part2.paths, 61);
diff --git a/crates/fparkan-terrain-format/src/lib.rs b/crates/fparkan-terrain-format/src/lib.rs
index 3c05cfe..639fc57 100644
--- a/crates/fparkan-terrain-format/src/lib.rs
+++ b/crates/fparkan-terrain-format/src/lib.rs
@@ -43,16 +43,18 @@ pub struct CompactSurfaceMask(pub u16);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct MaterialClassMask(pub u8);
-/// The two positional material-table selectors packed into a terrain face tag.
+/// The base and overlay selectors packed into a terrain face tag.
///
-/// The high byte selects from map-local `Land1.wea`; `0xff` is the observed
-/// no-selection sentinel. The low byte selects from `Land2.wea`.
+/// Both selectors address the map-local `Land1.wea` table. The low byte is
+/// always the base row; the high byte is an optional overlay row and uses
+/// `0xff` as its no-overlay sentinel. `Land2.wea` is a separate detail bank
+/// selected by the corresponding detail path.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct TerrainMaterialLayers {
- /// Optional high-byte selector for `Land1.wea`.
- pub land1_selector: Option<u8>,
- /// Low-byte selector for `Land2.wea`.
- pub land2_selector: u8,
+ /// Low-byte base selector for `Land1.wea`.
+ pub base_selector: u8,
+ /// Optional high-byte overlay selector for `Land1.wea`.
+ pub overlay_selector: Option<u8>,
}
/// One `World3D` material-manager lookup key.
@@ -79,31 +81,56 @@ impl TerrainMaterialLayers {
/// Constructs the decoded pair from its on-disk packed tag.
#[must_use]
pub const fn from_packed_tag(material_tag: u16) -> Self {
- let [land2_selector, land1] = material_tag.to_le_bytes();
+ let [base_selector, overlay] = material_tag.to_le_bytes();
Self {
- land1_selector: if land1 == u8::MAX { None } else { Some(land1) },
- land2_selector,
+ base_selector,
+ overlay_selector: if overlay == u8::MAX {
+ None
+ } else {
+ Some(overlay)
+ },
}
}
- /// Returns the table-zero `Land1.wea` selection when it exists.
+ /// Returns the base selection from `Land1.wea`.
#[must_use]
- pub fn land1_selection(self) -> Option<TerrainMaterialSelection> {
- self.land1_selector
+ pub fn base_selection(self) -> TerrainMaterialSelection {
+ TerrainMaterialSelection {
+ table_index: 0,
+ material_index: u16::from(self.base_selector),
+ }
+ }
+
+ /// Returns the optional overlay selection from `Land1.wea`.
+ #[must_use]
+ pub fn overlay_selection(self) -> Option<TerrainMaterialSelection> {
+ self.overlay_selector
.map(|material_index| TerrainMaterialSelection {
table_index: 0,
material_index: u16::from(material_index),
})
}
- /// Returns the table-one `Land2.wea` selection.
+ /// Returns the detail selection corresponding to the base row in
+ /// `Land2.wea`.
#[must_use]
- pub fn land2_selection(self) -> TerrainMaterialSelection {
+ pub fn detail_base_selection(self) -> TerrainMaterialSelection {
TerrainMaterialSelection {
table_index: 1,
- material_index: u16::from(self.land2_selector),
+ material_index: u16::from(self.base_selector),
}
}
+
+ /// Returns the optional detail selection corresponding to the overlay row
+ /// in `Land2.wea`.
+ #[must_use]
+ pub fn detail_overlay_selection(self) -> Option<TerrainMaterialSelection> {
+ self.overlay_selector
+ .map(|material_index| TerrainMaterialSelection {
+ table_index: 1,
+ material_index: u16::from(material_index),
+ })
+ }
}
/// Terrain face with 28-byte source layout.
@@ -231,7 +258,7 @@ pub struct LandMeshDocument {
/// Packed normals from type 4.
pub normals: Vec<[i8; 4]>,
/// Packed UV from type 5.
- pub uv0: Vec<[i16; 2]>,
+ pub uv0: Vec<[u16; 2]>,
/// Type 11 accelerator words.
pub accelerator: Vec<[u8; 4]>,
/// Type 14 auxiliary words.
@@ -631,7 +658,7 @@ pub fn decode_land_msh(nres: &NresDocument) -> Result<LandMeshDocument, TerrainF
validate_slots(nres, slots)?;
let positions = parse_positions(nres, positions)?;
let normals = parse_i8x4_stream(nres, TYPE_NORMALS, normals)?;
- let uv0 = parse_i16x2_stream(nres, TYPE_UV0, uv0)?;
+ let uv0 = parse_u16x2_stream(nres, TYPE_UV0, uv0)?;
let accelerator = parse_word_stream(nres, TYPE_ACCELERATOR, accelerator)?;
let aux14 = parse_word_stream(nres, TYPE_AUX14, aux14)?;
let aux18 = parse_word_stream(nres, TYPE_AUX18, aux18)?;
@@ -683,6 +710,11 @@ pub fn decode_land_map(nres: &NresDocument) -> Result<LandMapDocument, TerrainFo
let payload = nres.payload(entry.id())?;
let areal_count =
usize::try_from(meta.attr1).map_err(|_| TerrainFormatError::IntegerOverflow)?;
+ checked_count_bytes(
+ u64::from(meta.attr1),
+ u64::try_from(AREAL_PREFIX_SIZE).map_err(|_| TerrainFormatError::IntegerOverflow)?,
+ payload.len() as u64,
+ )?;
let mut cursor = Cursor::new(payload);
let mut areals = Vec::with_capacity(areal_count);
for area_index in 0..areal_count {
@@ -1072,11 +1104,11 @@ fn parse_i8x4_stream(
.collect())
}
-fn parse_i16x2_stream(
+fn parse_u16x2_stream(
nres: &NresDocument,
type_id: u32,
payload: &[u8],
-) -> Result<Vec<[i16; 2]>, TerrainFormatError> {
+) -> Result<Vec<[u16; 2]>, TerrainFormatError> {
if !payload.len().is_multiple_of(4) {
return Err(TerrainFormatError::InvalidSize {
type_id,
@@ -1088,7 +1120,7 @@ fn parse_i16x2_stream(
validate_stream(nres, type_id, 4, count)?;
let mut out = Vec::with_capacity(count);
for chunk in payload.chunks_exact(4) {
- out.push([read_i16(chunk, 0)?, read_i16(chunk, 2)?]);
+ out.push([read_u16(chunk, 0)?, read_u16(chunk, 2)?]);
}
Ok(out)
}
@@ -1311,6 +1343,7 @@ fn parse_areal_grid(
if cell_count == 0 {
return Err(TerrainFormatError::InvalidGridSize { cells_x, cells_y });
}
+ checked_count_bytes(u64::from(cell_count), 2, cursor.remaining() as u64)?;
let cell_count_usize =
usize::try_from(cell_count).map_err(|_| TerrainFormatError::IntegerOverflow)?;
let mut cells = Vec::with_capacity(cell_count_usize);
@@ -1364,13 +1397,6 @@ fn read_u16(bytes: &[u8], offset: usize) -> Result<u16, TerrainFormatError> {
Ok(u16::from_le_bytes([raw[0], raw[1]]))
}
-fn read_i16(bytes: &[u8], offset: usize) -> Result<i16, TerrainFormatError> {
- let raw = bytes
- .get(offset..offset + 2)
- .ok_or(TerrainFormatError::IntegerOverflow)?;
- Ok(i16::from_le_bytes([raw[0], raw[1]]))
-}
-
fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, TerrainFormatError> {
let raw = bytes
.get(offset..offset + 4)
@@ -1574,7 +1600,7 @@ mod tests {
}
#[test]
- fn terrain_material_tag_decodes_two_sidecar_selectors() {
+ fn terrain_material_tag_decodes_base_overlay_and_detail_selectors() {
let mut raw_face = face([0, 1, 2], [None, None, None]);
raw_face[4..6].copy_from_slice(&0x0102_u16.to_le_bytes());
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
@@ -1583,24 +1609,40 @@ mod tests {
assert_eq!(
document.faces[0].material_layers(),
TerrainMaterialLayers {
- land1_selector: Some(1),
- land2_selector: 2,
+ base_selector: 2,
+ overlay_selector: Some(1),
}
);
assert_eq!(
- document.faces[0].material_layers().land1_selection(),
- Some(TerrainMaterialSelection {
+ document.faces[0].material_layers().base_selection(),
+ TerrainMaterialSelection {
table_index: 0,
- material_index: 1,
- })
+ material_index: 2,
+ }
+ );
+ assert_eq!(
+ document.faces[0]
+ .material_layers()
+ .overlay_selection()
+ .expect("overlay")
+ .phase_key(),
+ 0x0000_0001
);
assert_eq!(
document.faces[0]
.material_layers()
- .land2_selection()
+ .detail_base_selection()
.phase_key(),
0x0001_0002
);
+ assert_eq!(
+ document.faces[0]
+ .material_layers()
+ .detail_overlay_selection()
+ .expect("detail overlay")
+ .phase_key(),
+ 0x0001_0001
+ );
raw_face[4..6].copy_from_slice(&0xff03_u16.to_le_bytes());
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
@@ -1608,18 +1650,28 @@ mod tests {
assert_eq!(
document.faces[0].material_layers(),
TerrainMaterialLayers {
- land1_selector: None,
- land2_selector: 3,
+ base_selector: 3,
+ overlay_selector: None,
}
);
- assert_eq!(document.faces[0].material_layers().land1_selection(), None);
assert_eq!(
- document.faces[0].material_layers().land2_selection(),
+ document.faces[0].material_layers().base_selection(),
TerrainMaterialSelection {
- table_index: 1,
+ table_index: 0,
material_index: 3,
}
);
+ assert_eq!(
+ document.faces[0].material_layers().overlay_selection(),
+ None
+ );
+ assert_eq!(
+ document.faces[0]
+ .material_layers()
+ .detail_base_selection()
+ .phase_key(),
+ 0x0001_0003
+ );
}
#[test]
@@ -1638,6 +1690,21 @@ mod tests {
}
#[test]
+ fn land_map_bounds_area_and_grid_reservations_by_payload() {
+ let empty = build_nres(&[entry(TYPE_AREAL_MAP, u32::MAX, 0, &[])]);
+ let nres = decode_nres(&empty).expect("nres");
+ assert!(decode_land_map(&nres).is_err());
+
+ let mut payload = Vec::new();
+ push_areal_prefix(&mut payload, 0, 0);
+ push_u32(&mut payload, 65_535);
+ push_u32(&mut payload, 65_535);
+ let bytes = build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)]);
+ let nres = decode_nres(&bytes).expect("nres");
+ assert!(decode_land_map(&nres).is_err());
+ }
+
+ #[test]
fn land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof() {
let nres = decode_nres(&minimal_land_map_with_poly(1, true)).expect("nres");
let document = decode_land_map(&nres).expect("land map");
diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs
index 7dced19..cd2a874 100644
--- a/crates/fparkan-terrain/src/lib.rs
+++ b/crates/fparkan-terrain/src/lib.rs
@@ -1,14 +1,16 @@
#![forbid(unsafe_code)]
//! Validated terrain runtime queries using XY ground coordinates and Z height.
-use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument};
+use fparkan_terrain_format::{LandMapDocument, LandMeshDocument};
use std::collections::VecDeque;
/// Terrain material-selector contract preserved from the decoded map mesh.
///
/// Applications access this semantic terrain API through this terrain crate
/// rather than taking a direct dependency on the binary-format parser.
-pub use fparkan_terrain_format::{TerrainMaterialLayers, TerrainMaterialSelection};
+pub use fparkan_terrain_format::{
+ FullSurfaceMask, TerrainMaterialLayers, TerrainMaterialSelection,
+};
/// Terrain world.
#[derive(Clone, Debug, Default)]
@@ -141,6 +143,21 @@ pub trait SurfaceQuery {
direction: [f32; 3],
mask: FullSurfaceMask,
) -> Result<Option<SurfaceHit>, TerrainError>;
+
+ /// Raycast while requiring every bit in `include` and rejecting every
+ /// face carrying a bit in `exclude`.
+ ///
+ /// The native landscape query has both predicates. The legacy
+ /// [`Self::raycast`] shorthand keeps its historical include-only form;
+ /// callers that model a native receiver or visibility test should use
+ /// this method so exclusion flags cannot be mistaken for inclusion bits.
+ fn raycast_excluding(
+ &self,
+ origin: [f32; 3],
+ direction: [f32; 3],
+ include: FullSurfaceMask,
+ exclude: FullSurfaceMask,
+ ) -> Result<Option<SurfaceHit>, TerrainError>;
}
/// Navigation query.
@@ -405,6 +422,28 @@ impl SurfaceQuery for TerrainWorld {
direction: [f32; 3],
mask: FullSurfaceMask,
) -> Result<Option<SurfaceHit>, TerrainError> {
+ self.raycast_filtered(origin, direction, mask, FullSurfaceMask(0))
+ }
+
+ fn raycast_excluding(
+ &self,
+ origin: [f32; 3],
+ direction: [f32; 3],
+ include: FullSurfaceMask,
+ exclude: FullSurfaceMask,
+ ) -> Result<Option<SurfaceHit>, TerrainError> {
+ self.raycast_filtered(origin, direction, include, exclude)
+ }
+}
+
+impl TerrainWorld {
+ fn raycast_filtered(
+ &self,
+ origin: [f32; 3],
+ direction: [f32; 3],
+ include: FullSurfaceMask,
+ exclude: FullSurfaceMask,
+ ) -> Result<Option<SurfaceHit>, TerrainError> {
if self.surfaces.is_empty() {
return Err(TerrainError::Unsupported);
}
@@ -413,7 +452,10 @@ impl SurfaceQuery for TerrainWorld {
let mut best: Option<SurfaceHit> = None;
for index in candidates {
let triangle = &self.surfaces[index];
- if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 {
+ if include.0 != 0 && triangle.mask.0 & include.0 != include.0 {
+ continue;
+ }
+ if triangle.mask.0 & exclude.0 != 0 {
continue;
}
let Some(distance) = triangle.raycast(origin, direction) else {
@@ -1032,6 +1074,31 @@ mod tests {
.expect("raycast"),
None
);
+
+ // Native include masks require every requested bit, while exclusion
+ // masks reject any matching face bit.
+ assert_eq!(
+ world
+ .raycast_excluding(
+ [0.25, 0.25, 2.0],
+ [0.0, 0.0, -1.0],
+ FullSurfaceMask(0x0000_0003),
+ FullSurfaceMask(0),
+ )
+ .expect("raycast include all bits"),
+ None
+ );
+ assert_eq!(
+ world
+ .raycast_excluding(
+ [0.25, 0.25, 2.0],
+ [0.0, 0.0, -1.0],
+ FullSurfaceMask(0),
+ FullSurfaceMask(0x0000_0001),
+ )
+ .expect("raycast exclusion"),
+ None
+ );
}
#[test]