aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-fx/src/atmosphere.rs
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-10-11 12:12:50 +0300
committerValentin Popov <valentin@popov.link>2026-10-11 12:12:50 +0300
commita8faba8aad441e87f8a8b94c148c01f9c075d621 (patch)
tree97aa4972990edba9991ff2e125edac3a7883f50f /crates/fparkan-fx/src/atmosphere.rs
parentaa51f3574d3d4d4e0fa59653eedd718fceb5d1bd (diff)
downloadfparkan-a8faba8aad441e87f8a8b94c148c01f9c075d621.tar.xz
fparkan-a8faba8aad441e87f8a8b94c148c01f9c075d621.zip
feat: complete map viewer scene and static CTL pose preview
Complete the interactive mission viewer with environment rendering, audio events, dynamic shadows, free-flight camera controls, and per-component CTL pose sampling.
Diffstat (limited to 'crates/fparkan-fx/src/atmosphere.rs')
-rw-r--r--crates/fparkan-fx/src/atmosphere.rs1756
1 files changed, 1756 insertions, 0 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);
+ }
+ }
+ }
+}