//! 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 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, /// 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, /// Four trailing floating point values. pub parameters: [f32; 4], /// Length-prefixed resource references. pub references: Vec, } 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, /// 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 { 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, } /// 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, } /// 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, } /// 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, /// 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 { 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 { 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 { 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> { 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 { 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 { 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 { 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) -> 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) { 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 { 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) { out.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); out.extend_from_slice(bytes); } fn key( out: &mut Vec, 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, 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)) -> Vec { 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 { 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) { 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); } } } }