From a8faba8aad441e87f8a8b94c148c01f9c075d621 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sun, 11 Oct 2026 13:12:50 +0400 Subject: 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. --- crates/fparkan-fx/src/environment.rs | 2803 ++++++++++++++++++++++++++++++++++ 1 file changed, 2803 insertions(+) create mode 100644 crates/fparkan-fx/src/environment.rs (limited to 'crates/fparkan-fx/src/environment.rs') diff --git a/crates/fparkan-fx/src/environment.rs b/crates/fparkan-fx/src/environment.rs new file mode 100644 index 0000000..babbd0f --- /dev/null +++ b/crates/fparkan-fx/src/environment.rs @@ -0,0 +1,2803 @@ +//! CPU-side precipitation and lightning output. +//! +//! The original game keeps atmospheric objects close to the observer. This +//! module follows that ownership boundary: it advances precipitation particles +//! and native lightning effect lifetimes and returns renderer/audio records +//! for the current frame. It does not invent a scene graph or hide resource +//! loading behind the simulation. + +use crate::sky::SkyMaterials; +use crate::{FxDocument, FxError, FxOpcode}; +use std::sync::Arc; + +// Terrain.dll writes these velocity vectors into the shared precipitation +// particle state on every update (CSnow at 10045E27 and CRain at 10046555). +// Keep the values together so a renderer cannot accidentally substitute the +// old placeholder speeds. +pub const RAIN_VELOCITY: [f32; 3] = [0.5, 0.0, -60.0]; +pub const SNOW_VELOCITY: [f32; 3] = [0.5, 0.0, -4.0]; + +/// Near plane used by Terrain's precipitation volume constructor at +/// `100444C0`. +pub const PRECIPITATION_NEAR: f32 = 2.0; +/// Far plane used by Terrain's precipitation volume constructor at +/// `100444C0`. +pub const PRECIPITATION_FAR: f32 = 50.0; +/// Half horizontal angle used by Terrain's precipitation volume constructor. +pub const PRECIPITATION_HALF_ANGLE_X: f32 = 1.3 * 0.5; +/// Half vertical angle used by Terrain's precipitation volume constructor. +pub const PRECIPITATION_HALF_ANGLE_Y: f32 = 0.975 * 0.5; +/// Native rain size factor (`Terrain.dll!65E30`). +pub const RAIN_SIZE_FACTOR: f32 = 0.0065; +/// Native snow size factor (`Terrain.dll!65DE8`). +pub const SNOW_SIZE_FACTOR: f32 = 0.0195; +/// Native snow minimum depth fade (`Terrain.dll!65D98`). +pub const SNOW_MIN_DEPTH_FADE: f32 = 0.1; +/// Native precipitation particle-count multiplier (`Terrain.dll!65DE4`). +pub const PARTICLE_COUNT_SCALE: f32 = 1_000.0; + +/// Native precipitation spawn volume. The fields mirror the descriptor +/// built by Terrain at `100444C0`; particles are sampled from this box by +/// `100445B0` using three 16-bit random values divided by 32768. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PrecipitationVolume { + /// Local-space lower corner `[near, -tan(angle_x)*far, -tan(angle_y)*far]`. + pub origin: [f32; 3], + /// Local-space extent `[far-near, 2*tan(angle_x)*far, 2*tan(angle_y)*far]`. + pub extent: [f32; 3], + /// Reciprocals stored by the native descriptor for containment tests. + pub reciprocal_extent: [f32; 3], +} + +impl PrecipitationVolume { + /// Reconstructs the descriptor constants used by Terrain. + #[must_use] + pub fn native() -> Self { + let width = PRECIPITATION_HALF_ANGLE_X.tan() * PRECIPITATION_FAR; + let height = PRECIPITATION_HALF_ANGLE_Y.tan() * PRECIPITATION_FAR; + let extent = [ + PRECIPITATION_FAR - PRECIPITATION_NEAR, + 2.0 * width, + 2.0 * height, + ]; + Self { + origin: [PRECIPITATION_NEAR, -width, -height], + extent, + reciprocal_extent: extent.map(|value| 1.0 / value), + } + } + + /// Rebuilds the same depth box for a camera projection. The native + /// emitter uses the camera FOV and aspect ratio for this box while the + /// reference descriptor above keeps the original 1.3 by 0.975 angles. + #[must_use] + pub fn for_camera(self, camera: Camera) -> Self { + let near = self.origin[0]; + let far = near + self.extent[0]; + let half_y = (camera.vertical_fov * 0.5).tan() * far; + let half_x = (camera.vertical_fov * 0.5).tan() * camera.aspect_ratio * far; + let extent = [far - near, 2.0 * half_x, 2.0 * half_y]; + Self { + origin: [near, -half_x, -half_y], + extent, + reciprocal_extent: extent.map(|value| 1.0 / value), + } + } + + /// Returns an axis-aligned world-space bound around the camera-local + /// descriptor. The native emitter transforms each sampled local point by + /// the camera basis before handing it to the particle object. + #[must_use] + pub fn bounds(self, camera: Camera) -> PrecipitationBounds { + let corners: [[f32; 3]; 8] = std::array::from_fn(|index| { + let local = [ + self.origin[0] + if index & 1 == 0 { 0.0 } else { self.extent[0] }, + self.origin[1] + if index & 2 == 0 { 0.0 } else { self.extent[1] }, + self.origin[2] + if index & 4 == 0 { 0.0 } else { self.extent[2] }, + ]; + camera.world_from_local(local) + }); + let mut min = [f32::INFINITY; 3]; + let mut max = [f32::NEG_INFINITY; 3]; + for corner in corners { + for axis in 0..3 { + min[axis] = min[axis].min(corner[axis]); + max[axis] = max[axis].max(corner[axis]); + } + } + PrecipitationBounds { min, max } + } + + /// Computes the native reference particle count for a weather intensity. + /// The camera-aware form below applies the current/reference volume ratio. + #[must_use] + pub fn particle_count(self, intensity: f32) -> usize { + self.particle_count_for(self, 1.0, intensity) + } + + /// Computes the native count from the current/reference volume ratio and + /// the object's density scalar. The original uses FISTP's nearest-even + /// conversion after multiplying by 1000. + #[must_use] + pub fn particle_count_for(self, current: Self, density: f32, intensity: f32) -> usize { + if !density.is_finite() || !intensity.is_finite() || density <= 0.0 || intensity <= 0.0 { + return 0; + } + let reference_volume = volume_product(self.extent); + let current_volume = volume_product(current.extent); + if !reference_volume.is_finite() || reference_volume <= 0.0 || !current_volume.is_finite() { + return 0; + } + let ratio = (current_volume / reference_volume).clamp(0.0, 1.0); + let value = f64::from(ratio) + * f64::from(density) + * f64::from(intensity) + * f64::from(PARTICLE_COUNT_SCALE); + if !value.is_finite() || value <= 0.0 { + 0 + } else if value >= usize::MAX as f64 { + usize::MAX + } else { + value.round_ties_even() as usize + } + } +} + +impl Default for PrecipitationVolume { + fn default() -> Self { + Self::native() + } +} + +/// World-space bounds for one observer-local precipitation volume. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PrecipitationBounds { + /// Inclusive lower corner. + pub min: [f32; 3], + /// Inclusive upper corner. + pub max: [f32; 3], +} + +impl PrecipitationBounds { + /// Checks whether a world-space point lies inside this bound. + #[must_use] + pub fn contains(self, point: [f32; 3]) -> bool { + point + .into_iter() + .zip(self.min.into_iter().zip(self.max)) + .all(|(value, (min, max))| value >= min && value <= max) + } +} + +// CLightning::Update (Terrain.dll!10043870) uses a DWORD game tick. +const LIGHTNING_COOLDOWN_MS: u64 = 6_000; +const LIGHTNING_DELAY_MS: f64 = 60_000.0; +const LIGHTNING_INTENSITY_CAP: f32 = 0.95; +// CLightning's effect descriptor stores a 600-unit vertical quad and +// CLightning::CreateEffect (100439D0) adds half of that length to the +// sampled Z coordinate before creating the visual effect. +const LIGHTNING_NATIVE_WIDTH: f32 = 40.0; +const LIGHTNING_NATIVE_LENGTH: f32 = 600.0; +const LIGHTNING_NATIVE_ORIGIN_OFFSET: f32 = 300.0; +/// Effective CLightning descriptor used by the renderer-facing quad. +pub const LIGHTNING_QUAD_DIMENSIONS: [f32; 3] = [ + LIGHTNING_NATIVE_WIDTH, + LIGHTNING_NATIVE_WIDTH, + LIGHTNING_NATIVE_LENGTH, +]; +const NATIVE_RANDOM_SCALE: f32 = 1.0 / 32_768.0; + +/// The CRT linear congruential generator used by Terrain.dll and Effect.dll. +/// Both modules read and update the same process-wide state, so the +/// environment system intentionally owns one stream and passes it through +/// each emitter in update order. +#[derive(Clone, Copy, Debug)] +struct NativeRng { + state: u32, +} + +impl NativeRng { + fn new(seed: u64) -> Self { + Self { state: seed as u32 } + } + + fn next_u15(&mut self) -> u16 { + self.state = self.state.wrapping_mul(0x343F_D).wrapping_add(0x269E_C); + ((self.state >> 16) & 0x7FFF) as u16 + } + + fn next_unit(&mut self) -> f32 { + f32::from(self.next_u15()) * NATIVE_RANDOM_SCALE + } +} + +/// The two precipitation classes present in the original environment data. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PrecipitationKind { + /// Rain drops using the `RAIN_DROP` material. + Rain, + /// Snow flakes using the `SNOWFLAKE` material. + Snow, +} + +/// A sampled weather state supplied by the atmosphere schedule or a caller. +#[derive(Clone, Debug, PartialEq)] +pub struct WeatherState { + /// Whether this weather object is currently running. + pub active: bool, + /// Non-negative emission strength. Values above one are capped for the + /// bounded CPU emitter. + pub intensity: f32, + /// RGBA color passed to the renderer. + pub color: [f32; 4], + /// Resource names attached to the active interval in `sky.ske`. + /// Rain uses the first name as its loop sound; lightning uses the FXID + /// selected by the caller. Keeping these names here avoids silently + /// substituting a global weather asset. + pub resources: Vec, + /// Material row resolved from the current `sky.wea` table. + pub material: Option, +} + +impl WeatherState { + /// Constructs a weather state. + #[must_use] + pub fn new(active: bool, intensity: f32, color: [f32; 4]) -> Self { + Self { + active, + intensity, + color, + resources: Vec::new(), + material: None, + } + } + + /// Constructs a weather state with resource names from the atmosphere + /// key that started its active interval. + #[must_use] + pub fn with_resources(active: bool, intensity: f32, color: [f32; 4], resources: I) -> Self + where + I: IntoIterator, + S: Into, + { + Self { + active, + intensity, + color, + resources: resources.into_iter().map(Into::into).collect(), + material: None, + } + } + + /// Constructs an inactive weather state. + #[must_use] + pub fn inactive() -> Self { + Self::new(false, 0.0, [0.0; 4]) + } + + fn sanitized(self) -> Self { + let color = self.color.map(|component| { + if component.is_finite() { + component.clamp(0.0, 1.0) + } else { + 0.0 + } + }); + let intensity = if self.intensity.is_finite() { + self.intensity.clamp(0.0, 1.0) + } else { + 0.0 + }; + Self { + active: self.active, + intensity, + color, + resources: self + .resources + .into_iter() + .filter(|resource| !resource.is_empty()) + .collect(), + material: self.material.filter(|material| !material.is_empty()), + } + } +} + +impl Default for WeatherState { + fn default() -> Self { + Self::inactive() + } +} + +/// Weather input for one environment update. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct WeatherInput { + /// Rain state. + pub rain: WeatherState, + /// Snow state. + pub snow: WeatherState, + /// Lightning state. + pub lightning: WeatherState, +} + +impl WeatherInput { + /// Constructs a clear-weather input. + #[must_use] + pub fn clear() -> Self { + Self { + rain: WeatherState::inactive(), + snow: WeatherState::inactive(), + lightning: WeatherState::inactive(), + } + } + + /// Converts a sampled `sky.ske` frame into environment input. + #[must_use] + pub fn from_atmosphere(frame: &crate::atmosphere::AtmosphereFrame) -> Self { + Self { + rain: from_atmosphere_weather(&frame.rain), + snow: from_atmosphere_weather(&frame.snow), + lightning: from_atmosphere_weather(&frame.lightning), + } + } + + /// Converts a sampled atmosphere frame and the current `sky.wea` table + /// into environment input. Rows seven and eight are the native snow and + /// rain material slots; the table is resolved at runtime so missions can + /// replace their names. + #[must_use] + pub fn from_atmosphere_with_materials( + frame: &crate::atmosphere::AtmosphereFrame, + materials: &SkyMaterials, + ) -> Self { + let mut input = Self::from_atmosphere(frame); + input.rain.material = materials.material_name(8).map(str::to_owned); + input.snow.material = materials.material_name(7).map(str::to_owned); + input + } + + fn sanitized(self) -> Self { + Self { + rain: self.rain.sanitized(), + snow: self.snow.sanitized(), + lightning: self.lightning.sanitized(), + } + } +} + +/// Camera snapshot used to keep finite weather geometry around the observer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Camera { + /// World-space camera position. The renderer uses +Z as up. + pub position: [f32; 3], + /// Unit world-space direction of camera-local depth. + pub forward: [f32; 3], + /// Unit world-space direction of camera-local horizontal offset. + pub right: [f32; 3], + /// Unit world-space direction of camera-local vertical offset. + pub up: [f32; 3], + /// Vertical camera field of view in radians. + pub vertical_fov: f32, + /// Horizontal-to-vertical projection aspect ratio. + pub aspect_ratio: f32, + /// Viewport width and height in pixels used by screen-space particle quads. + pub viewport: [f32; 2], +} + +impl Camera { + /// Creates a camera snapshot. + #[must_use] + pub const fn new(position: [f32; 3]) -> Self { + Self { + position, + forward: [1.0, 0.0, 0.0], + right: [0.0, 1.0, 0.0], + up: [0.0, 0.0, 1.0], + vertical_fov: 0.975, + // tan(0.65) / tan(0.4875), the original reference box aspect. + aspect_ratio: 1.4338578, + viewport: [640.0, 480.0], + } + } + + /// Creates a camera snapshot with the orientation used by the native + /// camera-local precipitation transform. + #[must_use] + pub const fn with_basis( + position: [f32; 3], + forward: [f32; 3], + right: [f32; 3], + up: [f32; 3], + ) -> Self { + Self { + position, + forward, + right, + up, + vertical_fov: 0.975, + aspect_ratio: 1.4338578, + viewport: [640.0, 480.0], + } + } + + /// Creates a camera snapshot with orientation and projection data. + #[must_use] + pub const fn with_projection( + position: [f32; 3], + forward: [f32; 3], + right: [f32; 3], + up: [f32; 3], + vertical_fov: f32, + aspect_ratio: f32, + ) -> Self { + Self { + position, + forward, + right, + up, + vertical_fov, + aspect_ratio, + viewport: [640.0, 480.0], + } + } + + /// Sets the pixel viewport used by the projected particle geometry. + #[must_use] + pub const fn with_viewport(self, viewport: [f32; 2]) -> Self { + Self { viewport, ..self } + } + + /// Returns the horizontal field of view represented by this projection. + #[must_use] + pub fn horizontal_fov(self) -> f32 { + 2.0 * ((self.vertical_fov * 0.5).tan() * self.aspect_ratio).atan() + } + + /// Returns Terrain's precipitation size query: viewport width divided by + /// the camera's horizontal FOV in radians. + #[must_use] + pub fn precipitation_size_scalar(self) -> f32 { + let fov = self.horizontal_fov(); + if fov.is_finite() && fov > 0.0 { + self.viewport[0] / fov + } else { + 0.0 + } + } + + /// Transforms a camera-local point into world space. + #[must_use] + pub fn world_from_local(self, local: [f32; 3]) -> [f32; 3] { + [ + self.position[0] + + self.forward[0] * local[0] + + self.right[0] * local[1] + + self.up[0] * local[2], + self.position[1] + + self.forward[1] * local[0] + + self.right[1] * local[1] + + self.up[1] * local[2], + self.position[2] + + self.forward[2] * local[0] + + self.right[2] * local[1] + + self.up[2] * local[2], + ] + } + + /// Transforms a camera-local vector into world space. + #[must_use] + pub fn world_vector(self, local: [f32; 3]) -> [f32; 3] { + [ + self.forward[0] * local[0] + self.right[0] * local[1] + self.up[0] * local[2], + self.forward[1] * local[0] + self.right[1] * local[1] + self.up[1] * local[2], + self.forward[2] * local[0] + self.right[2] * local[1] + self.up[2] * local[2], + ] + } + + /// Transforms a world-space point into camera-local coordinates. + #[must_use] + pub fn local_from_world(self, world: [f32; 3]) -> [f32; 3] { + let delta = [ + world[0] - self.position[0], + world[1] - self.position[1], + world[2] - self.position[2], + ]; + [ + dot3(self.forward, delta), + dot3(self.right, delta), + dot3(self.up, delta), + ] + } + + fn sanitized(self) -> Self { + let mut camera = self; + camera.position = camera.position.map(|component| { + if component.is_finite() { + component + } else { + 0.0 + } + }); + camera.forward = finite_unit(camera.forward).unwrap_or([1.0, 0.0, 0.0]); + camera.right = finite_unit(camera.right).unwrap_or([0.0, 1.0, 0.0]); + camera.up = finite_unit(camera.up).unwrap_or([0.0, 0.0, 1.0]); + if !camera.vertical_fov.is_finite() || camera.vertical_fov <= 0.0 { + camera.vertical_fov = 0.975; + } + camera.vertical_fov = camera.vertical_fov.min(std::f32::consts::PI - 0.001); + if !camera.aspect_ratio.is_finite() || camera.aspect_ratio <= 0.0 { + camera.aspect_ratio = 1.4338578; + } + for value in &mut camera.viewport { + if !value.is_finite() || *value <= 0.0 { + *value = 1.0; + } + } + camera + } +} + +impl Default for Camera { + fn default() -> Self { + Self::new([0.0; 3]) + } +} + +/// World-space range used by the native lightning position sampler. +/// +/// `CLightning::Update` samples X from `[x_min, x_max]`, Y from +/// `[y_min, y_max]`, and copies `z` unchanged. The atmosphere manager owns +/// these values in the original engine, so the default environment system +/// uses the camera position as a zero-size range until a caller supplies the +/// current bounds. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LightningBounds { + /// Lower and upper X bounds in world coordinates. + pub x: [f32; 2], + /// Lower and upper Y bounds in world coordinates. + pub y: [f32; 2], + /// Z coordinate copied to the effect origin. + pub z: f32, +} + +impl LightningBounds { + /// Creates a world-space lightning sampling range. + #[must_use] + pub const fn new(x_min: f32, x_max: f32, y_min: f32, y_max: f32, z: f32) -> Self { + Self { + x: [x_min, x_max], + y: [y_min, y_max], + z, + } + } + + fn sanitized(self) -> Option { + if self + .x + .into_iter() + .chain(self.y) + .chain([self.z]) + .all(f32::is_finite) + { + Some(self) + } else { + None + } + } +} + +/// Native point-light state produced by an Effect opcode 1 command. +/// +/// Effect.dll's opcode 1 handler (`1000F6E0`) updates a LightManager record. +/// The A and B groups are the submitted position and direction, C is the +/// four-component HDR light color, the scalar pair at body offsets 108 and +/// 112 is the light range, and body offsets 120, 124, and 128 are the native +/// attenuation coefficients. These values do not describe lightning mesh +/// geometry or texture coordinates. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PointLightFrame { + /// Opcode 1 type word. `env_lightning` uses type 7. + pub effect_type: u32, + /// Effect age supplied to the native evaluator, in seconds. + pub elapsed_seconds: f32, + /// Interpolation parameter between the opcode 1 start/end times. + pub normalized_time: f32, + /// FXID lifetime in seconds. + pub duration_seconds: f32, + /// World-space LightManager position. + pub position: [f32; 3], + /// World-space direction submitted alongside the point-light record. + pub direction: [f32; 3], + /// HDR RGBA color submitted to LightManager slot +0C. + pub color: [f32; 4], + /// Light range submitted to LightManager slot +10. + pub range: f32, + /// Native attenuation coefficients `(a0, a1, a2)`. + pub attenuation: [f32; 3], + /// The scalar at body offset 116. Its native role is an effect factor, + /// not alpha or a UV parameter, so it is preserved without reinterpretation. + pub effect_factor: f32, + /// Whether the opcode 1 command is active at `elapsed_seconds`. + pub active: bool, +} + +/// Native quad produced by the opcode 3 lightning visual. +/// +/// CLightning creates this effect after moving the sampled position 300 +/// units upward. The owner descriptor is `[40, 40, 600]`; opcode 1 does not +/// supply the quad vertices or texture coordinates. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LightningQuadFrame { + /// Four world-space vertices in native perimeter order: bottom plus + /// horizontal, bottom minus horizontal, top minus horizontal, top plus + /// horizontal. + pub vertices: [[f32; 3]; 4], + /// Native UVs in the same order as `vertices`. + pub uv: [[f32; 2]; 4], + /// CLightning's width, height, and effective streak length. + pub dimensions: [f32; 3], + /// Opcode 3 animated local position from the native start/delta channels. + pub local_offset: [f32; 3], + /// Opcode 3 animated local scale from the native start/end channels. + pub local_scale: [f32; 3], + /// Native opcode 3 alpha channel. `env_lightning` fades from one to zero + /// over its active interval. + pub opacity: f32, + /// Effect age supplied to the native opcode 3 evaluator, in seconds. + pub elapsed_seconds: f32, + /// Interpolation parameter between the opcode 3 start/end times. + pub normalized_time: f32, + /// FXID lifetime in seconds. + pub duration_seconds: f32, + /// Whether the opcode 3 command is active at `elapsed_seconds`. + pub active: bool, +} + +/// Screen-space quad data produced by the native rain/snow geometry paths. +/// `head`, `tail`, and `corners` are normalized device coordinates. The native +/// size remains in viewport pixels so a renderer can apply the same query +/// without mixing pixel and NDC units. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScreenBillboard { + /// World-space head point. + pub world_head: [f32; 3], + /// World-space tail point. + pub world_tail: [f32; 3], + /// Projected head point in NDC. + pub head: [f32; 2], + /// Projected tail point in NDC. Snow uses the same point as `head`. + pub tail: [f32; 2], + /// Four NDC corners in tail-left, tail-right, head-left, head-right order. + pub corners: [[f32; 2]; 4], + /// Texture coordinates for the four corners. + pub uv: [[f32; 2]; 4], + /// Native half-size argument in viewport pixels. + pub half_size: f32, + /// Camera-local depth of the projected head. + pub head_depth: f32, + /// Camera-local depth of the projected tail. + pub tail_depth: f32, +} + +/// Geometry and light output decoded from the FXID. +#[derive(Clone, Debug, PartialEq)] +pub struct LightningBolt { + /// Material archive and name resolved from opcode 3. + pub material: EffectResource, + /// World-space sampled position supplied to CLightning. + pub position: [f32; 3], + /// Opcode 3 world-space quad and material state. + pub quad: LightningQuadFrame, + /// Current weather intensity used by the event scheduler. + pub intensity: f32, + /// Effect age in seconds from its FXID start time. + pub age_seconds: f32, + /// FXID lifetime in seconds used by the native evaluator. + pub duration_seconds: f32, +} + +/// Native environment primitives consumed by the renderer. +#[derive(Clone, Debug, PartialEq)] +pub enum EnvironmentPrimitive { + /// A rain drop or snow flake billboard instance. + Particle { + /// Precipitation class. + kind: PrecipitationKind, + /// World-space position. + position: [f32; 3], + /// World-space velocity. + velocity: [f32; 3], + /// Native size argument after applying the rain/snow size factor. + size: f32, + /// RGBA color. + color: [f32; 4], + /// Material lookup name from `sky.wea`. + material: String, + /// Native projected quad geometry in viewport pixels. + screen: ScreenBillboard, + }, + /// A lightning bolt with opcode 3 quad data. + Lightning(LightningBolt), +} + +/// Action applied to a sound resource by the caller's audio backend. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SoundAction { + /// Start a loop at the event's volume. + StartLoop, + /// Change the volume of an already-running loop. + SetLoopVolume, + /// Stop a previously started loop. + StopLoop, + /// Play one sample once. + OneShot, +} + +/// One sound event generated by the environment simulation. +#[derive(Clone, Debug, PartialEq)] +pub struct SoundEvent { + /// Archive to resolve through the resource repository. Empty means the + /// audio owner should use its mission-configured weather library. + pub archive: String, + /// Entry name in `archive`. + pub name: String, + /// World-space emitter position. + pub position: [f32; 3], + /// Linear volume multiplier. + pub volume: f32, + /// Native minimum distance in world units. A non-positive value disables + /// the native range clamp for callers such as atmosphere loops. + pub min_distance: f32, + /// Native maximum distance in world units. A non-positive or non-finite + /// value disables the native range clamp. + pub max_distance: f32, + /// Native playback-frequency ratio evaluated at event creation. + pub frequency_ratio: f32, + /// Playback action. + pub action: SoundAction, +} + +/// Native opcode-2 sound parameters evaluated for one effect age. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SoundParameters { + /// DirectSound minimum distance in world units. + pub min_distance: f32, + /// DirectSound maximum distance in world units. + pub max_distance: f32, + /// Playback frequency ratio. The shipped thunder effect keeps this at 1. + pub frequency_ratio: f32, +} + +impl Default for SoundParameters { + fn default() -> Self { + Self { + min_distance: 0.0, + max_distance: f32::INFINITY, + frequency_ratio: 1.0, + } + } +} + +/// All visible and audible environment output for one frame. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct EnvironmentFrame { + /// Renderer primitives in stable emitter order. + pub primitives: Vec, + /// Dynamic point lights emitted by native Effect opcode 1 commands. + pub point_lights: Vec, + /// Sound transitions and one-shot events. + pub sounds: Vec, +} + +/// A validated resource name from the `env_lightning` FXID. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EffectResource { + /// Archive name. + pub archive: String, + /// Entry name. + pub name: String, +} + +/// The real visual and sound contracts extracted from `env_lightning`. +#[derive(Clone, Debug, PartialEq)] +pub struct LightningEffect { + /// FXID header lifetime in seconds. + pub duration_seconds: f32, + /// Visual material resource from the opcode 3 body. + pub visual: EffectResource, + /// Complete opcode-3 body used by the native quad evaluator. + pub visual_body: Arc<[u8]>, + /// Complete opcode-1 body for the native Effect evaluator. + pub opcode1_body: Arc<[u8]>, + /// Command index containing those visual parameters. + pub opcode1_command_index: usize, + /// Sound resource from the opcode 2 body. + pub sound: EffectResource, + /// Complete opcode-2 body used to evaluate the native thunder gain and + /// active interval at the event origin. + pub sound_body: Arc<[u8]>, + /// Command index containing the visual material. + pub visual_command_index: usize, + /// Command index containing the sound resource. + pub sound_command_index: usize, +} + +impl LightningEffect { + /// Extracts the visual, point-light, and sound contracts from a decoded + /// lightning FXID. + /// + /// The generic FXID parser intentionally preserves command bodies without + /// claiming field semantics. Opcode 3 has a material reference at body + /// offset 132, opcode 1 carries the LightManager record, and opcode 2 + /// has a sound reference at body offsets 80 and 112. + /// + /// # Errors + /// + /// Returns an error if the document does not contain those commands or + /// contains truncated command bodies. + pub fn from_fxid(document: &FxDocument) -> Result { + let duration_seconds = document.header().duration_seconds; + if !duration_seconds.is_finite() || duration_seconds <= 0.0 { + return Err(EnvironmentError::InvalidDuration { duration_seconds }); + } + + let mut visual = None; + let mut opcode1 = None; + let mut sound = None; + for (index, command) in document.commands().iter().enumerate() { + match command.opcode { + FxOpcode::Op3 if visual.is_none() => { + let resource = read_resource(index, command.opcode, &command.raw_body, 132)?; + validate_opcode3_body(index, &command.raw_body)?; + visual = Some((index, resource, Arc::clone(&command.raw_body))); + } + FxOpcode::Op1 if opcode1.is_none() => { + validate_opcode1_body(index, &command.raw_body)?; + opcode1 = Some((index, Arc::clone(&command.raw_body))); + } + FxOpcode::Op2 if sound.is_none() => { + validate_opcode2_body(index, &command.raw_body)?; + let resource = read_resource(index, command.opcode, &command.raw_body, 80)?; + let name = read_name(&command.raw_body, index, command.opcode, 112)?; + sound = Some(( + index, + EffectResource { + archive: resource.archive, + name, + }, + Arc::clone(&command.raw_body), + )); + } + _ => {} + } + } + + let Some((visual_command_index, visual, visual_body)) = visual else { + return Err(EnvironmentError::MissingCommand { + opcode: FxOpcode::Op3, + }); + }; + let Some((opcode1_command_index, opcode1_body)) = opcode1 else { + return Err(EnvironmentError::MissingCommand { + opcode: FxOpcode::Op1, + }); + }; + let Some((sound_command_index, sound, sound_body)) = sound else { + return Err(EnvironmentError::MissingCommand { + opcode: FxOpcode::Op2, + }); + }; + Ok(Self { + duration_seconds, + visual, + visual_body, + opcode1_body, + opcode1_command_index, + sound, + sound_body, + visual_command_index, + sound_command_index, + }) + } + + /// Evaluates the native opcode-2 distance and playback parameters at an + /// effect age. The FX command stores minimum/maximum distance at body + /// offsets `0x3c`/`0x40` and interpolates the frequency ratio from + /// `0x44` to `0x48`; body offset `0x4c` remains in the preserved raw + /// command because its backend meaning is not established. + #[must_use] + pub fn sound_parameters(&self, elapsed_seconds: f32) -> Option { + if !elapsed_seconds.is_finite() + || !self.duration_seconds.is_finite() + || self.duration_seconds <= 0.0 + { + return None; + } + let (normalized_time, _) = active_interval( + self.sound_body.as_ref(), + elapsed_seconds, + self.duration_seconds, + )?; + let min_distance = read_f32(self.sound_body.as_ref(), 60)?; + let max_distance = read_f32(self.sound_body.as_ref(), 64)?; + let frequency_ratio = lerp( + read_f32(self.sound_body.as_ref(), 68)?, + read_f32(self.sound_body.as_ref(), 72)?, + normalized_time, + ); + if !min_distance.is_finite() || !max_distance.is_finite() || !frequency_ratio.is_finite() { + return None; + } + Some(SoundParameters { + min_distance, + max_distance, + frequency_ratio, + }) + } + + /// Evaluates the native opcode 1 point-light command at `elapsed_seconds`. + #[must_use] + pub fn evaluate_point_light( + &self, + elapsed_seconds: f32, + origin: [f32; 3], + ) -> Option { + self.evaluate_point_light_with_basis(elapsed_seconds, origin, identity_basis()) + } + + /// Evaluates opcode 1 and applies a parent basis to its LightManager + /// position and direction. `basis` stores the world-space vectors + /// corresponding to local +X, +Y, and +Z. + #[must_use] + pub fn evaluate_point_light_with_basis( + &self, + elapsed_seconds: f32, + origin: [f32; 3], + basis: [[f32; 3]; 3], + ) -> Option { + if !self.valid_effect_time(elapsed_seconds, origin, basis) { + return None; + } + + let body = self.opcode1_body.as_ref(); + let effect_type = read_u32(body, 0)?; + let (normalized_time, _) = active_interval(body, elapsed_seconds, self.duration_seconds)?; + let local_position = lerp3(read_vec3(body, 12)?, read_vec3(body, 24)?, normalized_time); + let local_direction = lerp3(read_vec3(body, 36)?, read_vec3(body, 48)?, normalized_time); + let color = lerp4(read_vec4(body, 60)?, read_vec4(body, 76)?, normalized_time); + let range = lerp(read_f32(body, 108)?, read_f32(body, 112)?, normalized_time); + let attenuation = [ + read_f32(body, 120)?, + read_f32(body, 124)?, + read_f32(body, 128)?, + ]; + let effect_factor = read_f32(body, 116)?; + let direction = finite_unit(transform3(basis, local_direction)) + .unwrap_or_else(|| finite_unit(basis[0]).unwrap_or([1.0, 0.0, 0.0])); + + Some(PointLightFrame { + effect_type, + elapsed_seconds, + normalized_time, + duration_seconds: self.duration_seconds, + position: add3(origin, transform3(basis, local_position)), + direction, + color: color.map(sanitize_float), + range: sanitize_float(range).max(0.0), + attenuation: attenuation.map(sanitize_float), + effect_factor: sanitize_float(effect_factor), + active: true, + }) + } + + /// Evaluates the native opcode 3 visual quad at `elapsed_seconds`. + /// + /// The native owner supplies the effective descriptor `[40, 40, 600]` + /// and the effect origin after adding 300 units to the sampled Z. Opcode + /// 3 contributes the material modifiers and active interval; its fixed + /// full-texture UVs are independent of opcode 1. + #[must_use] + pub fn evaluate_quad( + &self, + elapsed_seconds: f32, + origin: [f32; 3], + camera: Camera, + ) -> Option { + self.evaluate_quad_with_mirror(elapsed_seconds, origin, camera, false) + } + + /// Evaluates the native opcode 3 visual quad with the per-bolt mirrored + /// texture orientation selected by `CLightning::CreateEffect`. + #[must_use] + pub fn evaluate_quad_with_mirror( + &self, + elapsed_seconds: f32, + origin: [f32; 3], + camera: Camera, + mirrored: bool, + ) -> Option { + let camera = camera.sanitized(); + if !self.valid_effect_time(elapsed_seconds, origin, identity_basis()) { + return None; + } + let body = self.visual_body.as_ref(); + let (normalized_time, _) = active_interval(body, elapsed_seconds, self.duration_seconds)?; + let local_offset = evaluate_position(body, normalized_time)?; + let local_scale = evaluate_scale(body, normalized_time)?; + // The owner matrix is diagonal. The mirrored path negates its local + // Y basis; that sign participates in both the inverse camera + // transform and the forward transform of the billboard frame. + // Effect.dll first computes `world_center = M_base * local_offset`, + // then builds `M_base * Diag(local_scale)`. It never applies the + // animated scale to the inverse camera vector. + let base_diag = lightning_parent_diagonal(mirrored); + let mut geometry_diag = base_diag.map(|value| value); + for (axis, scale) in geometry_diag.iter_mut().zip(local_scale) { + *axis *= scale; + } + let dimensions = [ + LIGHTNING_QUAD_DIMENSIONS[0] * local_scale[0], + LIGHTNING_QUAD_DIMENSIONS[1] * local_scale[1], + LIGHTNING_QUAD_DIMENSIONS[2] * local_scale[2], + ]; + let center = add3( + origin, + [ + base_diag[0] * local_offset[0], + base_diag[1] * local_offset[1], + base_diag[2] * local_offset[2], + ], + ); + + // The shipped env_lightning command is type 1. Its native receiver + // builds a local frame with columns `(axis, U, W)`, where + // `U = normalize(V x axis)` and `W = axis x U`. The canonical quad + // uses the first two columns; the third column supplies the + // camera-facing normal to the native receiver. `V` is intentionally + // not normalized before the exact parallel-view test. + let axis = [0.0, 0.0, 1.0]; + let camera_local = [ + (camera.position[0] - origin[0]) / base_diag[0], + (camera.position[1] - origin[1]) / base_diag[1], + (camera.position[2] - origin[2]) / base_diag[2], + ]; + let raw_view = sub3(camera_local, local_offset); + // Native type 1 compares the raw dot product against exactly +/-1. + // A near-parallel view therefore follows the cross-product path. + if dot3(raw_view, axis).abs() == 1.0 { + return None; + } + let horizontal_local = normalized_or_fallback(cross3(raw_view, axis), [1.0, 0.0, 0.0]); + let _normal_local = cross3(axis, horizontal_local); + let horizontal = transform_diagonal(geometry_diag, horizontal_local); + let vertical = transform_diagonal(geometry_diag, axis); + let bottom = sub3(center, scale3(vertical, 0.5)); + let top = add3(center, scale3(vertical, 0.5)); + let positive_horizontal = scale3(horizontal, 0.5); + let negative_horizontal = scale3(horizontal, -0.5); + Some(LightningQuadFrame { + vertices: [ + add3(bottom, positive_horizontal), + add3(bottom, negative_horizontal), + add3(top, negative_horizontal), + add3(top, positive_horizontal), + ], + uv: [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]], + dimensions, + local_offset: local_offset.map(sanitize_float), + local_scale: local_scale.map(sanitize_float), + opacity: sanitize_float(1.0 - normalized_time).clamp(0.0, 1.0), + elapsed_seconds, + normalized_time, + duration_seconds: self.duration_seconds, + active: true, + }) + } + + fn valid_effect_time( + &self, + elapsed_seconds: f32, + origin: [f32; 3], + basis: [[f32; 3]; 3], + ) -> bool { + elapsed_seconds.is_finite() + && self.duration_seconds.is_finite() + && self.duration_seconds > 0.0 + && elapsed_seconds >= 0.0 + && elapsed_seconds <= self.duration_seconds + && origin.iter().all(|value| value.is_finite()) + && basis.iter().flatten().all(|value| value.is_finite()) + } +} + +/// Decodes and validates the original `env_lightning` FXID payload. +pub fn decode_env_lightning_fxid(bytes: Arc<[u8]>) -> Result { + let document = crate::decode_fxid(bytes)?; + LightningEffect::from_fxid(&document) +} + +/// Environment simulation failure. +#[derive(Debug)] +pub enum EnvironmentError { + /// Generic FXID framing error. + Fx(FxError), + /// FXID lifetime is unusable for an active effect. + InvalidDuration { + /// Value from the FXID header. + duration_seconds: f32, + }, + /// Required command is absent. + MissingCommand { + /// Required opcode. + opcode: FxOpcode, + }, + /// A command body is too short for the established layout. + InvalidCommandLayout { + /// Command index. + command_index: usize, + /// Command opcode. + opcode: FxOpcode, + /// Required body length. + required: usize, + /// Actual body length. + actual: usize, + }, + /// A known command points at an unexpected resource. + UnexpectedResource { + /// Command index. + command_index: usize, + /// Resource field being checked. + part: &'static str, + /// Decoded archive name. + archive: String, + /// Decoded resource name. + name: String, + }, + /// Opcode 1 contains a non-finite visual parameter. + NonFiniteVisualValue { + /// Command index. + command_index: usize, + /// Value index in the decoded prefix. + index: usize, + /// Value read from the command body. + value: f32, + }, + /// Opcode 2 contains a non-finite sound parameter. + NonFiniteSoundValue { + /// Command index. + command_index: usize, + /// Value index in the numeric sound prefix. + index: usize, + /// Value read from the command body. + value: f32, + }, +} + +impl From for EnvironmentError { + fn from(value: FxError) -> Self { + Self::Fx(value) + } +} + +impl std::fmt::Display for EnvironmentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Fx(error) => write!(f, "{error}"), + Self::InvalidDuration { duration_seconds } => { + write!(f, "invalid env_lightning FXID duration {duration_seconds}") + } + Self::MissingCommand { opcode } => { + write!(f, "env_lightning is missing required {opcode:?} command") + } + Self::InvalidCommandLayout { + command_index, + opcode, + required, + actual, + } => write!( + f, + "env_lightning command {command_index} ({opcode:?}) needs {required} body bytes, found {actual}" + ), + Self::UnexpectedResource { + command_index, + part, + archive, + name, + } => write!( + f, + "unexpected env_lightning {part} at command {command_index}: {archive}/{name}" + ), + Self::NonFiniteVisualValue { + command_index, + index, + value, + } => write!( + f, + "env_lightning opcode 1 command {command_index} has non-finite visual value {index}: {value}" + ), + Self::NonFiniteSoundValue { + command_index, + index, + value, + } => write!( + f, + "env_lightning opcode 2 command {command_index} has non-finite sound value {index}: {value}" + ), + } + } +} + +impl std::error::Error for EnvironmentError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Fx(error) => Some(error), + Self::InvalidDuration { .. } + | Self::MissingCommand { .. } + | Self::InvalidCommandLayout { .. } + | Self::UnexpectedResource { .. } + | Self::NonFiniteVisualValue { .. } + | Self::NonFiniteSoundValue { .. } => None, + } + } +} + +/// Stateful CPU environment emitter. +#[derive(Clone, Debug)] +pub struct EnvironmentSystem { + time_seconds: f32, + time_millis: u64, + time_millis_fraction: f64, + /// Terrain and Effect share the CRT `rand` state. Keep one stream for + /// particle respawns, lightning scheduling, and lightning placement so + /// the native call order remains observable across subsystems. + rng: NativeRng, + rain: PrecipitationEmitter, + snow: PrecipitationEmitter, + materials: SkyMaterials, + lightning_effect: Option, + lightning_bounds: Option, + lightning: LightningState, + rain_loop_active: bool, + rain_loop_resource: Option, +} + +impl EnvironmentSystem { + /// Creates a deterministic environment system. + #[must_use] + pub fn new(seed: u64) -> Self { + Self { + time_seconds: 0.0, + time_millis: 0, + time_millis_fraction: 0.0, + rng: NativeRng::new(seed), + rain: PrecipitationEmitter::new(PrecipitationKind::Rain, PrecipitationVolume::native()), + snow: PrecipitationEmitter::new(PrecipitationKind::Snow, PrecipitationVolume::native()), + materials: SkyMaterials::default(), + lightning_effect: None, + lightning_bounds: None, + lightning: LightningState::new(), + rain_loop_active: false, + rain_loop_resource: None, + } + } + + /// Installs the mission's `sky.wea` material table. The rain and snow + /// emitters resolve rows eight and seven from this table on every frame. + pub fn set_sky_materials(&mut self, materials: SkyMaterials) { + self.materials = materials; + } + + /// Returns the active precipitation descriptor. + #[must_use] + pub fn precipitation_volume(&self) -> PrecipitationVolume { + self.rain.volume + } + + /// Installs a validated `env_lightning` effect contract. + pub fn set_lightning_effect(&mut self, effect: LightningEffect) { + self.lightning_effect = effect + .duration_seconds + .is_finite() + .then_some(effect) + .filter(|effect| effect.duration_seconds > 0.0); + self.lightning.clear_visual(); + } + + /// Removes the installed lightning effect. + pub fn clear_lightning_effect(&mut self) { + self.lightning_effect = None; + self.lightning.clear_visual(); + } + + /// Supplies the current world-space bounds used by the native lightning + /// sampler. Passing non-finite bounds makes the system fall back to the + /// camera position for subsequent flashes. + pub fn set_lightning_bounds(&mut self, bounds: LightningBounds) { + self.lightning_bounds = bounds.sanitized(); + } + + /// Removes explicit lightning bounds. Subsequent effects use the camera + /// position as a zero-size range. + pub fn clear_lightning_bounds(&mut self) { + self.lightning_bounds = None; + } + + /// Current simulation time in seconds. + #[must_use] + pub fn time_seconds(&self) -> f32 { + self.time_seconds + } + + /// Current simulation time in whole native milliseconds. + #[must_use] + pub fn time_millis(&self) -> u64 { + self.time_millis + } + + /// Advances precipitation and lightning and returns renderer/audio output. + /// + /// `dt_seconds` is sanitized to a finite non-negative value. Particle + /// positions are retained between frames and wrapped in the camera-local + /// descriptor using the native floor-based wrap operation, so a renderer + /// can consume this frame as a direct list of instances. + #[must_use] + pub fn update( + &mut self, + dt_seconds: f32, + weather: WeatherInput, + camera: Camera, + ) -> EnvironmentFrame { + let dt_seconds = if dt_seconds.is_finite() { + dt_seconds.max(0.0) + } else { + 0.0 + }; + let weather = weather.sanitized(); + let camera = camera.sanitized(); + self.time_seconds = self.time_seconds.max(0.0) + dt_seconds; + self.advance_millis(dt_seconds); + + let mut frame = EnvironmentFrame::default(); + self.rain.update( + dt_seconds, + &weather.rain, + camera, + self.materials.material_name(8), + &mut self.rng, + &mut frame.primitives, + ); + self.snow.update( + dt_seconds, + &weather.snow, + camera, + self.materials.material_name(7), + &mut self.rng, + &mut frame.primitives, + ); + + let rain_resource = weather.rain.resources.first().cloned(); + if weather.rain.active && self.rain_loop_active && rain_resource != self.rain_loop_resource + { + if let Some(name) = self.rain_loop_resource.clone() { + let (archive, name) = split_resource(&name); + frame.sounds.push(SoundEvent { + archive, + name, + position: camera.position, + volume: 0.0, + min_distance: 0.0, + max_distance: f32::INFINITY, + frequency_ratio: 1.0, + action: SoundAction::StopLoop, + }); + } + } + if weather.rain.active != self.rain_loop_active + || (weather.rain.active && rain_resource != self.rain_loop_resource) + { + let (resource, action) = if weather.rain.active { + (rain_resource.clone(), SoundAction::StartLoop) + } else { + (self.rain_loop_resource.clone(), SoundAction::StopLoop) + }; + if let Some(resource) = resource { + let (archive, name) = split_resource(&resource); + frame.sounds.push(SoundEvent { + archive, + name, + position: camera.position, + volume: weather.rain.intensity, + min_distance: 0.0, + max_distance: f32::INFINITY, + frequency_ratio: 1.0, + action, + }); + } + } else if weather.rain.active { + // Atmosphere intensity is continuously interpolated between + // keys. The native rain object keeps its loop alive while that + // value changes, so the audio owner must receive the current + // volume even when the lifecycle state is unchanged. + if let Some(name) = rain_resource.clone() { + let (archive, name) = split_resource(&name); + frame.sounds.push(SoundEvent { + archive, + name, + position: camera.position, + volume: weather.rain.intensity, + min_distance: 0.0, + max_distance: f32::INFINITY, + frequency_ratio: 1.0, + action: SoundAction::SetLoopVolume, + }); + } + } + self.rain_loop_active = weather.rain.active; + self.rain_loop_resource = weather.rain.active.then_some(rain_resource).flatten(); + + self.lightning.update( + self.time_millis, + weather.lightning, + camera, + self.lightning_effect.as_ref(), + self.lightning_bounds, + &mut self.rng, + &mut frame.primitives, + &mut frame.point_lights, + &mut frame.sounds, + ); + + frame + } + + /// Advances from an `AtmosphereFrame` sampled by the `sky.ske` parser. + #[must_use] + pub fn update_atmosphere( + &mut self, + dt_seconds: f32, + frame: &crate::atmosphere::AtmosphereFrame, + camera: Camera, + ) -> EnvironmentFrame { + self.update(dt_seconds, WeatherInput::from_atmosphere(frame), camera) + } + + /// Advances from a sampled `sky.ske` frame and its mission `sky.wea` + /// material table. + #[must_use] + pub fn update_atmosphere_with_materials( + &mut self, + dt_seconds: f32, + frame: &crate::atmosphere::AtmosphereFrame, + materials: &SkyMaterials, + camera: Camera, + ) -> EnvironmentFrame { + self.set_sky_materials(materials.clone()); + self.update( + dt_seconds, + WeatherInput::from_atmosphere_with_materials(frame, materials), + camera, + ) + } + + fn advance_millis(&mut self, dt_seconds: f32) { + let millis = self.time_millis_fraction + f64::from(dt_seconds) * 1_000.0; + if !millis.is_finite() || millis <= 0.0 { + return; + } + let whole = millis.floor(); + self.time_millis = self + .time_millis + .saturating_add(if whole >= u64::MAX as f64 { + u64::MAX + } else { + whole as u64 + }); + self.time_millis_fraction = millis - whole; + } +} + +#[derive(Clone, Debug)] +struct PrecipitationEmitter { + kind: PrecipitationKind, + volume: PrecipitationVolume, + particles: Vec, +} + +#[derive(Clone, Copy, Debug)] +struct ParticleState { + /// World-space head position. Native integration advances this directly + /// before converting it to camera-local coordinates for wrapping. + position: [f32; 3], + /// Previous world-space head position, used as the rain tail. + previous_position: [f32; 3], + respawns: u64, +} + +impl PrecipitationEmitter { + fn new(kind: PrecipitationKind, volume: PrecipitationVolume) -> Self { + Self { + kind, + volume, + particles: Vec::new(), + } + } + + fn update( + &mut self, + dt_seconds: f32, + state: &WeatherState, + camera: Camera, + default_material: Option<&str>, + rng: &mut NativeRng, + out: &mut Vec, + ) { + if !state.active || state.intensity <= 0.0 { + return; + } + let Some(material) = state + .material + .as_deref() + .or(default_material) + .filter(|material| !material.is_empty()) + else { + return; + }; + let current_volume = self.volume.for_camera(camera); + let desired = self + .volume + .particle_count_for(current_volume, 1.0, state.intensity); + self.particles.truncate(desired); + while self.particles.len() < desired { + let index = self.particles.len(); + self.particles.push(ParticleState { + position: [0.0; 3], + previous_position: [0.0; 3], + respawns: 0, + }); + self.respawn(index, camera, current_volume, rng); + } + + let velocity = self.velocity(); + let kind = self.kind; + let size_factor = match kind { + PrecipitationKind::Rain => RAIN_SIZE_FACTOR, + PrecipitationKind::Snow => SNOW_SIZE_FACTOR, + }; + let size = camera.precipitation_size_scalar() * size_factor; + for index in 0..desired { + let particle = &mut self.particles[index]; + particle.previous_position = particle.position; + particle.position[0] += velocity[0] * dt_seconds; + particle.position[1] += velocity[1] * dt_seconds; + particle.position[2] += velocity[2] * dt_seconds; + let local = camera.local_from_world(particle.position); + let mut wrapped_local = local; + let mut wrapped = false; + for axis in 0..3 { + let offset = local[axis] - current_volume.origin[axis]; + let wrapped_offset = wrap_local(offset, current_volume.extent[axis]); + wrapped_local[axis] = current_volume.origin[axis] + wrapped_offset; + wrapped |= (wrapped_offset - offset).abs() > f32::EPSILON; + } + if wrapped { + // A wrapped particle has no continuous temporal tail across + // the opposite faces of the volume. + particle.position = camera.world_from_local(wrapped_local); + particle.previous_position = particle.position; + } + let position = particle.position; + let tail = particle.previous_position; + out.push(EnvironmentPrimitive::Particle { + kind, + position, + velocity, + size, + color: state.color, + material: material.to_owned(), + screen: screen_billboard(kind, camera, position, tail, size, index), + }); + } + } + + fn respawn( + &mut self, + index: usize, + camera: Camera, + volume: PrecipitationVolume, + rng: &mut NativeRng, + ) { + let particle = &mut self.particles[index]; + let local = [ + rng.next_unit() * volume.extent[0], + rng.next_unit() * volume.extent[1], + rng.next_unit() * volume.extent[2], + ]; + particle.position = camera.world_from_local(add3(volume.origin, local)); + particle.previous_position = particle.position; + particle.respawns = particle.respawns.wrapping_add(1); + } + + fn velocity(&self) -> [f32; 3] { + match self.kind { + PrecipitationKind::Rain => RAIN_VELOCITY, + PrecipitationKind::Snow => SNOW_VELOCITY, + } + } +} + +#[derive(Clone, Debug)] +struct LightningState { + phase: u8, + last_time_ms: u64, + deadline_ms: u64, + active: bool, + visual: Option, +} + +#[derive(Clone, Debug)] +struct LightningRuntime { + effect: LightningEffect, + position: [f32; 3], + intensity: f32, + mirrored: bool, + started_ms: u64, +} + +impl LightningState { + fn new() -> Self { + Self { + // CLightning's constructor stores state 1, which means the first + // active update schedules a delay immediately. + phase: 1, + last_time_ms: 0, + deadline_ms: 0, + active: false, + visual: None, + } + } + + fn clear_visual(&mut self) { + self.visual = None; + } + + fn update( + &mut self, + now_ms: u64, + weather: WeatherState, + camera: Camera, + effect: Option<&LightningEffect>, + bounds: Option, + rng: &mut NativeRng, + primitives: &mut Vec, + point_lights: &mut Vec, + sounds: &mut Vec, + ) { + if !weather.active { + // The atmosphere destroys and recreates the lightning object on a + // stop/start transition. Reproduce that lifecycle so an old + // deadline cannot fire after clear weather. + self.active = false; + self.phase = 1; + self.last_time_ms = 0; + self.deadline_ms = 0; + self.visual = None; + return; + } + if !self.active { + self.active = true; + self.phase = 1; + self.last_time_ms = 0; + self.deadline_ms = 0; + } + + self.emit_visual(now_ms, weather.color, camera, primitives, point_lights); + + // Keep the state machine's one-transition-per-call behaviour. The + // original virtual update returns after changing state 0->1 or + // state 1->2; it does not catch up multiple lightning events after a + // long frame. + match self.phase { + 0 => { + if now_ms.saturating_sub(self.last_time_ms) >= LIGHTNING_COOLDOWN_MS { + self.phase = 1; + } + } + 1 => { + if weather.intensity <= 0.0 { + return; + } + let intensity = weather.intensity.min(LIGHTNING_INTENSITY_CAP); + let random = rng.next_unit(); + let delay = ((1.0 - f64::from(intensity)) * LIGHTNING_DELAY_MS * f64::from(random)) + .round_ties_even(); + let delay = if delay.is_finite() && delay >= 0.0 { + delay.min(u64::MAX as f64) as u64 + } else { + 0 + }; + self.deadline_ms = now_ms.saturating_add(delay); + self.phase = 2; + } + 2 => { + if now_ms < self.deadline_ms { + return; + } + let position = self.random_position(camera, bounds, rng); + if let Some(effect) = effect { + // CLightning consumes one CRT random value for the + // effect seed after sampling the X and Y coordinates. + // The seed has no renderer-facing field, but it remains + // part of the shared native stream. + let effect_seed = rng.next_unit(); + self.visual = Some(LightningRuntime { + effect: effect.clone(), + position, + intensity: weather.intensity, + mirrored: effect_seed < 0.5, + started_ms: now_ms, + }); + let parameters = effect.sound_parameters(0.0).unwrap_or_default(); + sounds.push(SoundEvent { + archive: effect.sound.archive.clone(), + name: effect.sound.name.clone(), + position, + // The native opcode-2 command owns its frequency and + // range; weather intensity controls scheduling, not + // the thunder sample's source gain. + volume: 1.0, + min_distance: parameters.min_distance, + max_distance: parameters.max_distance, + frequency_ratio: parameters.frequency_ratio, + action: SoundAction::OneShot, + }); + self.emit_visual(now_ms, weather.color, camera, primitives, point_lights); + } + self.last_time_ms = now_ms; + self.phase = 0; + } + _ => self.phase = 1, + } + } + + fn emit_visual( + &mut self, + now_ms: u64, + _color: [f32; 4], + camera: Camera, + primitives: &mut Vec, + point_lights: &mut Vec, + ) { + let Some(visual) = self.visual.as_ref() else { + return; + }; + let duration_ms = seconds_to_millis(visual.effect.duration_seconds); + let elapsed_ms = now_ms.saturating_sub(visual.started_ms); + if elapsed_ms >= duration_ms.max(1) { + self.visual = None; + return; + } + // CLightning creates the Effect object 300 world units above the + // sampled ground point. Its opcode 3 type-1 quad keeps a 600-unit + // world-Z streak axis and billboards only its horizontal axis. + let origin = [ + visual.position[0], + visual.position[1], + visual.position[2] + LIGHTNING_NATIVE_ORIGIN_OFFSET, + ]; + let basis = diagonal_basis(lightning_parent_diagonal(visual.mirrored)); + let elapsed_seconds = elapsed_ms as f32 * 0.001; + if let Some(point_light) = + visual + .effect + .evaluate_point_light_with_basis(elapsed_seconds, origin, basis) + { + point_lights.push(point_light); + } + if let Some(quad) = visual.effect.evaluate_quad_with_mirror( + elapsed_seconds, + origin, + camera, + visual.mirrored, + ) { + primitives.push(EnvironmentPrimitive::Lightning(LightningBolt { + material: visual.effect.visual.clone(), + position: visual.position, + quad, + intensity: visual.intensity, + age_seconds: elapsed_seconds, + duration_seconds: visual.effect.duration_seconds, + })); + } + } + + fn random_position( + &mut self, + camera: Camera, + bounds: Option, + rng: &mut NativeRng, + ) -> [f32; 3] { + let x_random = rng.next_unit(); + let y_random = rng.next_unit(); + let bounds = bounds.unwrap_or_else(|| { + LightningBounds::new( + camera.position[0], + camera.position[0], + camera.position[1], + camera.position[1], + camera.position[2], + ) + }); + [ + bounds.x[0] + (bounds.x[1] - bounds.x[0]) * x_random, + bounds.y[0] + (bounds.y[1] - bounds.y[0]) * y_random, + bounds.z, + ] + } +} + +fn from_atmosphere_weather(sample: &crate::atmosphere::WeatherSample) -> WeatherState { + WeatherState::with_resources( + sample.active, + sample.intensity, + sample.color, + sample.resources.iter().map(|resource| resource.as_str()), + ) +} + +fn split_resource(resource: &str) -> (String, String) { + resource.split_once('/').map_or_else( + || (String::new(), resource.to_owned()), + |(archive, name)| (archive.to_owned(), name.to_owned()), + ) +} + +fn seconds_to_millis(seconds: f32) -> u64 { + if !seconds.is_finite() || seconds <= 0.0 { + return 0; + } + let millis = f64::from(seconds) * 1_000.0; + if millis >= u64::MAX as f64 { + u64::MAX + } else { + millis.round() as u64 + } +} + +fn read_resource( + command_index: usize, + opcode: FxOpcode, + body: &[u8], + archive_offset: usize, +) -> Result { + let archive = read_name(body, command_index, opcode, archive_offset)?; + let name = read_name(body, command_index, opcode, archive_offset + 32)?; + Ok(EffectResource { archive, name }) +} + +fn validate_opcode1_body(command_index: usize, body: &[u8]) -> Result<(), EnvironmentError> { + let required = 132; + body.get(..required) + .ok_or(EnvironmentError::InvalidCommandLayout { + command_index, + opcode: FxOpcode::Op1, + required, + actual: body.len(), + })?; + for (index, offset) in (4..required).step_by(4).enumerate() { + let value = read_f32(body, offset).expect("validated opcode 1 layout"); + if !value.is_finite() { + return Err(EnvironmentError::NonFiniteVisualValue { + command_index, + index, + value, + }); + } + } + Ok(()) +} + +fn validate_opcode3_body(command_index: usize, body: &[u8]) -> Result<(), EnvironmentError> { + let required = 132; + body.get(..required) + .ok_or(EnvironmentError::InvalidCommandLayout { + command_index, + opcode: FxOpcode::Op3, + required, + actual: body.len(), + })?; + for (index, offset) in (4..required).step_by(4).enumerate() { + let value = read_f32(body, offset).expect("validated opcode 3 layout"); + if !value.is_finite() { + return Err(EnvironmentError::NonFiniteVisualValue { + command_index, + index, + value, + }); + } + } + Ok(()) +} + +fn validate_opcode2_body(command_index: usize, body: &[u8]) -> Result<(), EnvironmentError> { + let required = 144; + body.get(..required) + .ok_or(EnvironmentError::InvalidCommandLayout { + command_index, + opcode: FxOpcode::Op2, + required, + actual: body.len(), + })?; + for (index, offset) in (4..80).step_by(4).enumerate() { + let value = read_f32(body, offset).expect("validated opcode 2 layout"); + if !value.is_finite() { + return Err(EnvironmentError::NonFiniteSoundValue { + command_index, + index, + value, + }); + } + } + Ok(()) +} + +fn active_interval(body: &[u8], elapsed: f32, duration: f32) -> Option<(f32, f32)> { + let start = read_f32(body, 4)?; + let end = read_f32(body, 8)?; + if !start.is_finite() + || !end.is_finite() + || !duration.is_finite() + || duration <= 0.0 + || elapsed < start + || elapsed > end + { + return None; + } + let span = end - start; + let normalized = if span.abs() > f32::EPSILON { + ((elapsed - start) / span).clamp(0.0, 1.0) + } else { + 0.0 + }; + Some((normalized, end.min(duration))) +} + +fn evaluate_position(body: &[u8], normalized_time: f32) -> Option<[f32; 3]> { + let start = read_vec3(body, 36)?; + let delta = read_vec3(body, 48)?; + let exponent = read_vec3(body, 60)?; + Some(std::array::from_fn(|axis| { + let input = normalized_time.powf(exponent[axis]); + start[axis] + delta[axis] * input + })) +} + +fn evaluate_scale(body: &[u8], normalized_time: f32) -> Option<[f32; 3]> { + let start = read_vec3(body, 96)?; + let end = read_vec3(body, 108)?; + let exponent = read_vec3(body, 120)?; + Some(std::array::from_fn(|axis| { + let input = normalized_time.powf(exponent[axis]); + lerp(start[axis], end[axis], input) + })) +} + +fn read_name( + body: &[u8], + command_index: usize, + opcode: FxOpcode, + offset: usize, +) -> Result { + let end = offset + .checked_add(32) + .ok_or(EnvironmentError::InvalidCommandLayout { + command_index, + opcode, + required: usize::MAX, + actual: body.len(), + })?; + let bytes = body + .get(offset..end) + .ok_or(EnvironmentError::InvalidCommandLayout { + command_index, + opcode, + required: end, + actual: body.len(), + })?; + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + Ok(String::from_utf8_lossy(&bytes[..end]).into_owned()) +} + +fn volume_product(extent: [f32; 3]) -> f32 { + extent[0] * extent[1] * extent[2] +} + +fn finite_unit(vector: [f32; 3]) -> Option<[f32; 3]> { + let length = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt(); + if length.is_finite() && length > f32::EPSILON { + Some(vector.map(|component| component / length)) + } else { + None + } +} + +fn wrap_local(value: f32, extent: f32) -> f32 { + if !value.is_finite() || !extent.is_finite() || extent <= 0.0 { + return 0.0; + } + value - (value / extent).floor() * extent +} + +fn add3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] + right[0], left[1] + right[1], left[2] + right[2]] +} + +fn scale3(value: [f32; 3], scale: f32) -> [f32; 3] { + [value[0] * scale, value[1] * scale, value[2] * scale] +} + +fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 { + left[0].mul_add(right[0], left[1].mul_add(right[1], left[2] * right[2])) +} + +fn cross3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [ + left[1].mul_add(right[2], -(left[2] * right[1])), + left[2].mul_add(right[0], -(left[0] * right[2])), + left[0].mul_add(right[1], -(left[1] * right[0])), + ] +} + +fn lightning_parent_diagonal(mirrored: bool) -> [f32; 3] { + [ + LIGHTNING_QUAD_DIMENSIONS[0], + if mirrored { + -LIGHTNING_QUAD_DIMENSIONS[1] + } else { + LIGHTNING_QUAD_DIMENSIONS[1] + }, + LIGHTNING_QUAD_DIMENSIONS[2], + ] +} + +fn normalized_or_fallback(vector: [f32; 3], fallback: [f32; 3]) -> [f32; 3] { + finite_unit(vector).unwrap_or(fallback) +} + +fn transform_diagonal(diagonal: [f32; 3], vector: [f32; 3]) -> [f32; 3] { + [ + diagonal[0] * vector[0], + diagonal[1] * vector[1], + diagonal[2] * vector[2], + ] +} + +fn diagonal_basis(diagonal: [f32; 3]) -> [[f32; 3]; 3] { + [ + [diagonal[0], 0.0, 0.0], + [0.0, diagonal[1], 0.0], + [0.0, 0.0, diagonal[2]], + ] +} + +fn identity_basis() -> [[f32; 3]; 3] { + [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] +} + +fn read_u32(body: &[u8], offset: usize) -> Option { + let bytes = body.get(offset..offset.checked_add(4)?)?; + Some(u32::from_le_bytes(bytes.try_into().ok()?)) +} + +fn read_f32(body: &[u8], offset: usize) -> Option { + Some(f32::from_bits(read_u32(body, offset)?)) +} + +fn read_vec3(body: &[u8], offset: usize) -> Option<[f32; 3]> { + Some([ + read_f32(body, offset)?, + read_f32(body, offset.checked_add(4)?)?, + read_f32(body, offset.checked_add(8)?)?, + ]) +} + +fn read_vec4(body: &[u8], offset: usize) -> Option<[f32; 4]> { + Some([ + read_f32(body, offset)?, + read_f32(body, offset.checked_add(4)?)?, + read_f32(body, offset.checked_add(8)?)?, + read_f32(body, offset.checked_add(12)?)?, + ]) +} + +fn lerp(start: f32, end: f32, t: f32) -> f32 { + start + (end - start) * t +} + +fn lerp3(start: [f32; 3], end: [f32; 3], t: f32) -> [f32; 3] { + [ + lerp(start[0], end[0], t), + lerp(start[1], end[1], t), + lerp(start[2], end[2], t), + ] +} + +fn lerp4(start: [f32; 4], end: [f32; 4], t: f32) -> [f32; 4] { + [ + lerp(start[0], end[0], t), + lerp(start[1], end[1], t), + lerp(start[2], end[2], t), + lerp(start[3], end[3], t), + ] +} + +fn transform3(basis: [[f32; 3]; 3], local: [f32; 3]) -> [f32; 3] { + [ + basis[0][0].mul_add( + local[0], + basis[1][0].mul_add(local[1], basis[2][0] * local[2]), + ), + basis[0][1].mul_add( + local[0], + basis[1][1].mul_add(local[1], basis[2][1] * local[2]), + ), + basis[0][2].mul_add( + local[0], + basis[1][2].mul_add(local[1], basis[2][2] * local[2]), + ), + ] +} + +fn sanitize_float(value: f32) -> f32 { + if value.is_finite() { + value + } else { + 0.0 + } +} + +fn sub3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] - right[0], left[1] - right[1], left[2] - right[2]] +} + +fn screen_point(camera: Camera, point: [f32; 3]) -> ([f32; 2], f32) { + let delta = sub3(point, camera.position); + let depth = dot3(camera.forward, delta); + let depth_for_projection = depth.max(f32::EPSILON); + let half_vertical = (camera.vertical_fov * 0.5).tan(); + let half_horizontal = half_vertical * camera.aspect_ratio; + let ndc_x = dot3(camera.right, delta) / (depth_for_projection * half_horizontal); + let ndc_y = dot3(camera.up, delta) / (depth_for_projection * half_vertical); + let x = (ndc_x + 1.0) * 0.5 * camera.viewport[0]; + let y = (1.0 - ndc_y) * 0.5 * camera.viewport[1]; + ([x, y], depth) +} + +fn pixel_to_ndc(camera: Camera, point: [f32; 2]) -> [f32; 2] { + [ + point[0] * 2.0 / camera.viewport[0] - 1.0, + 1.0 - point[1] * 2.0 / camera.viewport[1], + ] +} + +fn screen_billboard( + kind: PrecipitationKind, + camera: Camera, + head_world: [f32; 3], + tail_world: [f32; 3], + size: f32, + index: usize, +) -> ScreenBillboard { + let (head_pixels, depth) = screen_point(camera, head_world); + let (tail_pixels, tail_depth) = screen_point(camera, tail_world); + let near = PRECIPITATION_NEAR; + let extent_x = PRECIPITATION_FAR - PRECIPITATION_NEAR; + let depth_fade = (1.0 - (depth - near) / extent_x).max(0.0); + let half_size = match kind { + PrecipitationKind::Rain => size * 0.5 * depth_fade, + PrecipitationKind::Snow => size * 0.5 * depth_fade.max(SNOW_MIN_DEPTH_FADE), + }; + let (corners_pixels, uv) = match kind { + PrecipitationKind::Rain => { + let direction = [ + head_pixels[0] - tail_pixels[0], + head_pixels[1] - tail_pixels[1], + ]; + let direction_length = + (direction[0] * direction[0] + direction[1] * direction[1]).sqrt(); + let perpendicular = if direction_length.is_finite() && direction_length > f32::EPSILON { + [ + -direction[1] / direction_length, + direction[0] / direction_length, + ] + } else { + [1.0, 0.0] + }; + let offset = [perpendicular[0] * half_size, perpendicular[1] * half_size]; + ( + [ + [tail_pixels[0] - offset[0], tail_pixels[1] - offset[1]], + [tail_pixels[0] + offset[0], tail_pixels[1] + offset[1]], + [head_pixels[0] - offset[0], head_pixels[1] - offset[1]], + [head_pixels[0] + offset[0], head_pixels[1] + offset[1]], + ], + [[0.0, 1.0], [1.0, 1.0], [0.0, 0.0], [1.0, 0.0]], + ) + } + PrecipitationKind::Snow => { + let signs = [[1.0, -1.0], [1.0, 1.0], [-1.0, -1.0], [-1.0, 1.0]]; + let mirror = index & 0x10 != 0; + let corners = std::array::from_fn(|corner| { + let [mut x, mut y] = signs[corner]; + if mirror { + std::mem::swap(&mut x, &mut y); + } + [ + head_pixels[0] + x * half_size, + head_pixels[1] + y * half_size, + ] + }); + let uv = if mirror { + [[1.0, 0.0], [1.0, 1.0], [0.0, 0.0], [0.0, 1.0]] + } else { + [[1.0, 0.0], [1.0, 1.0], [0.0, 0.0], [0.0, 1.0]] + }; + (corners, uv) + } + }; + let head = pixel_to_ndc(camera, head_pixels); + let tail = pixel_to_ndc(camera, tail_pixels); + let corners = corners_pixels.map(|corner| pixel_to_ndc(camera, corner)); + ScreenBillboard { + world_head: head_world, + world_tail: tail_world, + head, + tail, + corners, + uv, + half_size, + head_depth: depth, + tail_depth, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn active_rain_is_camera_local_and_uses_sky_material() { + let mut system = EnvironmentSystem::new(7); + let weather = WeatherInput { + rain: WeatherState::with_resources(true, 0.5, [0.7, 0.8, 1.0, 0.6], ["atm_rain1.wav"]), + ..WeatherInput::clear() + }; + let frame = system.update(1.0, weather.clone(), Camera::new([10.0, -2.0, 4.0])); + let particles = frame + .primitives + .iter() + .filter_map(|primitive| match primitive { + EnvironmentPrimitive::Particle { + kind, + position, + velocity, + material, + .. + } => Some((*kind, *position, *velocity, material.as_str())), + _ => None, + }) + .collect::>(); + + assert_eq!( + particles.len(), + system.precipitation_volume().particle_count(0.5) + ); + assert!(particles + .iter() + .all(|(kind, position, velocity, material)| { + *kind == PrecipitationKind::Rain + && *material == system.materials.material_name(8).unwrap() + && *velocity == RAIN_VELOCITY + && system + .precipitation_volume() + .bounds(Camera::new([10.0, -2.0, 4.0])) + .contains(*position) + })); + assert_eq!(frame.sounds.len(), 1); + assert_eq!(frame.sounds[0].name, "atm_rain1.wav"); + assert_eq!(frame.sounds[0].action, SoundAction::StartLoop); + + let update = system.update(0.1, weather, Camera::new([10.0, -2.0, 4.0])); + assert_eq!(update.sounds.len(), 1); + assert_eq!(update.sounds[0].action, SoundAction::SetLoopVolume); + assert_eq!(update.sounds[0].volume, 0.5); + } + + #[test] + fn precipitation_uses_native_volume_ratio_and_nearest_even_count() { + let reference = PrecipitationVolume::native(); + assert_eq!(reference.particle_count_for(reference, 1.0, 0.5004), 500); + assert_eq!(reference.particle_count_for(reference, 1.0, 0.5016), 502); + + let narrow_camera = Camera::with_projection( + [0.0; 3], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + 0.4875, + 1.0, + ); + let current = reference.for_camera(narrow_camera); + assert!(reference.particle_count_for(current, 1.0, 1.0) < reference.particle_count(1.0)); + } + + #[test] + fn precipitation_keeps_world_velocity_when_camera_turns_and_wraps() { + let mut system = EnvironmentSystem::new(3); + let camera = Camera::new([10.0, 20.0, 30.0]); + let weather = WeatherInput { + rain: WeatherState::new(true, 0.01, [1.0; 4]), + ..WeatherInput::clear() + }; + let initial = system.update(0.0, weather.clone(), camera); + let initial_position = initial + .primitives + .iter() + .find_map(|primitive| match primitive { + EnvironmentPrimitive::Particle { position, .. } => Some(*position), + _ => None, + }) + .expect("initial rain particle"); + let turned_camera = Camera::with_basis( + [10.0, 20.0, 30.0], + [0.0, 1.0, 0.0], + [-1.0, 0.0, 0.0], + [0.0, 0.0, 1.0], + ); + let turned = system.update(0.0, weather.clone(), turned_camera); + let turned_position = turned + .primitives + .iter() + .find_map(|primitive| match primitive { + EnvironmentPrimitive::Particle { position, .. } => Some(*position), + _ => None, + }) + .expect("turned rain particle"); + assert!(sub3(initial_position, turned_position) + .into_iter() + .all(|component| component.abs() < 1.0e-5)); + + let frame = system.update(10.0, weather, camera); + let particle = frame + .primitives + .iter() + .find_map(|primitive| match primitive { + EnvironmentPrimitive::Particle { + kind, + position, + velocity, + size, + screen, + .. + } => Some((*kind, *position, *velocity, *size, *screen)), + _ => None, + }) + .expect("rain particle"); + assert_eq!(particle.0, PrecipitationKind::Rain); + assert_eq!(particle.2, RAIN_VELOCITY); + assert_eq!( + particle.3, + camera.precipitation_size_scalar() * RAIN_SIZE_FACTOR + ); + assert!(system + .precipitation_volume() + .bounds(camera) + .contains(particle.1)); + assert!(particle + .4 + .corners + .iter() + .flatten() + .all(|value| value.is_finite())); + } + + #[test] + fn precipitation_size_uses_viewport_width_and_horizontal_fov() { + let camera = Camera::with_projection( + [0.0; 3], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + [0.0, 0.0, 1.0], + 0.975, + 16.0 / 9.0, + ) + .with_viewport([1920.0, 1080.0]); + let expected_fov = 2.0 * ((0.975_f32 * 0.5).tan() * (16.0_f32 / 9.0)).atan(); + assert!((camera.horizontal_fov() - expected_fov).abs() < 1.0e-6); + assert!((camera.precipitation_size_scalar() - 1920.0 / expected_fov).abs() < 1.0e-4); + } + + #[test] + fn lightning_fixture_extracts_real_material_and_sound_offsets() { + let mut bytes = header(3, 0.75); + // The shipped env_lightning FXID uses opcode words 3, 1, and 2 with + // bit 8 clear. Bit 8 is a native mode flag, not command enablement. + let mut visual = command(0x0003, 200); + visual[4..8].copy_from_slice(&1_u32.to_le_bytes()); + copy_name(&mut visual[4 + 132..4 + 164], b"material.lib"); + copy_name(&mut visual[4 + 164..4 + 196], b"env_lightning"); + bytes.extend_from_slice(&visual); + let mut opcode1 = command(0x0001, 224); + opcode1[4..8].copy_from_slice(&7_u32.to_le_bytes()); + opcode1[12..16].copy_from_slice(&0.99_f32.to_le_bytes()); + bytes.extend_from_slice(&opcode1); + let mut sound = command(0x0002, 148); + copy_name(&mut sound[4 + 80..4 + 112], b"sounds.lib"); + copy_name(&mut sound[4 + 112..4 + 144], b"atm_light1.wav"); + bytes.extend_from_slice(&sound); + + let effect = decode_env_lightning_fxid(Arc::from(bytes.into_boxed_slice())) + .expect("env_lightning fixture"); + assert_eq!(effect.duration_seconds, 0.75); + assert_eq!( + effect.visual, + EffectResource { + archive: "material.lib".to_owned(), + name: "env_lightning".to_owned() + } + ); + assert_eq!( + effect.sound, + EffectResource { + archive: "sounds.lib".to_owned(), + name: "atm_light1.wav".to_owned() + } + ); + } + + #[test] + fn opcode2_sound_parameters_use_native_distance_and_frequency_offsets() { + let mut bytes = header(3, 1.0); + let mut visual = command(0x0003, 200); + visual[4..8].copy_from_slice(&1_u32.to_le_bytes()); + copy_name(&mut visual[4 + 132..4 + 164], b"material.lib"); + copy_name(&mut visual[4 + 164..4 + 196], b"env_lightning"); + bytes.extend_from_slice(&visual); + let mut opcode1 = command(0x0001, 224); + opcode1[4..8].copy_from_slice(&7_u32.to_le_bytes()); + bytes.extend_from_slice(&opcode1); + let mut sound = command(0x0002, 148); + let body = &mut sound[4..]; + body[4..8].copy_from_slice(&0.0_f32.to_le_bytes()); + body[8..12].copy_from_slice(&1.0_f32.to_le_bytes()); + body[60..64].copy_from_slice(&100.0_f32.to_le_bytes()); + body[64..68].copy_from_slice(&1_500.0_f32.to_le_bytes()); + body[68..72].copy_from_slice(&0.75_f32.to_le_bytes()); + body[72..76].copy_from_slice(&1.25_f32.to_le_bytes()); + body[76..80].copy_from_slice(&0_u32.to_le_bytes()); + copy_name(&mut body[80..112], b"sounds.lib"); + copy_name(&mut body[112..144], b"atm_light1.wav"); + bytes.extend_from_slice(&sound); + + let effect = decode_env_lightning_fxid(Arc::from(bytes.into_boxed_slice())) + .expect("env_lightning sound fixture"); + assert_eq!( + effect.sound_parameters(0.5), + Some(SoundParameters { + min_distance: 100.0, + max_distance: 1_500.0, + frequency_ratio: 1.0, + }) + ); + } + + #[test] + fn opcode1_evaluator_emits_native_point_light_groups_and_maps_parent_axis() { + let mut bytes = header(3, 0.75); + let mut visual = command(0x0003, 200); + visual[4..8].copy_from_slice(&1_u32.to_le_bytes()); + copy_name(&mut visual[4 + 132..4 + 164], b"material.lib"); + copy_name(&mut visual[4 + 164..4 + 196], b"env_lightning"); + visual[4 + 4..4 + 8].copy_from_slice(&(-1.0_f32).to_le_bytes()); + visual[4 + 8..4 + 12].copy_from_slice(&1.0_f32.to_le_bytes()); + for offset in [96_usize, 100, 104, 108, 112, 116, 120, 124, 128] { + visual[4 + offset..4 + offset + 4].copy_from_slice(&1.0_f32.to_le_bytes()); + } + bytes.extend_from_slice(&visual); + let mut opcode1 = command(0x0001, 224); + let body = &mut opcode1[4..]; + body[0..4].copy_from_slice(&7_u32.to_le_bytes()); + body[4..8].copy_from_slice(&0.0_f32.to_le_bytes()); + body[8..12].copy_from_slice(&1.0_f32.to_le_bytes()); + body[12..24].copy_from_slice( + &[0.0_f32; 3] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(), + ); + body[24..36].copy_from_slice( + &[2.0_f32, 4.0, 6.0] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(), + ); + body[36..48].copy_from_slice( + &[1.0_f32, 0.0, 0.0] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(), + ); + body[48..60].copy_from_slice( + &[0.0_f32, 1.0, 0.0] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(), + ); + body[60..76].copy_from_slice( + &[7.0_f32, 7.0, 10.0, 0.0] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(), + ); + body[76..92].copy_from_slice( + &[0.0_f32, 0.0, 0.0, 0.0] + .iter() + .flat_map(|v| v.to_le_bytes()) + .collect::>(), + ); + body[108..112].copy_from_slice(&100.0_f32.to_le_bytes()); + body[112..116].copy_from_slice(&0.0_f32.to_le_bytes()); + body[116..120].copy_from_slice(&0.25_f32.to_le_bytes()); + body[120..124].copy_from_slice(&0.25_f32.to_le_bytes()); + body[124..128].copy_from_slice(&0.75_f32.to_le_bytes()); + body[128..132].copy_from_slice(&0.75_f32.to_le_bytes()); + bytes.extend_from_slice(&opcode1); + let mut sound = command(0x0002, 148); + copy_name(&mut sound[4 + 80..4 + 112], b"sounds.lib"); + copy_name(&mut sound[4 + 112..4 + 144], b"atm_light1.wav"); + bytes.extend_from_slice(&sound); + + let effect = decode_env_lightning_fxid(Arc::from(bytes.into_boxed_slice())) + .expect("env_lightning evaluator fixture"); + let basis = [[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]; + let frame = effect + .evaluate_point_light_with_basis(0.5, [10.0, 20.0, 30.0], basis) + .expect("active opcode 1 sample"); + assert_eq!(frame.effect_type, 7); + assert_eq!(frame.position, [12.0, 23.0, 31.0]); + let diagonal = 2.0_f32.sqrt().recip(); + assert!((frame.direction[0] - diagonal).abs() < 1.0e-6); + assert_eq!(frame.direction[1], 0.0); + assert!((frame.direction[2] - diagonal).abs() < 1.0e-6); + assert_eq!(frame.color, [3.5, 3.5, 5.0, 0.0]); + assert_eq!(frame.range, 50.0); + assert_eq!(frame.attenuation, [0.25, 0.75, 0.75]); + assert_eq!(frame.effect_factor, 0.25); + assert!(frame.active); + let quad = effect + .evaluate_quad(0.5, [10.0, 20.0, 330.0], Camera::new([0.0, 0.0, 0.0])) + .expect("active opcode 3 sample"); + assert_eq!(quad.dimensions, LIGHTNING_QUAD_DIMENSIONS); + assert_eq!( + quad.uv, + [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]] + ); + assert_eq!(quad.opacity, 0.25); + assert!(quad + .vertices + .iter() + .flatten() + .all(|value| value.is_finite())); + assert!(effect.evaluate_point_light(1.01, [0.0; 3]).is_none()); + } + + #[test] + fn opcode3_quad_applies_parent_diagonal_before_offset_and_scale() { + let mut body = vec![0; 196]; + body[0..4].copy_from_slice(&1_u32.to_le_bytes()); + body[4..8].copy_from_slice(&0.0_f32.to_le_bytes()); + body[8..12].copy_from_slice(&1.0_f32.to_le_bytes()); + body[36..48].copy_from_slice( + &[1.0_f32, 2.0, 0.0] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::>(), + ); + body[48..60].copy_from_slice( + &[1.0_f32, 0.0, 0.0] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::>(), + ); + body[60..72].copy_from_slice( + &[1.0_f32; 3] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::>(), + ); + body[96..108].copy_from_slice( + &[2.0_f32, 3.0, 1.0] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::>(), + ); + body[108..120].copy_from_slice( + &[4.0_f32, 5.0, 2.0] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::>(), + ); + body[120..132].copy_from_slice( + &[1.0_f32; 3] + .iter() + .flat_map(|value| value.to_le_bytes()) + .collect::>(), + ); + let effect = LightningEffect { + duration_seconds: 1.0, + visual: EffectResource { + archive: "material.lib".to_owned(), + name: "env_lightning".to_owned(), + }, + visual_body: Arc::from(body.into_boxed_slice()), + opcode1_body: Arc::from(vec![0; 132].into_boxed_slice()), + opcode1_command_index: 1, + sound: EffectResource { + archive: "sounds.lib".to_owned(), + name: "atm_light1.wav".to_owned(), + }, + sound_body: Arc::from(vec![0; 144].into_boxed_slice()), + visual_command_index: 0, + sound_command_index: 2, + }; + let quad = effect + .evaluate_quad(0.5, [10.0, 20.0, 30.0], Camera::new([130.0, 80.0, 100.0])) + .expect("synthetic opcode 3 sample"); + assert_eq!(quad.local_offset, [1.5, 2.0, 0.0]); + assert_eq!(quad.local_scale, [3.0, 4.0, 1.5]); + assert_eq!(quad.dimensions, [120.0, 160.0, 900.0]); + let center = quad + .vertices + .iter() + .fold([0.0; 3], |sum, vertex| add3(sum, *vertex)) + .map(|component| component * 0.25); + assert_eq!(center, [70.0, 100.0, 30.0]); + assert!((quad.vertices[2][2] - quad.vertices[0][2] - 900.0).abs() < 1.0e-5); + + let parallel_camera = Camera::new([70.0, 100.0, 630.0]); + assert!(effect + .evaluate_quad(0.5, [10.0, 20.0, 30.0], parallel_camera) + .is_none()); + let fallback_camera = Camera::new([70.0, 100.0, 1_230.0]); + let fallback = effect + .evaluate_quad(0.5, [10.0, 20.0, 30.0], fallback_camera) + .expect("non-unit parallel view uses native fallback axis"); + assert_eq!(fallback.vertices[0][1], fallback.vertices[1][1]); + assert!((fallback.vertices[0][0] - fallback.vertices[1][0] - 120.0).abs() < 1.0e-5); + } + + #[test] + fn lightning_uses_native_delay_cooldown_and_fx_lifetime() { + let effect = LightningEffect { + duration_seconds: 0.75, + visual: EffectResource { + archive: "material.lib".to_owned(), + name: "env_lightning".to_owned(), + }, + visual_body: { + let mut body = vec![0; 196]; + body[0..4].copy_from_slice(&1_u32.to_le_bytes()); + body[4..8].copy_from_slice(&(-1.0_f32).to_le_bytes()); + body[8..12].copy_from_slice(&1.0_f32.to_le_bytes()); + for offset in [96_usize, 100, 104, 108, 112, 116, 120, 124, 128] { + body[offset..offset + 4].copy_from_slice(&1.0_f32.to_le_bytes()); + } + Arc::from(body.into_boxed_slice()) + }, + opcode1_body: { + let mut body = vec![0; 220]; + body[8..12].copy_from_slice(&0.75_f32.to_le_bytes()); + body[36..40].copy_from_slice(&1.0_f32.to_le_bytes()); + body[128..132].copy_from_slice(&1.0_f32.to_le_bytes()); + Arc::from(body.into_boxed_slice()) + }, + opcode1_command_index: 1, + sound: EffectResource { + archive: "sounds.lib".to_owned(), + name: "atm_light1.wav".to_owned(), + }, + sound_body: Arc::from(vec![0; 144].into_boxed_slice()), + visual_command_index: 0, + sound_command_index: 2, + }; + let mut system = EnvironmentSystem::new(11); + system.set_lightning_effect(effect); + let weather = WeatherInput { + lightning: WeatherState::new(true, 1.0, [1.0; 4]), + ..WeatherInput::clear() + }; + let camera = Camera::new([50.0, 0.0, 0.0]); + system.set_lightning_bounds(LightningBounds::new(0.0, 0.0, 0.0, 0.0, 0.0)); + // The constructor starts in native state 1, which schedules a random + // delay. No external trigger sequence is needed. + let first = system.update(0.1, weather.clone(), camera); + assert!(first.sounds.is_empty()); + assert_eq!(system.lightning.phase, 2); + + let mut one_shots = 0; + let mut effect_frames = 0; + let mut point_light_frames = 0; + let mut inspected_shape = false; + for _ in 0..200 { + let frame = system.update(0.1, weather.clone(), camera); + point_light_frames += frame.point_lights.len(); + one_shots += frame + .sounds + .iter() + .filter(|sound| sound.action == SoundAction::OneShot) + .count(); + for primitive in &frame.primitives { + if let EnvironmentPrimitive::Lightning(bolt) = primitive { + inspected_shape = true; + assert_eq!(bolt.quad.dimensions, LIGHTNING_QUAD_DIMENSIONS); + assert!(bolt + .quad + .vertices + .iter() + .flatten() + .all(|value| value.is_finite())); + assert_eq!( + bolt.quad.uv, + [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]] + ); + assert!(bolt.age_seconds >= 0.0 && bolt.age_seconds < bolt.duration_seconds); + assert_eq!(bolt.duration_seconds, 0.75); + effect_frames += 1; + } + } + } + assert!(one_shots >= 2, "native timer must repeat lightning"); + assert!(effect_frames > 0); + assert!(inspected_shape); + assert!(point_light_frames > 0); + + let clear = system.update(0.1, WeatherInput::clear(), camera); + assert!(clear.primitives.is_empty()); + assert!(clear.point_lights.is_empty()); + assert!(clear.sounds.is_empty()); + } + + #[test] + fn fractional_milliseconds_do_not_drift_at_120_fps() { + let mut system = EnvironmentSystem::new(1); + for _ in 0..120 { + let _ = system.update(1.0 / 120.0, WeatherInput::clear(), Camera::default()); + } + assert_eq!(system.time_millis(), 1_000); + } + + fn header(command_count: u32, duration: f32) -> Vec { + let mut out = Vec::with_capacity(60); + out.extend_from_slice(&command_count.to_le_bytes()); + out.extend_from_slice(&1_u32.to_le_bytes()); + out.extend_from_slice(&duration.to_bits().to_le_bytes()); + out.extend_from_slice(&0.0_f32.to_bits().to_le_bytes()); + out.extend_from_slice(&0_u32.to_le_bytes()); + out.extend_from_slice(&0_u32.to_le_bytes()); + for _ in 0..9 { + out.extend_from_slice(&0.0_f32.to_bits().to_le_bytes()); + } + out + } + + fn command(word: u32, size: usize) -> Vec { + let mut out = vec![0; size]; + out[..4].copy_from_slice(&word.to_le_bytes()); + out + } + + fn copy_name(dst: &mut [u8], name: &[u8]) { + dst[..name.len()].copy_from_slice(name); + } +} -- cgit v1.2.3