diff options
| author | Valentin Popov <valentin@popov.link> | 2026-10-11 12:12:50 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-10-11 12:12:50 +0300 |
| commit | a8faba8aad441e87f8a8b94c148c01f9c075d621 (patch) | |
| tree | 97aa4972990edba9991ff2e125edac3a7883f50f /apps | |
| parent | aa51f3574d3d4d4e0fa59653eedd718fceb5d1bd (diff) | |
| download | fparkan-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 'apps')
| -rw-r--r-- | apps/fparkan-game/Cargo.toml | 7 | ||||
| -rw-r--r-- | apps/fparkan-game/src/audio.rs | 1798 | ||||
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 5822 |
3 files changed, 7394 insertions, 233 deletions
diff --git a/apps/fparkan-game/Cargo.toml b/apps/fparkan-game/Cargo.toml index 780a21c..69535f3 100644 --- a/apps/fparkan-game/Cargo.toml +++ b/apps/fparkan-game/Cargo.toml @@ -6,14 +6,21 @@ license.workspace = true repository.workspace = true [dependencies] +fparkan-animation = { path = "../../crates/fparkan-animation" } fparkan-assets = { path = "../../crates/fparkan-assets" } +fparkan-fx = { path = "../../crates/fparkan-fx" } fparkan-path = { path = "../../crates/fparkan-path" } +fparkan-resource = { path = "../../crates/fparkan-resource" } fparkan-render = { path = "../../crates/fparkan-render" } fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" } +fparkan-msh = { path = "../../crates/fparkan-msh" } +fparkan-prototype = { path = "../../crates/fparkan-prototype" } fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" } fparkan-runtime = { path = "../../crates/fparkan-runtime" } fparkan-terrain = { path = "../../crates/fparkan-terrain" } +fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format" } fparkan-vfs = { path = "../../crates/fparkan-vfs" } +rodio = { version = "0.22.2", default-features = false, features = ["playback", "vorbis", "wav", "symphonia-adpcm"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" winit = { version = "0.30", default-features = false, features = ["rwh_06"] } diff --git a/apps/fparkan-game/src/audio.rs b/apps/fparkan-game/src/audio.rs new file mode 100644 index 0000000..2de6464 --- /dev/null +++ b/apps/fparkan-game/src/audio.rs @@ -0,0 +1,1798 @@ +//! Audio playback for a running mission. +//! +//! The game keeps this module deliberately small. Mission ambience is read +//! from the names in mission.cfg, samples are resolved through the same VFS +//! and resource archive formats as the rest of the game, and rodio owns the +//! output thread. A sample is decoded once to validate it before it is +//! queued, then its bytes are retained so a new source can be created when a +//! loop or one-shot starts. + +use fparkan_fx::environment::{SoundAction, SoundEvent}; +use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy}; +use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository}; +use fparkan_vfs::Vfs; +use rodio::mixer::Mixer; +use rodio::source::{ChannelVolume, SeekError}; +use rodio::{ + ChannelCount, Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, SampleRate, Source, +}; +use std::collections::BTreeMap; +use std::io::Cursor; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const SWAV_TYPE: u32 = u32::from_le_bytes(*b"SWAV"); +const DEFAULT_MUSIC_TRACK: u8 = 2; +const FIRST_MUSIC_TRACK: u8 = 2; +const LAST_MUSIC_TRACK: u8 = 10; +const EAR_HALF_WIDTH: f32 = 0.15; +const VALIDATION_SAMPLES: usize = 64; +const AMBIENT_NIGHT_LENGTH: f32 = 1.1; +const AMBIENT_MIN_DELAY_SECONDS: f32 = 10.0; +const AMBIENT_DELAY_VARIANTS: u32 = 10; + +/// Position and orientation of the listener in world coordinates. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Listener { + /// Listener position. + pub position: [f32; 3], + /// Direction in which the camera looks. + pub forward: [f32; 3], + /// Camera up direction. + pub up: [f32; 3], +} + +impl Default for Listener { + fn default() -> Self { + Self { + position: [0.0; 3], + forward: [0.0, 1.0, 0.0], + up: [0.0, 0.0, 1.0], + } + } +} + +/// Audio loading/playback failure. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum AudioError { + /// Mission path or archive path is not a valid relative resource path. + InvalidPath { path: String, message: String }, + /// Mission configuration could not be read. + MissionConfigRead { path: String, message: String }, + /// Mission configuration does not contain the required audio object. + MissionConfig { path: String, message: String }, + /// A declared archive could not be opened. + Archive { path: String, message: String }, + /// A declared archive entry is absent. + MissingEntry { archive: String, name: String }, + /// A declared entry exists but contains no payload. + EmptyEntry { archive: String, name: String }, + /// A payload is not the expected SWAV entry type. + WrongEntryType { + archive: String, + name: String, + type_id: Option<u32>, + }, + /// The payload has a valid container but cannot be decoded by rodio. + Decode { resource: String, message: String }, + /// A decoded container produced no samples. + NoSamples { resource: String }, + /// A direct VFS file such as a music track is missing or unreadable. + FileRead { path: String, message: String }, + /// Track selection is outside the names shipped by the original game. + UnsupportedMusicTrack(u8), + /// The operating system has no usable output device. + DeviceUnavailable { message: String }, + /// A listener direction is non-finite or has zero length. + InvalidListener { field: &'static str }, + /// A listener or emitter position is non-finite. + InvalidPosition { field: &'static str }, +} + +impl std::fmt::Display for AudioError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidPath { path, message } => { + write!(f, "invalid audio path {path}: {message}") + } + Self::MissionConfigRead { path, message } => { + write!(f, "failed to read mission audio config {path}: {message}") + } + Self::MissionConfig { path, message } => { + write!(f, "invalid mission audio config {path}: {message}") + } + Self::Archive { path, message } => { + write!(f, "failed to open audio archive {path}: {message}") + } + Self::MissingEntry { archive, name } => { + write!( + f, + "declared audio resource {name} is missing from {archive}" + ) + } + Self::EmptyEntry { archive, name } => { + write!(f, "declared audio resource {name} in {archive} is empty") + } + Self::WrongEntryType { + archive, + name, + type_id, + } => write!( + f, + "audio resource {name} in {archive} has type {type_id:?}, expected SWAV" + ), + Self::Decode { resource, message } => { + write!(f, "failed to decode audio resource {resource}: {message}") + } + Self::NoSamples { resource } => { + write!(f, "audio resource {resource} contains no samples") + } + Self::FileRead { path, message } => { + write!(f, "failed to read audio file {path}: {message}") + } + Self::UnsupportedMusicTrack(track) => { + write!( + f, + "music track Track{track:02}.ogg is outside Track02..Track10" + ) + } + Self::DeviceUnavailable { message } => { + write!(f, "audio output device is unavailable: {message}") + } + Self::InvalidListener { field } => { + write!( + f, + "audio listener direction {field} must be finite and non-zero" + ) + } + Self::InvalidPosition { field } => { + write!(f, "audio {field} position must be finite") + } + } + } +} + +impl std::error::Error for AudioError {} + +/// Parsed names from the two mission audio objects. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct MissionAudioConfig { + /// Archive named by ambient_music_loop. + pub library: String, + /// Archive named by ambient_music_variation, or the loop archive when it + /// is omitted. + pub variation_library: String, + /// Theme loop name from THEME. + pub theme: String, + /// Ambient default variation names in numeric key order. + pub default_variations: Vec<String>, + /// Ambient day variation names in numeric key order. + pub day_variations: Vec<String>, + /// Ambient night variation names in numeric key order. + pub night_variations: Vec<String>, +} + +#[derive(Clone, Debug)] +struct SoundAsset { + archive: String, + name: String, + bytes: Arc<[u8]>, +} + +impl SoundAsset { + fn resource_name(&self) -> String { + format!("{}:{}", self.archive, self.name) + } +} + +struct AudioLibrary { + vfs: Arc<dyn Vfs>, + repository: Arc<CachedResourceRepository>, + theme: SoundAsset, + default_variations: Vec<SoundAsset>, + day_variations: Vec<SoundAsset>, + night_variations: Vec<SoundAsset>, + music: BTreeMap<u8, Arc<[u8]>>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum VariationPool { + Default, + Day, + Night, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct VariationSelection { + pool: VariationPool, + index: Option<usize>, +} + +/// The two 16-bit states used by the original ambient variation selector. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct VariationRng { + a: u16, + b: u16, +} + +impl VariationRng { + fn from_clock(clock: u32) -> Self { + Self { + a: clock as u16, + b: (clock >> 16) as u16, + } + } + + fn step(&mut self) { + self.a = self.a.wrapping_shl(1) ^ self.b; + self.b = self.b.wrapping_shr(1) ^ self.a; + } + + fn choose(&mut self, length: usize, last_index: &mut i32) -> Option<usize> { + if length == 0 { + *last_index = -1; + return None; + } + if length == 1 { + *last_index = 0; + return Some(0); + } + for _ in 0..=u16::MAX { + self.step(); + let candidate = usize::from(self.b) % length; + if candidate as i32 != *last_index { + *last_index = candidate as i32; + return Some(candidate); + } + } + // A zero state is not produced by the native clock seed, but keeping + // this fallback makes malformed or deterministic test seeds harmless. + let candidate = usize::from(self.b) % length; + *last_index = candidate as i32; + Some(candidate) + } +} + +/// CRT-style delay generator kept separate from the ambient pool selector. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct CrtRand { + state: u32, +} + +impl CrtRand { + fn new(state: u32) -> Self { + Self { state } + } + + fn next_u15(&mut self) -> u32 { + self.state = self.state.wrapping_mul(214_013).wrapping_add(2_531_011); + (self.state >> 16) & 0x7fff + } + + fn next_delay_seconds(&mut self) -> f32 { + AMBIENT_MIN_DELAY_SECONDS + (self.next_u15() % AMBIENT_DELAY_VARIANTS) as f32 + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +struct AmbienceScheduler { + elapsed_seconds: f32, + next_delay_seconds: f32, + pool: VariationPool, + variation_rng: VariationRng, + delay_rng: CrtRand, + last_index: i32, +} + +impl AmbienceScheduler { + fn new(clock_seed: u32, delay_seed: u32) -> Self { + Self { + elapsed_seconds: 0.0, + next_delay_seconds: 0.0, + pool: VariationPool::Default, + variation_rng: VariationRng::from_clock(clock_seed), + delay_rng: CrtRand::new(delay_seed), + last_index: -1, + } + } + + fn update( + &mut self, + dt_seconds: f32, + primary_rgb: [f32; 3], + default_len: usize, + day_len: usize, + night_len: usize, + ) -> Option<VariationSelection> { + self.pool = selected_pool(primary_rgb, day_len, night_len); + self.elapsed_seconds += finite_non_negative(dt_seconds); + if self.elapsed_seconds <= self.next_delay_seconds { + return None; + } + self.elapsed_seconds = 0.0; + let length = match self.pool { + VariationPool::Default => default_len, + VariationPool::Day => day_len, + VariationPool::Night => night_len, + }; + let index = self.variation_rng.choose(length, &mut self.last_index); + self.next_delay_seconds = self.delay_rng.next_delay_seconds(); + Some(VariationSelection { + pool: self.pool, + index, + }) + } +} + +fn finite_non_negative(value: f32) -> f32 { + if value.is_finite() { + value.max(0.0) + } else { + 0.0 + } +} + +fn random_seed() -> u32 { + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default(); + (duration.as_nanos() as u64 as u32) ^ duration.subsec_nanos().rotate_left(13) +} + +fn selected_pool(primary_rgb: [f32; 3], day_len: usize, night_len: usize) -> VariationPool { + if day_len == 0 && night_len == 0 { + return VariationPool::Default; + } + let length = primary_rgb + .iter() + .map(|value| value * value) + .sum::<f32>() + .sqrt(); + if length.is_finite() && length <= AMBIENT_NIGHT_LENGTH { + VariationPool::Night + } else { + VariationPool::Day + } +} + +struct SpatialOneShot { + player: NativeSpatialPlayer, + volume: f32, +} + +#[derive(Clone, Copy, Debug)] +struct NativeSpatialState { + emitter_position: [f32; 3], + left_ear: [f32; 3], + right_ear: [f32; 3], + volume: f32, + min_distance: f32, + max_distance: f32, +} + +/// A spatial player with the native DirectSound distance curve. +/// +/// `rodio::SpatialPlayer` applies an inverse-square curve unconditionally. +/// Native FX sounds instead use the DirectSound min/max distance curve, so +/// this small wrapper keeps rodio's mono and stereo panning behavior while +/// supplying the native per-channel gains itself. +struct NativeSpatialPlayer { + player: Player, + state: Arc<Mutex<NativeSpatialState>>, +} + +impl NativeSpatialPlayer { + fn connect_new( + mixer: &Mixer, + emitter_position: [f32; 3], + left_ear: [f32; 3], + right_ear: [f32; 3], + ) -> Self { + Self { + player: Player::connect_new(mixer), + state: Arc::new(Mutex::new(NativeSpatialState { + emitter_position, + left_ear, + right_ear, + volume: 1.0, + min_distance: 0.0, + max_distance: f32::INFINITY, + })), + } + } + + fn set_emitter_position(&self, position: [f32; 3]) { + self.state + .lock() + .expect("audio state poisoned") + .emitter_position = position; + } + + fn set_left_ear_position(&self, position: [f32; 3]) { + self.state.lock().expect("audio state poisoned").left_ear = position; + } + + fn set_right_ear_position(&self, position: [f32; 3]) { + self.state.lock().expect("audio state poisoned").right_ear = position; + } + + fn set_sound_parameters(&self, min_distance: f32, max_distance: f32) { + let mut state = self.state.lock().expect("audio state poisoned"); + state.min_distance = min_distance; + state.max_distance = max_distance; + } + + fn append<S>(&self, source: S) + where + S: Source + Send + 'static, + { + self.player + .append(NativeSpatialSource::new(source, Arc::clone(&self.state))); + } + + #[cfg(test)] + fn volume(&self) -> f32 { + self.state.lock().expect("audio state poisoned").volume + } + + fn set_volume(&self, volume: f32) { + self.state.lock().expect("audio state poisoned").volume = volume; + } + + fn set_speed(&self, speed: f32) { + self.player.set_speed(speed); + } + + fn play(&self) { + self.player.play(); + } + + fn pause(&self) { + self.player.pause(); + } + + #[cfg(test)] + fn is_paused(&self) -> bool { + self.player.is_paused() + } + + fn clear(&self) { + self.player.clear(); + } + + fn stop(&self) { + self.player.stop(); + } + + fn empty(&self) -> bool { + self.player.empty() + } +} + +struct NativeSpatialSource<I> +where + I: Source, +{ + input: ChannelVolume<I>, + state: Arc<Mutex<NativeSpatialState>>, + channel_gains: [f32; 2], + refresh_remaining: usize, +} + +const NATIVE_SPATIAL_REFRESH_SAMPLES: usize = 256; + +impl<I> NativeSpatialSource<I> +where + I: Source, +{ + fn new(input: I, state: Arc<Mutex<NativeSpatialState>>) -> Self { + Self { + input: ChannelVolume::new(input, vec![0.0, 0.0]), + state, + channel_gains: [0.0, 0.0], + refresh_remaining: 0, + } + } +} + +impl<I> Iterator for NativeSpatialSource<I> +where + I: Source, +{ + type Item = f32; + + fn next(&mut self) -> Option<Self::Item> { + if self.refresh_remaining == 0 { + if let Ok(state) = self.state.lock() { + self.channel_gains = native_spatial_channel_gains(*state); + } + self.refresh_remaining = NATIVE_SPATIAL_REFRESH_SAMPLES; + } + self.refresh_remaining -= 1; + self.input.set_volume(0, self.channel_gains[0]); + self.input.set_volume(1, self.channel_gains[1]); + self.input.next() + } +} + +impl<I> Source for NativeSpatialSource<I> +where + I: Source, +{ + fn current_span_len(&self) -> Option<usize> { + self.input.current_span_len() + } + + fn channels(&self) -> ChannelCount { + self.input.channels() + } + + fn sample_rate(&self) -> SampleRate { + self.input.sample_rate() + } + + fn total_duration(&self) -> Option<std::time::Duration> { + self.input.total_duration() + } + + fn try_seek(&mut self, position: std::time::Duration) -> Result<(), SeekError> { + self.input.try_seek(position) + } +} + +impl AudioLibrary { + fn load(vfs: Arc<dyn Vfs>, mission_cfg: &NormalizedPath) -> Result<Self, AudioError> { + let repository = Arc::new(CachedResourceRepository::new(Arc::clone(&vfs))); + let cfg_bytes = vfs + .read(mission_cfg) + .map_err(|source| AudioError::MissionConfigRead { + path: mission_cfg.as_str().to_string(), + message: source.to_string(), + })?; + let config = parse_mission_audio_config(&cfg_bytes).map_err(|message| { + AudioError::MissionConfig { + path: mission_cfg.as_str().to_string(), + message, + } + })?; + let archive = + archive_path(config.library.as_bytes()).map_err(|source| AudioError::InvalidPath { + path: config.library.clone(), + message: source.to_string(), + })?; + let variation_archive = + archive_path(config.variation_library.as_bytes()).map_err(|source| { + AudioError::InvalidPath { + path: config.variation_library.clone(), + message: source.to_string(), + } + })?; + + let theme = load_swav(&repository, &archive, &config.theme)?; + let default_variations = config + .default_variations + .iter() + .map(|name| load_swav(&repository, &variation_archive, name)) + .collect::<Result<Vec<_>, _>>()?; + let day_variations = config + .day_variations + .iter() + .map(|name| load_swav(&repository, &variation_archive, name)) + .collect::<Result<Vec<_>, _>>()?; + let night_variations = config + .night_variations + .iter() + .map(|name| load_swav(&repository, &variation_archive, name)) + .collect::<Result<Vec<_>, _>>()?; + Ok(Self { + vfs, + repository, + theme, + default_variations, + day_variations, + night_variations, + music: BTreeMap::new(), + }) + } + + fn load_fx_sound(&self, archive: &str, name: &str) -> Result<SoundAsset, AudioError> { + let archive = + archive_path(archive.as_bytes()).map_err(|source| AudioError::InvalidPath { + path: archive.to_string(), + message: source.to_string(), + })?; + load_swav(&self.repository, &archive, name) + } + + fn music_bytes(&mut self, track: u8) -> Result<Arc<[u8]>, AudioError> { + let path = music_path(track)?; + if let Some(bytes) = self.music.get(&track) { + return Ok(Arc::clone(bytes)); + } + let bytes = self + .vfs + .read(&path) + .map_err(|source| AudioError::FileRead { + path: path.as_str().to_string(), + message: source.to_string(), + })?; + let bytes = validate_audio_bytes(path.as_str(), &bytes)?; + self.music.insert(track, Arc::clone(&bytes)); + Ok(bytes) + } +} + +/// Runtime audio state for one loaded mission. +pub(crate) struct GameAudio { + stream: MixerDeviceSink, + ambient: Player, + music: Player, + variations: Player, + weather_loop: NativeSpatialPlayer, + one_shots: Vec<SpatialOneShot>, + library: AudioLibrary, + ambience: AmbienceScheduler, + listener: Listener, + paused: bool, + dynamic_sounds: BTreeMap<String, SoundAsset>, + weather_volume: f32, + weather_position: [f32; 3], +} + +impl GameAudio { + /// Opens and validates mission audio using the caller's VFS. + /// + /// mission is the same relative path passed to the runtime, usually + /// MISSIONS/Autodemo.00/data.tma. Its neighbouring mission.cfg supplies + /// the ambient names. Only the declared samples and selected music tracks + /// are read. + /// + /// Returns a resource/configuration error for missing or malformed audio, + /// or DeviceUnavailable when no output device can be opened. The caller + /// may report the latter and continue without audio. + pub(crate) fn new(vfs: Arc<dyn Vfs>, mission: &str) -> Result<Self, AudioError> { + let mission_cfg = mission_cfg_path(mission)?; + let library = AudioLibrary::load(vfs, &mission_cfg)?; + let mut stream = DeviceSinkBuilder::open_default_sink().map_err(|source| { + AudioError::DeviceUnavailable { + message: source.to_string(), + } + })?; + stream.log_on_drop(false); + let ambient = Player::connect_new(stream.mixer()); + let music = Player::connect_new(stream.mixer()); + let variations = Player::connect_new(stream.mixer()); + let listener = Listener::default(); + let (left, right) = ear_positions(listener)?; + let weather_loop = + NativeSpatialPlayer::connect_new(stream.mixer(), listener.position, left, right); + let mut audio = Self { + stream, + ambient, + music, + variations, + weather_loop, + one_shots: Vec::new(), + library, + ambience: AmbienceScheduler::new(random_seed(), random_seed()), + listener, + paused: false, + dynamic_sounds: BTreeMap::new(), + weather_volume: 1.0, + weather_position: listener.position, + }; + audio.start_theme()?; + audio.play_music_track(DEFAULT_MUSIC_TRACK)?; + Ok(audio) + } + + /// Updates rodio's left/right ears from the current free camera. + pub(crate) fn update_listener( + &mut self, + position: [f32; 3], + forward: [f32; 3], + up: [f32; 3], + ) -> Result<(), AudioError> { + let listener = Listener { + position, + forward, + up, + }; + let (left, right) = ear_positions(listener)?; + self.listener = listener; + self.weather_loop.set_left_ear_position(left); + self.weather_loop.set_right_ear_position(right); + self.weather_loop + .set_emitter_position(self.weather_position); + self.retain_active_one_shots(); + for one_shot in &self.one_shots { + one_shot.player.set_left_ear_position(left); + one_shot.player.set_right_ear_position(right); + one_shot.player.set_volume(one_shot.volume); + } + self.refresh_weather_volume(); + Ok(()) + } + + /// Advances the native ambient variation timer and starts a selected + /// sample when its strict elapsed-time boundary is crossed. + /// + /// `dt_seconds` is the non-negative delta for this frame, not an + /// absolute mission clock. + /// + /// `primary_rgb` is the first active celestial light's base color before + /// any camera-dependent sun glare is applied. The original atmosphere + /// treats a color length at or below 1.1 as night. + pub(crate) fn update_ambience( + &mut self, + dt_seconds: f32, + primary_rgb: [f32; 3], + ) -> Result<(), AudioError> { + if self.paused { + return Ok(()); + } + let selection = self.ambience.update( + dt_seconds, + primary_rgb, + self.library.default_variations.len(), + self.library.day_variations.len(), + self.library.night_variations.len(), + ); + let Some(selection) = selection else { + return Ok(()); + }; + let Some(index) = selection.index else { + return Ok(()); + }; + let asset = match selection.pool { + VariationPool::Default => self.library.default_variations.get(index), + VariationPool::Day => self.library.day_variations.get(index), + VariationPool::Night => self.library.night_variations.get(index), + } + .cloned() + .ok_or_else(|| AudioError::NoSamples { + resource: format!("ambient variation {:?}[{index}]", selection.pool), + })?; + self.variations.clear(); + self.variations.append(decoder_for(&asset)?); + if self.paused { + self.variations.pause(); + } else { + self.variations.play(); + } + Ok(()) + } + + /// Applies one sound event emitted by the environment simulation. + /// + /// Resource names are resolved lazily from the archive named by the event; + /// this keeps mission `sky.ske`/FX references data-driven and avoids + /// requiring every possible weather sample during mission startup. + pub(crate) fn handle_sound_event(&mut self, event: &SoundEvent) -> Result<(), AudioError> { + validate_point(event.position, "emitter")?; + let volume = if event.volume.is_finite() { + event.volume.max(0.0) + } else { + 0.0 + }; + let min_distance = finite_non_negative(event.min_distance); + let max_distance = if event.max_distance.is_finite() { + event.max_distance.max(0.0) + } else { + f32::INFINITY + }; + let frequency_ratio = if event.frequency_ratio.is_finite() && event.frequency_ratio > 0.0 { + event.frequency_ratio + } else { + 1.0 + }; + match event.action { + SoundAction::StartLoop => { + let asset = self.dynamic_sound(&event.archive, &event.name)?; + self.weather_loop.clear(); + self.weather_loop.set_emitter_position(event.position); + self.weather_position = event.position; + self.weather_volume = volume; + self.weather_loop + .set_sound_parameters(min_distance, max_distance); + self.weather_loop.set_volume(volume); + self.weather_loop.set_speed(frequency_ratio); + self.weather_loop + .append(decoder_for(&asset)?.repeat_infinite()); + if self.paused { + self.weather_loop.pause(); + } else { + self.weather_loop.play(); + } + } + SoundAction::SetLoopVolume => { + self.weather_loop.set_emitter_position(event.position); + self.weather_position = event.position; + self.weather_volume = volume; + self.weather_loop + .set_sound_parameters(min_distance, max_distance); + self.weather_loop.set_volume(volume); + self.weather_loop.set_speed(frequency_ratio); + } + SoundAction::StopLoop => { + self.weather_loop.clear(); + self.weather_loop.set_volume(1.0); + self.weather_volume = 1.0; + self.weather_position = self.listener.position; + self.weather_loop.set_sound_parameters(0.0, f32::INFINITY); + self.weather_loop.set_speed(1.0); + } + SoundAction::OneShot => { + self.retain_active_one_shots(); + let asset = self.dynamic_sound(&event.archive, &event.name)?; + self.one_shots.push(self.new_spatial_one_shot( + &asset, + event.position, + volume, + min_distance, + max_distance, + frequency_ratio, + )?); + } + } + Ok(()) + } + + /// Selects one of the original MUSIC/Track02..10.ogg files and loops it. + pub(crate) fn play_music_track(&mut self, track: u8) -> Result<(), AudioError> { + let bytes = self.library.music_bytes(track)?; + self.music.clear(); + self.music.append( + decoder_for_bytes(&format!("MUSIC/Track{track:02}.ogg"), &bytes)?.repeat_infinite(), + ); + if self.paused { + self.music.pause(); + } else { + self.music.play(); + } + Ok(()) + } + + /// Pauses or resumes every live audio source without changing selection. + pub(crate) fn set_paused(&mut self, paused: bool) { + self.paused = paused; + for player in [&self.ambient, &self.music, &self.variations] { + if paused { + player.pause(); + } else { + player.play(); + } + } + if paused { + self.weather_loop.pause(); + } else { + self.weather_loop.play(); + } + for one_shot in &self.one_shots { + if paused { + one_shot.player.pause(); + } else { + one_shot.player.play(); + } + } + } + + /// Focus callback used by the window event loop. + pub(crate) fn on_focus_changed(&mut self, focused: bool) { + self.set_paused(!focused); + } + + /// Stops all sources before the window/event loop exits. + pub(crate) fn shutdown(&mut self) { + self.ambient.stop(); + self.music.stop(); + self.variations.stop(); + self.weather_loop.stop(); + for one_shot in &self.one_shots { + one_shot.player.stop(); + } + } + + fn start_theme(&self) -> Result<(), AudioError> { + self.ambient + .append(decoder_for(&self.library.theme)?.repeat_infinite()); + if self.paused { + self.ambient.pause(); + } else { + self.ambient.play(); + } + Ok(()) + } + + fn new_spatial_one_shot( + &self, + asset: &SoundAsset, + emitter: [f32; 3], + volume: f32, + min_distance: f32, + max_distance: f32, + frequency_ratio: f32, + ) -> Result<SpatialOneShot, AudioError> { + validate_point(emitter, "emitter")?; + let (left, right) = ear_positions(self.listener)?; + let player = NativeSpatialPlayer::connect_new(self.stream.mixer(), emitter, left, right); + player.set_sound_parameters(min_distance, max_distance); + player.set_volume(volume); + player.set_speed(frequency_ratio); + player.append(decoder_for(asset)?); + if self.paused { + player.pause(); + } else { + player.play(); + } + Ok(SpatialOneShot { player, volume }) + } + + fn refresh_weather_volume(&self) { + self.weather_loop.set_volume(self.weather_volume); + } + + fn dynamic_sound(&mut self, archive: &str, name: &str) -> Result<SoundAsset, AudioError> { + let archive = if archive.is_empty() { + self.library.theme.archive.as_str() + } else { + archive + }; + let key = format!( + "{}:{}", + archive.to_ascii_lowercase(), + name.to_ascii_lowercase() + ); + if let Some(asset) = self.dynamic_sounds.get(&key) { + return Ok(asset.clone()); + } + let asset = self.library.load_fx_sound(archive, name)?; + self.dynamic_sounds.insert(key, asset.clone()); + Ok(asset) + } + + fn retain_active_one_shots(&mut self) { + self.one_shots.retain(|one_shot| !one_shot.player.empty()); + } +} + +impl Drop for GameAudio { + fn drop(&mut self) { + self.shutdown(); + } +} + +type AudioDecoder = Decoder<Cursor<Arc<[u8]>>>; + +fn decoder_for(asset: &SoundAsset) -> Result<AudioDecoder, AudioError> { + decoder_for_bytes(&asset.resource_name(), &asset.bytes) +} + +fn decoder_for_bytes(resource: &str, bytes: &Arc<[u8]>) -> Result<AudioDecoder, AudioError> { + Decoder::try_from(Cursor::new(Arc::clone(bytes))).map_err(|source| AudioError::Decode { + resource: resource.to_string(), + message: source.to_string(), + }) +} + +fn validate_audio_bytes(resource: &str, bytes: &Arc<[u8]>) -> Result<Arc<[u8]>, AudioError> { + if bytes.is_empty() { + return Err(AudioError::EmptyEntry { + archive: "VFS".to_string(), + name: resource.to_string(), + }); + } + let decoder = decoder_for_bytes(resource, bytes)?; + if decoder.take(VALIDATION_SAMPLES).count() == 0 { + return Err(AudioError::NoSamples { + resource: resource.to_string(), + }); + } + Ok(Arc::clone(bytes)) +} + +fn load_swav( + repository: &CachedResourceRepository, + archive: &NormalizedPath, + name: &str, +) -> Result<SoundAsset, AudioError> { + let display_archive = archive.as_str().to_string(); + let resource = resource_name(name.as_bytes()); + let archive_id = repository + .open_archive_unchanged(archive) + .map_err(|source| AudioError::Archive { + path: display_archive.clone(), + message: source.to_string(), + })?; + let entry = repository + .find(archive_id, &resource) + .map_err(|source| AudioError::Archive { + path: display_archive.clone(), + message: source.to_string(), + })? + .ok_or_else(|| AudioError::MissingEntry { + archive: display_archive.clone(), + name: name.to_string(), + })?; + let info = repository + .entry_info(entry) + .map_err(|source| AudioError::Archive { + path: display_archive.clone(), + message: source.to_string(), + })?; + if info.key.type_id != Some(SWAV_TYPE) { + return Err(AudioError::WrongEntryType { + archive: display_archive, + name: name.to_string(), + type_id: info.key.type_id, + }); + } + let bytes = repository + .read(entry) + .map_err(|source| AudioError::Archive { + path: archive.as_str().to_string(), + message: source.to_string(), + })?; + if bytes.is_empty() { + return Err(AudioError::EmptyEntry { + archive: archive.as_str().to_string(), + name: name.to_string(), + }); + } + let bytes = Arc::<[u8]>::from(bytes.as_slice()); + let bytes = validate_audio_bytes(&format!("{}:{name}", archive.as_str()), &bytes)?; + Ok(SoundAsset { + archive: archive.as_str().to_string(), + name: name.to_string(), + bytes, + }) +} + +fn music_path(track: u8) -> Result<NormalizedPath, AudioError> { + if !(FIRST_MUSIC_TRACK..=LAST_MUSIC_TRACK).contains(&track) { + return Err(AudioError::UnsupportedMusicTrack(track)); + } + let display = format!("MUSIC/Track{track:02}.ogg"); + normalize_relative(display.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| { + AudioError::InvalidPath { + path: display, + message: source.to_string(), + } + }) +} + +/// Derives the sibling mission.cfg path from a mission TMA path. +pub(crate) fn mission_cfg_path(mission: &str) -> Result<NormalizedPath, AudioError> { + let mission = + normalize_relative(mission.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| { + AudioError::InvalidPath { + path: mission.to_string(), + message: source.to_string(), + } + })?; + let cfg = mission.as_str().rsplit_once('/').map_or_else( + || "mission.cfg".to_string(), + |(parent, _)| format!("{parent}/mission.cfg"), + ); + normalize_relative(cfg.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| { + AudioError::InvalidPath { + path: cfg, + message: source.to_string(), + } + }) +} + +fn ear_positions(listener: Listener) -> Result<([f32; 3], [f32; 3]), AudioError> { + validate_point(listener.position, "position")?; + let forward = unit(listener.forward, "forward")?; + let up = unit(listener.up, "up")?; + let right = unit(cross(forward, up), "forward/up")?; + let left_ear = sub(listener.position, scale(right, EAR_HALF_WIDTH)); + let right_ear = add(listener.position, scale(right, EAR_HALF_WIDTH)); + validate_point(left_ear, "position")?; + validate_point(right_ear, "position")?; + Ok((left_ear, right_ear)) +} + +const DIRECTSOUND_DB_COEFFICIENT: f32 = 3_321.927_978_515_625; +const DIRECTSOUND_MIN_VOLUME_DB: f32 = -10_000.0; +const DIRECTSOUND_PAN_UNITS: f32 = 4_000.0; + +/// Converts a DirectSound distance range into a linear sample multiplier. +/// The native listener uses rolloff 1.0 and stores volume in hundredths of a +/// decibel. Its max-distance branch is strict (`distance > max`). +fn native_distance_gain(distance: f32, min_distance: f32, max_distance: f32) -> f32 { + if !distance.is_finite() + || !min_distance.is_finite() + || min_distance <= 0.0 + || !max_distance.is_finite() + || max_distance <= min_distance + { + return 1.0; + } + if distance <= min_distance { + return 1.0; + } + if distance > max_distance { + return 10.0_f32.powf(DIRECTSOUND_MIN_VOLUME_DB / 2_000.0); + } + let ratio = min_distance / (min_distance + (distance - min_distance)); + if !ratio.is_finite() || ratio <= 0.0 { + return 10.0_f32.powf(DIRECTSOUND_MIN_VOLUME_DB / 2_000.0); + } + let decibels = (ratio.log10() * DIRECTSOUND_DB_COEFFICIENT) + .round() + .max(DIRECTSOUND_MIN_VOLUME_DB); + 10.0_f32.powf(decibels / 2_000.0) +} + +fn native_spatial_channel_gains(state: NativeSpatialState) -> [f32; 2] { + let listener_position = midpoint(state.left_ear, state.right_ear); + let distance = squared_distance(state.emitter_position, listener_position).sqrt(); + let attenuation = native_distance_gain(distance, state.min_distance, state.max_distance); + // Effect.dll computes software pan from the listener's right vector and + // the normalized emitter direction, then passes +/-4000 to DirectSound. + // The engine's ear positions already encode that right vector. Do not use + // the distance to each ear: that heuristic reverses the native pan when a + // source is placed to the listener's right. + let ear_axis = sub(state.right_ear, state.left_ear); + let ear_axis_length = squared_distance(ear_axis, [0.0; 3]).sqrt(); + let emitter_direction = sub(state.emitter_position, listener_position); + let emitter_length = squared_distance(emitter_direction, [0.0; 3]).sqrt(); + let pan = if ear_axis_length.is_finite() + && ear_axis_length > f32::EPSILON + && emitter_length.is_finite() + && emitter_length > f32::EPSILON + { + let right = scale(ear_axis, 1.0 / ear_axis_length); + let direction = scale(emitter_direction, 1.0 / emitter_length); + (DIRECTSOUND_PAN_UNITS * dot(right, direction)).round() + } else { + 0.0 + }; + let (left_pan, right_pan) = if pan > 0.0 { + (10.0_f32.powf(-pan / 2_000.0), 1.0) + } else if pan < 0.0 { + (1.0, 10.0_f32.powf(pan / 2_000.0)) + } else { + (1.0, 1.0) + }; + let volume = if state.volume.is_finite() { + state.volume.max(0.0) + } else { + 0.0 + } * attenuation; + [volume * left_pan, volume * right_pan] +} + +fn dot(left: [f32; 3], right: [f32; 3]) -> f32 { + left[0].mul_add(right[0], left[1].mul_add(right[1], left[2] * right[2])) +} + +fn squared_distance(left: [f32; 3], right: [f32; 3]) -> f32 { + left.into_iter() + .zip(right) + .map(|(left, right)| (left - right) * (left - right)) + .sum() +} + +fn midpoint(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [ + (left[0] + right[0]) * 0.5, + (left[1] + right[1]) * 0.5, + (left[2] + right[2]) * 0.5, + ] +} + +fn validate_point(point: [f32; 3], field: &'static str) -> Result<(), AudioError> { + if point.iter().all(|value| value.is_finite()) { + Ok(()) + } else { + Err(AudioError::InvalidPosition { field }) + } +} + +fn unit(vector: [f32; 3], field: &'static str) -> Result<[f32; 3], AudioError> { + if !vector.iter().all(|value| value.is_finite()) { + return Err(AudioError::InvalidListener { field }); + } + let length = vector[0] + .mul_add( + vector[0], + vector[1].mul_add(vector[1], vector[2] * vector[2]), + ) + .sqrt(); + if !length.is_finite() || length <= f32::EPSILON { + return Err(AudioError::InvalidListener { field }); + } + Ok(vector.map(|value| value / length)) +} + +fn cross(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [ + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ] +} + +fn add(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] + right[0], left[1] + right[1], left[2] + right[2]] +} + +fn sub(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] - right[0], left[1] - right[1], left[2] - right[2]] +} + +fn scale(vector: [f32; 3], factor: f32) -> [f32; 3] { + [vector[0] * factor, vector[1] * factor, vector[2] * factor] +} + +fn parse_mission_audio_config(bytes: &[u8]) -> Result<MissionAudioConfig, String> { + #[derive(Default)] + struct Object { + library: Option<String>, + theme: Option<String>, + default: BTreeMap<usize, String>, + day: BTreeMap<usize, String>, + night: BTreeMap<usize, String>, + } + + let text = String::from_utf8_lossy(bytes); + let mut loop_object = Object::default(); + let mut variation_object = Object::default(); + let mut current = None; + for raw_line in text.lines() { + let line = raw_line.split('#').next().unwrap_or_default().trim(); + if line.is_empty() { + continue; + } + let mut tokens = line.split_whitespace(); + if tokens + .next() + .is_some_and(|token| token.eq_ignore_ascii_case("object")) + { + current = tokens.next().map(|name| name.to_ascii_lowercase()); + continue; + } + if line.eq_ignore_ascii_case("end") { + current = None; + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let key = key.trim().to_ascii_uppercase(); + let value = parse_cfg_value(value.trim())?; + match current.as_deref() { + Some("ambient_music_loop") => { + if key == "LIBRARY" { + loop_object.library = Some(value); + } else if key == "THEME" { + loop_object.theme = Some(value); + } + } + Some("ambient_music_variation") => { + if key == "LIBRARY" { + variation_object.library = Some(value); + } else if let Some(index) = key.strip_prefix("DEFAULT_VARIATION") { + let index = index + .parse::<usize>() + .map_err(|_| format!("invalid default variation key {key}"))?; + if index == 0 { + return Err(format!("zero default variation key {key}")); + } + variation_object.default.insert(index, value); + } else if let Some(index) = key.strip_prefix("DAY_VARIATION") { + let index = index + .parse::<usize>() + .map_err(|_| format!("invalid day variation key {key}"))?; + if index == 0 { + return Err(format!("zero day variation key {key}")); + } + variation_object.day.insert(index, value); + } else if let Some(index) = key.strip_prefix("NIGHT_VARIATION") { + let index = index + .parse::<usize>() + .map_err(|_| format!("invalid night variation key {key}"))?; + if index == 0 { + return Err(format!("zero night variation key {key}")); + } + variation_object.night.insert(index, value); + } + } + _ => {} + } + } + let library = loop_object + .library + .ok_or_else(|| "ambient_music_loop.library is missing".to_string())?; + let theme = loop_object + .theme + .ok_or_else(|| "ambient_music_loop.THEME is missing".to_string())?; + let variation_library = variation_object.library.unwrap_or_else(|| library.clone()); + Ok(MissionAudioConfig { + library, + variation_library, + theme, + default_variations: variation_object.default.into_values().collect(), + day_variations: variation_object.day.into_values().collect(), + night_variations: variation_object.night.into_values().collect(), + }) +} + +fn parse_cfg_value(value: &str) -> Result<String, String> { + if let Some(rest) = value.strip_prefix('"') { + let end = rest + .find('"') + .ok_or_else(|| "unterminated quoted value".to_string())?; + return Ok(rest[..end].to_string()); + } + Ok(value + .split_whitespace() + .next() + .unwrap_or_default() + .to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_original_ambient_names_in_numeric_order() { + let config = parse_mission_audio_config( + br#" + object ambient_music_loop + library = "sounds.lib" + THEME = "atm_c1_lp.wav" + end + object ambient_music_variation + library = "ambient.lib" + DEFAULT_VARIATION2 = "atm_default2.wav" + DEFAULT_VARIATION1 = "atm_default1.wav" + DAY_VARIATION2 = "atm_bird2.wav" + DAY_VARIATION1 = "atm_bees.wav" + NIGHT_VARIATION2 = "atm_frog2.wav" + NIGHT_VARIATION1 = "atm_frog1.wav" + end + "#, + ) + .expect("config"); + assert_eq!(config.library, "sounds.lib"); + assert_eq!(config.variation_library, "ambient.lib"); + assert_eq!(config.theme, "atm_c1_lp.wav"); + assert_eq!( + config.default_variations, + ["atm_default1.wav", "atm_default2.wav"] + ); + assert_eq!(config.day_variations, ["atm_bees.wav", "atm_bird2.wav"]); + assert_eq!(config.night_variations, ["atm_frog1.wav", "atm_frog2.wav"]); + } + + #[test] + fn omitted_variation_library_uses_loop_library() { + let config = parse_mission_audio_config( + br#" + object ambient_music_loop + library = "sounds.lib" + THEME = "atm_c1_lp.wav" + end + object ambient_music_variation + DEFAULT_VARIATION1 = "atm_c1_1.wav" + end + "#, + ) + .expect("config"); + assert_eq!(config.variation_library, "sounds.lib"); + assert_eq!(config.default_variations, ["atm_c1_1.wav"]); + } + + #[test] + fn duplicate_native_variation_key_keeps_last_definition() { + let config = parse_mission_audio_config( + br#" + object ambient_music_loop + library = "sounds.lib" + THEME = "atm_c1_lp.wav" + end + object ambient_music_variation + DAY_VARIATION1 = "old.wav" + DAY_VARIATION1 = "native-last.wav" + end + "#, + ) + .expect("config"); + assert_eq!(config.day_variations, ["native-last.wav"]); + } + + #[test] + fn variation_rng_matches_native_two_state_step() { + let mut rng = VariationRng { + a: 0x1234, + b: 0xabcd, + }; + rng.step(); + assert_eq!((rng.a, rng.b), (0x8fa5, 0xda43)); + rng.step(); + assert_eq!((rng.a, rng.b), (0xc509, 0xa828)); + rng.step(); + assert_eq!((rng.a, rng.b), (0x223a, 0x762e)); + } + + #[test] + fn variation_rng_does_not_repeat_last_index_when_pool_has_choices() { + let mut rng = VariationRng { + a: 0x1234, + b: 0xabcd, + }; + let mut last = -1; + let mut previous = None; + for _ in 0..64 { + let selected = rng.choose(4, &mut last).expect("non-empty pool"); + assert_ne!(Some(selected), previous); + previous = Some(selected); + } + } + + #[test] + fn variation_rng_clears_last_index_for_empty_pool() { + let mut rng = VariationRng { + a: 0x1234, + b: 0xabcd, + }; + let mut last = 2; + assert_eq!(rng.choose(0, &mut last), None); + assert_eq!(last, -1); + assert_eq!(rng.choose(3, &mut last), Some(0)); + assert_eq!(last, 0); + } + + #[test] + fn crt_delay_uses_native_multiplier_and_ten_to_nineteen_seconds() { + let mut rng = CrtRand::new(1); + assert_eq!(rng.next_u15(), 41); + assert_eq!(rng.next_delay_seconds(), 17.0); + assert!((10.0..20.0).contains(&rng.next_delay_seconds())); + } + + #[test] + fn ambience_timer_uses_strict_boundary_and_initial_positive_tick() { + let mut scheduler = AmbienceScheduler { + elapsed_seconds: 0.0, + next_delay_seconds: 0.0, + pool: VariationPool::Default, + variation_rng: VariationRng { + a: 0x1234, + b: 0xabcd, + }, + delay_rng: CrtRand::new(16), + last_index: -1, + }; + assert_eq!(scheduler.update(0.0, [2.0, 0.0, 0.0], 0, 1, 1), None); + let initial = scheduler + .update(0.001, [2.0, 0.0, 0.0], 0, 1, 1) + .expect("first positive tick selects a variation"); + assert_eq!(initial.pool, VariationPool::Day); + assert_eq!(initial.index, Some(0)); + assert_eq!(scheduler.next_delay_seconds, 10.0); + assert_eq!(scheduler.update(10.0, [2.0, 0.0, 0.0], 0, 1, 1), None); + assert!(scheduler.update(0.0001, [2.0, 0.0, 0.0], 0, 1, 1).is_some()); + } + + #[test] + fn ambience_pool_selection_has_no_cross_day_fallback() { + let mut scheduler = AmbienceScheduler::new(1, 1); + let selected = scheduler + .update(0.01, [0.0, 0.0, 0.0], 2, 1, 0) + .expect("night selection attempt"); + assert_eq!(selected.pool, VariationPool::Night); + assert_eq!(selected.index, None); + + let mut scheduler = AmbienceScheduler::new(1, 1); + let selected = scheduler + .update(0.01, [2.0, 0.0, 0.0], 2, 0, 0) + .expect("default selection"); + assert_eq!(selected.pool, VariationPool::Default); + assert_eq!(selected.index, Some(0)); + } + + #[test] + fn night_threshold_is_native_primary_rgb_length() { + assert_eq!(selected_pool([1.1, 0.0, 0.0], 1, 1), VariationPool::Night); + assert_eq!(selected_pool([1.1001, 0.0, 0.0], 1, 1), VariationPool::Day); + assert_eq!(selected_pool([0.0, 0.0, 0.0], 0, 0), VariationPool::Default); + } + + #[test] + fn derives_sibling_mission_config_path() { + assert_eq!( + mission_cfg_path("MISSIONS/Single.01/data.tma") + .expect("mission path") + .as_str(), + "MISSIONS/Single.01/mission.cfg" + ); + } + + #[test] + fn listener_ears_are_spaced_across_camera_right() { + let (left, right) = ear_positions(Listener::default()).expect("ears"); + assert_eq!(left, [-EAR_HALF_WIDTH, 0.0, 0.0]); + assert_eq!(right, [EAR_HALF_WIDTH, 0.0, 0.0]); + } + + #[test] + fn native_pan_uses_listener_right_axis_and_directsound_units() { + let state = NativeSpatialState { + emitter_position: [0.0, 0.0, 0.0], + left_ear: [-EAR_HALF_WIDTH, 0.0, 0.0], + right_ear: [EAR_HALF_WIDTH, 0.0, 0.0], + volume: 1.0, + min_distance: 0.0, + max_distance: f32::INFINITY, + }; + assert_eq!(native_spatial_channel_gains(state), [1.0, 1.0]); + + let right = NativeSpatialState { + emitter_position: [1.0, 0.0, 0.0], + ..state + }; + let right_gains = native_spatial_channel_gains(right); + assert!((right_gains[0] - 0.01).abs() < 1.0e-6); + assert_eq!(right_gains[1], 1.0); + + let left = NativeSpatialState { + emitter_position: [-1.0, 0.0, 0.0], + ..state + }; + let left_gains = native_spatial_channel_gains(left); + assert_eq!(left_gains[0], 1.0); + assert!((left_gains[1] - 0.01).abs() < 1.0e-6); + } + + #[test] + fn native_distance_gain_keeps_min_and_max_boundaries_native() { + let below_min = native_distance_gain(99.0, 100.0, 1_500.0); + let at_min = native_distance_gain(100.0, 100.0, 1_500.0); + let at_max = native_distance_gain(1_500.0, 100.0, 1_500.0); + let past_max = native_distance_gain(1_500.001, 100.0, 1_500.0); + + assert_eq!(below_min, 1.0); + assert_eq!(at_min, 1.0); + assert!(at_max > past_max); + assert!(at_max < 1.0); + assert!((past_max - 1.0e-5).abs() < 1.0e-8); + } + + #[test] + fn rejects_non_finite_or_unbounded_listener_inputs() { + assert!(ear_positions(Listener { + position: [f32::NAN, 0.0, 0.0], + ..Listener::default() + }) + .is_err()); + assert!(ear_positions(Listener { + forward: [f32::MAX, f32::MAX, f32::MAX], + ..Listener::default() + }) + .is_err()); + } + + #[test] + #[ignore = "requires the installed GOG game files; set FPARKAN_GAME_ROOT"] + fn decodes_gog_pcm_adpcm_and_ogg_assets() { + let root = std::env::var_os("FPARKAN_GAME_ROOT") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy")); + assert!( + root.is_dir(), + "game root does not exist: {}", + root.display() + ); + let vfs: Arc<dyn Vfs> = Arc::new(fparkan_vfs::DirectoryVfs::new(&root)); + let mission_cfg = normalize_relative( + b"MISSIONS/Autodemo.00/mission.cfg", + PathPolicy::StrictLegacy, + ) + .expect("mission cfg path"); + let mut library = AudioLibrary::load(Arc::clone(&vfs), &mission_cfg).expect("audio assets"); + assert_eq!(library.theme.name, "atm_c1_lp.wav"); + assert!(!library.day_variations.is_empty()); + assert!(!library.night_variations.is_empty()); + assert_eq!(wav_format_tag(&library.theme.bytes), Some(1)); + assert_decodes_samples(&library.theme.resource_name(), &library.theme.bytes, 256); + + let adpcm = library + .day_variations + .iter() + .find(|asset| wav_format_tag(&asset.bytes) == Some(2)) + .expect("an original day variation must use Microsoft ADPCM"); + assert_eq!(wav_format_tag(&adpcm.bytes), Some(2)); + assert_decodes_samples(&adpcm.resource_name(), &adpcm.bytes, 256); + + let music = library + .music_bytes(DEFAULT_MUSIC_TRACK) + .expect("original Track02 OGG"); + assert!(music.starts_with(b"OggS")); + assert_decodes_samples("MUSIC/Track02.ogg", &music, 256); + assert_eq!(library.music.len(), 1, "music must load lazily"); + } + + #[test] + #[ignore = "requires the installed GOG game files; set FPARKAN_GAME_ROOT"] + fn loads_every_gog_mission_variation_pool() { + let root = std::env::var_os("FPARKAN_GAME_ROOT") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy")); + assert!( + root.is_dir(), + "game root does not exist: {}", + root.display() + ); + let mut paths = Vec::new(); + collect_mission_configs(&root, &mut paths).expect("enumerate mission configs"); + assert!(!paths.is_empty(), "GOG install has no mission.cfg files"); + let vfs: Arc<dyn Vfs> = Arc::new(fparkan_vfs::DirectoryVfs::new(&root)); + let mut default_pools = 0; + let mut day_pools = 0; + let mut night_pools = 0; + for path in paths { + let relative = path + .strip_prefix(&root) + .expect("mission config under game root") + .to_string_lossy() + .replace('\\', "/"); + let mission_cfg = normalize_relative(relative.as_bytes(), PathPolicy::StrictLegacy) + .expect("mission cfg path"); + let library = AudioLibrary::load(Arc::clone(&vfs), &mission_cfg) + .unwrap_or_else(|error| panic!("load {}: {error}", mission_cfg.as_str())); + default_pools += usize::from(!library.default_variations.is_empty()); + day_pools += usize::from(!library.day_variations.is_empty()); + night_pools += usize::from(!library.night_variations.is_empty()); + } + assert!(default_pools > 0, "GOG missions must exercise DEFAULT pool"); + assert!(day_pools > 0, "GOG missions must exercise DAY pool"); + assert!(night_pools > 0, "GOG missions must exercise NIGHT pool"); + } + + #[test] + #[ignore = "requires installed GOG assets and a usable OS audio output"] + fn starts_pauses_and_drops_gog_audio() { + let root = std::env::var_os("FPARKAN_GAME_ROOT") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy")); + assert!( + root.is_dir(), + "game root does not exist: {}", + root.display() + ); + let vfs: Arc<dyn Vfs> = Arc::new(fparkan_vfs::DirectoryVfs::new(&root)); + let mut audio = + GameAudio::new(vfs, "MISSIONS/Autodemo.00/data.tma").expect("open original game audio"); + audio + .handle_sound_event(&SoundEvent { + archive: "sounds.lib".to_string(), + name: "atm_rain1.wav".to_string(), + position: [0.0; 3], + volume: 0.0, + min_distance: 0.0, + max_distance: f32::INFINITY, + frequency_ratio: 1.0, + action: SoundAction::StartLoop, + }) + .expect("start original rain loop"); + assert_eq!(audio.weather_loop.volume(), 0.0); + audio + .handle_sound_event(&SoundEvent { + archive: "sounds.lib".to_string(), + name: "atm_rain1.wav".to_string(), + position: [1.0, 2.0, 3.0], + volume: 0.5, + min_distance: 0.0, + max_distance: f32::INFINITY, + frequency_ratio: 1.0, + action: SoundAction::SetLoopVolume, + }) + .expect("update original rain loop volume"); + assert_eq!(audio.weather_loop.volume(), 0.5); + audio + .update_listener([10.0, 20.0, 30.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]) + .expect("move listener"); + audio + .handle_sound_event(&SoundEvent { + archive: "sounds.lib".to_string(), + name: "atm_light1.wav".to_string(), + position: [3.0, 4.0, 5.0], + volume: 0.75, + min_distance: 100.0, + max_distance: 1500.0, + frequency_ratio: 1.0, + action: SoundAction::OneShot, + }) + .expect("play original lightning sample"); + assert_eq!( + audio + .one_shots + .last() + .unwrap() + .player + .state + .lock() + .unwrap() + .emitter_position, + [3.0, 4.0, 5.0] + ); + audio + .handle_sound_event(&SoundEvent { + archive: "sounds.lib".to_string(), + name: "atm_rain1.wav".to_string(), + position: [10.0, 20.0, 30.0], + volume: 0.0, + min_distance: 0.0, + max_distance: f32::INFINITY, + frequency_ratio: 1.0, + action: SoundAction::StopLoop, + }) + .expect("stop original rain loop"); + assert!(audio.weather_loop.empty()); + audio.set_paused(true); + assert!(audio.ambient.is_paused()); + assert!(audio.music.is_paused()); + assert!(audio.weather_loop.is_paused()); + audio.set_paused(false); + assert!(!audio.ambient.is_paused()); + assert!(!audio.music.is_paused()); + audio.shutdown(); + } + + fn assert_decodes_samples(resource: &str, bytes: &Arc<[u8]>, minimum: usize) { + let decoded = decoder_for_bytes(resource, bytes) + .expect("decoder construction") + .take(minimum) + .count(); + assert_eq!(decoded, minimum, "{resource} decoded too few samples"); + } + + fn wav_format_tag(bytes: &[u8]) -> Option<u16> { + if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" { + return None; + } + let mut offset = 12usize; + while offset.checked_add(8)? <= bytes.len() { + let chunk_len = usize::try_from(u32::from_le_bytes( + bytes[offset + 4..offset + 8].try_into().ok()?, + )) + .ok()?; + let data_start = offset.checked_add(8)?; + let data_end = data_start.checked_add(chunk_len)?; + if data_end > bytes.len() { + return None; + } + if &bytes[offset..offset + 4] == b"fmt " && chunk_len >= 2 { + return Some(u16::from_le_bytes( + bytes[data_start..data_start + 2].try_into().ok()?, + )); + } + offset = data_end.checked_add(chunk_len & 1)?; + } + None + } + + fn collect_mission_configs( + directory: &std::path::Path, + output: &mut Vec<std::path::PathBuf>, + ) -> std::io::Result<()> { + for entry in std::fs::read_dir(directory)? { + let path = entry?.path(); + if path.is_dir() { + collect_mission_configs(&path, output)?; + } else if path + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case("mission.cfg")) + { + output.push(path); + } + } + Ok(()) + } +} diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 2130bf5..7405458 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -2,40 +2,75 @@ //! `FParkan` render-planning composition root. +use fparkan_animation::{AnimationTime, NodePoseBuffer}; use fparkan_assets::{ - load_standalone_wear_material_texture_mip0_rgba8_from_root, PreparedTextureUsage, - PreparedVisual, + load_material_name_texture_rgba8_and_phase_with_document_from_root, + load_resource_entry_bytes_from_root, + load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root, + page_uv_transform, sample_material_phase, Mat0Document, MaterialPhase, MaterialPhaseSample, + PreparedControlNodeBinding, PreparedMaterialPhase, PreparedTexture, PreparedTextureUsage, + PreparedVisual, RgbaImage, StandaloneWearMaterialLoader, +}; +use fparkan_fx::atmosphere::TypedAtmosphere; +use fparkan_fx::environment::{ + decode_env_lightning_fxid, Camera as FxCamera, EnvironmentFrame, EnvironmentPrimitive, + EnvironmentSystem, PrecipitationKind, ScreenBillboard, +}; +use fparkan_fx::shadow::{ + ShadowCamera, ShadowCaster, ShadowFrame, ShadowLight, ShadowLodSettings, ShadowPageCache, + ShadowScene, ShadowSphere, ShadowTriangle, SHADOW_PRIMARY_DIRECTIONAL_FLAG, +}; +use fparkan_fx::sky::{ + sprite_half_size_pixels, SkyFrame, SkyMaterials, SkyMesh, SkyPassKind, SkySpriteFrame, + SkySystem, SunVisibility, SKY_FLARE_ROWS, +}; +use fparkan_msh::{ + node38_fallback_hierarchy, node38_sampled_hierarchy, node38_sampled_hierarchy_at_times, + ModelAsset, }; use fparkan_path::ResourceName; use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan}; -use fparkan_render::{LegacyD3d7Projection, LegacyIron3dEulerTransform, RawCameraTransform}; +use fparkan_prototype::{unit_component_tree, PROTOTYPE_TYPE_EXTO}; +use fparkan_render::{ + LegacyBlendMode, LegacyD3d7Projection, LegacyDepthMode, LegacyIron3dEulerTransform, + LegacyPipelineState, RawCameraTransform, +}; use fparkan_render_vulkan::{ + node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root, + node38_pose_relative_to_root_from_hierarchy, project_land_msh_to_static_mesh_in_legacy_world_space, - project_land_msh_to_static_mesh_in_xy_frame, project_msh_to_static_mesh_in_world_space_with_node_fallback_poses, + project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount, + project_msh_to_static_mesh_in_world_space_with_node_pose_buffer, + project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount, project_msh_to_static_mesh_in_world_space_with_node_sampled_poses, - project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses, - project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanSmokeFrameOutcome, - VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticCamera, VulkanStaticMaterial, - VulkanStaticMesh, VulkanStaticTexture, VulkanStaticXyFrame, + project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount, + VulkanDynamicDrawRange, VulkanNodePose, VulkanPointLight, VulkanSmokeFrameOutcome, + VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticCamera, VulkanStaticDrawRange, + VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, VulkanStaticTextureMip, + VulkanStaticVertex, }; use fparkan_runtime::{ create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets, loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets, MissionObjectDraft, MissionRequest, }; -use fparkan_terrain::TerrainMaterialLayers; -use fparkan_terrain::TerrainWorld; +use fparkan_terrain::{FullSurfaceMask, SurfaceQuery, TerrainMaterialLayers, TerrainWorld}; use fparkan_vfs::DirectoryVfs; use serde::Deserialize; +use std::collections::{HashMap, HashSet}; use std::num::NonZeroUsize; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::{Duration, Instant}; use winit::application::ApplicationHandler; use winit::dpi::PhysicalSize as WinitPhysicalSize; -use winit::event::WindowEvent; +use winit::event::{DeviceEvent, DeviceId, ElementState, MouseButton, WindowEvent}; use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; -use winit::window::{Window, WindowId}; +use winit::keyboard::{KeyCode, PhysicalKey}; +use winit::window::{CursorGrabMode, Window, WindowId}; + +mod audio; fn main() { let raw_args = std::env::args().skip(1).collect::<Vec<_>>(); @@ -56,7 +91,9 @@ fn run(args: &[String]) -> Result<String, String> { let args = Args::parse(args)?; let services = EngineServices::new(Arc::new(DirectoryVfs::new(&args.root))); let mut engine = create(services).map_err(|err| err.to_string())?; + let mission_assets_started = Instant::now(); let loaded = load_requested_mission(&mut engine, &args)?; + let mission_assets_duration = mission_assets_started.elapsed(); let mission_assets = loaded_mission_assets(&engine) .ok_or_else(|| "mission assets are unavailable after loading".to_string())?; @@ -73,6 +110,7 @@ fn run(args: &[String]) -> Result<String, String> { .as_deref() .map(load_legacy_camera_capture) .transpose()?; + let terrain_materials_duration_started = Instant::now(); let preview = static_preview_mesh_and_materials( mission_assets, terrain, @@ -83,13 +121,38 @@ fn run(args: &[String]) -> Result<String, String> { &args.root, &loaded.land_msh_path, )?; + let terrain_materials_duration = terrain_materials_duration_started.elapsed(); + // Atmosphere files are mission-local assets. Resolving them from the + // entire install tree would silently attach the first unrelated mission's + // sky to the selected map (there are many `sky.ske` files in the campaign + // tree). + let sky_started = Instant::now(); + let environment = load_environment(&args.root, &args.mission, args.atmosphere_seconds)?; + let sky_duration = sky_started.elapsed(); + let audio = match audio::GameAudio::new(Arc::new(DirectoryVfs::new(&args.root)), &args.mission) + { + Ok(audio) => Some(audio), + Err(audio::AudioError::DeviceUnavailable { message }) => { + eprintln!("audio output unavailable; continuing without sound: {message}"); + None + } + Err(error) => return Err(format!("load mission audio: {error}")), + }; run_static_vulkan_mode( preview, + environment, + audio, args.frames, &args.mission, loaded.object_count, args.readback_out.as_deref(), args.validation, + StartupTimings { + mission_assets: mission_assets_duration, + terrain_materials: terrain_materials_duration, + sky: sky_duration, + gpu_initialization: Duration::ZERO, + }, ) } @@ -108,25 +171,3349 @@ fn load_requested_mission( .map_err(|err| err.to_string()) } -/// Static geometry and descriptors belonging to the selected preview roots. +/// Camera state used by the interactive world-space preview. +#[derive(Clone, Copy, Debug, PartialEq)] +struct FreeFlightCamera { + position: [f32; 3], + yaw: f32, + pitch: f32, + vertical_fov: f32, + near_plane: f32, + far_plane: f32, + move_speed: f32, +} + +impl FreeFlightCamera { + fn from_mesh(mesh: &VulkanStaticMesh) -> Result<Self, String> { + let mut min = [f32::INFINITY; 3]; + let mut max = [f32::NEG_INFINITY; 3]; + for vertex in &mesh.vertices { + for axis in 0..3 { + let value = vertex.position[axis]; + if !value.is_finite() { + return Err("world preview contains a non-finite vertex".to_string()); + } + min[axis] = min[axis].min(value); + max[axis] = max[axis].max(value); + } + } + if !min.iter().chain(max.iter()).all(|value| value.is_finite()) { + return Err("world preview has no finite bounds".to_string()); + } + Self::from_bounds(min, max) + } + + fn from_bounds(min: [f32; 3], max: [f32; 3]) -> Result<Self, String> { + if !min.iter().chain(max.iter()).all(|value| value.is_finite()) { + return Err("free-flight bounds must be finite".to_string()); + } + let center = [ + (min[0] + max[0]) * 0.5, + (min[1] + max[1]) * 0.5, + (min[2] + max[2]) * 0.5, + ]; + let radius = (0..3) + .map(|axis| (max[axis] - min[axis]).abs()) + .fold(1.0_f32, f32::max); + if !radius.is_finite() || radius <= f32::EPSILON { + return Err("free-flight bounds are degenerate".to_string()); + } + let position = [ + center[0], + center[1] - radius * 2.5, + center[2] + radius * 1.25, + ]; + let direction = sub3(center, position); + let distance = length3(direction) + .ok_or_else(|| "free-flight initial camera has no direction to the map".to_string())?; + let yaw = direction[1].atan2(direction[0]); + let pitch = (direction[2] / distance).clamp(-1.0, 1.0).asin(); + Ok(Self { + position, + yaw, + pitch, + vertical_fov: std::f32::consts::FRAC_PI_3, + near_plane: (radius * 0.01).clamp(0.05, 0.1), + far_plane: (radius * 32.0).max(100.0), + // Keep the default traversal speed comfortable on a full mission + // map; Shift/Ctrl still provide the coarse speed controls. + move_speed: (radius * 0.03).clamp(4.0, 40.0), + }) + } + + fn from_mesh_and_terrain( + mesh: &VulkanStaticMesh, + terrain: &TerrainWorld, + ) -> Result<Self, String> { + let mut camera = Self::from_mesh(mesh)?; + let positions = terrain + .source_positions() + .ok_or_else(|| "free-flight terrain has no source positions".to_string())?; + let mut min = [f32::INFINITY; 3]; + let mut max = [f32::NEG_INFINITY; 3]; + for position in positions { + if !position.iter().all(|value| value.is_finite()) { + return Err("free-flight terrain contains a non-finite position".to_string()); + } + for axis in 0..3 { + min[axis] = min[axis].min(position[axis]); + max[axis] = max[axis].max(position[axis]); + } + } + if !min.iter().chain(max.iter()).all(|value| value.is_finite()) { + return Err("free-flight terrain has no finite bounds".to_string()); + } + let extent = (max[0] - min[0]).max(max[1] - min[1]); + if !extent.is_finite() || extent <= f32::EPSILON { + return Err("free-flight terrain has degenerate XY bounds".to_string()); + } + let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5]; + let offset = extent * 0.18; + let requested_spawn = [ + (center[0] - offset).clamp(min[0], max[0]), + (center[1] - offset).clamp(min[1], max[1]), + ]; + let sample_offsets = [ + [0.0, 0.0], + [0.18, 0.18], + [-0.18, 0.18], + [0.18, -0.18], + [-0.18, -0.18], + [0.0, 0.18], + [0.18, 0.0], + [-0.18, 0.0], + [0.0, -0.18], + ]; + let height_world = |xy: [f32; 2]| { + terrain + .height_at(xy) + .ok() + .flatten() + .filter(|height| height.is_finite()) + .map(|height| height / 32.0) + }; + let (spawn_xy, spawn_ground) = sample_offsets + .into_iter() + .map(|sample| { + [ + (requested_spawn[0] + sample[0] * extent).clamp(min[0], max[0]), + (requested_spawn[1] + sample[1] * extent).clamp(min[1], max[1]), + ] + }) + .find_map(|xy| height_world(xy).map(|height| (xy, height))) + .ok_or_else(|| "free-flight terrain has no queryable spawn surface".to_string())?; + let target_ground = height_world(center).unwrap_or(spawn_ground); + let clearance = (extent * 0.005).clamp(2.0, 8.0); + camera.position = [spawn_xy[0], spawn_xy[1], spawn_ground + clearance]; + let target = [center[0], center[1], target_ground + 1.5]; + let direction = sub3(target, camera.position); + let distance = length3(direction) + .ok_or_else(|| "free-flight terrain spawn has no view direction".to_string())?; + camera.yaw = direction[1].atan2(direction[0]); + camera.pitch = (direction[2] / distance).clamp(-1.0, 1.0).asin(); + Ok(camera) + } + + fn forward(self) -> [f32; 3] { + let (sin_yaw, cos_yaw) = self.yaw.sin_cos(); + let (sin_pitch, cos_pitch) = self.pitch.sin_cos(); + [cos_pitch * cos_yaw, cos_pitch * sin_yaw, sin_pitch] + } + + fn right(self) -> [f32; 3] { + normalize3(cross3(self.forward(), [0.0, 0.0, 1.0])).unwrap_or([1.0, 0.0, 0.0]) + } + + fn up(self) -> [f32; 3] { + normalize3(cross3(self.right(), self.forward())).unwrap_or([0.0, 0.0, 1.0]) + } + + fn look_delta(&mut self, dx: f64, dy: f64) { + const SENSITIVITY: f32 = 0.0025; + let dx = dx as f32; + let dy = dy as f32; + if dx.is_finite() && dy.is_finite() { + // Moving the mouse right turns the view to the right. + self.yaw -= dx * SENSITIVITY; + self.pitch = (self.pitch - dy * SENSITIVITY).clamp(-1.5, 1.5); + } + } + + fn advance(&mut self, keys: &HashSet<KeyCode>, seconds: f32) { + if !seconds.is_finite() || seconds <= 0.0 { + return; + } + let mut movement = [0.0; 3]; + let forward = self.forward(); + let right = self.right(); + if keys.contains(&KeyCode::KeyW) { + add3_in_place(&mut movement, forward); + } + if keys.contains(&KeyCode::KeyS) { + add3_in_place(&mut movement, scale3(forward, -1.0)); + } + if keys.contains(&KeyCode::KeyD) { + add3_in_place(&mut movement, right); + } + if keys.contains(&KeyCode::KeyA) { + add3_in_place(&mut movement, scale3(right, -1.0)); + } + if keys.contains(&KeyCode::KeyE) { + add3_in_place(&mut movement, [0.0, 0.0, 1.0]); + } + if keys.contains(&KeyCode::KeyQ) { + add3_in_place(&mut movement, [0.0, 0.0, -1.0]); + } + let Some(direction) = normalize3(movement) else { + return; + }; + let mut speed = self.move_speed; + if keys.contains(&KeyCode::ShiftLeft) || keys.contains(&KeyCode::ShiftRight) { + speed *= 4.0; + } + if keys.contains(&KeyCode::ControlLeft) || keys.contains(&KeyCode::ControlRight) { + speed *= 0.25; + } + self.position = add3(self.position, scale3(direction, speed * seconds)); + } + + fn vulkan_camera(self, aspect: f32) -> VulkanStaticCamera { + let aspect = if aspect.is_finite() && aspect > f32::EPSILON { + aspect + } else { + 16.0 / 9.0 + }; + let forward = self.forward(); + let right = self.right(); + let up = self.up(); + // VulkanStaticCamera carries row-major D3D-style data. GLSL interprets + // those bytes as a column-major mat4, so shader multiplication is the + // transpose of this row-vector transform: world * V * P. The camera + // basis is right-handed with +Z-up world coordinates and +Z depth. + let view = [ + right[0], + up[0], + forward[0], + 0.0, + right[1], + up[1], + forward[1], + 0.0, + right[2], + up[2], + forward[2], + 0.0, + -dot3(right, self.position), + -dot3(up, self.position), + -dot3(forward, self.position), + 1.0, + ]; + let half_fov = (self.vertical_fov * 0.5).clamp(0.05, 1.5); + let focal = half_fov.tan().recip(); + let near_plane = self.near_plane.max(0.001); + let far_plane = self.far_plane.max(near_plane + 0.001); + let projection = [ + focal / aspect, + 0.0, + 0.0, + 0.0, + 0.0, + -focal, + 0.0, + 0.0, + 0.0, + 0.0, + far_plane / (far_plane - near_plane), + 1.0, + 0.0, + 0.0, + -(near_plane * far_plane) / (far_plane - near_plane), + 0.0, + ]; + VulkanStaticCamera { + clip_from_world: multiply_row_major(view, projection), + } + } +} + +const ENVIRONMENT_SPRITE_ROWS: [usize; 2] = [3, 4]; +const ENVIRONMENT_FLARE_SLOTS: usize = 12; +const ENVIRONMENT_PARTICLE_SLOTS: usize = 1_000; + +#[derive(Clone, Copy, Debug)] +struct EnvironmentGpuRange { + range_index: usize, + vertex_start: usize, + vertex_capacity: usize, + material_index: usize, +} + +#[derive(Clone, Debug)] +struct EnvironmentMaterialAsset { + texture: VulkanStaticTexture, + phase: PreparedMaterialPhase, + document: Mat0Document, + phase_textures: Vec<VulkanStaticTexture>, + phases: Vec<PreparedMaterialPhase>, +} + +/// One source MAT0 document retained for frame-time sampling. The renderer +/// receives the selected coefficients through its dynamic material state; +/// keeping this binding beside the prepared scene avoids rebuilding assets or +/// reopening archives on every redraw. +#[derive(Clone, Debug)] +struct MaterialPhaseBinding { + document: Mat0Document, + animation_block_index: usize, + wear_row_start_ms: u32, + random_state: u32, + frozen_phase_index: Option<usize>, + phase_uv_transforms: Vec<[f32; 4]>, +} + +/// Texture phases for the material stages that do not select the draw +/// variant. Terrain MAT0 rows are independent: Land1 base, Land2 detail, +/// Land1 overlay, and Land2 overlay-detail can each have their own animation +/// timeline. Keeping these handles beside the binding lets the renderer +/// update the three descriptor images in place without materialising every +/// Cartesian product of phase indices. +#[derive(Clone, Debug)] +struct MaterialStageTextures { + detail: Option<Vec<VulkanStaticTexture>>, + overlay: Option<Vec<VulkanStaticTexture>>, + overlay_detail: Option<Vec<VulkanStaticTexture>>, + applied_material_phases: HashMap<usize, [usize; 3]>, +} + +impl MaterialPhaseBinding { + fn sample( + &mut self, + material_index: usize, + clock_ms: u32, + ) -> Result<MaterialPhaseSample, String> { + if let Some(phase_index) = self.frozen_phase_index { + let phase_index = phase_index.min(self.document.phases.len().saturating_sub(1)); + let phase = self.document.phases.get(phase_index).ok_or_else(|| { + format!( + "sample MAT0 material {material_index} at clock {clock_ms}ms: no material phases" + ) + })?; + return Ok(MaterialPhaseSample { + phase_index, + coefficients: phase.coefficients(), + texture_raw: phase.texture_raw, + }); + } + + // GetPhase's random mode asks the CRT generator for one fresh value + // per query. Animated loop/ping-pong/clamp modes do not consume the + // RNG and receive the sampler's ignored value. + let block = self + .document + .animation_blocks + .get(self.animation_block_index) + .or_else(|| self.document.animation_blocks.first()); + let random_value = if block.is_some_and(|block| block.header_raw & 7 == 3) { + self.random_state = self + .random_state + .wrapping_mul(214_013) + .wrapping_add(2_531_011); + (self.random_state >> 16) & 0x7fff + } else { + 0 + }; + sample_material_phase( + &self.document, + self.animation_block_index, + clock_ms, + self.wear_row_start_ms, + random_value, + ) + .map_err(|error| { + format!( + "sample MAT0 material {} at clock {clock_ms}ms: {error}", + material_index + ) + }) + } + + fn page_uv_transform(&self, phase_index: usize) -> [f32; 4] { + self.phase_uv_transforms + .get(phase_index) + .copied() + .or_else(|| self.phase_uv_transforms.first().copied()) + .unwrap_or([0.0, 0.0, 1.0, 1.0]) + } + + fn checked_page_uv_transform(&self, phase_index: usize) -> Result<[f32; 4], String> { + self.phase_uv_transforms + .get(phase_index) + .copied() + .ok_or_else(|| { + format!( + "MAT0 Page transform is missing phase {phase_index} of {}", + self.phase_uv_transforms.len() + ) + }) + } +} + +#[derive(Clone, Debug)] +struct MaterialVariantSet { + phase_material_indices: Vec<usize>, + range_indices: Vec<usize>, +} + +#[derive(Clone, Debug)] +struct MaterialAnimationBinding { + initial_phase_index: usize, + variants: Vec<MaterialVariantSet>, + base: MaterialPhaseBinding, + detail: Option<MaterialPhaseBinding>, + overlay: Option<MaterialPhaseBinding>, + overlay_detail: Option<MaterialPhaseBinding>, + stage_textures: Option<MaterialStageTextures>, +} + +#[derive(Clone, Copy, Debug)] +struct ActiveMaterialState { + material_index: usize, + diffuse_alpha: f32, +} + +fn update_material_animations( + bindings: &mut [MaterialAnimationBinding], + materials: &[VulkanStaticMaterial], + renderer: &mut VulkanSmokeRenderer, + clock_ms: u32, +) -> Result<HashMap<usize, ActiveMaterialState>, String> { + // Keep the resolved selector beside the draw range for this frame. The + // renderer's material arrays are keyed by selector, so any later + // environment pass that writes alpha or lighting must target the phase + // variant selected above rather than the material that happened to be + // active when the range was allocated. + let mut active_materials = HashMap::new(); + for binding in bindings { + let base_sample = binding.base.sample(binding.initial_phase_index, clock_ms)?; + let detail_sample = binding + .detail + .as_mut() + .map(|detail| detail.sample(binding.initial_phase_index, clock_ms)) + .transpose()?; + let overlay_sample = binding + .overlay + .as_mut() + .map(|overlay| overlay.sample(binding.initial_phase_index, clock_ms)) + .transpose()?; + let overlay_detail_sample = binding + .overlay_detail + .as_mut() + .map(|overlay_detail| overlay_detail.sample(binding.initial_phase_index, clock_ms)) + .transpose()?; + let mut updated_materials = HashSet::new(); + for variant in &binding.variants { + let material_index = selected_material_variant_index(variant, base_sample.phase_index) + .ok_or_else(|| "MAT0 material animation has no texture variant".to_string())?; + for &range_index in &variant.range_indices { + active_materials.insert( + range_index, + ActiveMaterialState { + material_index, + diffuse_alpha: base_sample.coefficients.opacity, + }, + ); + renderer + .set_draw_range_material(range_index, material_index) + .map_err(|error| format!("update MAT0 material draw selector: {error}"))?; + } + if !updated_materials.insert(material_index) { + continue; + } + let material = materials.get(material_index).ok_or_else(|| { + format!("MAT0 material animation selector {material_index} is out of bounds") + })?; + let base_coefficients = base_sample.coefficients; + let overlay_coefficients = overlay_sample.map(|sample| sample.coefficients); + let (base_directional, base_additive, overlay_directional, overlay_additive) = + if material.lightmap_mode { + // Terrain mode 3 samples the lightmap in the secondary + // stage and uses the authored directional RGB as its + // multiplicative lightmap coefficient. It has no normal + // lighting or overlay stage. + ([0.0; 3], base_coefficients.directional_rgb, None, None) + } else { + ( + base_coefficients.directional_rgb, + base_coefficients.additive_rgb, + overlay_coefficients.map(|coefficients| coefficients.directional_rgb), + overlay_coefficients.map(|coefficients| coefficients.additive_rgb), + ) + }; + renderer + .set_material_alphas( + material_index, + base_coefficients.opacity.clamp(0.0, 1.0), + overlay_coefficients + .map_or(0.0, |coefficients| coefficients.opacity.clamp(0.0, 1.0)), + ) + .map_err(|error| format!("update MAT0 material alpha: {error}"))?; + renderer + .set_material_lighting_with_overlay( + material_index, + base_directional, + base_additive, + overlay_directional, + overlay_additive, + ) + .map_err(|error| format!("update MAT0 material lighting: {error}"))?; + renderer + .set_material_specular( + material_index, + base_coefficients.specular_rgb, + base_coefficients.power, + overlay_coefficients.map(|coefficients| coefficients.specular_rgb), + overlay_coefficients.map(|coefficients| coefficients.power), + ) + .map_err(|error| format!("update MAT0 material specular: {error}"))?; + + // Detail and overlay textures have independent MAT0 phase + // selectors. Their descriptor images are updated only when a + // selected base material sees a new stage tuple; this keeps the + // steady-state frame path from idling Vulkan for every draw. + if !material.lightmap_mode { + if let Some(stage_textures) = binding.stage_textures.as_mut() { + let stage_phases = [ + detail_sample.map_or(0, |sample| sample.phase_index), + overlay_sample.map_or(0, |sample| sample.phase_index), + overlay_detail_sample.map_or(0, |sample| sample.phase_index), + ]; + if stage_textures + .applied_material_phases + .get(&material_index) + .copied() + != Some(stage_phases) + { + let detail_texture = stage_textures + .detail + .as_ref() + .and_then(|textures| textures.get(stage_phases[0])) + .cloned(); + let overlay_texture = stage_textures + .overlay + .as_ref() + .and_then(|textures| textures.get(stage_phases[1])) + .cloned(); + let overlay_detail_texture = stage_textures + .overlay_detail + .as_ref() + .and_then(|textures| textures.get(stage_phases[2])) + .cloned(); + if let Some(texture) = detail_texture.as_ref() { + renderer + .update_material_texture(material_index, 1, texture) + .map_err(|error| format!("update MAT0 detail texture: {error}"))?; + } + if let Some(texture) = overlay_texture.as_ref() { + renderer + .update_material_texture(material_index, 2, texture) + .map_err(|error| format!("update MAT0 overlay texture: {error}"))?; + } + if let Some(texture) = overlay_detail_texture.as_ref() { + renderer + .update_material_texture(material_index, 3, texture) + .map_err(|error| { + format!("update MAT0 overlay detail texture: {error}") + })?; + } + stage_textures + .applied_material_phases + .insert(material_index, stage_phases); + } + } + } + let overlay_uv = overlay_sample + .map(|sample| { + binding + .overlay + .as_ref() + .map_or([0.0, 0.0, 1.0, 1.0], |overlay| { + overlay.page_uv_transform(sample.phase_index) + }) + }) + .or(material.overlay_uv_transform) + .unwrap_or([0.0, 0.0, 1.0, 1.0]); + let detail_uv = if material.lightmap_mode { + material.detail_uv_transform + } else { + detail_sample + .map(|sample| { + binding + .detail + .as_ref() + .map_or(material.detail_uv_transform, |detail| { + detail.page_uv_transform(sample.phase_index) + }) + }) + .or(Some(material.detail_uv_transform)) + .unwrap_or([0.0, 0.0, 1.0, 1.0]) + }; + let overlay_detail_uv = if material.lightmap_mode { + material + .overlay_detail_uv_transform + .unwrap_or([0.0, 0.0, 1.0, 1.0]) + } else { + overlay_detail_sample + .map(|sample| { + binding.overlay_detail.as_ref().map_or( + material + .overlay_detail_uv_transform + .unwrap_or([0.0, 0.0, 1.0, 1.0]), + |overlay_detail| overlay_detail.page_uv_transform(sample.phase_index), + ) + }) + .or(material.overlay_detail_uv_transform) + .unwrap_or([0.0, 0.0, 1.0, 1.0]) + }; + let base_uv = binding + .base + .checked_page_uv_transform(base_sample.phase_index)?; + renderer + .set_material_uv_transforms( + material_index, + [base_uv, detail_uv, overlay_uv, overlay_detail_uv], + ) + .map_err(|error| format!("update MAT0 material Page transform: {error}"))?; + } + } + Ok(active_materials) +} + +fn native_world_transparent(range: &VulkanStaticDrawRange, diffuse_alpha: f32) -> bool { + diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 0 +} + +fn register_material_range( + bindings: &mut [MaterialAnimationBinding], + material_index: usize, + range_index: usize, +) { + for binding in bindings { + if let Some(variant) = binding.variants.iter_mut().find(|variant| { + variant + .phase_material_indices + .get(binding.initial_phase_index) + .copied() + == Some(material_index) + }) { + variant.range_indices.push(range_index); + return; + } + } +} + +fn find_material_animation_variant( + bindings: &[MaterialAnimationBinding], + material_index: usize, +) -> Option<(usize, Vec<usize>)> { + bindings + .iter() + .enumerate() + .find_map(|(binding_index, binding)| { + binding.variants.iter().find_map(|variant| { + (variant + .phase_material_indices + .get(binding.initial_phase_index) + .copied() + == Some(material_index)) + .then(|| (binding_index, variant.phase_material_indices.clone())) + }) + }) +} + +fn selected_material_variant_index( + variant: &MaterialVariantSet, + phase_index: usize, +) -> Option<usize> { + variant.phase_material_indices.get(phase_index).copied() +} + +fn remap_material_animation_ranges( + bindings: &mut [MaterialAnimationBinding], + remap: &[usize], +) -> Result<(), String> { + for binding in bindings { + for variant in &mut binding.variants { + for range_index in &mut variant.range_indices { + *range_index = *remap + .get(*range_index) + .ok_or_else(|| "MAT0 draw range remap is out of bounds".to_string())?; + } + } + } + Ok(()) +} + +fn phase_texture_name(phase: &MaterialPhase) -> Option<ResourceName> { + let length = phase + .texture_raw + .iter() + .position(|byte| *byte == 0) + .unwrap_or(phase.texture_raw.len()); + (length != 0).then(|| ResourceName(phase.texture_raw[..length].to_vec())) +} + +fn phase_uv_transforms_from_assets( + document: &Mat0Document, + assets: &MissionAssets, +) -> Vec<[f32; 4]> { + document + .phases + .iter() + .map(|phase| { + phase_texture_name(phase) + .and_then(|name| { + assets + .textures + .iter() + .find(|texture| { + texture.usage == PreparedTextureUsage::Diffuse + && texture.source.name == name + }) + .and_then(|texture| { + page_uv_transform( + texture.texm.width(), + texture.texm.height(), + &texture.texm.page_rects(), + i8::from_ne_bytes([phase.parameters[17]]), + ) + .ok() + }) + }) + .unwrap_or([0.0, 0.0, 1.0, 1.0]) + }) + .collect() +} + +fn phase_uv_transforms_from_standalone( + loader: &mut StandaloneWearMaterialLoader, + wear_path: &Path, + material_index: u16, + document: &Mat0Document, +) -> Result<Vec<[f32; 4]>, String> { + document + .phases + .iter() + .enumerate() + .map(|(phase_index, phase)| { + if phase_texture_name(phase).is_none() { + return Ok([0.0, 0.0, 1.0, 1.0]); + } + loader + .load( + wear_path, + material_index, + u16::try_from(phase_index).unwrap_or(u16::MAX), + ) + .map(|(_, prepared)| prepared.page_uv_transform) + }) + .collect() +} + +#[derive(Clone, Copy, Debug)] +struct EnvironmentSkyLayer { + kind: SkyPassKind, + stage: Option<usize>, + secondary_stage: Option<usize>, + material_index: usize, + diffuse_alpha: f32, + static_directional_rgb: [f32; 3], + static_additive_rgb: [f32; 3], + translation_z: f32, + range: EnvironmentGpuRange, +} + +#[derive(Clone, Copy, Debug)] +struct EnvironmentSpriteLayer { + row: usize, + material_index: usize, + diffuse_alpha: f32, + flare_index: Option<usize>, + range: EnvironmentGpuRange, +} + +/// Fixed-capacity GPU allocations for the CPU-owned sky and environment +/// output. The Vulkan renderer only receives one shared vertex update and +/// smaller draw counts each frame; descriptor images and index allocations +/// stay stable while the camera and weather move. +#[derive(Clone, Debug)] +struct EnvironmentGpuScene { + material_animations: Vec<MaterialAnimationBinding>, + sky_layers: Vec<EnvironmentSkyLayer>, + sprites: Vec<EnvironmentSpriteLayer>, + rain: EnvironmentGpuRange, + snow: EnvironmentGpuRange, + lightning: EnvironmentGpuRange, + lightning_material_index: usize, +} + +fn world_pipeline_state(blend: LegacyBlendMode, depth: LegacyDepthMode) -> LegacyPipelineState { + LegacyPipelineState { + blend, + depth, + ..LegacyPipelineState::default() + } +} + +fn environment_vertex(position: [f32; 3], color: [f32; 3], uv: [f32; 2]) -> VulkanStaticVertex { + environment_vertex_with_alpha(position, color, 1.0, uv) +} + +fn environment_vertex_with_alpha( + position: [f32; 3], + color: [f32; 3], + alpha: f32, + uv: [f32; 2], +) -> VulkanStaticVertex { + environment_vertex_with_alpha_normal(position, color, alpha, uv, [0.0, 0.0, 1.0]) +} + +fn environment_vertex_with_alpha_normal( + position: [f32; 3], + color: [f32; 3], + alpha: f32, + uv: [f32; 2], + normal: [f32; 3], +) -> VulkanStaticVertex { + VulkanStaticVertex { + position, + color, + normal, + uv, + detail_uv: [0.0, 0.0], + overlay_alpha: alpha.clamp(0.0, 1.0), + } +} + +fn sky_normal(raw: [i8; 3]) -> [f32; 3] { + // Ngi32's cloud-light decode uses the signed-byte scale 1/128 and keeps + // the authored vector length. Renormalizing (or using 1/127) changes the + // edge values and shifts the native cloud shading. + [ + f32::from(raw[0]) / 128.0, + f32::from(raw[1]) / 128.0, + f32::from(raw[2]) / 128.0, + ] +} + +fn solid_environment_texture(rgba: [u8; 4]) -> VulkanStaticTexture { + let mip = VulkanStaticTextureMip { + width: 1, + height: 1, + rgba8: rgba.to_vec(), + }; + VulkanStaticTexture { + width: 1, + height: 1, + rgba8: mip.rgba8.clone(), + mip_levels: vec![mip], + } +} + +fn vulkan_texture_from_rgba_mips( + mips: Vec<RgbaImage>, + label: &str, +) -> Result<VulkanStaticTexture, String> { + let first = mips + .first() + .ok_or_else(|| format!("{label} has no mip zero"))?; + Ok(VulkanStaticTexture { + width: first.width, + height: first.height, + rgba8: first.rgba8.clone(), + mip_levels: mips + .into_iter() + .map(|mip| VulkanStaticTextureMip { + width: mip.width, + height: mip.height, + rgba8: mip.rgba8, + }) + .collect(), + }) +} + +fn append_environment_material( + materials: &mut Vec<VulkanStaticMaterial>, + material_names: &mut HashMap<String, usize>, + material_assets: &HashMap<String, EnvironmentMaterialAsset>, + material_animations: &mut Vec<MaterialAnimationBinding>, + name: &str, + fallback_row: usize, +) -> Result<usize, String> { + if let Some(index) = material_names.get(name).copied() { + return Ok(index); + } + let asset = material_assets.get(name).ok_or_else(|| { + format!("environment material {name:?} is missing from resolved material.lib/MAT0 assets") + })?; + let phases = if asset.phases.is_empty() { + vec![asset.phase] + } else { + asset.phases.clone() + }; + let first_material_index = materials.len(); + let mut phase_material_indices = Vec::with_capacity(phases.len()); + for (phase_index, phase) in phases.iter().enumerate() { + let index = materials.len(); + let material_index = u16::try_from(index).map_err(|_| { + "environment exceeds the available 16-bit material selector space".to_string() + })?; + materials.push(VulkanStaticMaterial { + material_index, + texture: asset + .phase_textures + .get(phase_index) + .cloned() + .unwrap_or_else(|| asset.texture.clone()), + directional_rgb: phase.directional_rgb, + additive_rgb: phase.additive_rgb, + uv_transform: phase.page_uv_transform, + detail_texture: Some(solid_environment_texture([128, 128, 128, 255])), + overlay_texture: None, + overlay_detail_texture: None, + diffuse_alpha: phase.diffuse_alpha.clamp(0.0, 1.0), + overlay_diffuse_alpha: None, + overlay_directional_rgb: None, + overlay_additive_rgb: None, + specular_rgb: phase.specular_rgb, + specular_power: phase.power, + overlay_specular_rgb: None, + overlay_specular_power: None, + detail_uv_transform: [0.0, 0.0, 1.0, 1.0], + overlay_uv_transform: None, + overlay_detail_uv_transform: None, + // Native cloud draws carry shade/fog flags. The remaining sky rows + // are unlit overlays; all rows still retain their decoded normals. + unlit: fallback_row != 2, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: matches!(fallback_row, 0..=2), + }); + phase_material_indices.push(index); + } + let document = asset.document.clone(); + material_animations.push(MaterialAnimationBinding { + initial_phase_index: 0, + variants: vec![MaterialVariantSet { + phase_material_indices, + range_indices: Vec::new(), + }], + base: MaterialPhaseBinding { + document, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: first_material_index as u32, + frozen_phase_index: None, + phase_uv_transforms: phases.iter().map(|phase| phase.page_uv_transform).collect(), + }, + detail: None, + overlay: None, + overlay_detail: None, + stage_textures: None, + }); + material_names.insert(name.to_owned(), first_material_index); + Ok(first_material_index) +} + +fn append_environment_gradient_material( + materials: &mut Vec<VulkanStaticMaterial>, +) -> Result<usize, String> { + let index = materials.len(); + let material_index = u16::try_from(index).map_err(|_| { + "environment exceeds the available 16-bit material selector space".to_string() + })?; + materials.push(VulkanStaticMaterial { + material_index, + texture: solid_environment_texture([255, 255, 255, 255]), + directional_rgb: [1.0; 3], + additive_rgb: [0.0; 3], + uv_transform: [0.0, 0.0, 1.0, 1.0], + detail_texture: Some(solid_environment_texture([128, 128, 128, 255])), + overlay_texture: None, + overlay_detail_texture: None, + diffuse_alpha: 1.0, + overlay_diffuse_alpha: None, + overlay_directional_rgb: None, + overlay_additive_rgb: None, + specular_rgb: [0.0; 3], + specular_power: 0, + overlay_specular_rgb: None, + overlay_specular_power: None, + detail_uv_transform: [0.0, 0.0, 1.0, 1.0], + overlay_uv_transform: None, + overlay_detail_uv_transform: None, + unlit: true, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: true, + }); + Ok(index) +} + +fn shadow_atlas_texture() -> VulkanStaticTexture { + let rgba8 = vec![0_u8; 256 * 256 * 4]; + VulkanStaticTexture { + width: 256, + height: 256, + rgba8: rgba8.clone(), + mip_levels: vec![VulkanStaticTextureMip { + width: 256, + height: 256, + rgba8, + }], + } +} + +fn append_shadow_material(materials: &mut Vec<VulkanStaticMaterial>) -> Result<usize, String> { + let material_index = u16::try_from(materials.len()).map_err(|_| { + "shadow material selector exceeds the available 16-bit material selector space".to_string() + })?; + let index = materials.len(); + materials.push(VulkanStaticMaterial { + material_index, + texture: shadow_atlas_texture(), + directional_rgb: [1.0; 3], + additive_rgb: [0.0; 3], + uv_transform: [0.0, 0.0, 1.0, 1.0], + detail_texture: Some(solid_environment_texture([255, 255, 255, 255])), + overlay_texture: None, + overlay_detail_texture: None, + diffuse_alpha: 1.0, + overlay_diffuse_alpha: None, + overlay_directional_rgb: None, + overlay_additive_rgb: None, + specular_rgb: [0.0; 3], + specular_power: 0, + overlay_specular_rgb: None, + overlay_specular_power: None, + detail_uv_transform: [0.0, 0.0, 1.0, 1.0], + overlay_uv_transform: None, + overlay_detail_uv_transform: None, + // Combiner mode 1 keeps the atlas alpha and the projected receiver's + // per-vertex alpha while bypassing world lighting/fog. + unlit: true, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: false, + }); + Ok(index) +} + +fn shadow_placeholder_vertices() -> Vec<VulkanStaticVertex> { + [ + environment_vertex_with_alpha([0.0, 0.0, 0.0], [1.0; 3], 0.0, [0.0, 0.0]), + environment_vertex_with_alpha([1.0, 0.0, 0.0], [1.0; 3], 0.0, [1.0, 0.0]), + environment_vertex_with_alpha([0.0, 1.0, 0.0], [1.0; 3], 0.0, [0.0, 1.0]), + environment_vertex_with_alpha([1.0, 1.0, 0.0], [1.0; 3], 0.0, [1.0, 1.0]), + ] + .to_vec() +} + +fn shadow_triangle(positions: [[f32; 3]; 3], mask: u32) -> ShadowTriangle { + let normal = normalize3(cross3( + sub3(positions[1], positions[0]), + sub3(positions[2], positions[0]), + )) + .unwrap_or([0.0, 0.0, 1.0]); + ShadowTriangle { + positions, + normal, + mask, + } +} + +fn terrain_shadow_receivers( + terrain: &fparkan_terrain_format::LandMeshDocument, +) -> Result<Vec<ShadowTriangle>, String> { + terrain + .faces + .iter() + .map(|face| { + let positions = face.vertices.map(|index| { + let source = terrain + .positions + .get(usize::from(index)) + .copied() + .unwrap_or([0.0; 3]); + [source[0], source[1], source[2] / 32.0] + }); + if !positions.iter().flatten().all(|value| value.is_finite()) { + return Err("terrain shadow receiver contains a non-finite position".to_string()); + } + Ok(shadow_triangle(positions, face.flags.0)) + }) + .collect() +} + +fn static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec<ShadowTriangle> { + mesh.indices + .chunks_exact(3) + .filter_map(|triangle| { + let positions = [ + mesh.vertices + .get(usize::try_from(triangle[0]).ok()?)? + .position, + mesh.vertices + .get(usize::try_from(triangle[1]).ok()?)? + .position, + mesh.vertices + .get(usize::try_from(triangle[2]).ok()?)? + .position, + ]; + positions + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(shadow_triangle(positions, 0)) + }) + .collect() +} + +#[derive(Clone, Copy, Debug)] +struct ShadowComponentSphere { + center: [f32; 3], + radius: f32, +} + +fn mounted_shadow_sphere( + center: [f32; 3], + radius: f32, + mount_pose: Option<VulkanNodePose>, + node1_pose: Option<VulkanNodePose>, +) -> Option<ShadowComponentSphere> { + if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() || radius <= 0.0 { + return None; + } + let center = mount_pose.map_or(center, |parent_pose| { + // The native mounted-unit sphere query keeps node 1's accumulated + // rotation but replaces its translation with the accumulated parent + // translation. This is deliberately different from the render mount + // composition, which also carries node 1's translation. + let rotation = node1_pose.map_or(parent_pose.rotation, |node_pose| { + multiply_unit_quaternions(parent_pose.rotation, node_pose.rotation) + }); + add3( + parent_pose.translation, + rotate_unit_vector(center, rotation), + ) + }); + center + .iter() + .all(|value| value.is_finite()) + .then_some(ShadowComponentSphere { center, radius }) +} + +fn aggregate_shadow_caster( + id: u32, + native_kind: u32, + transform: LegacyIron3dEulerTransform, + scale: [f32; 3], + triangles: Vec<ShadowTriangle>, + component_spheres: &[ShadowComponentSphere], +) -> Option<ShadowCaster> { + if triangles.is_empty() { + return None; + } + let weight = component_spheres + .iter() + .map(|sphere| sphere.radius) + .sum::<f32>(); + if !weight.is_finite() || weight <= f32::EPSILON { + return None; + } + // Native 9510 aggregates visible root/EXTO spheres by radius-weighted + // center, then encloses every component sphere around that center. + let aggregate_center = component_spheres.iter().fold([0.0; 3], |sum, sphere| { + add3(sum, scale3(sphere.center, sphere.radius)) + }); + let aggregate_center = scale3(aggregate_center, weight.recip()); + let radius = component_spheres + .iter() + .map(|sphere| length3(sub3(sphere.center, aggregate_center)).unwrap_or(0.0) + sphere.radius) + .fold(0.0, f32::max); + let max_scale = scale.iter().copied().fold(f32::NEG_INFINITY, f32::max); + if !aggregate_center.iter().all(|value| value.is_finite()) + || !radius.is_finite() + || radius <= f32::EPSILON + || !max_scale.is_finite() + || max_scale <= 0.0 + { + return None; + } + // The native scale flag applies to the aggregate exactly once. The + // mission Euler transform then rotates/translates that scaled sphere. + let scaled_center = [ + aggregate_center[0] * scale[0], + aggregate_center[1] * scale[1], + aggregate_center[2] * scale[2], + ]; + let center = transform.try_transform_scaled_point(scaled_center, [1.0; 3])?; + let radius = radius * max_scale; + if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() { + return None; + } + let world_axes = transform + .try_row_major() + .map(|matrix| { + [ + [matrix[0], matrix[4], matrix[8]], + [matrix[1], matrix[5], matrix[9]], + [matrix[2], matrix[6], matrix[10]], + ] + }) + .unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]); + Some(ShadowCaster { + id, + world_axes, + sphere: ShadowSphere { center, radius }, + native_kind, + triangles, + }) +} + +fn shadow_lights( + sky_frame: Option<&SkyFrame<'_>>, + environment_frame: Option<&EnvironmentFrame>, + renderer: &VulkanSmokeRenderer, +) -> Vec<ShadowLight> { + let mut lights = Vec::new(); + if let Some(sky_frame) = sky_frame { + lights.extend(sky_frame.directional_lights.into_iter().enumerate().map( + |(index, light)| { + ShadowLight::Directional { + direction: light.direction, + rgb: light.color, + active: light.active, + // CSun's constructor marks both the sun (slot 0) and + // moon (slot 2) as primary. ShadowReference selects the + // first one for kind 3 and accumulates both active + // primary directions for ordinary objects (kind 4). + flags: (index == 0 || index == 2) + .then_some(SHADOW_PRIMARY_DIRECTIONAL_FLAG) + .unwrap_or(0), + } + }, + )); + } else { + let uniforms = renderer.frame_uniforms(); + lights.extend( + uniforms + .directional_lights + .into_iter() + .enumerate() + .map(|(index, light)| ShadowLight::Directional { + direction: light.direction, + rgb: light.rgb, + active: light.active, + flags: (index == 0 || index == 2) + .then_some(SHADOW_PRIMARY_DIRECTIONAL_FLAG) + .unwrap_or(0), + }), + ); + } + if let Some(environment_frame) = environment_frame { + lights.extend( + environment_frame + .point_lights + .iter() + .map(|light| ShadowLight::Point { + position: light.position, + rgb: [light.color[0], light.color[1], light.color[2]], + range: light.range, + coefficients: light.attenuation, + active: light.active, + flags: 0, + }), + ); + } else { + let point = renderer.frame_uniforms().point_light; + lights.push(ShadowLight::Point { + position: point.position, + rgb: point.rgb, + range: point.range, + coefficients: point.coefficients, + active: point.active, + flags: 0, + }); + } + lights +} + +/// Native `Terrain30737` expands each caster sphere by 100 world units before +/// testing it against the six planes in the current world frustum. Filtering +/// here matters because `CShade` only accepts its first twenty candidates; +/// off-screen objects must not consume that budget. +const SHADOW_FRUSTUM_RADIUS_MARGIN: f32 = 100.0; + +fn native_shadow_caster_visible(caster: &ShadowCaster, clip_from_world: [f32; 16]) -> bool { + let radius = caster.sphere.radius + SHADOW_FRUSTUM_RADIUS_MARGIN; + if !radius.is_finite() || radius < 0.0 || !caster.sphere.center.iter().all(|v| v.is_finite()) { + return false; + } + + // The camera matrix is row-major D3D-style storage. A world point is + // transformed by p * M, so each clip coordinate uses one matrix column. + // The six Vulkan clip half-spaces are x+w, -x+w, y+w, -y+w, z, and w-z. + let planes = [ + [ + clip_from_world[0] + clip_from_world[3], + clip_from_world[4] + clip_from_world[7], + clip_from_world[8] + clip_from_world[11], + clip_from_world[12] + clip_from_world[15], + ], + [ + -clip_from_world[0] + clip_from_world[3], + -clip_from_world[4] + clip_from_world[7], + -clip_from_world[8] + clip_from_world[11], + -clip_from_world[12] + clip_from_world[15], + ], + [ + clip_from_world[1] + clip_from_world[3], + clip_from_world[5] + clip_from_world[7], + clip_from_world[9] + clip_from_world[11], + clip_from_world[13] + clip_from_world[15], + ], + [ + -clip_from_world[1] + clip_from_world[3], + -clip_from_world[5] + clip_from_world[7], + -clip_from_world[9] + clip_from_world[11], + -clip_from_world[13] + clip_from_world[15], + ], + [ + clip_from_world[2], + clip_from_world[6], + clip_from_world[10], + clip_from_world[14], + ], + [ + clip_from_world[3] - clip_from_world[2], + clip_from_world[7] - clip_from_world[6], + clip_from_world[11] - clip_from_world[10], + clip_from_world[15] - clip_from_world[14], + ], + ]; + planes.into_iter().all(|plane| { + let normal_length = + (plane[0].mul_add(plane[0], plane[1].mul_add(plane[1], plane[2] * plane[2]))).sqrt(); + if !normal_length.is_finite() || normal_length <= f32::EPSILON { + return false; + } + let distance = plane[0].mul_add( + caster.sphere.center[0], + plane[1].mul_add( + caster.sphere.center[1], + plane[2].mul_add(caster.sphere.center[2], plane[3]), + ), + ) / normal_length; + distance.is_finite() && distance > -radius + }) +} + +fn native_visible_shadow_casters( + casters: &[ShadowCaster], + clip_from_world: [f32; 16], +) -> Vec<ShadowCaster> { + casters + .iter() + .filter(|caster| native_shadow_caster_visible(caster, clip_from_world)) + .cloned() + .collect() +} + +fn append_shadow_frame_geometry( + mesh: &mut VulkanStaticMesh, + shadow_range: EnvironmentGpuRange, + shadow_base_vertex: usize, + shadow_base_index: usize, + frame: &ShadowFrame, +) -> Result<VulkanDynamicDrawRange, String> { + if shadow_base_vertex > mesh.vertices.len() || shadow_base_index > mesh.indices.len() { + return Err("shadow dynamic base is outside the shared mesh".to_string()); + } + mesh.vertices.truncate(shadow_base_vertex); + mesh.indices.truncate(shadow_base_index); + let vertex_base = u32::try_from(mesh.vertices.len()) + .map_err(|_| "shadow vertex base exceeds u32".to_string())?; + let first_index = u32::try_from(mesh.indices.len()) + .map_err(|_| "shadow index base exceeds u32".to_string())?; + if frame.indices.iter().any(|&index| { + usize::try_from(index) + .ok() + .is_none_or(|index| index >= frame.vertices.len()) + }) { + return Err("shadow frame index exceeds its vertex stream".to_string()); + } + mesh.vertices.extend(frame.vertices.iter().map(|vertex| { + environment_vertex_with_alpha_normal( + vertex.position, + [1.0; 3], + vertex.alpha.clamp(0.0, 1.0), + vertex.uv, + vertex.normal, + ) + })); + mesh.indices.extend( + frame + .indices + .iter() + .map(|&index| { + vertex_base + .checked_add(index) + .ok_or_else(|| "shadow index exceeds u32".to_string()) + }) + .collect::<Result<Vec<_>, _>>()?, + ); + let index_count = u32::try_from(frame.indices.len()) + .map_err(|_| "shadow index count exceeds u32".to_string())?; + if let Some(range) = mesh.draw_ranges.get_mut(shadow_range.range_index) { + range.first_index = first_index; + range.index_count = index_count; + } + Ok(VulkanDynamicDrawRange { + range_index: shadow_range.range_index, + first_index, + index_count, + }) +} + +fn shadow_atlas_texture_from_frame(frame: &ShadowFrame) -> VulkanStaticTexture { + VulkanStaticTexture { + width: 256, + height: 256, + rgba8: frame.atlas_rgba8.clone(), + mip_levels: vec![VulkanStaticTextureMip { + width: 256, + height: 256, + rgba8: frame.atlas_rgba8.clone(), + }], + } +} + +fn append_environment_range( + mesh: &mut VulkanStaticMesh, + material_index: usize, + vertices: Vec<VulkanStaticVertex>, + local_indices: Vec<u32>, + pipeline_state: LegacyPipelineState, +) -> Result<EnvironmentGpuRange, String> { + if vertices.is_empty() || local_indices.is_empty() || !local_indices.len().is_multiple_of(3) { + return Err("environment geometry range is empty or not triangle-aligned".to_string()); + } + let vertex_start = mesh.vertices.len(); + let first_index = mesh.indices.len(); + let material_index = u16::try_from(material_index) + .map_err(|_| "environment material selector exceeds u16".to_string())?; + let vertex_start_u32 = u32::try_from(vertex_start) + .map_err(|_| "environment vertex allocation exceeds u32".to_string())?; + for index in local_indices { + mesh.indices.push( + vertex_start_u32 + .checked_add(index) + .ok_or_else(|| "environment index exceeds u32".to_string())?, + ); + } + let index_count = u32::try_from(mesh.indices.len() - first_index) + .map_err(|_| "environment index allocation exceeds u32".to_string())?; + let vertex_capacity = vertices.len(); + mesh.vertices.extend(vertices); + let first_index = u32::try_from(first_index) + .map_err(|_| "environment first index exceeds u32".to_string())?; + let range_index = mesh.draw_ranges.len(); + mesh.draw_ranges + .push(fparkan_render_vulkan::VulkanStaticDrawRange { + first_index, + index_count, + material_index, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state, + alpha_test_reference: 0, + }); + Ok(EnvironmentGpuRange { + range_index, + vertex_start, + vertex_capacity, + material_index: usize::from(material_index), + }) +} + +/// Reorders indexed draw segments without breaking the static mesh contract. +/// +/// Draw ranges are required to cover one contiguous index buffer. Changing +/// only their vector order therefore is insufficient when a background pass +/// must run before mission geometry: the corresponding index segments have to +/// move with them and every range receives a new `first_index`. +fn reorder_draw_ranges( + mesh: &mut VulkanStaticMesh, + old_order: &[usize], +) -> Result<Vec<usize>, String> { + if old_order.len() != mesh.draw_ranges.len() { + return Err("draw-range reorder does not cover the whole mesh".to_string()); + } + let mut seen = vec![false; mesh.draw_ranges.len()]; + let old_ranges = mesh.draw_ranges.clone(); + let old_indices = mesh.indices.clone(); + let mut indices = Vec::with_capacity(old_indices.len()); + let mut ranges = Vec::with_capacity(old_ranges.len()); + let mut remap = vec![usize::MAX; old_ranges.len()]; + for (new_index, &old_index) in old_order.iter().enumerate() { + if old_index >= old_ranges.len() || seen[old_index] { + return Err("draw-range reorder contains a duplicate or invalid range".to_string()); + } + seen[old_index] = true; + let old_range = old_ranges[old_index]; + let first = usize::try_from(old_range.first_index) + .map_err(|_| "draw-range first index exceeds addressable memory".to_string())?; + let count = usize::try_from(old_range.index_count) + .map_err(|_| "draw-range index count exceeds addressable memory".to_string())?; + let end = first + .checked_add(count) + .ok_or_else(|| "draw-range index segment overflows address space".to_string())?; + let segment = old_indices + .get(first..end) + .ok_or_else(|| "draw-range index segment is outside the mesh".to_string())?; + let first_index = u32::try_from(indices.len()) + .map_err(|_| "reordered index buffer exceeds u32".to_string())?; + indices.extend_from_slice(segment); + let mut range = old_range; + range.first_index = first_index; + ranges.push(range); + remap[old_index] = new_index; + } + if seen.iter().any(|seen| !seen) || indices.len() != old_indices.len() { + return Err("draw-range reorder does not cover the whole index buffer".to_string()); + } + mesh.indices = indices; + mesh.draw_ranges = ranges; + Ok(remap) +} + +fn quad_indices(slot_count: usize) -> Result<Vec<u32>, String> { + let mut indices = Vec::with_capacity( + slot_count + .checked_mul(6) + .ok_or_else(|| "environment quad index count overflow".to_string())?, + ); + for slot in 0..slot_count { + let base = u32::try_from( + slot.checked_mul(4) + .ok_or_else(|| "environment quad vertex count overflow".to_string())?, + ) + .map_err(|_| "environment quad vertex count exceeds u32".to_string())?; + indices.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 1, base + 3]); + } + Ok(indices) +} + +fn placeholder_quad_vertices( + camera: &FreeFlightCamera, + slot_count: usize, + size: f32, +) -> Vec<VulkanStaticVertex> { + let center = add3( + camera.position, + scale3(camera.forward(), camera.far_plane * 0.5), + ); + let right = scale3(camera.right(), size); + let up = scale3(camera.up(), size); + (0..slot_count) + .flat_map(|_| { + [ + environment_vertex(sub3(sub3(center, right), up), [1.0; 3], [0.0, 1.0]), + environment_vertex(add3(sub3(center, up), right), [1.0; 3], [1.0, 1.0]), + environment_vertex(add3(sub3(center, right), up), [1.0; 3], [0.0, 0.0]), + environment_vertex(add3(add3(center, right), up), [1.0; 3], [1.0, 0.0]), + ] + }) + .collect() +} + +fn screen_gradient_quad_vertices( + camera: &FreeFlightCamera, + viewport: [f32; 2], + color: [f32; 3], +) -> [VulkanStaticVertex; 4] { + let width = viewport[0].max(1.0); + let height = viewport[1].max(1.0); + let aspect = width / height; + let fov = camera.vertical_fov.clamp(0.05, 3.0); + // The quad is placed close to the camera and drawn with depth disabled. + // It therefore covers the complete projected viewport while the later + // dome, terrain, and object ranges provide their own depth-tested detail. + let distance = 1.0_f32; + let half_height = distance * (fov * 0.5).tan() * 1.05; + let half_width = half_height * aspect; + let center = add3(camera.position, scale3(camera.forward(), distance)); + let right = scale3(camera.right(), half_width); + let up = scale3(camera.up(), half_height); + [ + environment_vertex(sub3(sub3(center, right), up), color, [0.0, 1.0]), + environment_vertex(add3(sub3(center, up), right), color, [1.0, 1.0]), + environment_vertex(add3(sub3(center, right), up), color, [0.0, 0.0]), + environment_vertex(add3(add3(center, right), up), color, [1.0, 0.0]), + ] +} + +impl EnvironmentGpuScene { + fn fixed_range_indices(&self) -> HashSet<usize> { + self.sky_layers + .iter() + .map(|layer| layer.range.range_index) + .chain(self.sprites.iter().map(|sprite| sprite.range.range_index)) + .chain([ + self.rain.range_index, + self.snow.range_index, + self.lightning.range_index, + ]) + .collect() + } + + fn new( + mesh: &mut VulkanStaticMesh, + materials: &mut Vec<VulkanStaticMaterial>, + preview_material_animations: &mut Vec<MaterialAnimationBinding>, + sun_occlusion_range_indices: &mut Vec<usize>, + environment_materials: &SkyMaterials, + material_assets: &HashMap<String, EnvironmentMaterialAsset>, + sky_mesh: &SkyMesh, + camera: &FreeFlightCamera, + shadow_range_index: &mut usize, + ) -> Result<Self, String> { + let mut names = HashMap::new(); + let mut material_animations = Vec::new(); + let mut material_indices = [None; 9]; + let fallback_names = SkyMaterials::default(); + for row in 0..9 { + let name = environment_materials + .material_name(row) + .or_else(|| fallback_names.material_name(row)) + .unwrap_or("ENVIRONMENT"); + let index = append_environment_material( + materials, + &mut names, + material_assets, + &mut material_animations, + name, + row, + )?; + material_indices[row] = Some(index); + } + let gradient_material_index = append_environment_gradient_material(materials)?; + let nebula_material_index = material_indices[0] + .ok_or_else(|| "nebula material selector is unavailable".to_string())?; + let stars_material_index = material_indices[1] + .ok_or_else(|| "stars material selector is unavailable".to_string())?; + // Native sky mode 4 uses one descriptor set: TEX0 is the nebula and + // TEX1 is the independently animated stars texture whose alpha chooses + // the contribution. Keep the stars MAT0 binding as the nebula binding's + // detail stage; do not form a Cartesian product of the two phase rows. + let (nebula_binding_index, nebula_phase_materials) = + find_material_animation_variant(&material_animations, nebula_material_index) + .ok_or_else(|| { + format!("MAT0 sky material {nebula_material_index} has no phase variants") + })?; + let (stars_binding_index, stars_phase_materials) = + find_material_animation_variant(&material_animations, stars_material_index) + .ok_or_else(|| { + format!("MAT0 sky material {stars_material_index} has no phase variants") + })?; + if stars_phase_materials.is_empty() { + return Err("stars material has no phase variants".to_string()); + } + let stars_descriptors = stars_phase_materials + .iter() + .map(|&material_index| { + let material = materials.get(material_index).ok_or_else(|| { + format!("stars material descriptor {material_index} is unavailable") + })?; + Ok((material.texture.clone(), material.uv_transform)) + }) + .collect::<Result<Vec<_>, String>>()?; + let stars_binding = material_animations + .get(stars_binding_index) + .ok_or_else(|| "stars material animation binding is unavailable".to_string())? + .base + .clone(); + let stars_stage_textures = stars_descriptors + .iter() + .map(|(texture, _)| texture.clone()) + .collect(); + let stars_initial_uv = stars_descriptors + .first() + .map(|(_, uv_transform)| *uv_transform) + .unwrap_or([0.0, 0.0, 1.0, 1.0]); + for &material_index in &nebula_phase_materials { + let stars_texture = stars_descriptors + .first() + .map(|(texture, _)| texture.clone()) + .ok_or_else(|| "stars material descriptor is unavailable".to_string())?; + let nebula = materials.get_mut(material_index).ok_or_else(|| { + format!("nebula material descriptor {material_index} is unavailable") + })?; + nebula.detail_texture = Some(stars_texture); + nebula.detail_uv_transform = stars_initial_uv; + nebula.sky_nebula_stars = true; + } + let nebula_binding = material_animations + .get_mut(nebula_binding_index) + .ok_or_else(|| "nebula material animation binding is unavailable".to_string())?; + nebula_binding.detail = Some(stars_binding); + nebula_binding.stage_textures = Some(MaterialStageTextures { + detail: Some(stars_stage_textures), + overlay: None, + overlay_detail: None, + // Force TEX1 to the independently sampled stars phase on the first + // frame, including when the initial star descriptor is already set. + applied_material_phases: HashMap::new(), + }); + let lightning_material_index = append_environment_material( + materials, + &mut names, + material_assets, + &mut material_animations, + "env_lightning", + 3, + )?; + + let mut scene = Self { + material_animations, + sky_layers: Vec::with_capacity(4), + sprites: Vec::with_capacity(ENVIRONMENT_SPRITE_ROWS.len() + ENVIRONMENT_FLARE_SLOTS), + rain: EnvironmentGpuRange { + range_index: 0, + vertex_start: 0, + vertex_capacity: 0, + material_index: material_indices[8].unwrap_or(0), + }, + snow: EnvironmentGpuRange { + range_index: 0, + vertex_start: 0, + vertex_capacity: 0, + material_index: material_indices[7].unwrap_or(0), + }, + lightning: EnvironmentGpuRange { + range_index: 0, + vertex_start: 0, + vertex_capacity: 0, + material_index: lightning_material_index, + }, + lightning_material_index, + }; + + let screen_range = append_environment_range( + mesh, + gradient_material_index, + screen_gradient_quad_vertices(camera, [16.0, 9.0], [1.0; 3]).to_vec(), + quad_indices(1)?, + world_pipeline_state(LegacyBlendMode::Opaque, LegacyDepthMode::Disabled), + )?; + scene.sky_layers.push(EnvironmentSkyLayer { + kind: SkyPassKind::ScreenGradient, + stage: None, + secondary_stage: None, + material_index: gradient_material_index, + diffuse_alpha: 1.0, + static_directional_rgb: [1.0; 3], + static_additive_rgb: [0.0; 3], + translation_z: 0.0, + range: screen_range, + }); + + let nebula_material = materials + .get(nebula_material_index) + .ok_or_else(|| "nebula material descriptor is unavailable".to_string())?; + let nebula_vertices = sky_mesh + .vertices + .iter() + .map(|vertex| { + let color = vertex.color_rgba(); + environment_vertex_with_alpha_normal( + add3(vertex.position, camera.position), + [color[0], color[1], color[2]], + color[3], + vertex.uv[0], + sky_normal(vertex.normal), + ) + }) + .collect(); + let nebula_range = append_environment_range( + mesh, + nebula_material_index, + nebula_vertices, + sky_mesh.indices.clone(), + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + nebula_material_index, + nebula_range.range_index, + ); + scene.sky_layers.push(EnvironmentSkyLayer { + kind: SkyPassKind::NebulaStars, + stage: Some(0), + secondary_stage: Some(1), + material_index: nebula_material_index, + diffuse_alpha: nebula_material.diffuse_alpha, + static_directional_rgb: nebula_material.directional_rgb, + static_additive_rgb: nebula_material.additive_rgb, + translation_z: 0.0, + range: nebula_range, + }); + + let dome_vertices = sky_mesh + .vertices + .iter() + .map(|vertex| { + let color = vertex.color_rgba(); + environment_vertex_with_alpha_normal( + add3(vertex.position, camera.position), + [color[0], color[1], color[2]], + color[3], + [0.0, 0.0], + sky_normal(vertex.normal), + ) + }) + .collect(); + let dome_range = append_environment_range( + mesh, + gradient_material_index, + dome_vertices, + sky_mesh.indices.clone(), + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + scene.sky_layers.push(EnvironmentSkyLayer { + kind: SkyPassKind::DomeGradient, + stage: None, + secondary_stage: None, + material_index: gradient_material_index, + diffuse_alpha: 1.0, + static_directional_rgb: [1.0; 3], + static_additive_rgb: [0.0; 3], + translation_z: 0.0, + range: dome_range, + }); + + let clouds_material_index = material_indices[2] + .ok_or_else(|| "cloud material selector is unavailable".to_string())?; + let clouds_material = materials + .get(clouds_material_index) + .ok_or_else(|| "cloud material descriptor is unavailable".to_string())?; + let clouds_vertices = sky_mesh + .vertices + .iter() + .map(|vertex| { + let color = vertex.color_rgba(); + environment_vertex_with_alpha_normal( + add3(add3(vertex.position, camera.position), [0.0, 0.0, -5000.0]), + [color[0], color[1], color[2]], + color[3], + vertex.uv[2], + sky_normal(vertex.normal), + ) + }) + .collect(); + let clouds_range = append_environment_range( + mesh, + clouds_material_index, + clouds_vertices, + sky_mesh.indices.clone(), + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + clouds_material_index, + clouds_range.range_index, + ); + scene.sky_layers.push(EnvironmentSkyLayer { + kind: SkyPassKind::Clouds, + stage: Some(2), + secondary_stage: None, + material_index: clouds_material_index, + diffuse_alpha: clouds_material.diffuse_alpha, + static_directional_rgb: clouds_material.directional_rgb, + static_additive_rgb: clouds_material.additive_rgb, + translation_z: -5000.0, + range: clouds_range, + }); + + let sprite_size = (camera.far_plane * 0.01).max(1.0); + for row in ENVIRONMENT_SPRITE_ROWS { + let material_index = material_indices[row] + .ok_or_else(|| format!("sky sprite row {row} has no material selector"))?; + let range = append_environment_range( + mesh, + material_index, + placeholder_quad_vertices(camera, 1, sprite_size), + quad_indices(1)?, + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + material_index, + range.range_index, + ); + scene.sprites.push(EnvironmentSpriteLayer { + row, + material_index, + diffuse_alpha: materials + .get(material_index) + .map_or(1.0, |material| material.diffuse_alpha), + flare_index: None, + range, + }); + } + + for flare_index in 0..ENVIRONMENT_FLARE_SLOTS { + let row = SKY_FLARE_ROWS[flare_index]; + let material_index = material_indices[row] + .ok_or_else(|| format!("sky flare row {row} has no material selector"))?; + let range = append_environment_range( + mesh, + material_index, + placeholder_quad_vertices(camera, 1, sprite_size), + quad_indices(1)?, + world_pipeline_state(LegacyBlendMode::Additive, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + material_index, + range.range_index, + ); + scene.sprites.push(EnvironmentSpriteLayer { + row, + material_index, + diffuse_alpha: materials + .get(material_index) + .map_or(1.0, |material| material.diffuse_alpha), + flare_index: Some(flare_index), + range, + }); + } + + let particle_indices = quad_indices(ENVIRONMENT_PARTICLE_SLOTS)?; + scene.rain = append_environment_range( + mesh, + material_indices[8] + .ok_or_else(|| "rain material selector is unavailable".to_string())?, + placeholder_quad_vertices(camera, ENVIRONMENT_PARTICLE_SLOTS, 0.25), + particle_indices.clone(), + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + scene.rain.material_index, + scene.rain.range_index, + ); + scene.snow = append_environment_range( + mesh, + material_indices[7] + .ok_or_else(|| "snow material selector is unavailable".to_string())?, + placeholder_quad_vertices(camera, ENVIRONMENT_PARTICLE_SLOTS, 0.25), + particle_indices, + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + scene.snow.material_index, + scene.snow.range_index, + ); + scene.lightning = append_environment_range( + mesh, + lightning_material_index, + placeholder_quad_vertices(camera, 1, sprite_size), + quad_indices(1)?, + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + register_material_range( + &mut scene.material_animations, + scene.lightning.material_index, + scene.lightning.range_index, + ); + + // Preserve the native sky order while keeping the indexed mesh + // contiguous. The screen gradient must be the first draw, and the + // cloud dome follows all directional sprites. Reordering the range + // metadata alone would leave first_index values pointing into the old + // segment order, so move each index segment together with its range. + let range_for_kind = |kind: SkyPassKind| { + scene + .sky_layers + .iter() + .find(|layer| layer.kind == kind) + .map(|layer| layer.range.range_index) + }; + let screen_index = range_for_kind(SkyPassKind::ScreenGradient) + .ok_or_else(|| "screen gradient range was not allocated".to_string())?; + let nebula_index = range_for_kind(SkyPassKind::NebulaStars) + .ok_or_else(|| "nebula sky range was not allocated".to_string())?; + let dome_index = range_for_kind(SkyPassKind::DomeGradient) + .ok_or_else(|| "dome gradient range was not allocated".to_string())?; + let cloud_index = range_for_kind(SkyPassKind::Clouds) + .ok_or_else(|| "cloud sky range was not allocated".to_string())?; + let sprite_indices = scene + .sprites + .iter() + .map(|sprite| sprite.range.range_index) + .collect::<Vec<_>>(); + if sprite_indices.is_empty() { + return Err("sky sprite ranges were not allocated".to_string()); + } + let rain_index = scene.rain.range_index; + let snow_index = scene.snow.range_index; + let lightning_index = scene.lightning.range_index; + let environment_indices = [ + screen_index, + nebula_index, + dome_index, + cloud_index, + rain_index, + snow_index, + lightning_index, + *shadow_range_index, + ] + .into_iter() + .chain(sprite_indices.iter().copied()) + .collect::<HashSet<_>>(); + let old_range_count = mesh.draw_ranges.len(); + let mut old_order = Vec::with_capacity(old_range_count); + old_order.push(screen_index); + old_order.extend((0..old_range_count).filter(|index| { + !environment_indices.contains(index) + && *index != screen_index + && *index != nebula_index + && *index != dome_index + && *index != cloud_index + })); + old_order.push(*shadow_range_index); + old_order.extend([nebula_index, dome_index]); + old_order.extend(sprite_indices.iter().copied()); + old_order.extend([cloud_index, rain_index, snow_index, lightning_index]); + let remap = reorder_draw_ranges(mesh, &old_order)?; + for layer in &mut scene.sky_layers { + layer.range.range_index = *remap + .get(layer.range.range_index) + .ok_or_else(|| "sky draw range remap is out of bounds".to_string())?; + } + for sprite in &mut scene.sprites { + sprite.range.range_index = *remap + .get(sprite.range.range_index) + .ok_or_else(|| "sky sprite draw range remap is out of bounds".to_string())?; + } + scene.rain.range_index = *remap + .get(scene.rain.range_index) + .ok_or_else(|| "rain draw range remap is out of bounds".to_string())?; + scene.snow.range_index = *remap + .get(scene.snow.range_index) + .ok_or_else(|| "snow draw range remap is out of bounds".to_string())?; + scene.lightning.range_index = *remap + .get(scene.lightning.range_index) + .ok_or_else(|| "lightning draw range remap is out of bounds".to_string())?; + remap_material_animation_ranges(&mut scene.material_animations, &remap)?; + remap_material_animation_ranges(preview_material_animations, &remap)?; + for range_index in sun_occlusion_range_indices { + *range_index = *remap + .get(*range_index) + .ok_or_else(|| "sun occlusion range remap is out of bounds".to_string())?; + } + *shadow_range_index = *remap + .get(*shadow_range_index) + .ok_or_else(|| "shadow draw range remap is out of bounds".to_string())?; + Ok(scene) + } + + fn update( + &mut self, + mesh: &mut VulkanStaticMesh, + materials: &[VulkanStaticMaterial], + renderer: &mut VulkanSmokeRenderer, + sky_frame: &SkyFrame, + sky_mesh: &SkyMesh, + environment_frame: &EnvironmentFrame, + camera: &FreeFlightCamera, + viewport: [f32; 2], + material_clock_ms: u32, + terrain: &TerrainWorld, + sun_occlusion_range_indices: &[usize], + ) -> Result<(), String> { + let active_materials = update_material_animations( + &mut self.material_animations, + materials, + renderer, + material_clock_ms, + )?; + // Resolve the native glare once for the frame. CSky feeds the + // boosted primary color back into the light manager and uses the + // positive boost delta for both the screen-gradient horizon floor and + // the dome palette clamp. + let projected_sun = + project_direction_pixels(camera, sky_frame.sun.geometry_direction(), viewport) + .unwrap_or([f32::NAN; 2]); + let optics = sky_frame + .sun_optics(SunVisibility { + projected_sun, + viewport, + view_forward: camera.forward(), + // Flares use the same terrain/object ray query as the native + // visibility gate. The sun sprite itself deliberately stays + // independent of this test, matching the native layer's + // `requires_occlusion` split. + unoccluded: native_sun_unoccluded_with_world( + terrain, + mesh, + sun_occlusion_range_indices, + camera, + sky_frame.sun.geometry_direction(), + ), + }) + .ok(); + let glare_rgb_delta = optics.map_or([0.0; 3], |optics| { + std::array::from_fn(|index| { + (optics.primary_color[index] - sky_frame.sun.color[index]).max(0.0) + }) + }); + let gradient = sky_frame.screen_gradient(camera.yaw, glare_rgb_delta); + if let Some(optics) = optics { + let mut uniforms = renderer.frame_uniforms(); + if sky_frame.sun.active { + uniforms.directional_lights[0].rgb = optics.primary_color; + } + renderer + .set_frame_uniforms(uniforms) + .map_err(|error| format!("update glare light uniforms: {error}"))?; + } + let gradient_rgba = gradient.color.rgba(); + for layer in &self.sky_layers { + let range = layer.range; + let destination = mesh + .vertices + .get_mut(range.vertex_start..range.vertex_start + range.vertex_capacity) + .ok_or_else(|| "sky vertex allocation is out of bounds".to_string())?; + let pass_frame = sky_frame + .passes + .iter() + .find(|pass| pass.kind == layer.kind) + .ok_or_else(|| format!("sky frame is missing {:?} pass", layer.kind))?; + match layer.kind { + SkyPassKind::ScreenGradient => { + if destination.len() != 4 { + return Err("screen gradient vertex allocation changed".to_string()); + } + destination.copy_from_slice(&screen_gradient_quad_vertices( + camera, + viewport, + [gradient_rgba[0], gradient_rgba[1], gradient_rgba[2]], + )); + } + SkyPassKind::NebulaStars | SkyPassKind::DomeGradient | SkyPassKind::Clouds => { + if destination.len() != sky_mesh.vertices.len() { + return Err( + "sky mesh vertex count changed after GPU allocation".to_string() + ); + } + for (target, source) in destination.iter_mut().zip(&sky_mesh.vertices) { + let source_color = match layer.kind { + // Native mode 4 receives a white DIFFUSE and + // chooses nebula/stars through TEX1 alpha. + SkyPassKind::NebulaStars | SkyPassKind::Clouds => [1.0; 4], + SkyPassKind::DomeGradient => { + gradient.clamp_dome_color(source.color).rgba() + } + SkyPassKind::ScreenGradient => unreachable!(), + }; + target.position = add3( + add3(source.position, camera.position), + [0.0, 0.0, layer.translation_z], + ); + target.color = [source_color[0], source_color[1], source_color[2]]; + target.uv = layer + .stage + .and_then(|stage| source.uv.get(stage).copied()) + .unwrap_or([0.0, 0.0]); + target.detail_uv = layer + .secondary_stage + .and_then(|stage| source.uv.get(stage).copied()) + .unwrap_or([0.0, 0.0]); + target.normal = sky_normal(source.normal); + target.overlay_alpha = source_color[3]; + } + } + } + let material_index = active_materials + .get(&range.range_index) + .map(|state| state.material_index) + .unwrap_or(layer.material_index); + if !active_materials.contains_key(&range.range_index) { + renderer + .set_material_alphas(material_index, layer.diffuse_alpha.clamp(0.0, 1.0), 0.0) + .map_err(|error| format!("update sky material alpha: {error}"))?; + } + if layer.kind == SkyPassKind::Clouds { + let directional_rgb = pass_frame + .material_color + .unwrap_or(layer.static_directional_rgb); + renderer + .set_material_lighting( + material_index, + directional_rgb, + layer.static_additive_rgb, + ) + .map_err(|error| format!("update cloud material lighting: {error}"))?; + } + renderer + .set_draw_range_index_count( + range.range_index, + if layer.kind == SkyPassKind::ScreenGradient { + 6 + } else { + u32::try_from(sky_mesh.indices.len()) + .map_err(|_| "sky index count exceeds u32".to_string())? + }, + ) + .map_err(|error| format!("update sky draw range: {error}"))?; + } + + // Flares share their source material row, so their packed alpha lives + // in each vertex. Upload the shared phase alpha once before emitting + // the twelve fixed-capacity ranges. + let mut flare_materials = HashMap::new(); + for sprite in &self.sprites { + if sprite.flare_index.is_some() + && !active_materials.contains_key(&sprite.range.range_index) + { + flare_materials.insert(sprite.material_index, sprite.diffuse_alpha); + } + } + for (material_index, diffuse_alpha) in flare_materials { + renderer + .set_material_alphas(material_index, diffuse_alpha.clamp(0.0, 1.0), 0.0) + .map_err(|error| format!("update sky flare alpha: {error}"))?; + } + for sprite in &self.sprites { + let mut active = false; + let mut vertices = None; + let mut material_alpha = 0.0; + if let Some(flare_index) = sprite.flare_index { + if let Some(flare) = + optics.and_then(|optics| optics.flares.get(flare_index).copied()) + { + let direction = screen_ray_from_pixels(camera, flare.center_pixels, viewport); + active = flare.active && direction.is_some() && flare.color[3] > 0.0; + if let Some(direction) = direction { + vertices = Some(sprite_quad_vertices_pixels( + camera, + direction, + (camera.far_plane * 0.5).max(camera.near_plane * 2.0), + flare.half_size_pixels, + flare.half_size_pixels, + viewport, + [flare.color[0], flare.color[1], flare.color[2]], + flare.color[3], + flare.uv, + )); + } + } + } else if let Some(frame) = sprite_frame_for_row(sky_frame, sprite.row) { + let projected = project_direction_pixels(camera, frame.direction, viewport); + active = frame.active + && frame.intensity > 0.0 + && projected.is_some_and(|center| { + center[0] >= 0.0 + && center[0] <= viewport[0] + && center[1] >= 0.0 + && center[1] <= viewport[1] + }); + if active { + if let Ok([half_width, half_height]) = sprite_half_size_pixels( + frame.size_values, + viewport, + horizontal_fov(camera, viewport[0] / viewport[1].max(1.0)), + ) { + vertices = Some(sprite_quad_vertices_pixels( + camera, + frame.direction, + (camera.far_plane * 0.5).max(camera.near_plane * 2.0), + half_width, + half_height, + viewport, + frame.color, + frame.alpha, + frame.uv, + )); + } else { + active = false; + } + } + material_alpha = if active { + // The packed sprite alpha is carried by the vertex. The + // MAT0 phase alpha remains the material multiplier; + // intensity controls lifecycle visibility, not a second + // alpha fade. + sprite.diffuse_alpha + } else { + 0.0 + }; + } + if active { + if let Some(vertices) = vertices { + let destination = mesh + .vertices + .get_mut(sprite.range.vertex_start..sprite.range.vertex_start + 4) + .ok_or_else(|| { + "sky sprite vertex allocation is out of bounds".to_string() + })?; + destination.copy_from_slice(&vertices); + } else { + active = false; + } + } + if sprite.flare_index.is_none() { + let material_index = active_materials + .get(&sprite.range.range_index) + .map(|state| state.material_index) + .unwrap_or(sprite.material_index); + // The animation update above has already restored the + // sampled phase alpha for an active animated material. A + // hidden sprite still needs an explicit zero, while a static + // sprite uses its authored material alpha when visible. + let should_write_alpha = + !active || !active_materials.contains_key(&sprite.range.range_index); + if should_write_alpha { + renderer + .set_material_alphas(material_index, material_alpha.clamp(0.0, 1.0), 0.0) + .map_err(|error| format!("update sky sprite alpha: {error}"))?; + } + } + renderer + .set_draw_range_index_count(sprite.range.range_index, if active { 6 } else { 0 }) + .map_err(|error| format!("update sky sprite draw range: {error}"))?; + } + + let mut rain_count = 0usize; + let mut snow_count = 0usize; + let mut lightning = None; + for primitive in &environment_frame.primitives { + match primitive { + EnvironmentPrimitive::Particle { + kind, + color, + screen, + .. + } => { + let (range, slot) = match kind { + PrecipitationKind::Rain if rain_count < ENVIRONMENT_PARTICLE_SLOTS => { + let slot = rain_count; + rain_count += 1; + (self.rain, slot) + } + PrecipitationKind::Snow if snow_count < ENVIRONMENT_PARTICLE_SLOTS => { + let slot = snow_count; + snow_count += 1; + (self.snow, slot) + } + _ => continue, + }; + let vertices = + precipitation_quad_vertices(*kind, screen, *color, camera, viewport); + let start = range.vertex_start + slot * 4; + let destination = mesh.vertices.get_mut(start..start + 4).ok_or_else(|| { + "precipitation vertex allocation is out of bounds".to_string() + })?; + destination.copy_from_slice(&vertices); + } + EnvironmentPrimitive::Lightning(bolt) if lightning.is_none() => { + lightning = Some(bolt); + } + EnvironmentPrimitive::Lightning(_) => {} + } + } + renderer + .set_draw_range_index_count( + self.rain.range_index, + u32::try_from(rain_count * 6) + .map_err(|_| "rain draw count exceeds u32".to_string())?, + ) + .map_err(|error| format!("update rain draw range: {error}"))?; + renderer + .set_draw_range_index_count( + self.snow.range_index, + u32::try_from(snow_count * 6) + .map_err(|_| "snow draw count exceeds u32".to_string())?, + ) + .map_err(|error| format!("update snow draw range: {error}"))?; + + let lightning_alpha = if let Some(bolt) = lightning { + let vertices = lightning_quad_vertices(bolt); + let destination = mesh + .vertices + .get_mut(self.lightning.vertex_start..self.lightning.vertex_start + 4) + .ok_or_else(|| "lightning vertex allocation is out of bounds".to_string())?; + destination.copy_from_slice(&vertices); + bolt.intensity.clamp(0.0, 1.0) + } else { + 0.0 + }; + let lightning_material_index = active_materials + .get(&self.lightning.range_index) + .map(|state| state.material_index) + .unwrap_or(self.lightning_material_index); + renderer + .set_material_alphas(lightning_material_index, lightning_alpha, 0.0) + .map_err(|error| format!("update lightning material alpha: {error}"))?; + renderer + .set_draw_range_index_count( + self.lightning.range_index, + if lightning.is_some() { 6 } else { 0 }, + ) + .map_err(|error| format!("update lightning draw range: {error}"))?; + Ok(()) + } +} + +fn sprite_frame_for_row<'frame, 'materials>( + sky_frame: &'frame SkyFrame<'materials>, + row: usize, +) -> Option<SkySpriteFrame<'materials>> { + sky_frame + .sprites + .iter() + .copied() + .find(|sprite| sprite.row == row) +} + +fn horizontal_fov(camera: &FreeFlightCamera, aspect: f32) -> f32 { + let aspect = if aspect.is_finite() && aspect > 0.0 { + aspect + } else { + 16.0 / 9.0 + }; + 2.0 * ((camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan() * aspect).atan() +} + +/// Tests the camera-to-sun segment against the validated mission terrain. +/// +/// The native CSun caller starts a half-unit in front of the camera along the +/// normalized geometry direction, then queries the finite segment ending at +/// the sun center. `Land.msh` stores Z in the native 32x height unit while the +/// renderer's world projection divides it by 32, so only the Z components are +/// scaled before the terrain query. The ray parameter remains normalized to +/// the finite segment and is therefore independent of world distance. +#[cfg(test)] +fn native_sun_unoccluded( + terrain: &TerrainWorld, + camera: &FreeFlightCamera, + direction: [f32; 3], +) -> bool { + let empty_mesh = VulkanStaticMesh { + vertices: Vec::new(), + indices: Vec::new(), + draw_ranges: Vec::new(), + }; + native_sun_unoccluded_with_world(terrain, &empty_mesh, &[], camera, direction) +} + +fn native_sun_unoccluded_with_world( + terrain: &TerrainWorld, + world_mesh: &VulkanStaticMesh, + sun_occlusion_range_indices: &[usize], + camera: &FreeFlightCamera, + direction: [f32; 3], +) -> bool { + // Native CSun requests object kinds 1, 3, 4, and 10 (the aggregate + // object mask is 0x41A) and excludes full surface flag 0x20. The preview + // range list is built from roots whose raw mission kind is one of those + // four eligible kinds; fixed atmosphere ranges are remapped out before + // this call. + let Some(direction) = normalize3(direction) else { + return false; + }; + let sun_center = add3( + camera.position, + scale3( + direction, + (camera.far_plane * 0.5).max(camera.near_plane * 2.0), + ), + ); + let start = add3(camera.position, scale3(direction, 0.5)); + let segment = sub3(sun_center, start); + if !sun_center + .iter() + .chain(start.iter()) + .chain(segment.iter()) + .all(|value| value.is_finite()) + { + return false; + } + if dot3(segment, segment) <= f32::EPSILON { + return true; + } + let origin = [start[0], start[1], start[2] * 32.0]; + let terrain_segment = [segment[0], segment[1], segment[2] * 32.0]; + if let Ok(Some(hit)) = terrain.raycast_excluding( + origin, + terrain_segment, + FullSurfaceMask(0), + FullSurfaceMask(0x0000_0020), + ) { + if hit.distance.is_finite() && hit.distance <= 1.0 { + return false; + } + } + !world_mesh_sun_occluded(world_mesh, sun_occlusion_range_indices, start, segment) +} + +fn world_mesh_sun_occluded( + mesh: &VulkanStaticMesh, + range_indices: &[usize], + origin: [f32; 3], + direction: [f32; 3], +) -> bool { + for &range_index in range_indices { + let Some(range) = mesh.draw_ranges.get(range_index).copied() else { + continue; + }; + // The native world query receives the compact exclusion bit 0x8, + // which is the world-space representation of full surface flag 0x20. + if range.batch_flags & 0x0008 != 0 { + continue; + } + let Ok(first_index) = usize::try_from(range.first_index) else { + continue; + }; + let Ok(index_count) = usize::try_from(range.index_count) else { + continue; + }; + let Some(indices) = mesh + .indices + .get(first_index..first_index.saturating_add(index_count)) + else { + continue; + }; + for triangle in indices.chunks_exact(3) { + let Ok(a) = usize::try_from(triangle[0]) else { + continue; + }; + let Ok(b) = usize::try_from(triangle[1]) else { + continue; + }; + let Ok(c) = usize::try_from(triangle[2]) else { + continue; + }; + let (Some(&a), Some(&b), Some(&c)) = ( + mesh.vertices.get(a), + mesh.vertices.get(b), + mesh.vertices.get(c), + ) else { + continue; + }; + if segment_intersects_triangle(origin, direction, a.position, b.position, c.position) { + return true; + } + } + } + false +} + +fn segment_intersects_triangle( + origin: [f32; 3], + direction: [f32; 3], + a: [f32; 3], + b: [f32; 3], + c: [f32; 3], +) -> bool { + let edge1 = sub3(b, a); + let edge2 = sub3(c, a); + let pvec = cross3(direction, edge2); + let determinant = dot3(edge1, pvec); + if !determinant.is_finite() || determinant.abs() <= f32::EPSILON { + return false; + } + let inverse = determinant.recip(); + let tvec = sub3(origin, a); + let u = dot3(tvec, pvec) * inverse; + if !u.is_finite() || !(0.0..=1.0).contains(&u) { + return false; + } + let qvec = cross3(tvec, edge1); + let v = dot3(direction, qvec) * inverse; + if !v.is_finite() || v < 0.0 || u + v > 1.0 { + return false; + } + let distance = dot3(edge2, qvec) * inverse; + distance.is_finite() && (0.0..=1.0).contains(&distance) +} + +/// Replays the native draw-item callback: one whole indexed batch is keyed by +/// the camera distance to its first indexed vertex. +fn first_indexed_vertex_distance( + mesh: &VulkanStaticMesh, + range_index: usize, + camera_position: [f32; 3], +) -> Result<f32, String> { + let range: VulkanStaticDrawRange = *mesh + .draw_ranges + .get(range_index) + .ok_or_else(|| "world draw range index is out of bounds".to_string())?; + let first_index = usize::try_from(range.first_index) + .map_err(|_| "world draw range first index is not addressable".to_string())?; + let vertex_index = + usize::try_from(*mesh.indices.get(first_index).ok_or_else(|| { + "world draw range first index is outside the index buffer".to_string() + })?) + .map_err(|_| "world draw range vertex index is not addressable".to_string())?; + let vertex = mesh + .vertices + .get(vertex_index) + .ok_or_else(|| "world draw range vertex index is outside the vertex buffer".to_string())?; + let delta = sub3(vertex.position, camera_position); + let distance = dot3(delta, delta).sqrt(); + distance + .is_finite() + .then_some(distance) + .ok_or_else(|| "world draw range sort distance is non-finite".to_string()) +} + +fn project_direction_pixels( + camera: &FreeFlightCamera, + direction: [f32; 3], + viewport: [f32; 2], +) -> Option<[f32; 2]> { + let direction = normalize3(direction)?; + let width = viewport[0]; + let height = viewport[1]; + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return None; + } + let depth = dot3(camera.forward(), direction); + if !depth.is_finite() || depth <= camera.near_plane.max(0.001) { + return None; + } + let aspect = width / height; + let half_vertical = (camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan(); + let half_horizontal = half_vertical * aspect; + let ndc_x = dot3(camera.right(), direction) / (depth * half_horizontal); + let ndc_y = dot3(camera.up(), direction) / (depth * half_vertical); + let pixels = [(ndc_x * 0.5 + 0.5) * width, (0.5 - ndc_y * 0.5) * height]; + pixels + .iter() + .all(|value| value.is_finite()) + .then_some(pixels) +} + +fn screen_ray_from_pixels( + camera: &FreeFlightCamera, + pixels: [f32; 2], + viewport: [f32; 2], +) -> Option<[f32; 3]> { + if !pixels.iter().all(|value| value.is_finite()) + || !viewport.iter().all(|value| value.is_finite()) + || viewport[0] <= 0.0 + || viewport[1] <= 0.0 + { + return None; + } + let aspect = viewport[0] / viewport[1]; + let half_vertical = (camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan(); + let half_horizontal = half_vertical * aspect; + let ndc_x = pixels[0] / viewport[0] * 2.0 - 1.0; + let ndc_y = 1.0 - pixels[1] / viewport[1] * 2.0; + normalize3(add3( + add3( + camera.forward(), + scale3(camera.right(), ndc_x * half_horizontal), + ), + scale3(camera.up(), ndc_y * half_vertical), + )) +} + +fn sprite_quad_vertices_pixels( + camera: &FreeFlightCamera, + direction: [f32; 3], + distance: f32, + half_width_pixels: f32, + half_height_pixels: f32, + viewport: [f32; 2], + color: [f32; 3], + alpha: f32, + uv: [[f32; 2]; 4], +) -> [VulkanStaticVertex; 4] { + let direction = normalize3(direction).unwrap_or(camera.forward()); + let center = add3(camera.position, scale3(direction, distance)); + let depth = dot3(camera.forward(), direction) + .mul_add(distance, 0.0) + .max(camera.near_plane.max(0.001)); + let width = viewport[0].max(1.0); + let height = viewport[1].max(1.0); + let aspect = width / height; + let vertical_fov = camera.vertical_fov.clamp(0.05, 3.0); + let horizontal_fov_radians = horizontal_fov(camera, aspect); + let world_per_pixel_x = 2.0 * depth * (horizontal_fov_radians * 0.5).tan() / width; + let world_per_pixel_y = 2.0 * depth * (vertical_fov * 0.5).tan() / height; + let right = scale3( + camera.right(), + half_width_pixels.max(0.0) * world_per_pixel_x, + ); + let up = scale3(camera.up(), half_height_pixels.max(0.0) * world_per_pixel_y); + // Native sun and flare UVs are ordered left-top, left-bottom, right-bottom, + // right-top. The shared index order is bottom-left, bottom-right, top-left, + // top-right. + let uv = [uv[1], uv[2], uv[0], uv[3]]; + [ + environment_vertex_with_alpha(sub3(sub3(center, right), up), color, alpha, uv[0]), + environment_vertex_with_alpha(add3(sub3(center, up), right), color, alpha, uv[1]), + environment_vertex_with_alpha(add3(sub3(center, right), up), color, alpha, uv[2]), + environment_vertex_with_alpha(add3(add3(center, right), up), color, alpha, uv[3]), + ] +} + +fn precipitation_quad_vertices( + kind: PrecipitationKind, + screen: &ScreenBillboard, + color: [f32; 4], + camera: &FreeFlightCamera, + viewport: [f32; 2], +) -> [VulkanStaticVertex; 4] { + let rgb = [color[0], color[1], color[2]]; + let depths = match kind { + PrecipitationKind::Rain => [ + screen.tail_depth, + screen.tail_depth, + screen.head_depth, + screen.head_depth, + ], + PrecipitationKind::Snow => [screen.head_depth; 4], + }; + std::array::from_fn(|index| { + environment_vertex_with_alpha( + world_from_ndc(camera, screen.corners[index], depths[index], viewport), + rgb, + color[3], + screen.uv[index], + ) + }) +} + +fn world_from_ndc( + camera: &FreeFlightCamera, + ndc: [f32; 2], + depth: f32, + viewport: [f32; 2], +) -> [f32; 3] { + let depth = if depth.is_finite() { + depth.max(camera.near_plane.max(0.001)) + } else { + camera.near_plane.max(0.001) + }; + let aspect = viewport[0].max(1.0) / viewport[1].max(1.0); + let half_vertical = (camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan(); + let half_horizontal = half_vertical * aspect; + let offset = add3( + scale3(camera.right(), ndc[0] * half_horizontal), + scale3(camera.up(), ndc[1] * half_vertical), + ); + add3( + camera.position, + scale3(add3(camera.forward(), offset), depth), + ) +} + +fn lightning_quad_vertices( + bolt: &fparkan_fx::environment::LightningBolt, +) -> [VulkanStaticVertex; 4] { + std::array::from_fn(|index| { + environment_vertex(bolt.quad.vertices[index], [1.0; 3], bolt.quad.uv[index]) + }) +} + +/// CPU atmosphere state shared by the interactive preview and the renderer's +/// per-frame light/fog block. The sky mesh and weather primitives stay on the +/// same update clock, so camera movement cannot leave environment audio or +/// lighting one frame behind. +struct DynamicEnvironment { + schedule: TypedAtmosphere, + sky: SkySystem, + environment: EnvironmentSystem, + materials: SkyMaterials, + material_assets: HashMap<String, EnvironmentMaterialAsset>, + schedule_offset_seconds: f32, + time_seconds: f32, +} + +impl DynamicEnvironment { + fn update( + &mut self, + dt_seconds: f32, + camera: &FreeFlightCamera, + aspect: f32, + viewport: [f32; 2], + renderer: &mut VulkanSmokeRenderer, + mut audio: Option<&mut audio::GameAudio>, + ) -> Result<(SkyFrame<'_>, SkyMesh, EnvironmentFrame), String> { + let dt_seconds = if dt_seconds.is_finite() { + dt_seconds.max(0.0) + } else { + 0.0 + }; + self.time_seconds = if self.time_seconds.is_finite() { + self.time_seconds + dt_seconds + } else { + 0.0 + }; + let absolute_time_seconds = self.schedule_offset_seconds + self.time_seconds; + let atmosphere = self.schedule.sample(absolute_time_seconds); + let mut sky_mesh = self.sky.mesh().clone(); + let sky_frame = + SkyFrame::from_atmosphere(&atmosphere, &self.materials, absolute_time_seconds); + sky_mesh.update_colors(&sky_frame.sky); + if let Some(audio) = audio.as_mut() { + let primary_rgb = if sky_frame.sun.active { + sky_frame.sun.color + } else if sky_frame.moon.active { + sky_frame.moon.color + } else { + [0.0; 3] + }; + audio + .update_ambience(dt_seconds, primary_rgb) + .map_err(|error| format!("update ambient variation: {error}"))?; + } + + let mut uniforms = renderer.frame_uniforms(); + uniforms.clip_from_world = camera.vulkan_camera(aspect).clip_from_world; + for (target, source) in uniforms + .directional_lights + .iter_mut() + .zip(sky_frame.directional_lights) + { + target.direction = source.direction; + target.rgb = source.color; + target.coefficients = [0.0, 0.0, 1.0]; + target.active = source.active; + } + uniforms.lighting_floor = sky_frame.lighting_floor; + uniforms.fog_color = sky_frame.fog.color; + uniforms.fog_start = sky_frame.fog.start.max(0.0); + uniforms.fog_end = sky_frame.fog.end.max(uniforms.fog_start); + uniforms.camera_position = camera.position; + renderer + .set_frame_uniforms(uniforms) + .map_err(|error| format!("update sky frame uniforms: {error}"))?; + + let fx_camera = FxCamera::with_projection( + camera.position, + camera.forward(), + camera.right(), + camera.up(), + camera.vertical_fov, + aspect, + ) + .with_viewport(viewport); + let environment_frame = self.environment.update_atmosphere_with_materials( + dt_seconds, + &atmosphere, + &self.materials, + fx_camera, + ); + let mut uniforms = renderer.frame_uniforms(); + uniforms.point_light = environment_frame + .point_lights + .iter() + .find(|light| light.active) + .map(|light| VulkanPointLight { + position: light.position, + rgb: [light.color[0], light.color[1], light.color[2]], + range: light.range, + coefficients: light.attenuation, + active: true, + }) + .unwrap_or_default(); + renderer + .set_frame_uniforms(uniforms) + .map_err(|error| format!("update environment point-light uniforms: {error}"))?; + if let Some(audio) = audio.as_mut() { + for sound in &environment_frame.sounds { + audio + .handle_sound_event(sound) + .map_err(|error| format!("play environment sound: {error}"))?; + } + } + Ok((sky_frame, sky_mesh, environment_frame)) + } +} + +fn load_environment( + root: &Path, + mission: &str, + atmosphere_seconds: Option<f32>, +) -> Result<Option<DynamicEnvironment>, String> { + let mission_dir = mission_asset_directory(root, mission)?; + let Some(sky_path) = find_mission_file(&mission_dir, "sky.ske")? else { + return Ok(None); + }; + let sky_bytes = std::fs::read(&sky_path) + .map_err(|error| format!("read atmosphere {}: {error}", sky_path.display()))?; + let schedule = TypedAtmosphere::parse(&sky_bytes) + .map_err(|error| format!("parse atmosphere {}: {error}", sky_path.display()))?; + + let sky_wea_path = match find_sibling_file(&sky_path, "sky.wea") { + Some(path) => Some(path), + None => find_mission_file(&mission_dir, "sky.wea")?, + } + .ok_or_else(|| format!("selected atmosphere {} has no sky.wea", sky_path.display()))?; + let materials = SkyMaterials::parse( + &std::fs::read(&sky_wea_path) + .map_err(|error| format!("read sky materials {}: {error}", sky_wea_path.display()))?, + ) + .map_err(|error| format!("parse sky materials {}: {error}", sky_wea_path.display()))?; + let mut sky_material_loader = StandaloneWearMaterialLoader::new(root); + let mut material_assets = + load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root( + root, + &sky_wea_path, + 0, + ) + .map_err(|error| { + format!( + "resolve sky material WEAR {}: {error}", + sky_wea_path.display() + ) + })? + .into_iter() + .enumerate() + .map( + |(material_index, (name, mips, phase, document))| -> Result<_, String> { + let initial_phase = phase; + let mut phase_textures = Vec::with_capacity(document.phases.len()); + let mut phases = Vec::with_capacity(document.phases.len()); + for phase_index in 0..document.phases.len() { + let phase_index_u16 = u16::try_from(phase_index).unwrap_or(u16::MAX); + let (phase_mips, sampled_phase) = if phase_index == 0 { + (mips.clone(), initial_phase) + } else { + sky_material_loader + .load( + &sky_wea_path, + u16::try_from(material_index).unwrap_or(u16::MAX), + phase_index_u16, + ) + .map_err(|error| { + format!("resolve sky material phase {phase_index}: {error}") + })? + }; + phase_textures.push(vulkan_texture_from_rgba_mips(phase_mips, "sky material")?); + phases.push(sampled_phase); + } + let texture = phase_textures + .first() + .cloned() + .ok_or_else(|| "sky material has no MAT0 phases".to_string())?; + Ok(( + String::from_utf8_lossy(&name).into_owned(), + EnvironmentMaterialAsset { + texture, + phase: initial_phase, + document, + phase_textures, + phases, + }, + )) + }, + ) + .collect::<Result<HashMap<_, _>, String>>()?; + let effect_bytes = load_resource_entry_bytes_from_root(root, "effects.rlb", "env_lightning") + .map_err(|error| format!("load effects.rlb/env_lightning: {error}"))?; + let lightning_effect = decode_env_lightning_fxid(effect_bytes) + .map_err(|error| format!("decode effects.rlb/env_lightning: {error}"))?; + if !lightning_effect + .visual + .archive + .eq_ignore_ascii_case("material.lib") + { + return Err(format!( + "env_lightning visual archive must be material.lib, got {:?}", + lightning_effect.visual.archive + )); + } + let (lightning_mips, lightning_phase, lightning_document) = + load_material_name_texture_rgba8_and_phase_with_document_from_root( + root, + &lightning_effect.visual.name, + 0, + ) + .map_err(|error| { + format!( + "resolve env_lightning MAT0 {}: {error}", + lightning_effect.visual.name + ) + })?; + let mut lightning_phase_textures = vec![vulkan_texture_from_rgba_mips( + lightning_mips, + "env_lightning material", + )?]; + let mut lightning_phases = vec![lightning_phase]; + for phase_index in 1..lightning_document.phases.len() { + let (phase_mips, phase, _document) = + load_material_name_texture_rgba8_and_phase_with_document_from_root( + root, + &lightning_effect.visual.name, + u16::try_from(phase_index).unwrap_or(u16::MAX), + ) + .map_err(|error| { + format!( + "resolve env_lightning MAT0 phase {phase_index} {}: {error}", + lightning_effect.visual.name + ) + })?; + lightning_phase_textures.push(vulkan_texture_from_rgba_mips( + phase_mips, + "env_lightning material", + )?); + lightning_phases.push(phase); + } + let lightning_texture = lightning_phase_textures + .first() + .cloned() + .ok_or_else(|| "env_lightning MAT0 has no phases".to_string())?; + material_assets.insert( + lightning_effect.visual.name.clone(), + EnvironmentMaterialAsset { + texture: lightning_texture, + phase: lightning_phase, + document: lightning_document, + phase_textures: lightning_phase_textures, + phases: lightning_phases, + }, + ); + let initial_offset_seconds = schedule.initial_offset_seconds() as f32; + let sky = SkySystem::new(Default::default(), materials.clone()) + .map_err(|error| format!("build sky geometry: {error}"))?; + let time_seconds = atmosphere_seconds + .map(|seconds| seconds - initial_offset_seconds) + .unwrap_or(0.0); + let mut environment = EnvironmentSystem::new(0x4650_4152_4B41_4E_u64); + environment.set_lightning_effect(lightning_effect); + Ok(Some(DynamicEnvironment { + schedule, + sky, + environment, + materials, + material_assets, + schedule_offset_seconds: initial_offset_seconds, + time_seconds, + })) +} + +/// Returns the host directory containing the selected mission's assets. +/// Mission keys are the same relative paths accepted by the runtime VFS; the +/// host root is only used here for the small set of loose mission-local files +/// that are not archive entries (`sky.ske` and `sky.wea`). +fn mission_asset_directory(root: &Path, mission: &str) -> Result<PathBuf, String> { + let mission_path = Path::new(mission); + if mission_path.is_absolute() { + return Err("mission environment path must be relative to --root".to_string()); + } + let directory = mission_path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let directory = root.join(directory); + if !directory.is_dir() { + return Err(format!( + "selected mission asset directory does not exist: {}", + directory.display() + )); + } + Ok(directory) +} + +/// Finds one mission-local loose environment file. A direct sibling wins; +/// nested `env`, `environment`, or `planet` layouts are accepted only when +/// they contain a single matching file, so a second unrelated candidate never +/// changes the selected mission silently. +fn find_mission_file(root: &Path, name: &str) -> Result<Option<PathBuf>, String> { + let direct = root.join(name); + if direct.is_file() { + return Ok(Some(direct)); + } + let mut stack = vec![root.to_path_buf()]; + let mut matches = Vec::new(); + while let Some(directory) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() { + stack.push(path); + } else if file_type.is_file() + && path + .file_name() + .is_some_and(|file_name| file_name.eq_ignore_ascii_case(name)) + { + matches.push(path); + } + } + } + matches.sort(); + match matches.as_slice() { + [] => Ok(None), + [path] => Ok(Some(path.clone())), + _ => Err(format!( + "selected mission has multiple {name} files: {}", + matches + .iter() + .map(|path| path.display().to_string()) + .collect::<Vec<_>>() + .join(", ") + )), + } +} + +fn find_sibling_file(source: &Path, name: &str) -> Option<PathBuf> { + source + .parent() + .map(|parent| parent.join(name)) + .filter(|path| path.is_file()) +} + +fn add3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] + right[0], left[1] + right[1], left[2] + right[2]] +} + +fn add3_in_place(target: &mut [f32; 3], value: [f32; 3]) { + *target = add3(*target, value); +} + +fn sub3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] - right[0], left[1] - right[1], left[2] - right[2]] +} + +fn scale3(value: [f32; 3], factor: f32) -> [f32; 3] { + [value[0] * factor, value[1] * factor, value[2] * factor] +} + +fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 { + left[0].mul_add(right[0], left[1].mul_add(right[1], left[2] * right[2])) +} + +fn cross3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [ + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ] +} + +fn length3(value: [f32; 3]) -> Option<f32> { + let length = dot3(value, value).sqrt(); + length + .is_finite() + .then_some(length) + .filter(|length| *length > f32::EPSILON) +} + +fn normalize3(value: [f32; 3]) -> Option<[f32; 3]> { + let inverse = length3(value)?.recip(); + Some(scale3(value, inverse)) +} + +fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] { + let mut result = [0.0; 16]; + for row in 0..4 { + for column in 0..4 { + result[row * 4 + column] = (0..4) + .map(|inner| left[row * 4 + inner] * right[inner * 4 + column]) + .sum(); + } + } + result +} + struct StaticPreviewScene { mesh: VulkanStaticMesh, materials: Vec<VulkanStaticMaterial>, + material_animations: Vec<MaterialAnimationBinding>, + sun_occlusion_range_indices: Vec<usize>, + shadow_casters: Vec<ShadowCaster>, + shadow_receivers: Vec<ShadowTriangle>, + shadow_material_index: usize, + terrain: Arc<TerrainWorld>, camera: VulkanStaticCamera, + free_camera: Option<FreeFlightCamera>, camera_mode: &'static str, mesh_components: usize, terrain_components: usize, } -/// Projects the mission terrain plus every MSH component of the explicitly -/// bounded static-preview roots into one diagnostic XY frame. +#[derive(Clone, Copy, Debug, Default)] +struct StartupTimings { + mission_assets: Duration, + terrain_materials: Duration, + sky: Duration, + gpu_initialization: Duration, +} + +#[derive(Clone, Copy, Debug, Default)] +struct ShadowFrameEvidence { + visible_casters: usize, + projected_indices: usize, +} + +fn elapsed_ms(duration: Duration) -> f64 { + duration.as_secs_f64() * 1_000.0 +} + +fn unit_component_is_visible(index: usize, source_type: Option<u32>) -> bool { + index == 0 || source_type == Some(PROTOTYPE_TYPE_EXTO) +} + +fn native_sun_object_kind_eligible(source_type: Option<u32>) -> bool { + native_shadow_kind(source_type).is_some() +} + +const PROTOTYPE_TYPE_FORT: u32 = u32::from_le_bytes(*b"FORT"); +const PROTOTYPE_TYPE_BTLU: u32 = u32::from_le_bytes(*b"BTLU"); +const PROTOTYPE_TYPE_STAT: u32 = u32::from_le_bytes(*b"STAT"); + +/// Maps the resolved registry type to the native object-kind selector used by +/// CSun/CShade. The mission object's raw kind is an unrelated TMA field; the +/// renderer receives the registry provenance from the prepared visual. +fn native_shadow_kind(source_type: Option<u32>) -> Option<u32> { + match source_type { + Some(PROTOTYPE_TYPE_FORT) => Some(3), + Some(PROTOTYPE_TYPE_BTLU) => Some(4), + Some(PROTOTYPE_TYPE_STAT) => Some(10), + _ => None, + } +} + +fn compose_unit_pose(parent: VulkanNodePose, child: VulkanNodePose) -> VulkanNodePose { + let rotated = rotate_unit_vector(child.translation, parent.rotation); + VulkanNodePose { + translation: [ + parent.translation[0] + rotated[0], + parent.translation[1] + rotated[1], + parent.translation[2] + rotated[2], + ], + rotation: multiply_unit_quaternions(parent.rotation, child.rotation), + } +} + +fn rotate_unit_vector(value: [f32; 3], rotation: [f32; 4]) -> [f32; 3] { + let [x, y, z, w] = rotation; + let tx = 2.0 * (y * value[2] - z * value[1]); + let ty = 2.0 * (z * value[0] - x * value[2]); + let tz = 2.0 * (x * value[1] - y * value[0]); + [ + value[0] + w * tx + (y * tz - z * ty), + value[1] + w * ty + (z * tx - x * tz), + value[2] + w * tz + (x * ty - y * tx), + ] +} + +fn multiply_unit_quaternions(left: [f32; 4], right: [f32; 4]) -> [f32; 4] { + let [lx, ly, lz, lw] = left; + let [rx, ry, rz, rw] = right; + [ + lw * rx + lx * rw + ly * rz - lz * ry, + lw * ry - lx * rz + ly * rw + lz * rx, + lw * rz + lx * ry - ly * rx + lz * rw, + lw * rw - lx * rx - ly * ry - lz * rz, + ] +} + +/// Projects the mission terrain plus every MSH component of the selected roots +/// into one shared world-space mesh. /// -/// This intentionally takes only the first MAT0 texture request for each MSH -/// batch selector. Terrain maps its proven low-byte packed tag channel through -/// the map-local `Land2.wea` table. The high-byte `Land1.wea` channel is -/// retained on draw ranges but awaits a recovered blend equation; orientation, -/// later material phases, animation, lightmaps and gameplay visibility remain -/// outside this bridge. +/// Terrain resolves its packed low-byte base and high-byte overlay through +/// map-local `Land1.wea`, then pairs each layer with its `Land2.wea` +/// microtexture and type-18 UV stream. Type-14 alpha remains a continuous +/// interpolated overlay mask. MSH component hierarchy, animation sampling, +/// lightmaps, and gameplay visibility are handled by the preview bridge below. + +fn static_preview_node_times( + node_count: usize, + bindings: &[PreparedControlNodeBinding], +) -> Result<Vec<Option<AnimationTime>>, String> { + let mut node_times = vec![Some(AnimationTime(0.0)); node_count]; + for binding in bindings { + // Disabled rows are filtered while loading CTLD, but preserve the + // native guard here in case a caller constructs MissionAssets itself. + if binding.flags & 4 != 0 { + continue; + } + if binding.node_index >= node_count { + return Err(format!( + "control binding node {} is outside model node count {node_count}", + binding.node_index + )); + } + if !binding.frame_a.is_finite() + || !binding.frame_b.is_finite() + || !binding.initial_blend.is_finite() + || binding.frame_a < 0.0 + || binding.frame_b < 0.0 + { + return Err(format!( + "control binding for node {} contains an invalid frame or blend", + binding.node_index + )); + } + + let mut blend = binding.initial_blend; + if binding.flags & 1 != 0 { + if blend > 1.0 { + blend -= 1.0; + } + if blend < 0.0 { + blend += 1.0; + } + } else { + blend = blend.clamp(0.0, 1.0); + } + if binding.flags & 2 != 0 { + blend = 1.0 - blend; + } + let sample_time = (1.0 - blend) * binding.frame_a + blend * binding.frame_b; + if !sample_time.is_finite() || sample_time < 0.0 { + return Err(format!( + "control binding for node {} produced a negative or non-finite sample time unsupported by the static sampler", + binding.node_index + )); + } + // Source order is significant: native initialization writes each + // enabled row in turn, so a later row for the same node wins. + node_times[binding.node_index] = Some(AnimationTime(sample_time)); + } + Ok(node_times) +} + +fn static_preview_component_pose_buffer( + model: &ModelAsset, + bindings: &[PreparedControlNodeBinding], + static_animation_frame: Option<u16>, +) -> Result<Option<NodePoseBuffer>, String> { + if let Some(frame) = static_animation_frame { + return Ok(node38_sampled_hierarchy(model, frame)); + } + let node_times = static_preview_node_times(model.node_count, bindings)?; + Ok(node38_sampled_hierarchy_at_times(model, &node_times) + .or_else(|| node38_fallback_hierarchy(model))) +} fn static_preview_mesh_and_materials( assets: &MissionAssets, @@ -141,197 +3528,373 @@ fn static_preview_mesh_and_materials( let terrain_mesh = terrain .source_mesh() .ok_or_else(|| "runtime terrain does not retain its validated source mesh".to_string())?; - let xy_frame = legacy_camera - .is_none() - .then(|| static_preview_xy_frame(assets, terrain, roots)) - .transpose()?; let mut mesh = VulkanStaticMesh { vertices: Vec::new(), indices: Vec::new(), draw_ranges: Vec::new(), }; let mut materials = Vec::new(); - let terrain_component = if legacy_camera.is_some() { - project_land_msh_to_static_mesh_in_legacy_world_space(terrain_mesh) - } else { - let frame = xy_frame.ok_or_else(|| "missing diagnostic XY frame".to_string())?; - project_land_msh_to_static_mesh_in_xy_frame(terrain_mesh, frame) - } - .map_err(|err| format!("project mission terrain for Vulkan: {err}"))?; + let mut material_animations = Vec::new(); + let mut sun_occlusion_range_indices = Vec::new(); + let mut shadow_casters = Vec::new(); + let shadow_receivers = terrain_shadow_receivers(terrain_mesh)?; + let mut terrain_material_loader = StandaloneWearMaterialLoader::new(root); + let terrain_component = project_land_msh_to_static_mesh_in_legacy_world_space(terrain_mesh) + .map_err(|err| format!("project mission terrain for Vulkan: {err}"))?; let terrain_materials = static_preview_terrain_base_materials( + &mut terrain_material_loader, root, land_msh_path, &terrain_component, &mut materials, + &mut material_animations, + static_material_phase, + )?; + append_static_preview_component( + &mut mesh, + terrain_component, + &terrain_materials, + Some(&mut material_animations), )?; - append_static_preview_component(&mut mesh, terrain_component, &terrain_materials)?; let mut mesh_components = 0; - for (object_index, root) in roots.iter().enumerate() { - for visual_id in assets.visuals_for_object(object_index) { - let visual = assets.visual_by_id(*visual_id).ok_or_else(|| { - format!( - "static preview root {object_index} references unknown visual {visual_id:?}" - ) - })?; - let Some(model_id) = visual.model_id else { + for (object_index, mission_root) in roots.iter().enumerate() { + let visual_ids = assets.visuals_for_object(object_index); + let records = &mission_root.unit_components; + let tree = if records.is_empty() { + None + } else { + Some(unit_component_tree(records).map_err(|error| { + format!("decode unit component tree for mission object {object_index}: {error}") + })?) + }; + if let Some(tree) = tree.as_ref() { + if tree.len() != visual_ids.len() { + return Err(format!( + "mission object {object_index} unit tree has {} records but {} visuals", + tree.len(), + visual_ids.len() + )); + } + } + let component_refs = visual_ids + .iter() + .map(|visual_id| { + let visual = assets.visual_by_id(*visual_id).ok_or_else(|| { + format!( + "static preview root {object_index} references unknown visual {visual_id:?}" + ) + })?; + let model = visual + .model_id + .map(|model_id| { + assets.model_by_id(model_id).ok_or_else(|| { + format!( + "static preview visual {visual_id:?} references unknown model {model_id:?}" + ) + }) + }) + .transpose()?; + Ok((visual, model)) + }) + .collect::<Result<Vec<_>, String>>()?; + let component_controls = assets.object_component_controls.get(object_index); + if component_controls.is_some_and(|controls| controls.len() != component_refs.len()) { + return Err(format!( + "mission object {object_index} has {} visuals but {} component controls", + component_refs.len(), + component_controls.map_or(0, Vec::len) + )); + } + let root_native_kind = component_refs + .first() + .and_then(|(visual, _)| native_shadow_kind(visual.source_type)); + let root_sun_eligible = native_sun_object_kind_eligible( + component_refs + .first() + .and_then(|(visual, _)| visual.source_type), + ); + let mut root_shadow_triangles = Vec::new(); + let mut root_shadow_spheres = Vec::new(); + let mut component_mount_poses = vec![None; component_refs.len()]; + let mut component_pose_buffers = vec![None; component_refs.len()]; + for (component_index, (visual, model)) in component_refs.iter().enumerate() { + let Some(model) = *model else { continue; }; - let model = assets.model_by_id(model_id).ok_or_else(|| { - format!("static preview visual {visual_id:?} references unknown model {model_id:?}") - })?; - let transform = LegacyIron3dEulerTransform { - translation: root.position, - orientation_radians: root.orientation_raw, - }; - let component = if legacy_camera.is_some() { - if let Some(frame) = static_animation_frame { - project_msh_to_static_mesh_in_world_space_with_node_sampled_poses( - &model.validated, - transform, - root.scale, - frame, - ) + let component_bindings = component_controls + .and_then(|controls| controls.get(component_index)) + .and_then(Option::as_ref) + .map_or(&[][..], |control| control.bindings.as_slice()); + component_pose_buffers[component_index] = static_preview_component_pose_buffer( + &model.validated, + component_bindings, + static_animation_frame, + )?; + let should_render = tree.as_ref().map_or(true, |_| { + unit_component_is_visible(component_index, visual.source_type) + }); + // Native AniMesh only resolves global MSH node sockets for EXTO + // geometry. Internal INTO components use controller/equipment + // slots, whose `parent_or_link` values are not MSH node indices. + // Do not ask the MSH hierarchy to resolve hidden internal parts. + let mount_pose = if should_render { + if let Some(tree) = tree.as_ref() { + let record = records.get(component_index).ok_or_else(|| { + format!("unit component {component_index} is outside decoded records") + })?; + let parent_index = tree[component_index].parent_index; + match parent_index { + None => None, + Some(parent_index) => { + let parent_visible = component_refs.get(parent_index).is_some_and( + |(parent_visual, _)| { + unit_component_is_visible( + parent_index, + parent_visual.source_type, + ) + }, + ); + if !parent_visible { + None + } else { + let parent_model = component_refs + .get(parent_index) + .and_then(|(_, model)| *model) + .ok_or_else(|| { + format!( + "unit component {component_index} parent {parent_index} has no model for mount socket" + ) + })?; + let parent_hierarchy = component_pose_buffers + .get(parent_index) + .and_then(Option::as_ref); + let parent_is_mounted = + component_mount_poses[parent_index].is_some(); + let socket = + usize::try_from(record.parent_or_link).map_err(|_| { + format!( + "unit component {component_index} has negative mount socket {}", + record.parent_or_link + ) + })?; + let parent_socket = match parent_hierarchy { + Some(hierarchy) if parent_is_mounted => { + node38_pose_relative_to_root_from_hierarchy( + hierarchy, socket, + ) + } + Some(hierarchy) => { + node38_pose_from_hierarchy(hierarchy, socket) + } + None if parent_is_mounted => node38_pose_relative_to_root( + &parent_model.validated, + static_animation_frame, + socket, + ), + None => node38_pose( + &parent_model.validated, + static_animation_frame, + socket, + ), + } + .ok_or_else(|| { + format!( + "unit component {component_index} mount socket {socket} is absent from parent {parent_index} (parent source={:?}, node_count={}, slots={}, animation={})", + parent_model.source, + parent_model.validated.node_count, + parent_model.validated.slots.len(), + parent_model.validated.animation.is_some(), + ) + })?; + let parent_root_pose = component_mount_poses[parent_index] + .unwrap_or(VulkanNodePose { + translation: [0.0; 3], + rotation: [0.0, 0.0, 0.0, 1.0], + }); + Some(compose_unit_pose(parent_root_pose, parent_socket)) + } + } + } } else { - project_msh_to_static_mesh_in_world_space_with_node_fallback_poses( - &model.validated, - transform, - root.scale, - ) + None } } else { - let frame = xy_frame.ok_or_else(|| "missing diagnostic XY frame".to_string())?; - if let Some(animation_frame) = static_animation_frame { - project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses( - &model.validated, - frame, - transform, - root.scale, - animation_frame, - ) - } else { - project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses( - &model.validated, - frame, - transform, - root.scale, - ) + None + }; + if let Some(mount_pose) = mount_pose { + component_mount_poses[component_index] = Some(mount_pose); + } + + let transform = LegacyIron3dEulerTransform { + translation: mission_root.position, + orientation_radians: mission_root.orientation_raw, + }; + if should_render { + let pose_buffer = component_pose_buffers[component_index].as_ref(); + let component = match (pose_buffer, mount_pose, static_animation_frame) { + (Some(poses), Some(mount), _) => { + project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount( + &model.validated, + transform, + mission_root.scale, + poses, + mount, + ) + } + (Some(poses), None, _) => { + project_msh_to_static_mesh_in_world_space_with_node_pose_buffer( + &model.validated, + transform, + mission_root.scale, + poses, + ) + } + (None, Some(mount), Some(frame)) => { + project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount( + &model.validated, + transform, + mission_root.scale, + frame, + mount, + ) + } + (None, Some(mount), None) => { + project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount( + &model.validated, + transform, + mission_root.scale, + mount, + ) + } + (None, None, Some(frame)) => { + project_msh_to_static_mesh_in_world_space_with_node_sampled_poses( + &model.validated, + transform, + mission_root.scale, + frame, + ) + } + (None, None, None) => { + project_msh_to_static_mesh_in_world_space_with_node_fallback_poses( + &model.validated, + transform, + mission_root.scale, + ) + } + } + .map_err(|err| format!("project mission MSH for Vulkan: {err}"))?; + let component_shadow_triangles = static_mesh_shadow_triangles(&component); + if root_native_kind.is_some() { + root_shadow_triangles.extend(component_shadow_triangles); + let node1_pose = mount_pose.and_then(|_| match pose_buffer { + Some(poses) => node38_pose_relative_to_root_from_hierarchy(poses, 1), + None => node38_pose_relative_to_root( + &model.validated, + static_animation_frame, + 1, + ), + }); + if let Some(sphere) = mounted_shadow_sphere( + model.validated.bounding_sphere_center, + model.validated.bounding_sphere_radius, + mount_pose, + node1_pose, + ) { + root_shadow_spheres.push(sphere); + } } + let selector_remap = static_preview_component_materials( + assets, + visual, + &component, + static_material_phase, + &mut materials, + &mut material_animations, + )?; + let lightmap_textures = static_preview_lightmap_textures(assets, visual)?; + let range_start = mesh.draw_ranges.len(); + append_static_preview_component_with_lightmaps( + &mut mesh, + component, + &selector_remap, + Some(&mut materials), + &lightmap_textures, + Some(&mut material_animations), + )?; + if root_sun_eligible { + sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len()); + } + mesh_components += 1; + } + } + if let Some(native_kind) = root_native_kind { + let caster_id = u32::try_from(shadow_casters.len()) + .map_err(|_| "shadow caster id exceeds u32".to_string())?; + if let Some(caster) = aggregate_shadow_caster( + caster_id, + native_kind, + LegacyIron3dEulerTransform { + translation: mission_root.position, + orientation_radians: mission_root.orientation_raw, + }, + mission_root.scale, + root_shadow_triangles, + &root_shadow_spheres, + ) { + shadow_casters.push(caster); } - .map_err(|err| format!("project mission MSH for Vulkan: {err}"))?; - let selector_remap = static_preview_component_materials( - assets, - visual, - &component, - static_material_phase, - &mut materials, - )?; - append_static_preview_component(&mut mesh, component, &selector_remap)?; - mesh_components += 1; } } if mesh_components == 0 { return Err("selected static preview roots have no mesh-backed visual".to_string()); } - let camera = legacy_camera.unwrap_or_default(); + let free_camera = if legacy_camera.is_none() { + Some(FreeFlightCamera::from_mesh_and_terrain(&mesh, terrain)?) + } else { + None + }; + let camera = legacy_camera.unwrap_or_else(|| { + free_camera + .map(|camera| camera.vulkan_camera(16.0 / 9.0)) + .unwrap_or_default() + }); + let shadow_material_index = append_shadow_material(&mut materials)?; Ok(StaticPreviewScene { mesh, materials, + material_animations, + sun_occlusion_range_indices, + shadow_casters, + shadow_receivers, + shadow_material_index, + terrain: Arc::new(terrain.clone()), camera, + free_camera, camera_mode: if legacy_camera.is_some() { "legacy-d3d7-capture" } else { - "diagnostic-xy" + "free-flight" }, mesh_components, terrain_components: 1, }) } -fn static_preview_xy_frame( - assets: &MissionAssets, - terrain: &TerrainWorld, - roots: &[MissionObjectDraft], -) -> Result<VulkanStaticXyFrame, String> { - let mut min_x = f32::INFINITY; - let mut max_x = f32::NEG_INFINITY; - let mut min_y = f32::INFINITY; - let mut max_y = f32::NEG_INFINITY; - let terrain_positions = terrain - .source_positions() - .ok_or_else(|| "runtime terrain does not retain source positions".to_string())?; - for position in terrain_positions { - extend_static_preview_xy_bounds(*position, &mut min_x, &mut max_x, &mut min_y, &mut max_y)?; - } - for (object_index, root) in roots.iter().enumerate() { - if !root - .position - .iter() - .chain(root.scale.iter()) - .all(|value| value.is_finite()) - { - return Err(format!( - "static preview root {object_index} has a non-finite position or scale" - )); - } - for visual_id in assets.visuals_for_object(object_index) { - let visual = assets.visual_by_id(*visual_id).ok_or_else(|| { - format!( - "static preview root {object_index} references unknown visual {visual_id:?}" - ) - })?; - let Some(model_id) = visual.model_id else { - continue; - }; - let model = assets.model_by_id(model_id).ok_or_else(|| { - format!("static preview visual {visual_id:?} references unknown model {model_id:?}") - })?; - for position in &model.validated.positions { - let position = LegacyIron3dEulerTransform { - translation: root.position, - orientation_radians: root.orientation_raw, - } - .try_transform_scaled_point(*position, root.scale) - .ok_or_else(|| { - "static preview contains a non-finite transformed XY position".to_string() - })?; - extend_static_preview_xy_bounds( - position, &mut min_x, &mut max_x, &mut min_y, &mut max_y, - )?; - } - } - } - VulkanStaticXyFrame::from_bounds(min_x, max_x, min_y, max_y) - .map_err(|err| format!("build static preview XY frame: {err}")) -} - -fn extend_static_preview_xy_bounds( - position: [f32; 3], - min_x: &mut f32, - max_x: &mut f32, - min_y: &mut f32, - max_y: &mut f32, -) -> Result<(), String> { - if !position.iter().all(|value| value.is_finite()) { - return Err("static preview contains a non-finite XY position".to_string()); - } - *min_x = min_x.min(position[0]); - *max_x = max_x.max(position[0]); - *min_y = min_y.min(position[1]); - *max_y = max_y.max(position[1]); - Ok(()) -} - fn static_preview_terrain_base_materials( + loader: &mut StandaloneWearMaterialLoader, root: &std::path::Path, land_msh_path: &str, mesh: &VulkanStaticMesh, materials: &mut Vec<VulkanStaticMaterial>, + material_animations: &mut Vec<MaterialAnimationBinding>, + static_material_phase: Option<u16>, ) -> Result<Vec<(u16, u16)>, String> { let land_path = root.join(land_msh_path); - let wear_path = land_path + let land_parent = land_path .parent() - .ok_or_else(|| format!("terrain mesh has no parent path: {}", land_path.display()))? - .join("Land2.wea"); + .ok_or_else(|| format!("terrain mesh has no parent path: {}", land_path.display()))?; + let land1_path = land_parent.join("Land1.wea"); + let land2_path = land_parent.join("Land2.wea"); let mut packed_tags = mesh .draw_ranges .iter() @@ -342,29 +3905,231 @@ fn static_preview_terrain_base_materials( packed_tags .into_iter() .map(|packed_tag| { - let selection = TerrainMaterialLayers::from_packed_tag(packed_tag).land2_selection(); - let image = load_standalone_wear_material_texture_mip0_rgba8_from_root( - root, - &wear_path, - selection.material_index, - ) - .map_err(|err| { - format!( - "resolve terrain base material tag 0x{packed_tag:04x} through {}: {err}", - wear_path.display() - ) - })?; - let preview_selector = u16::try_from(materials.len()).map_err(|_| { - "static preview exceeds the available 16-bit material selector space".to_string() - })?; - materials.push(VulkanStaticMaterial { - material_index: preview_selector, - texture: VulkanStaticTexture { - width: image.width, - height: image.height, - rgba8: image.rgba8, + let layers = TerrainMaterialLayers::from_packed_tag(packed_tag); + let base_document = loader + .material_document(&land1_path, layers.base_selection().material_index) + .map_err(|err| format!("resolve terrain base MAT0 document: {err}"))?; + let detail_document = loader + .material_document(&land2_path, layers.detail_base_selection().material_index) + .map_err(|err| format!("resolve terrain detail MAT0 document: {err}"))?; + let overlay_selection = layers.overlay_selection(); + let overlay_detail_selection = layers.detail_overlay_selection(); + let overlay_document = overlay_selection + .map(|selection| loader.material_document(&land1_path, selection.material_index)) + .transpose() + .map_err(|err| format!("resolve terrain overlay MAT0 document: {err}"))?; + let overlay_detail_document = overlay_detail_selection + .map(|selection| loader.material_document(&land2_path, selection.material_index)) + .transpose() + .map_err(|err| format!("resolve terrain overlay detail MAT0 document: {err}"))?; + let base_phases = load_standalone_phase_set( + loader, + &land1_path, + layers.base_selection().material_index, + &base_document, + )?; + let detail_phases = load_standalone_phase_set( + loader, + &land2_path, + layers.detail_base_selection().material_index, + &detail_document, + )?; + let overlay_phases = overlay_selection + .map(|selection| { + load_standalone_phase_set( + loader, + &land1_path, + selection.material_index, + overlay_document.as_ref().expect("overlay document"), + ) + }) + .transpose()?; + let overlay_detail_phases = overlay_detail_selection + .map(|selection| { + load_standalone_phase_set( + loader, + &land2_path, + selection.material_index, + overlay_detail_document + .as_ref() + .expect("overlay detail document"), + ) + }) + .transpose()?; + let phase_count = base_phases.len(); + if phase_count == 0 || detail_phases.is_empty() { + return Err(format!( + "terrain material tag 0x{packed_tag:04x} has incomplete phase tables" + )); + } + if overlay_phases.as_ref().is_some_and(Vec::is_empty) + || overlay_detail_phases.as_ref().is_some_and(Vec::is_empty) + { + return Err(format!( + "terrain material tag 0x{packed_tag:04x} has an empty optional phase table" + )); + } + let initial_phase_index = + usize::from(static_material_phase.unwrap_or(0)).min(phase_count.saturating_sub(1)); + let initial_detail_phase_index = usize::from(static_material_phase.unwrap_or(0)) + .min(detail_phases.len().saturating_sub(1)); + let initial_overlay_phase_index = overlay_phases.as_ref().map(|phases| { + usize::from(static_material_phase.unwrap_or(0)).min(phases.len().saturating_sub(1)) + }); + let initial_overlay_detail_phase_index = overlay_detail_phases.as_ref().map(|phases| { + usize::from(static_material_phase.unwrap_or(0)).min(phases.len().saturating_sub(1)) + }); + let mut phase_material_indices = Vec::with_capacity(phase_count); + for phase_index in 0..phase_count { + let (base, base_phase) = &base_phases[phase_index]; + // The base draw selector follows the base MAT0 timeline. The + // other descriptor stages start at their own initial phase; + // frame-time sampling below can then advance them separately. + let (detail, detail_phase) = &detail_phases[initial_detail_phase_index]; + let overlay = overlay_phases + .as_ref() + .zip(initial_overlay_phase_index) + .map(|(phases, phase_index)| &phases[phase_index]); + let overlay_detail = overlay_detail_phases + .as_ref() + .zip(initial_overlay_detail_phase_index) + .map(|(phases, phase_index)| &phases[phase_index]); + let preview_selector = u16::try_from(materials.len()).map_err(|_| { + "static preview exceeds the available 16-bit material selector space" + .to_string() + })?; + materials.push(VulkanStaticMaterial { + material_index: preview_selector, + texture: base.clone(), + detail_texture: Some(detail.clone()), + overlay_texture: overlay.map(|(texture, _)| texture.clone()), + overlay_detail_texture: overlay_detail.map(|(texture, _)| texture.clone()), + diffuse_alpha: base_phase.diffuse_alpha, + overlay_diffuse_alpha: overlay.map(|(_, phase)| phase.diffuse_alpha), + directional_rgb: base_phase.directional_rgb, + additive_rgb: base_phase.additive_rgb, + uv_transform: base_phase.page_uv_transform, + overlay_directional_rgb: overlay.map(|(_, phase)| phase.directional_rgb), + overlay_additive_rgb: overlay.map(|(_, phase)| phase.additive_rgb), + specular_rgb: base_phase.specular_rgb, + specular_power: base_phase.power, + overlay_specular_rgb: overlay.map(|(_, phase)| phase.specular_rgb), + overlay_specular_power: overlay.map(|(_, phase)| phase.power), + detail_uv_transform: detail_phase.page_uv_transform, + overlay_uv_transform: overlay.map(|(_, phase)| phase.page_uv_transform), + overlay_detail_uv_transform: overlay_detail + .map(|(_, phase)| phase.page_uv_transform), + unlit: false, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: false, + }); + phase_material_indices.push(usize::from(preview_selector)); + } + let base_uvs = phase_uv_transforms_from_standalone( + loader, + &land1_path, + layers.base_selection().material_index, + &base_document, + )?; + let detail_uvs = phase_uv_transforms_from_standalone( + loader, + &land2_path, + layers.detail_base_selection().material_index, + &detail_document, + )?; + let overlay_uvs = match (&overlay_document, layers.overlay_selection()) { + (Some(document), Some(selection)) => phase_uv_transforms_from_standalone( + loader, + &land1_path, + selection.material_index, + document, + )?, + _ => Vec::new(), + }; + let overlay_detail_uvs = + match (&overlay_detail_document, layers.detail_overlay_selection()) { + (Some(document), Some(selection)) => phase_uv_transforms_from_standalone( + loader, + &land2_path, + selection.material_index, + document, + )?, + _ => Vec::new(), + }; + let stage_textures = MaterialStageTextures { + detail: Some( + detail_phases + .iter() + .map(|(texture, _)| texture.clone()) + .collect(), + ), + overlay: overlay_phases + .as_ref() + .map(|phases| phases.iter().map(|(texture, _)| texture.clone()).collect()), + overlay_detail: overlay_detail_phases + .as_ref() + .map(|phases| phases.iter().map(|(texture, _)| texture.clone()).collect()), + applied_material_phases: phase_material_indices + .iter() + .copied() + .map(|material_index| { + ( + material_index, + [ + initial_detail_phase_index, + initial_overlay_phase_index.unwrap_or(0), + initial_overlay_detail_phase_index.unwrap_or(0), + ], + ) + }) + .collect(), + }; + material_animations.push(MaterialAnimationBinding { + initial_phase_index: static_material_phase + .map_or(0, usize::from) + .min(base_document.phases.len().saturating_sub(1)), + variants: vec![MaterialVariantSet { + phase_material_indices, + range_indices: Vec::new(), + }], + base: MaterialPhaseBinding { + document: base_document, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: packed_tag as u32, + frozen_phase_index: static_material_phase.map(usize::from), + phase_uv_transforms: base_uvs, }, + detail: Some(MaterialPhaseBinding { + document: detail_document, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: packed_tag as u32 ^ 0x5A5A_A5A5, + frozen_phase_index: static_material_phase.map(usize::from), + phase_uv_transforms: detail_uvs, + }), + overlay: overlay_document.map(|document| MaterialPhaseBinding { + document, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: packed_tag as u32 ^ 0xA5A5_5A5A, + frozen_phase_index: static_material_phase.map(usize::from), + phase_uv_transforms: overlay_uvs, + }), + overlay_detail: overlay_detail_document.map(|document| MaterialPhaseBinding { + document, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: packed_tag as u32 ^ 0xC3C3_3C3C, + frozen_phase_index: static_material_phase.map(usize::from), + phase_uv_transforms: overlay_detail_uvs, + }), + stage_textures: Some(stage_textures), }); + let preview_selector = + u16::try_from(materials.len() - phase_count + initial_phase_index) + .map_err(|_| "terrain material selector exceeds u16".to_string())?; Ok((packed_tag, preview_selector)) }) .collect() @@ -376,6 +4141,7 @@ fn static_preview_component_materials( mesh: &VulkanStaticMesh, static_material_phase: Option<u16>, materials: &mut Vec<VulkanStaticMaterial>, + material_animations: &mut Vec<MaterialAnimationBinding>, ) -> Result<Vec<(u16, u16)>, String> { let mut source_selectors = mesh .draw_ranges @@ -398,44 +4164,143 @@ fn static_preview_component_materials( let material = assets.material_by_id(*material_id).ok_or_else(|| { format!("static preview prepared material {source_selector} is unavailable") })?; - let texture_name = static_material_phase - .and_then(|phase_index| { - let phase = material.mat0.phases.get(usize::from(phase_index))?; - let length = phase.texture_raw.iter().position(|byte| *byte == 0).unwrap_or(phase.texture_raw.len()); - (length != 0).then(|| ResourceName(phase.texture_raw[..length].to_vec())) - }) - .or_else(|| material.texture_requests.first().cloned()) - .ok_or_else(|| { - format!("static preview material {source_selector} has no MAT0 diffuse texture") + let initial_phase_index = usize::from(static_material_phase.unwrap_or(0)) + .min(material.mat0.phases.len().saturating_sub(1)); + if material.mat0.phases.is_empty() { + return Err(format!( + "static preview material {source_selector} has no MAT0 phases" + )); + } + let mut phase_material_indices = Vec::with_capacity(material.mat0.phases.len()); + for phase in &material.mat0.phases { + let texture_name = phase_texture_name(phase); + let prepared_texture = texture_name.as_ref().and_then(|name| { + assets.textures.iter().find(|texture| { + texture.usage == PreparedTextureUsage::Diffuse + && texture.source.name == *name + }) + }); + // Empty MAT0 texture names remain valid untextured materials. + let texture = prepared_texture + .map(|texture| { + prepared_texture_to_vulkan(texture, "static preview diffuse texture") + }) + .transpose()? + .unwrap_or_else(|| solid_environment_texture([255, 255, 255, 255])); + let coefficients = phase.coefficients(); + let uv_transform = if let Some(texture) = prepared_texture { + page_uv_transform( + texture.texm.width(), + texture.texm.height(), + &texture.texm.page_rects(), + coefficients.page_index, + )? + } else { + [0.0, 0.0, 1.0, 1.0] + }; + let preview_selector = u16::try_from(materials.len()).map_err(|_| { + "static preview exceeds the available 16-bit material selector space" + .to_string() })?; + materials.push(VulkanStaticMaterial { + material_index: preview_selector, + texture, + detail_texture: None, + overlay_texture: None, + overlay_detail_texture: None, + diffuse_alpha: coefficients.opacity, + overlay_diffuse_alpha: None, + directional_rgb: coefficients.directional_rgb, + additive_rgb: coefficients.additive_rgb, + uv_transform, + overlay_directional_rgb: None, + overlay_additive_rgb: None, + specular_rgb: coefficients.specular_rgb, + specular_power: coefficients.power, + overlay_specular_rgb: None, + overlay_specular_power: None, + detail_uv_transform: [0.0, 0.0, 1.0, 1.0], + overlay_uv_transform: None, + overlay_detail_uv_transform: None, + unlit: false, + sky_nebula_stars: false, + lightmap_mode: false, + sky_far_depth: false, + }); + phase_material_indices.push(usize::from(preview_selector)); + } + let preview_selector = u16::try_from( + *phase_material_indices + .get(initial_phase_index) + .ok_or_else(|| "MAT0 phase variant table is empty".to_string())?, + ) + .map_err(|_| "MAT0 phase variant selector exceeds u16".to_string())?; + material_animations.push(MaterialAnimationBinding { + initial_phase_index, + variants: vec![MaterialVariantSet { + phase_material_indices, + range_indices: Vec::new(), + }], + base: MaterialPhaseBinding { + document: material.mat0.clone(), + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: material_id.raw() as u32, + frozen_phase_index: static_material_phase.map(usize::from), + phase_uv_transforms: phase_uv_transforms_from_assets(&material.mat0, assets), + }, + detail: None, + overlay: None, + overlay_detail: None, + stage_textures: None, + }); + Ok((source_selector, preview_selector)) + }) + .collect() +} + +fn prepared_texture_to_vulkan( + texture: &PreparedTexture, + label: &str, +) -> Result<VulkanStaticTexture, String> { + let mip_images = (0..texture.texm.mip_count()) + .map(|level| texture.decode_mip_rgba8(u32::try_from(level).unwrap_or(u32::MAX))) + .collect::<Result<Vec<_>, _>>() + .map_err(|err| format!("decode {label} {:?}: {err}", texture.source.name))?; + let image = mip_images + .first() + .ok_or_else(|| format!("{label} {:?} has no mip zero", texture.source.name))?; + Ok(VulkanStaticTexture { + width: image.width, + height: image.height, + rgba8: image.rgba8.clone(), + mip_levels: mip_images + .into_iter() + .map(|image| VulkanStaticTextureMip { + width: image.width, + height: image.height, + rgba8: image.rgba8, + }) + .collect(), + }) +} + +fn static_preview_lightmap_textures( + assets: &MissionAssets, + visual: &PreparedVisual, +) -> Result<Vec<VulkanStaticTexture>, String> { + visual + .lightmap_ids + .iter() + .map(|id| { let texture = assets .textures .iter() .find(|texture| { - texture.usage == PreparedTextureUsage::Diffuse && texture.source.name == texture_name + texture.id == *id && texture.usage == PreparedTextureUsage::Lightmap }) - .ok_or_else(|| { - format!( - "static preview diffuse texture {texture_name:?} for material {source_selector} is unavailable" - ) - })?; - let image = texture.decode_mip_rgba8(0).map_err(|err| { - format!( - "decode static preview diffuse texture {texture_name:?} for material {source_selector}: {err}" - ) - })?; - let preview_selector = u16::try_from(materials.len()).map_err(|_| { - "static preview exceeds the available 16-bit material selector space".to_string() - })?; - materials.push(VulkanStaticMaterial { - material_index: preview_selector, - texture: VulkanStaticTexture { - width: image.width, - height: image.height, - rgba8: image.rgba8, - }, - }); - Ok((source_selector, preview_selector)) + .ok_or_else(|| format!("static preview lightmap {id:?} is unavailable"))?; + prepared_texture_to_vulkan(texture, "static preview lightmap") }) .collect() } @@ -444,6 +4309,25 @@ fn append_static_preview_component( target: &mut VulkanStaticMesh, component: VulkanStaticMesh, selector_remap: &[(u16, u16)], + material_animations: Option<&mut Vec<MaterialAnimationBinding>>, +) -> Result<(), String> { + append_static_preview_component_with_lightmaps( + target, + component, + selector_remap, + None, + &[], + material_animations, + ) +} + +fn append_static_preview_component_with_lightmaps( + target: &mut VulkanStaticMesh, + component: VulkanStaticMesh, + selector_remap: &[(u16, u16)], + mut materials: Option<&mut Vec<VulkanStaticMaterial>>, + lightmap_textures: &[VulkanStaticTexture], + mut material_animations: Option<&mut Vec<MaterialAnimationBinding>>, ) -> Result<(), String> { let vertex_base = u32::try_from(target.vertices.len()) .map_err(|_| "static preview vertex count exceeds u32".to_string())?; @@ -466,8 +4350,14 @@ fn append_static_preview_component( }) .collect::<Result<Vec<_>, _>>()?, ); + // A lightmapped range must keep the lightmap material family when MAT0 + // advances to another phase. The range's original selector identifies + // the base binding, while the lightmap selector identifies the secondary + // texture. Keep the generated phase family and the binding/variant it + // belongs to so update_material_animations can rebind every frame. + let mut lightmap_materials: HashMap<(usize, u8), (Vec<usize>, usize, usize)> = HashMap::new(); for range in component.draw_ranges { - let material_index = selector_remap + let source_material_index = selector_remap .iter() .find_map(|(source, preview)| (*source == range.material_index).then_some(*preview)) .ok_or_else(|| { @@ -476,6 +4366,95 @@ fn append_static_preview_component( range.material_index ) })?; + let (material_index, lightmap_variant) = if lightmap_textures + .get(usize::from(range.lightmap_index)) + .is_some() + { + let key = (usize::from(source_material_index), range.lightmap_index); + let (phase_material_indices, binding_index, variant_index) = if let Some(existing) = + lightmap_materials.get(&key) + { + existing.clone() + } else { + let bindings = material_animations.as_deref().ok_or_else(|| { + "static preview lightmap animation storage is unavailable".to_string() + })?; + let (binding_index, source_phase_indices) = + find_material_animation_variant(bindings, usize::from(source_material_index)) + .ok_or_else(|| { + format!( + "static preview material selector {source_material_index} has no MAT0 animation binding" + ) + })?; + let lightmap = lightmap_textures + .get(usize::from(range.lightmap_index)) + .expect("lightmap presence checked above"); + let materials = materials.as_mut().ok_or_else(|| { + "static preview lightmap material storage is unavailable".to_string() + })?; + let mut phase_material_indices = Vec::with_capacity(source_phase_indices.len()); + for source_phase_index in source_phase_indices { + let mut lightmap_material = materials + .get(source_phase_index) + .cloned() + .ok_or_else(|| { + format!( + "static preview material selector {source_phase_index} is unavailable" + ) + })?; + let original_directional = lightmap_material.directional_rgb; + lightmap_material.directional_rgb = [0.0; 3]; + lightmap_material.additive_rgb = original_directional; + lightmap_material.detail_texture = Some(lightmap.clone()); + lightmap_material.detail_uv_transform = [0.0, 0.0, 1.0, 1.0]; + lightmap_material.overlay_texture = None; + lightmap_material.overlay_detail_texture = None; + lightmap_material.overlay_diffuse_alpha = None; + lightmap_material.overlay_directional_rgb = None; + lightmap_material.overlay_additive_rgb = None; + lightmap_material.overlay_uv_transform = None; + lightmap_material.overlay_detail_uv_transform = None; + lightmap_material.unlit = false; + lightmap_material.sky_nebula_stars = false; + lightmap_material.lightmap_mode = true; + let index = u16::try_from(materials.len()).map_err(|_| { + "static preview exceeds the available 16-bit material selector space" + .to_string() + })?; + lightmap_material.material_index = index; + materials.push(lightmap_material); + phase_material_indices.push(usize::from(index)); + } + let bindings = material_animations.as_deref_mut().ok_or_else(|| { + "static preview lightmap animation storage is unavailable".to_string() + })?; + let variant_index = bindings[binding_index].variants.len(); + bindings[binding_index].variants.push(MaterialVariantSet { + phase_material_indices: phase_material_indices.clone(), + range_indices: Vec::new(), + }); + let value = (phase_material_indices, binding_index, variant_index); + lightmap_materials.insert(key, value.clone()); + value + }; + let initial_material = *phase_material_indices + .get( + material_animations + .as_deref() + .and_then(|bindings| bindings.get(binding_index)) + .map_or(0, |binding| binding.initial_phase_index), + ) + .ok_or_else(|| "static preview lightmap phase table is empty".to_string())?; + ( + u16::try_from(initial_material).map_err(|_| { + "static preview lightmap material selector exceeds u16".to_string() + })?, + Some((binding_index, variant_index)), + ) + } else { + (source_material_index, None) + }; + let target_range_index = target.draw_ranges.len(); target .draw_ranges .push(fparkan_render_vulkan::VulkanStaticDrawRange { @@ -485,27 +4464,146 @@ fn append_static_preview_component( material_index, ..range }); + if let Some(bindings) = material_animations.as_deref_mut() { + if let Some((binding_index, variant_index)) = lightmap_variant { + bindings + .get_mut(binding_index) + .and_then(|binding| binding.variants.get_mut(variant_index)) + .ok_or_else(|| { + "static preview lightmap animation variant is missing".to_string() + })? + .range_indices + .push(target_range_index); + } else { + register_material_range( + bindings, + usize::from(source_material_index), + target_range_index, + ); + } + } } Ok(()) } +fn load_standalone_vulkan_texture( + loader: &mut StandaloneWearMaterialLoader, + wear_path: &Path, + material_index: u16, + phase_index: u16, +) -> Result<(VulkanStaticTexture, PreparedMaterialPhase), String> { + let (mips, phase) = loader.load(wear_path, material_index, phase_index)?; + let first = mips + .first() + .ok_or_else(|| "standalone TEXM mip loader returned no mip zero".to_string())?; + Ok(( + VulkanStaticTexture { + width: first.width, + height: first.height, + rgba8: first.rgba8.clone(), + mip_levels: mips + .into_iter() + .map(|image| VulkanStaticTextureMip { + width: image.width, + height: image.height, + rgba8: image.rgba8, + }) + .collect(), + }, + phase, + )) +} + +fn load_standalone_phase_set( + loader: &mut StandaloneWearMaterialLoader, + wear_path: &Path, + material_index: u16, + document: &Mat0Document, +) -> Result<Vec<(VulkanStaticTexture, PreparedMaterialPhase)>, String> { + (0..document.phases.len()) + .map(|phase_index| { + load_standalone_vulkan_texture( + loader, + wear_path, + material_index, + u16::try_from(phase_index) + .map_err(|_| "MAT0 phase index exceeds u16".to_string())?, + ) + }) + .collect() +} + fn run_static_vulkan_mode( preview: StaticPreviewScene, + environment: Option<DynamicEnvironment>, + audio: Option<audio::GameAudio>, target_frames: u64, mission: &str, object_count: usize, readback_out: Option<&std::path::Path>, enable_validation: bool, + startup_timings: StartupTimings, ) -> Result<String, String> { let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?; event_loop.set_control_flow(ControlFlow::Poll); + let mut preview = preview; + let mut environment = environment; + // Reserve the projected-shadow draw range before the environment builder + // reorders its fixed passes. The range itself remains a stable draw slot; + // frame geometry is appended after every static environment allocation. + let mut shadow_range = append_environment_range( + &mut preview.mesh, + preview.shadow_material_index, + shadow_placeholder_vertices(), + quad_indices(1)?, + world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly), + )?; + let environment_gpu = match environment.as_mut() { + Some(environment) => { + let camera = preview.free_camera.unwrap_or_else(|| { + FreeFlightCamera::from_mesh(&preview.mesh).unwrap_or(FreeFlightCamera { + position: [0.0; 3], + yaw: 0.0, + pitch: 0.0, + vertical_fov: std::f32::consts::FRAC_PI_3, + near_plane: 0.1, + far_plane: 100_000.0, + move_speed: 1.0, + }) + }); + let environment_materials = environment.materials.clone(); + let material_assets = environment.material_assets.clone(); + let sky_mesh = environment.sky.mesh().clone(); + Some(EnvironmentGpuScene::new( + &mut preview.mesh, + &mut preview.materials, + &mut preview.material_animations, + &mut preview.sun_occlusion_range_indices, + &environment_materials, + &material_assets, + &sky_mesh, + &camera, + &mut shadow_range.range_index, + )?) + } + None => None, + }; + let shadow_base_vertex = preview.mesh.vertices.len(); + let shadow_base_index = preview.mesh.indices.len(); let mut app = StaticVulkanApp::new( preview, + environment, + environment_gpu, + shadow_range, + shadow_base_vertex, + shadow_base_index, + audio, target_frames, mission, object_count, readback_out.map(std::path::Path::to_path_buf), enable_validation, + startup_timings, ); if let Err(err) = event_loop.run_app(&mut app) { app.error = Some(format!("winit event loop: {err}")); @@ -516,7 +4614,32 @@ fn run_static_vulkan_mode( struct StaticVulkanApp { mesh: VulkanStaticMesh, materials: Vec<VulkanStaticMaterial>, + material_animations: Vec<MaterialAnimationBinding>, + sun_occlusion_range_indices: Vec<usize>, + terrain: Arc<TerrainWorld>, + world_range_indices: Vec<usize>, + world_range_index_counts: HashMap<usize, u32>, camera: VulkanStaticCamera, + free_camera: Option<FreeFlightCamera>, + environment: Option<DynamicEnvironment>, + environment_gpu: Option<EnvironmentGpuScene>, + shadow_range: EnvironmentGpuRange, + shadow_base_vertex: usize, + shadow_base_index: usize, + shadow_material_index: usize, + shadow_casters: Vec<ShadowCaster>, + shadow_receivers: Vec<ShadowTriangle>, + shadow_cache: ShadowPageCache, + shadow_last_evidence: ShadowFrameEvidence, + environment_last_tick: Instant, + material_last_tick: Instant, + material_elapsed: Duration, + material_time_ms: u32, + environment_primitive_count: usize, + pressed_keys: HashSet<KeyCode>, + last_tick: Instant, + mouse_look: bool, + audio: Option<audio::GameAudio>, camera_mode: &'static str, mesh_components: usize, terrain_components: usize, @@ -525,6 +4648,7 @@ struct StaticVulkanApp { object_count: usize, readback_out: Option<PathBuf>, enable_validation: bool, + startup_timings: StartupTimings, window_id: Option<WindowId>, window: Option<Window>, renderer: Option<VulkanSmokeRenderer>, @@ -536,16 +4660,64 @@ struct StaticVulkanApp { impl StaticVulkanApp { fn new( preview: StaticPreviewScene, + environment: Option<DynamicEnvironment>, + environment_gpu: Option<EnvironmentGpuScene>, + shadow_range: EnvironmentGpuRange, + shadow_base_vertex: usize, + shadow_base_index: usize, + audio: Option<audio::GameAudio>, target_frames: u64, mission: &str, object_count: usize, readback_out: Option<PathBuf>, enable_validation: bool, + startup_timings: StartupTimings, ) -> Self { + let mut fixed_range_indices = environment_gpu + .as_ref() + .map(EnvironmentGpuScene::fixed_range_indices) + .unwrap_or_default(); + fixed_range_indices.insert(shadow_range.range_index); + let world_range_indices = (0..preview.mesh.draw_ranges.len()) + .filter(|range_index| !fixed_range_indices.contains(range_index)) + .collect(); + let world_range_index_counts = preview + .mesh + .draw_ranges + .iter() + .enumerate() + .filter(|(range_index, _)| !fixed_range_indices.contains(range_index)) + .map(|(range_index, range)| (range_index, range.index_count)) + .collect(); Self { mesh: preview.mesh, materials: preview.materials, + material_animations: preview.material_animations, + sun_occlusion_range_indices: preview.sun_occlusion_range_indices, + terrain: preview.terrain, + world_range_indices, + world_range_index_counts, camera: preview.camera, + free_camera: preview.free_camera, + environment, + environment_gpu, + shadow_base_vertex, + shadow_base_index, + shadow_material_index: preview.shadow_material_index, + shadow_range, + shadow_casters: preview.shadow_casters, + shadow_receivers: preview.shadow_receivers, + shadow_cache: ShadowPageCache::default(), + shadow_last_evidence: ShadowFrameEvidence::default(), + environment_last_tick: Instant::now(), + material_last_tick: Instant::now(), + material_elapsed: Duration::ZERO, + material_time_ms: 0, + environment_primitive_count: 0, + pressed_keys: HashSet::new(), + last_tick: Instant::now(), + mouse_look: false, + audio, camera_mode: preview.camera_mode, mesh_components: preview.mesh_components, terrain_components: preview.terrain_components, @@ -554,6 +4726,7 @@ impl StaticVulkanApp { object_count, readback_out, enable_validation, + startup_timings, window_id: None, window: None, renderer: None, @@ -563,6 +4736,313 @@ impl StaticVulkanApp { } } + fn configure_world_draw_ranges( + &self, + renderer: &mut VulkanSmokeRenderer, + ) -> Result<(), String> { + for &range_index in &self.world_range_indices { + let range = self + .mesh + .draw_ranges + .get(range_index) + .ok_or_else(|| "world draw range index is out of bounds".to_string())?; + let alpha = self + .materials + .get(usize::from(range.material_index)) + .map_or(1.0, |material| material.diffuse_alpha); + let transparent = native_world_transparent(range, alpha); + renderer + .set_draw_range_transparency(range_index, transparent) + .map_err(|error| format!("configure world draw range {range_index}: {error}"))?; + renderer + .set_draw_range_index_count( + range_index, + if alpha > 0.0 { range.index_count } else { 0 }, + ) + .map_err(|error| { + format!("configure world draw range {range_index} count: {error}") + })?; + } + Ok(()) + } + + fn update_world_transparency( + mesh: &VulkanStaticMesh, + materials: &[VulkanStaticMaterial], + world_range_indices: &[usize], + world_range_index_counts: &HashMap<usize, u32>, + active_materials: &HashMap<usize, ActiveMaterialState>, + renderer: &mut VulkanSmokeRenderer, + ) -> Result<(), String> { + for &range_index in world_range_indices { + let range = mesh + .draw_ranges + .get(range_index) + .ok_or_else(|| "world draw range index is out of bounds".to_string())?; + let state = active_materials.get(&range_index); + let alpha = state.map_or_else( + || { + materials + .get(usize::from(range.material_index)) + .map_or(1.0, |material| material.diffuse_alpha) + }, + |state| state.diffuse_alpha, + ); + renderer + .set_draw_range_transparency(range_index, native_world_transparent(range, alpha)) + .map_err(|error| format!("update world draw range {range_index}: {error}"))?; + let count = if alpha > 0.0 { + *world_range_index_counts + .get(&range_index) + .ok_or_else(|| "world draw range count is missing".to_string())? + } else { + 0 + }; + renderer + .set_draw_range_index_count(range_index, count) + .map_err(|error| format!("update world draw range {range_index} count: {error}"))?; + } + Ok(()) + } + + fn world_draw_sort_keys(&self, camera_position: [f32; 3]) -> Result<Vec<(usize, f32)>, String> { + let mut keys = Vec::with_capacity(self.world_range_indices.len()); + for &range_index in &self.world_range_indices { + let sort_key = first_indexed_vertex_distance(&self.mesh, range_index, camera_position)?; + keys.push((range_index, sort_key)); + } + Ok(keys) + } + + fn update_free_camera(&mut self) -> Result<(), String> { + if self.free_camera.is_none() { + return Ok(()); + } + let now = Instant::now(); + let elapsed = now + .saturating_duration_since(self.last_tick) + .min(Duration::from_millis(100)); + self.last_tick = now; + let Some(window) = self.window.as_ref() else { + return Ok(()); + }; + let size = window.inner_size(); + let aspect = if size.height == 0 { + 16.0 / 9.0 + } else { + size.width as f32 / size.height as f32 + }; + let (camera, listener_position, listener_forward, listener_up) = { + let free_camera = self + .free_camera + .as_mut() + .ok_or_else(|| "free-flight camera disappeared".to_string())?; + free_camera.advance(&self.pressed_keys, elapsed.as_secs_f32()); + ( + free_camera.vulkan_camera(aspect), + free_camera.position, + free_camera.forward(), + free_camera.up(), + ) + }; + self.camera = camera; + let sort_keys = self.world_draw_sort_keys(listener_position)?; + if let Some(renderer) = self.renderer.as_mut() { + renderer + .set_camera(camera) + .map_err(|error| format!("update free-flight camera: {error}"))?; + for (range_index, sort_key) in sort_keys { + renderer + .set_draw_range_sort_key(range_index, sort_key) + .map_err(|error| { + format!("update world draw range {range_index} sort key: {error}") + })?; + } + } + if let Some(audio) = self.audio.as_mut() { + audio + .update_listener(listener_position, listener_forward, listener_up) + .map_err(|error| format!("update audio listener: {error}"))?; + } + Ok(()) + } + + fn update_material_clock(&mut self) { + let now = Instant::now(); + let elapsed = now + .saturating_duration_since(self.material_last_tick) + .min(Duration::from_millis(100)); + self.material_last_tick = now; + self.material_elapsed = self.material_elapsed.saturating_add(elapsed); + // Keep the native u32 millisecond clock while flooring only the + // accumulated duration. This preserves sub-millisecond frame deltas + // instead of dropping them once per redraw. + self.material_time_ms = self.material_elapsed.as_millis() as u32; + } + + fn update_shadow( + mesh: &mut VulkanStaticMesh, + shadow_cache: &mut ShadowPageCache, + shadow_casters: &[ShadowCaster], + shadow_receivers: &[ShadowTriangle], + shadow_range: EnvironmentGpuRange, + shadow_base_vertex: usize, + shadow_base_index: usize, + shadow_material_index: usize, + renderer: &mut VulkanSmokeRenderer, + sky_frame: Option<&SkyFrame<'_>>, + environment_frame: Option<&EnvironmentFrame>, + shadow_camera: Option<ShadowCamera>, + ) -> Result<ShadowFrameEvidence, String> { + let lights = shadow_lights(sky_frame, environment_frame, renderer); + let visible_casters = native_visible_shadow_casters( + shadow_casters, + renderer.frame_uniforms().clip_from_world, + ); + let frame = shadow_cache + .build(ShadowScene { + lights: &lights, + casters: &visible_casters, + receivers: shadow_receivers, + fade: 1.0, + smooth_passes: 1, + camera: shadow_camera, + lod: ShadowLodSettings::default(), + }) + .map_err(|error| format!("build projected shadow frame: {error}"))?; + let geometry = append_shadow_frame_geometry( + mesh, + shadow_range, + shadow_base_vertex, + shadow_base_index, + &frame, + )?; + let atlas = shadow_atlas_texture_from_frame(&frame); + renderer + .update_material_texture(shadow_material_index, 0, &atlas) + .map_err(|error| format!("upload projected shadow atlas: {error}"))?; + renderer + .update_mesh_geometry(&mesh.vertices, &mesh.indices, &[geometry]) + .map_err(|error| format!("upload environment and shadow geometry: {error}"))?; + Ok(ShadowFrameEvidence { + visible_casters: visible_casters.len(), + projected_indices: frame.indices.len(), + }) + } + + fn update_environment(&mut self) -> Result<(), String> { + let Some(renderer) = self.renderer.as_mut() else { + return Ok(()); + }; + let viewport = self + .window + .as_ref() + .map(|window| { + let size = window.inner_size(); + [size.width.max(1) as f32, size.height.max(1) as f32] + }) + .unwrap_or([1280.0, 720.0]); + let aspect = viewport[0] / viewport[1].max(1.0); + let now = Instant::now(); + let dt = now + .saturating_duration_since(self.environment_last_tick) + .min(Duration::from_millis(100)) + .as_secs_f32(); + self.environment_last_tick = now; + let active_materials = update_material_animations( + &mut self.material_animations, + &self.materials, + renderer, + self.material_time_ms, + )?; + Self::update_world_transparency( + &self.mesh, + &self.materials, + &self.world_range_indices, + &self.world_range_index_counts, + &active_materials, + renderer, + )?; + let free_camera = self.free_camera; + if let (Some(camera), Some(environment)) = (free_camera, self.environment.as_mut()) { + let (sky_frame, sky_mesh, environment_frame) = + environment.update(dt, &camera, aspect, viewport, renderer, self.audio.as_mut())?; + self.environment_primitive_count = environment_frame.primitives.len(); + if let Some(environment_gpu) = self.environment_gpu.as_mut() { + environment_gpu.update( + &mut self.mesh, + &self.materials, + renderer, + &sky_frame, + &sky_mesh, + &environment_frame, + &camera, + viewport, + self.material_time_ms, + &self.terrain, + &self.sun_occlusion_range_indices, + )?; + } + let shadow_camera = ShadowCamera { + position: camera.position, + viewport_width: viewport[0], + horizontal_fov: horizontal_fov(&camera, aspect), + }; + self.shadow_last_evidence = Self::update_shadow( + &mut self.mesh, + &mut self.shadow_cache, + &self.shadow_casters, + &self.shadow_receivers, + self.shadow_range, + self.shadow_base_vertex, + self.shadow_base_index, + self.shadow_material_index, + renderer, + Some(&sky_frame), + Some(&environment_frame), + Some(shadow_camera), + )?; + } else { + let shadow_camera = free_camera.map(|camera| ShadowCamera { + position: camera.position, + viewport_width: viewport[0], + horizontal_fov: horizontal_fov(&camera, aspect), + }); + self.shadow_last_evidence = Self::update_shadow( + &mut self.mesh, + &mut self.shadow_cache, + &self.shadow_casters, + &self.shadow_receivers, + self.shadow_range, + self.shadow_base_vertex, + self.shadow_base_index, + self.shadow_material_index, + renderer, + None, + None, + shadow_camera, + )?; + } + Ok(()) + } + + fn set_mouse_look(&mut self, active: bool) { + self.mouse_look = active; + if let Some(window) = self.window.as_ref() { + if active { + // Relative device motion keeps the camera usable at the edge + // of the window. Locked grab is unavailable on a few window + // backends, so confined grab is a safe fallback. + if window.set_cursor_grab(CursorGrabMode::Locked).is_err() { + let _ = window.set_cursor_grab(CursorGrabMode::Confined); + } + } else { + let _ = window.set_cursor_grab(CursorGrabMode::None); + } + window.set_cursor_visible(!active); + } + } + fn schedule_next_redraw(&self) { if let Some(window) = self.window.as_ref() { window.request_redraw(); @@ -570,6 +5050,9 @@ impl StaticVulkanApp { } fn complete(&mut self, event_loop: &ActiveEventLoop) { + if let Some(audio) = self.audio.as_mut() { + audio.shutdown(); + } let Some(renderer) = self.renderer.take() else { self.error = Some("native Vulkan renderer was not initialized".to_string()); event_loop.exit(); @@ -588,8 +5071,10 @@ impl StaticVulkanApp { && (report.validation.warning_count != 0 || report.validation.error_count != 0) { self.error = Some(format!( - "native Vulkan validation must stay clean (warnings={}, errors={})", - report.validation.warning_count, report.validation.error_count + "native Vulkan validation must stay clean (warnings={}, errors={}, vuids={:?})", + report.validation.warning_count, + report.validation.error_count, + report.validation.vuids, )); event_loop.exit(); return; @@ -623,8 +5108,14 @@ impl StaticVulkanApp { } (None, _) => None, }; + let readback_format = report + .readback_artifact + .as_ref() + .map_or(report.renderer_report.swapchain_image_format, |artifact| { + artifact.format + }); self.output = Some(format!( - "rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, materials={}, validation={}, readback={}", + "rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, materials={}, environment_primitives={}, shadow_casters={}, shadow_indices={}, swapchain_recreates={}, validation={}, swapchain_format={}, readback_format={}, readback={}, startup_ms={{mission_assets:{:.1},terrain_materials:{:.1},sky:{:.1},gpu_initialization:{:.1}}}", self.mission, self.frames_presented, self.object_count, @@ -632,8 +5123,18 @@ impl StaticVulkanApp { self.terrain_components, self.camera_mode, self.materials.len(), + self.environment_primitive_count, + self.shadow_last_evidence.visible_casters, + self.shadow_last_evidence.projected_indices, + report.swapchain_recreate_count, validation_status, + report.renderer_report.swapchain_image_format, + readback_format, readback_path.as_deref().unwrap_or("none"), + elapsed_ms(self.startup_timings.mission_assets), + elapsed_ms(self.startup_timings.terrain_materials), + elapsed_ms(self.startup_timings.sky), + elapsed_ms(self.startup_timings.gpu_initialization), )); event_loop.exit(); } @@ -648,6 +5149,21 @@ impl StaticVulkanApp { } impl ApplicationHandler for StaticVulkanApp { + fn device_event( + &mut self, + _event_loop: &ActiveEventLoop, + _device_id: DeviceId, + event: DeviceEvent, + ) { + if self.mouse_look { + if let DeviceEvent::MouseMotion { delta } = event { + if let Some(camera) = self.free_camera.as_mut() { + camera.look_delta(delta.0, delta.1); + } + } + } + } + fn resumed(&mut self, event_loop: &ActiveEventLoop) { if self.window.is_some() { return; @@ -661,7 +5177,7 @@ impl ApplicationHandler for StaticVulkanApp { } }; let attributes = Window::default_attributes() - .with_title("FParkan static mission Vulkan") + .with_title("FParkan mission") .with_inner_size(WinitPhysicalSize::new(plan.width, plan.height)); let window = match event_loop.create_window(attributes) { Ok(window) => window, @@ -677,7 +5193,8 @@ impl ApplicationHandler for StaticVulkanApp { return; }; let size = window.inner_size(); - let renderer = match VulkanSmokeRenderer::new(&VulkanSmokeRendererCreateInfo { + let gpu_initialization_started = Instant::now(); + let mut renderer = match VulkanSmokeRenderer::new(&VulkanSmokeRendererCreateInfo { application_name: "fparkan-game".to_string(), native_handles, drawable_extent: (size.width.max(1), size.height.max(1)), @@ -695,9 +5212,47 @@ impl ApplicationHandler for StaticVulkanApp { return; } }; + if let Err(error) = self.configure_world_draw_ranges(&mut renderer) { + self.error = Some(error); + event_loop.exit(); + return; + } + if let Some(free_camera) = self.free_camera { + match self.world_draw_sort_keys(free_camera.position) { + Ok(sort_keys) => { + for (range_index, sort_key) in sort_keys { + if let Err(error) = renderer.set_draw_range_sort_key(range_index, sort_key) + { + self.error = Some(format!( + "update world draw range {range_index} sort key: {error}" + )); + event_loop.exit(); + return; + } + } + } + Err(error) => { + self.error = Some(error); + event_loop.exit(); + return; + } + } + } + self.startup_timings.gpu_initialization = gpu_initialization_started.elapsed(); + renderer.set_readback_enabled(self.readback_out.is_some()); self.window_id = Some(window.id()); self.renderer = Some(renderer); self.window = Some(window); + self.last_tick = Instant::now(); + self.environment_last_tick = Instant::now(); + self.material_last_tick = Instant::now(); + self.material_elapsed = Duration::ZERO; + self.material_time_ms = 0; + if let Err(error) = self.update_free_camera() { + self.error = Some(error); + event_loop.exit(); + return; + } self.schedule_next_redraw(); } @@ -714,12 +5269,53 @@ impl ApplicationHandler for StaticVulkanApp { WindowEvent::CloseRequested => { self.complete(event_loop); } + WindowEvent::Focused(focused) => { + if !focused { + self.pressed_keys.clear(); + self.set_mouse_look(false); + } + if let Some(audio) = self.audio.as_mut() { + audio.on_focus_changed(focused); + } + } + WindowEvent::KeyboardInput { event, .. } => { + if let PhysicalKey::Code(code) = event.physical_key { + if code == KeyCode::Escape && event.state == ElementState::Pressed { + if self.mouse_look { + self.set_mouse_look(false); + } else { + self.complete(event_loop); + } + } else if event.state == ElementState::Pressed { + self.pressed_keys.insert(code); + } else { + self.pressed_keys.remove(&code); + } + } + } + WindowEvent::MouseInput { state, button, .. } => { + if button == MouseButton::Right { + self.set_mouse_look(state == ElementState::Pressed); + } + } + WindowEvent::CursorMoved { .. } => {} WindowEvent::Resized(size) => { if let Some(renderer) = self.renderer.as_mut() { renderer.request_resize((size.width, size.height)); } } WindowEvent::RedrawRequested => { + if let Err(error) = self.update_free_camera() { + self.error = Some(error); + event_loop.exit(); + return; + } + self.update_material_clock(); + if let Err(error) = self.update_environment() { + self.error = Some(error); + event_loop.exit(); + return; + } let Some(renderer) = self.renderer.as_mut() else { self.error = Some("native Vulkan renderer was not initialized".to_string()); event_loop.exit(); @@ -738,7 +5334,7 @@ impl ApplicationHandler for StaticVulkanApp { return; } } - if self.frames_presented >= self.target_frames { + if self.target_frames != 0 && self.frames_presented >= self.target_frames { self.complete(event_loop); } else { self.schedule_next_redraw(); @@ -755,11 +5351,12 @@ impl ApplicationHandler for StaticVulkanApp { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq)] struct Args { root: PathBuf, mission: String, frames: u64, + atmosphere_seconds: Option<f32>, validation: bool, readback_out: Option<PathBuf>, preview_roots: NonZeroUsize, @@ -772,7 +5369,11 @@ impl Args { fn parse(args: &[String]) -> Result<Self, String> { let mut root = None; let mut mission = None; - let mut frames = 1; + // Zero means keep the interactive preview open until the user closes + // it. A bounded `--frames N` remains available for smoke/readback + // runs and validation scripts. + let mut frames = 0; + let mut atmosphere_seconds = None; let mut validation = false; let mut readback_out = None; // A native static-Vulkan invocation is the usable mission preview, so @@ -807,6 +5408,19 @@ impl Args { .parse() .map_err(|_| "--frames must be an integer".to_string())?; } + "--atmosphere-seconds" => { + let value = iter + .next() + .ok_or_else(|| "--atmosphere-seconds requires a value".to_string())? + .parse::<f32>() + .map_err(|_| "--atmosphere-seconds must be a number".to_string())?; + if !value.is_finite() || value < 0.0 { + return Err( + "--atmosphere-seconds must be finite and non-negative".to_string() + ); + } + atmosphere_seconds = Some(value); + } "--validation" => { validation = true; } @@ -855,13 +5469,11 @@ impl Args { } let root = root.ok_or_else(|| "missing --root".to_string())?; let mission = mission.ok_or_else(|| "missing --mission".to_string())?; - if frames == 0 { - return Err("--frames must be greater than zero".to_string()); - } Ok(Self { root, mission, frames, + atmosphere_seconds, validation, readback_out, preview_roots, @@ -950,7 +5562,7 @@ fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result<String, String> { } fn usage() -> String { - "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--validation] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>]".to_string() + "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--atmosphere-seconds <seconds>] [--validation] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>]\nstatic preview uses saved per-component CTLD defaults, with uncontrolled nodes at frame 0; --static-animation-frame overrides all components globally.".to_string() } #[cfg(test)] @@ -962,6 +5574,314 @@ mod tests { } #[test] + #[ignore = "requires licensed corpus"] + fn licensed_corpus_part1_prepares_every_mission() { + prepare_licensed_corpus("FPARKAN_CORPUS_PART1_ROOT", 29); + } + + #[test] + #[ignore = "requires licensed corpus"] + fn licensed_corpus_part2_prepares_every_mission() { + prepare_licensed_corpus("FPARKAN_CORPUS_PART2_ROOT", 31); + } + + fn prepare_licensed_corpus(variable: &str, expected_missions: usize) { + let root = std::env::var_os(variable) + .map(PathBuf::from) + .unwrap_or_else(|| panic!("{variable} is required")); + assert!( + root.is_dir(), + "licensed corpus root is missing: {}", + root.display() + ); + + let mut missions = Vec::new(); + let mut directories = vec![root.clone()]; + while let Some(directory) = directories.pop() { + let entries = std::fs::read_dir(&directory) + .unwrap_or_else(|error| panic!("read {}: {error}", directory.display())); + for entry in entries { + let path = entry + .unwrap_or_else(|error| { + panic!("read entry in {}: {error}", directory.display()) + }) + .path(); + if path.is_dir() { + directories.push(path); + } else if path + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case("data.tma")) + { + let relative = path + .strip_prefix(&root) + .expect("mission path is under corpus root") + .to_str() + .expect("mission path is UTF-8") + .replace('\\', "/"); + missions.push(relative); + } + } + } + missions.sort(); + assert_eq!( + missions.len(), + expected_missions, + "installed mission count for {variable}" + ); + + let vfs: Arc<dyn fparkan_vfs::Vfs> = Arc::new(DirectoryVfs::new(&root)); + let mut prepared = 0usize; + let mut environment_missions = 0usize; + for mission in missions { + let mut engine = create(EngineServices::new(vfs.clone())).expect("engine"); + let loaded = load_mission_static_preview_roots( + &mut engine, + MissionRequest { + key: mission.clone(), + }, + NonZeroUsize::MAX, + ) + .unwrap_or_else(|error| panic!("prepare runtime mission {mission}: {error}")); + let assets = loaded_mission_assets(&engine).expect("mission assets"); + let terrain = loaded_terrain(&engine).expect("mission terrain"); + let roots = loaded_mission_object_drafts(&engine).expect("mission object drafts"); + let preview = static_preview_mesh_and_materials( + assets, + terrain, + roots, + None, + None, + None, + &root, + &loaded.land_msh_path, + ) + .unwrap_or_else(|error| panic!("prepare renderer mission {mission}: {error}")); + assert!(!preview.mesh.vertices.is_empty(), "{mission}: empty mesh"); + assert!( + !preview.materials.is_empty(), + "{mission}: empty material set" + ); + + if load_environment(&root, &mission, None) + .unwrap_or_else(|error| panic!("prepare environment mission {mission}: {error}")) + .is_some() + { + environment_missions += 1; + } + + // `GameAudio::new` resolves and decodes every sample declared by + // mission.cfg before opening the OS device. A headless CI host is + // therefore still a useful resource check: only the final device + // step may be unavailable. + match audio::GameAudio::new(vfs.clone(), &mission) { + Ok(mut audio) => audio.shutdown(), + Err(audio::AudioError::DeviceUnavailable { .. }) => {} + Err(error) => panic!("prepare audio mission {mission}: {error}"), + } + prepared += 1; + } + + assert_eq!(prepared, expected_missions); + assert_eq!(environment_missions, expected_missions); + } + + #[test] + fn draw_range_reorder_remaps_all_material_animation_bindings() { + let range = |first_index, material_index| VulkanStaticDrawRange { + first_index, + index_count: 3, + material_index, + lightmap_index: u8::MAX, + batch_flags: 0, + pipeline_state: world_pipeline_state( + LegacyBlendMode::Opaque, + LegacyDepthMode::TestReadOnly, + ), + alpha_test_reference: 0, + }; + let mut mesh = VulkanStaticMesh { + vertices: Vec::new(), + indices: (0..9).collect(), + draw_ranges: vec![range(0, 10), range(3, 20), range(6, 30)], + }; + let empty_binding = || MaterialPhaseBinding { + document: Mat0Document { + version: 0, + animation_block_count: 0, + phases: Vec::new(), + prefix: Vec::new(), + header_opaque: [0; 2], + animation_blocks: Vec::new(), + }, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: 0, + frozen_phase_index: Some(0), + phase_uv_transforms: Vec::new(), + }; + let mut bindings = vec![MaterialAnimationBinding { + initial_phase_index: 0, + variants: vec![MaterialVariantSet { + phase_material_indices: vec![10], + range_indices: vec![0, 2], + }], + base: empty_binding(), + detail: None, + overlay: None, + overlay_detail: None, + stage_textures: None, + }]; + + let remap = reorder_draw_ranges(&mut mesh, &[2, 0, 1]).expect("reorder"); + remap_material_animation_ranges(&mut bindings, &remap).expect("binding remap"); + + assert_eq!( + mesh.indices, + (6..9).chain(0..3).chain(3..6).collect::<Vec<_>>() + ); + assert_eq!(mesh.draw_ranges[0].material_index, 30); + assert_eq!(mesh.draw_ranges[1].material_index, 10); + assert_eq!(mesh.draw_ranges[2].material_index, 20); + assert_eq!(bindings[0].variants[0].range_indices, vec![1, 0]); + } + + #[test] + fn native_world_transparency_uses_sampled_alpha_and_native_batch_flags() { + let range = |blend, batch_flags| VulkanStaticDrawRange { + first_index: 0, + index_count: 3, + material_index: 0, + lightmap_index: u8::MAX, + batch_flags, + pipeline_state: world_pipeline_state(blend, LegacyDepthMode::TestReadOnly), + alpha_test_reference: 0, + }; + + // Transparency is driven by the native predicate. The pipeline blend + // enum is an independent rendering detail and does not qualify an + // opaque range on its own. + assert!(!native_world_transparent( + &range(LegacyBlendMode::SourceAlpha, 0), + 1.0 + )); + assert!(native_world_transparent( + &range(LegacyBlendMode::Opaque, 0), + 0.999 + )); + assert!(native_world_transparent( + &range(LegacyBlendMode::Opaque, 0x100), + 1.0 + )); + assert!(native_world_transparent( + &range(LegacyBlendMode::Opaque, 0x8), + 1.0 + )); + } + + #[test] + fn native_sun_object_source_types_map_to_world_kinds() { + assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_FORT)), Some(3)); + assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_BTLU)), Some(4)); + assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_STAT)), Some(10)); + assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_FORT))); + assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_BTLU))); + assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_STAT))); + assert!(!native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_EXTO))); + assert!(!native_sun_object_kind_eligible(None)); + } + + fn test_shadow_caster(id: u32, center: [f32; 3]) -> ShadowCaster { + ShadowCaster { + id, + world_axes: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]], + sphere: ShadowSphere { + center, + radius: 0.0, + }, + native_kind: 4, + triangles: Vec::new(), + } + } + + #[test] + fn shadow_frustum_filters_before_the_native_twenty_caster_budget() { + let casters = [ + test_shadow_caster(1, [202.0, 0.0, 0.0]), + test_shadow_caster(2, [0.0, 0.0, 0.0]), + ]; + let visible = + native_visible_shadow_casters(&casters, VulkanStaticCamera::default().clip_from_world); + assert_eq!( + visible.iter().map(|caster| caster.id).collect::<Vec<_>>(), + [2] + ); + } + + #[test] + fn shadow_frustum_keeps_the_native_hundred_unit_sphere_margin() { + let matrix = VulkanStaticCamera::default().clip_from_world; + assert!(native_shadow_caster_visible( + &test_shadow_caster(1, [100.99, 0.0, 0.0]), + matrix + )); + assert!(!native_shadow_caster_visible( + &test_shadow_caster(2, [101.0, 0.0, 0.0]), + matrix + )); + } + + #[test] + fn mounted_shadow_bounds_ignore_child_translation_and_scale_aggregate_once() { + let parent = VulkanNodePose { + translation: [10.0, 20.0, 30.0], + rotation: [0.0, 0.0, 0.0, 1.0], + }; + let node1 = VulkanNodePose { + // Native bounds replace this translation with the accumulated + // parent translation while retaining node 1's rotation. + translation: [99.0, -77.0, 42.0], + rotation: [0.0, 0.0, (0.5_f32).sqrt(), (0.5_f32).sqrt()], + }; + let first = mounted_shadow_sphere([2.0, 0.0, 0.0], 2.0, Some(parent), Some(node1)) + .expect("finite mounted sphere"); + assert!((first.center[0] - 10.0).abs() < 1.0e-5); + assert!((first.center[1] - 22.0).abs() < 1.0e-5); + assert!((first.center[2] - 30.0).abs() < 1.0e-5); + + let second = ShadowComponentSphere { + center: [12.0, 22.0, 30.0], + radius: 1.0, + }; + let caster = aggregate_shadow_caster( + 7, + 4, + LegacyIron3dEulerTransform { + translation: [1.0, 2.0, 3.0], + orientation_radians: [0.0; 3], + }, + [2.0, 3.0, 4.0], + vec![shadow_triangle( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]], + 0, + )], + &[first, second], + ) + .expect("aggregate caster"); + let nearly_equal = |left: f32, right: f32| assert!((left - right).abs() < 1.0e-4); + nearly_equal(caster.sphere.center[0], 22.333_334); + nearly_equal(caster.sphere.center[1], 68.0); + nearly_equal(caster.sphere.center[2], 123.0); + nearly_equal(caster.sphere.radius, 10.666_667); + } + + #[test] + fn internal_components_do_not_enter_msh_socket_mount_path() { + assert!(unit_component_is_visible(0, None)); + assert!(!unit_component_is_visible(1, None)); + assert!(unit_component_is_visible(1, Some(PROTOTYPE_TYPE_EXTO))); + } + + #[test] fn parses_required_args() { assert_eq!( Args::parse(&strings(&[ @@ -976,6 +5896,7 @@ mod tests { root: PathBuf::from("testdata/IS"), mission: "MISSIONS/Autodemo.00/data.tma".to_string(), frames: 3, + atmosphere_seconds: None, validation: false, readback_out: None, preview_roots: NonZeroUsize::MAX, @@ -987,6 +5908,301 @@ mod tests { } #[test] + fn defaults_to_interactive_frame_loop() { + let parsed = Args::parse(&strings(&[ + "--root", + "testdata/IS", + "--mission", + "MISSIONS/Autodemo.00/data.tma", + ])) + .expect("default interactive arguments"); + assert_eq!(parsed.frames, 0); + assert_eq!(parsed.preview_roots, NonZeroUsize::MAX); + assert_eq!(parsed.static_animation_frame, None); + } + + #[test] + fn lightmap_ranges_stay_on_the_decorated_variant_when_phase_changes() { + let empty_mat0 = Mat0Document { + version: 0, + animation_block_count: 0, + phases: Vec::new(), + prefix: Vec::new(), + header_opaque: [0; 2], + animation_blocks: Vec::new(), + }; + let mut bindings = vec![MaterialAnimationBinding { + initial_phase_index: 0, + variants: vec![MaterialVariantSet { + phase_material_indices: vec![10, 11], + range_indices: Vec::new(), + }], + base: MaterialPhaseBinding { + document: empty_mat0, + animation_block_index: 0, + wear_row_start_ms: 0, + random_state: 0, + frozen_phase_index: Some(0), + phase_uv_transforms: Vec::new(), + }, + detail: None, + overlay: None, + overlay_detail: None, + stage_textures: None, + }]; + let (binding_index, source_phases) = + find_material_animation_variant(&bindings, 10).expect("base material variant"); + assert_eq!(source_phases, vec![10, 11]); + bindings[binding_index].variants.push(MaterialVariantSet { + phase_material_indices: vec![20, 21], + range_indices: vec![7], + }); + + let lightmap_variant = &bindings[binding_index].variants[1]; + assert_eq!(lightmap_variant.range_indices, vec![7]); + assert_eq!( + selected_material_variant_index(lightmap_variant, 0), + Some(20) + ); + assert_eq!( + selected_material_variant_index(lightmap_variant, 1), + Some(21) + ); + } + + fn row_vector_transform(point: [f32; 4], matrix: [f32; 16]) -> [f32; 4] { + let mut result = [0.0; 4]; + for column in 0..4 { + result[column] = (0..4) + .map(|row| point[row] * matrix[row * 4 + column]) + .sum(); + } + result + } + + #[test] + fn free_flight_camera_uses_right_handed_z_up_basis_and_positive_depth() { + let camera = FreeFlightCamera { + position: [0.0, 0.0, 0.0], + yaw: 0.0, + pitch: 0.0, + vertical_fov: std::f32::consts::FRAC_PI_2, + near_plane: 1.0, + far_plane: 11.0, + move_speed: 1.0, + }; + + let nearly_equal = |left: f32, right: f32| assert!((left - right).abs() < 1.0e-5); + assert_eq!(camera.forward(), [1.0, 0.0, 0.0]); + assert_eq!(camera.right(), [0.0, -1.0, 0.0]); + assert_eq!(camera.up(), [0.0, 0.0, 1.0]); + let matrix = camera.vulkan_camera(1.0).clip_from_world; + let near = row_vector_transform([1.0, 0.0, 0.0, 1.0], matrix); + let far = row_vector_transform([11.0, 0.0, 0.0, 1.0], matrix); + let camera_right = row_vector_transform([1.0, -1.0, 0.0, 1.0], matrix); + let world_up = row_vector_transform([1.0, 0.0, 1.0, 1.0], matrix); + nearly_equal(near[0], 0.0); + nearly_equal(near[1], 0.0); + nearly_equal(near[2], 0.0); + nearly_equal(near[3], 1.0); + assert!(camera_right[0] / camera_right[3] > 0.0); + assert!(world_up[1] / world_up[3] < 0.0); + nearly_equal(far[2] / far[3], 1.0); + } + + #[test] + fn sky_normals_keep_native_signed_byte_scale_without_renormalizing() { + assert_eq!(sky_normal([-128, 127, -64]), [-1.0, 127.0 / 128.0, -0.5]); + } + + #[test] + fn sun_visibility_uses_a_valid_ray_and_fails_open_without_surfaces() { + let camera = FreeFlightCamera { + position: [10.0, 20.0, 30.0], + yaw: 0.0, + pitch: 0.0, + vertical_fov: 1.0, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 1.0, + }; + let terrain = TerrainWorld::default(); + assert!(native_sun_unoccluded(&terrain, &camera, [1.0, 0.0, 0.0])); + assert!(!native_sun_unoccluded(&terrain, &camera, [0.0, 0.0, 0.0])); + + let terrain = TerrainWorld::from_land_msh(&fparkan_terrain_format::LandMeshDocument { + streams: Vec::new(), + nodes_raw: Vec::new(), + slots: fparkan_terrain_format::TerrainSlotTable { + header_raw: Vec::new(), + slots_raw: Vec::new(), + }, + // The native Land.msh height is 32; renderer world Z is 1. + positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.0]], + normals: Vec::new(), + uv0: Vec::new(), + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: vec![fparkan_terrain_format::TerrainFace28 { + flags: FullSurfaceMask(1), + material_tag: 0, + aux_tag: 0, + vertices: [0, 1, 2], + neighbors: [None, None, None], + tail_raw: [0; 8], + raw: [0; 28], + }], + }) + .expect("synthetic sun-occluder terrain"); + let blocked_camera = FreeFlightCamera { + position: [0.25, 0.25, 2.0], + yaw: 0.0, + pitch: 0.0, + vertical_fov: 1.0, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 1.0, + }; + assert!(!native_sun_unoccluded( + &terrain, + &blocked_camera, + [0.0, 0.0, -1.0] + )); + + // The same hit is beyond a short native sun segment, so it must not + // occlude the light even though the terrain contains a surface. + let short_segment_camera = FreeFlightCamera { + far_plane: 1.0, + ..blocked_camera + }; + assert!(native_sun_unoccluded( + &terrain, + &short_segment_camera, + [0.0, 0.0, -1.0] + )); + + let world_mesh = VulkanStaticMesh::smoke_triangle(); + assert!(!native_sun_unoccluded_with_world( + &TerrainWorld::default(), + &world_mesh, + &[0], + &blocked_camera, + [0.0, 0.0, -1.0] + )); + assert!(native_sun_unoccluded_with_world( + &TerrainWorld::default(), + &world_mesh, + &[], + &blocked_camera, + [0.0, 0.0, -1.0] + )); + + let excluded_terrain = + TerrainWorld::from_land_msh(&fparkan_terrain_format::LandMeshDocument { + streams: Vec::new(), + nodes_raw: Vec::new(), + slots: fparkan_terrain_format::TerrainSlotTable { + header_raw: Vec::new(), + slots_raw: Vec::new(), + }, + positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.0]], + normals: Vec::new(), + uv0: Vec::new(), + accelerator: Vec::new(), + aux14: Vec::new(), + aux18: Vec::new(), + faces: vec![fparkan_terrain_format::TerrainFace28 { + flags: FullSurfaceMask(0x20), + material_tag: 0, + aux_tag: 0, + vertices: [0, 1, 2], + neighbors: [None, None, None], + tail_raw: [0; 8], + raw: [0; 28], + }], + }) + .expect("excluded terrain"); + assert!(native_sun_unoccluded_with_world( + &excluded_terrain, + &world_mesh, + &[], + &blocked_camera, + [0.0, 0.0, -1.0] + )); + + let mut excluded_world_mesh = world_mesh.clone(); + excluded_world_mesh.draw_ranges[0].batch_flags = 0x0008; + assert!(native_sun_unoccluded_with_world( + &TerrainWorld::default(), + &excluded_world_mesh, + &[0], + &blocked_camera, + [0.0, 0.0, -1.0] + )); + } + + #[test] + fn world_sort_key_uses_the_first_indexed_vertex() { + let mesh = VulkanStaticMesh::smoke_triangle(); + let distance = first_indexed_vertex_distance(&mesh, 0, [0.0, -0.55, -3.0]) + .expect("first indexed vertex distance"); + assert!((distance - 3.0).abs() < 1.0e-6); + } + + #[test] + fn free_flight_camera_advances_wasd_and_vertical_controls() { + let mut camera = FreeFlightCamera { + position: [0.0, 0.0, 0.0], + yaw: 0.0, + pitch: 0.0, + vertical_fov: 1.0, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 2.0, + }; + let mut keys = HashSet::new(); + keys.insert(KeyCode::KeyW); + keys.insert(KeyCode::KeyD); + camera.advance(&keys, 1.0); + let diagonal = 2.0 / 2.0_f32.sqrt(); + assert!((camera.position[0] - diagonal).abs() < 1.0e-5); + assert!((camera.position[1] + diagonal).abs() < 1.0e-5); + assert_eq!(camera.position[2], 0.0); + + keys.clear(); + keys.insert(KeyCode::KeyE); + keys.insert(KeyCode::ShiftLeft); + camera.advance(&keys, 0.5); + assert!((camera.position[2] - 4.0).abs() < 1.0e-5); + } + + #[test] + fn free_flight_mouse_right_turns_toward_previous_right_and_vertical_is_world_z() { + let mut camera = FreeFlightCamera { + position: [0.0, 0.0, 0.0], + yaw: 0.0, + pitch: 0.6, + vertical_fov: 1.0, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 2.0, + }; + let old_right = camera.right(); + let old_forward = camera.forward(); + camera.look_delta(100.0, 0.0); + assert!(dot3(camera.forward(), old_right) > 0.0); + assert!(dot3(camera.forward(), old_forward) < 1.0); + + let mut keys = HashSet::new(); + keys.insert(KeyCode::KeyE); + camera.advance(&keys, 0.5); + assert!((camera.position[0]).abs() < 1.0e-5); + assert!((camera.position[1]).abs() < 1.0e-5); + assert!((camera.position[2] - 1.0).abs() < 1.0e-5); + } + + #[test] fn parses_validation_flag() { let parsed = Args::parse(&strings(&[ "--root", @@ -1045,6 +6261,61 @@ mod tests { } #[test] + fn static_preview_control_defaults_apply_native_flags_and_last_row_wins() { + let binding = + |node_index, frame_a, frame_b, initial_blend, flags| PreparedControlNodeBinding { + node_index, + frame_a, + frame_b, + initial_blend, + flags, + }; + let times = static_preview_node_times( + 6, + &[ + binding(1, 1.0, 3.0, 0.0, 0), + binding(2, 1.0, 3.0, 1.25, 1), + binding(3, 1.0, 3.0, -0.5, 1), + binding(4, 1.0, 3.0, -0.5, 0), + binding(1, 1.0, 3.0, 0.25, 2), + binding(usize::MAX, f32::NAN, f32::INFINITY, f32::NAN, 4), + ], + ) + .expect("valid static CTLD defaults"); + let values = times + .iter() + .map(|time| time.map(|time| time.0)) + .collect::<Vec<_>>(); + assert_eq!( + values, + vec![ + Some(0.0), + Some(2.5), + Some(1.5), + Some(2.0), + Some(1.0), + Some(0.0) + ] + ); + } + + #[test] + fn static_preview_rejects_negative_derived_control_sample_time() { + let error = static_preview_node_times( + 1, + &[PreparedControlNodeBinding { + node_index: 0, + frame_a: 0.0, + frame_b: 2.0, + initial_blend: -2.0, + flags: 1, + }], + ) + .expect_err("one wrap still leaves an unsupported negative sample time"); + assert!(error.contains("negative or non-finite sample time")); + } + + #[test] fn parses_static_material_phase() { let parsed = Args::parse(&strings(&[ "--root", @@ -1146,11 +6417,13 @@ mod tests { &mut merged, VulkanStaticMesh::smoke_triangle(), &[(0, 4)], + None, )?; append_static_preview_component( &mut merged, VulkanStaticMesh::smoke_triangle(), &[(0, 9)], + None, )?; assert_eq!(merged.vertices.len(), 6); @@ -1168,7 +6441,10 @@ mod tests { let vertex = fparkan_render_vulkan::VulkanStaticVertex { position: [0.0, 0.0, 0.0], color: [1.0, 1.0, 1.0], + normal: [0.0, 0.0, 1.0], uv: [0.0, 0.0], + detail_uv: [0.0, 0.0], + overlay_alpha: 0.0, }; let mut merged = VulkanStaticMesh { vertices: vec![vertex; usize::from(u16::MAX) + 1], @@ -1180,9 +6456,89 @@ mod tests { &mut merged, VulkanStaticMesh::smoke_triangle(), &[(0, 0)], + None, )?; assert_eq!(merged.indices, vec![65_536, 65_537, 65_538]); Ok(()) } + + #[test] + fn precipitation_upload_reconstructs_native_ndc_depths_and_uvs() { + let camera = FreeFlightCamera { + position: [10.0, 20.0, 30.0], + yaw: 0.0, + pitch: 0.0, + vertical_fov: std::f32::consts::FRAC_PI_2, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 1.0, + }; + let screen = ScreenBillboard { + world_head: [0.0; 3], + world_tail: [0.0; 3], + head: [0.0, 0.0], + tail: [0.0, 0.0], + corners: [[-0.25, -0.5], [0.25, -0.5], [-0.25, 0.5], [0.25, 0.5]], + uv: [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6], [0.7, 0.8]], + half_size: 12.0, + head_depth: 8.0, + tail_depth: 4.0, + }; + let vertices = precipitation_quad_vertices( + PrecipitationKind::Rain, + &screen, + [1.0, 0.5, 0.25, 0.75], + &camera, + [100.0, 100.0], + ); + assert_eq!(vertices[0].uv, screen.uv[0]); + assert_eq!(vertices[3].uv, screen.uv[3]); + assert!( + (dot3( + sub3(vertices[0].position, camera.position), + camera.forward() + ) - 4.0) + .abs() + < 1.0e-5 + ); + assert!( + (dot3( + sub3(vertices[3].position, camera.position), + camera.forward() + ) - 8.0) + .abs() + < 1.0e-5 + ); + assert_eq!(vertices[0].overlay_alpha, 0.75); + } + + #[test] + fn sprite_upload_remaps_native_left_top_uv_order_to_quad_positions() { + let camera = FreeFlightCamera { + position: [0.0; 3], + yaw: 0.0, + pitch: 0.0, + vertical_fov: 1.0, + near_plane: 0.1, + far_plane: 100.0, + move_speed: 1.0, + }; + let uv = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]]; + let vertices = sprite_quad_vertices_pixels( + &camera, + camera.forward(), + 10.0, + 1.0, + 1.0, + [100.0, 100.0], + [1.0; 3], + 1.0, + uv, + ); + assert_eq!( + vertices.map(|vertex| vertex.uv), + [uv[1], uv[2], uv[0], uv[3]] + ); + } } |
