diff options
Diffstat (limited to 'crates/fparkan-fx')
| -rw-r--r-- | crates/fparkan-fx/src/atmosphere.rs | 1756 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/environment.rs | 2803 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/lib.rs | 40 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/shadow.rs | 1785 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/sky.rs | 2079 |
5 files changed, 8456 insertions, 7 deletions
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 ¤t 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) + ); + } +} |
