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