Files
fparkan/crates/fparkan-fx/src/environment.rs
T
Valentin Popov 0195960de3
Docs Deploy / Build and Deploy MkDocs (push) Successful in 33s
fix(fx): preserve rain tails on zero-time frames
2026-10-18 10:21:25 +04:00

3150 lines
112 KiB
Rust

//! 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::{FxCommand, 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,
/// Native LightManager selector selected from the opcode 1 type.
pub light_selector: u32,
/// Native LightManager flags selected from the opcode 1 type.
pub native_flags: u32,
/// Native update value compared with the opcode 1 start/end fields.
pub command_time: f32,
/// Interpolation parameter between the opcode 1 start/end times.
pub normalized_time: 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 `command_time`.
pub active: bool,
}
/// Evaluates a native opcode 1 point-light command at `command_time` and
/// applies a parent basis to its local position and direction. `basis` stores
/// the world-space vectors corresponding to local +X, +Y, and +Z.
/// `command_time` is resolved by the caller; FX header duration and lifecycle
/// remain with that caller. The interpolated range is retained as authored;
/// native Op1 does not clamp it to zero.
#[must_use]
pub fn evaluate_op1_point_light_with_basis(
command: &FxCommand,
command_time: f32,
origin: [f32; 3],
basis: [[f32; 3]; 3],
) -> Option<PointLightFrame> {
if command.opcode != FxOpcode::Op1
|| !command_time.is_finite()
|| !origin
.into_iter()
.chain(basis.into_iter().flatten())
.all(f32::is_finite)
{
return None;
}
let body = command.raw_body.as_ref();
const FLOAT_OFFSETS: &[usize] = &[
4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 68, 72, 76, 80, 84, 88, 108,
112, 116, 120, 124, 128,
];
if body.len() < 132
|| FLOAT_OFFSETS
.iter()
.any(|&offset| read_f32(body, offset).is_none_or(|value| !value.is_finite()))
{
return None;
}
let normalized_time = native_opcode1_interval(body, command_time)?;
if !normalized_time.is_finite() {
return None;
}
let sample = sample_op1_point_light(body, normalized_time)?;
let (light_selector, native_flags) = opcode1_light_manager_mapping(sample.effect_type)?;
if light_selector != 1 {
return None;
}
let transformed_direction = transform3(basis, sample.local_direction);
let position = add3(origin, transform3(basis, sample.local_position));
if !position.into_iter().all(f32::is_finite)
|| !transformed_direction.into_iter().all(f32::is_finite)
|| !sample.color.into_iter().all(f32::is_finite)
|| !sample.range.is_finite()
|| !sample.attenuation.into_iter().all(f32::is_finite)
|| !sample.effect_factor.is_finite()
{
return None;
}
let direction = normalize_op1_direction(transformed_direction);
Some(PointLightFrame {
effect_type: sample.effect_type,
light_selector,
native_flags,
command_time,
normalized_time,
position,
direction,
color: sample.color,
range: sample.range,
attenuation: sample.attenuation,
effect_factor: sample.effect_factor,
active: true,
})
}
#[derive(Clone, Copy)]
struct Op1PointLightSample {
effect_type: u32,
local_position: [f32; 3],
local_direction: [f32; 3],
color: [f32; 4],
range: f32,
attenuation: [f32; 3],
effect_factor: f32,
}
fn sample_op1_point_light(body: &[u8], normalized_time: f32) -> Option<Op1PointLightSample> {
Some(Op1PointLightSample {
effect_type: read_u32(body, 0)?,
local_position: lerp3(read_vec3(body, 12)?, read_vec3(body, 24)?, normalized_time),
local_direction: lerp3(read_vec3(body, 36)?, read_vec3(body, 48)?, normalized_time),
color: lerp4(read_vec4(body, 60)?, read_vec4(body, 76)?, normalized_time),
range: lerp(read_f32(body, 108)?, read_f32(body, 112)?, normalized_time),
attenuation: [
read_f32(body, 120)?,
read_f32(body, 124)?,
read_f32(body, 128)?,
],
effect_factor: read_f32(body, 116)?,
})
}
fn normalize_op1_direction(direction: [f32; 3]) -> [f32; 3] {
// Native FastProc normalization preserves a degenerate vector as zero;
// this path must not invent a fallback axis.
let length =
(direction[0] * direction[0] + direction[1] * direction[1] + direction[2] * direction[2])
.sqrt();
if length.is_finite() && length > 0.0 {
direction.map(|component| component / length)
} else {
[0.0; 3]
}
}
fn lightning_point_light_direction(local_direction: [f32; 3], basis: [[f32; 3]; 3]) -> [f32; 3] {
// Preserve the pre-existing Lightning consumer fallback, which is
// separate from the generic native Op1 behavior.
finite_unit(transform3(basis, local_direction))
.unwrap_or_else(|| finite_unit(basis[0]).unwrap_or([1.0, 0.0, 0.0]))
}
/// Maps the native opcode 1 type switch to its LightManager record selector
/// and flags. Types 3 and 4 select non-point record classes; generic point
/// lights reject those selectors, while Lightning keeps the full mapping.
fn opcode1_light_manager_mapping(effect_type: u32) -> Option<(u32, u32)> {
Some(match effect_type {
1 => (1, 0x8000_0000),
2 => (1, 0),
3 => (3, 0),
4 => (4, 0),
5 => (1, 0),
6 => (1, 0xA000_0000),
7 => (1, 0x2000_0000),
_ => return None,
})
}
/// 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 (normalized_time, _) = active_interval(body, elapsed_seconds, self.duration_seconds)?;
let sample = sample_op1_point_light(body, normalized_time)?;
let (light_selector, native_flags) =
opcode1_light_manager_mapping(sample.effect_type).unwrap_or((0, 0));
let direction = lightning_point_light_direction(sample.local_direction, basis);
Some(PointLightFrame {
effect_type: sample.effect_type,
light_selector,
native_flags,
command_time: elapsed_seconds,
normalized_time,
position: add3(origin, transform3(basis, sample.local_position)),
direction,
color: sample.color.map(sanitize_float),
range: sanitize_float(sample.range).max(0.0),
attenuation: sample.attenuation.map(sanitize_float),
effect_factor: sanitize_float(sample.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];
if dt_seconds > 0.0 {
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 native_opcode1_interval(body: &[u8], command_time: f32) -> Option<f32> {
let start = read_f32(body, 4)?;
let end = read_f32(body, 8)?;
if !command_time.is_finite()
|| !start.is_finite()
|| !end.is_finite()
|| command_time < start
|| command_time > end
{
return None;
}
let span = end - start;
if !span.is_finite() {
return None;
}
// Effect.dll divides by the interval span without an epsilon gate
// (1000F703-1000F783). A zero-width interval produces 0/0, which cannot
// yield a finite LightManager sample; keep that command inactive here.
if span == 0.0 {
return None;
}
let normalized = ((command_time - start) / span).clamp(0.0, 1.0);
normalized.is_finite().then_some(normalized)
}
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 duplicate_zero_delta_keeps_rain_frame_and_tail_unchanged() {
let mut system = EnvironmentSystem::new(3);
let camera = Camera::new([10.0, 20.0, 30.0]);
let weather = WeatherInput {
rain: WeatherState::with_resources(true, 0.01, [1.0; 4], ["atm_rain1.wav"]),
..WeatherInput::clear()
};
let _ = system.update(0.0, weather.clone(), camera);
let advanced = system.update(0.001, weather.clone(), camera);
let time_seconds = system.time_seconds();
let repeated = system.update(0.0, weather, camera);
assert_eq!(system.time_seconds(), time_seconds);
assert_eq!(advanced, repeated);
assert!(advanced.primitives.iter().any(|primitive| match primitive {
EnvironmentPrimitive::Particle { screen, .. } => {
screen.world_head != screen.world_tail
}
EnvironmentPrimitive::Lightning(_) => false,
}));
}
#[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 generic_opcode1_uses_native_command_time_not_header_duration_phase() {
// These type/start/end values come from the shipped f_blinklight_r
// (header mode 2, duration 2s) and b_sphere_main (mode 0, duration
// 6s) Op1 records. Their local command clocks must reach their own
// authored windows; dividing by the FX lifetime would miss both.
let blinklight = op1_test_command(6, 0.75, 1.0);
let sphere = op1_test_command(5, 1.0 / 3.0, 1.0);
let blink_midpoint = evaluate_op1_point_light_identity(&blinklight, 0.875, [0.0; 3])
.expect("blink light command midpoint");
let sphere_midpoint = evaluate_op1_point_light_identity(&sphere, 2.0 / 3.0, [0.0; 3])
.expect("sphere command midpoint");
assert_eq!(blink_midpoint.effect_type, 6);
assert_eq!(blink_midpoint.light_selector, 1);
assert_eq!(blink_midpoint.native_flags, 0xA000_0000);
assert_eq!(blink_midpoint.command_time, 0.875);
assert!((blink_midpoint.normalized_time - 0.5).abs() < 1.0e-6);
assert_eq!(sphere_midpoint.effect_type, 5);
assert_eq!(sphere_midpoint.light_selector, 1);
assert_eq!(sphere_midpoint.native_flags, 0);
assert!((sphere_midpoint.normalized_time - 0.5).abs() < 1.0e-6);
// The native comparisons accept the endpoint; the effect remains
// active at `end`, where its interpolation reaches one.
let blink_end = evaluate_op1_point_light_identity(&blinklight, 1.0, [0.0; 3])
.expect("inclusive end of native interval");
assert_eq!(blink_end.normalized_time, 1.0);
assert!(
evaluate_op1_point_light_identity(&blinklight, 1.0 + f32::EPSILON, [0.0; 3]).is_none()
);
assert!(
evaluate_op1_point_light_identity(&op1_test_command(3, 0.0, 1.0), 0.5, [0.0; 3])
.is_none()
);
assert!(
evaluate_op1_point_light_identity(&op1_test_command(4, 0.0, 1.0), 0.5, [0.0; 3])
.is_none()
);
}
#[test]
fn generic_opcode1_preserves_narrow_native_intervals_and_rejects_zero_span() {
let narrow_end = f32::EPSILON * 0.5;
let narrow = op1_test_command(5, 0.0, narrow_end);
let sample = evaluate_op1_point_light_identity(&narrow, narrow_end * 0.5, [0.0; 3])
.expect("non-zero native interval below epsilon");
assert!((sample.normalized_time - 0.5).abs() < 1.0e-6);
let zero_width = op1_test_command(5, 0.0, 0.0);
assert!(evaluate_op1_point_light_identity(&zero_width, 0.0, [0.0; 3]).is_none());
}
#[test]
fn generic_opcode1_preserves_negative_native_range() {
let mut command = op1_test_command(5, 0.0, 1.0);
let mut body = command.raw_body.to_vec();
body[108..112].copy_from_slice(&(-2.0_f32).to_le_bytes());
body[112..116].copy_from_slice(&(-4.0_f32).to_le_bytes());
command.raw_body = Arc::from(body.into_boxed_slice());
let sample = evaluate_op1_point_light_identity(&command, 0.5, [0.0; 3])
.expect("native Op1 sample with a negative range");
assert_eq!(sample.range, -3.0);
}
#[test]
fn generic_opcode1_keeps_zero_direction_zero() {
let mut command = op1_test_command(5, 0.0, 1.0);
let mut body = command.raw_body.to_vec();
body[36..60].fill(0);
command.raw_body = Arc::from(body.into_boxed_slice());
let sample = evaluate_op1_point_light_identity(&command, 0.5, [0.0; 3])
.expect("native Op1 sample with a zero direction");
assert_eq!(sample.direction, [0.0; 3]);
}
#[test]
fn generic_opcode1_validates_only_values_consumed_by_the_handler() {
let mut command = op1_test_command(7, 0.0, 1.0);
let mut body = command.raw_body.to_vec();
body[92..96].copy_from_slice(&f32::NAN.to_le_bytes());
command.raw_body = Arc::from(body.into_boxed_slice());
assert!(evaluate_op1_point_light_identity(&command, 0.5, [0.0; 3]).is_some());
let mut body = command.raw_body.to_vec();
body[80..84].copy_from_slice(&f32::NAN.to_le_bytes());
command.raw_body = Arc::from(body.into_boxed_slice());
assert!(evaluate_op1_point_light_identity(&command, 0.5, [0.0; 3]).is_none());
}
#[test]
fn generic_opcode1_rejects_non_finite_transformed_values() {
let command = op1_test_command(5, 0.0, 1.0);
assert!(evaluate_op1_point_light_with_basis(
&command,
0.5,
[f32::MAX, 0.0, 0.0],
[[f32::MAX, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
)
.is_none());
}
fn op1_test_command(effect_type: u32, start: f32, end: f32) -> FxCommand {
let mut body = vec![0; 132];
body[0..4].copy_from_slice(&effect_type.to_le_bytes());
body[4..8].copy_from_slice(&start.to_le_bytes());
body[8..12].copy_from_slice(&end.to_le_bytes());
body[12..24].copy_from_slice(
&[0.0_f32; 3]
.iter()
.flat_map(|value| value.to_le_bytes())
.collect::<Vec<_>>(),
);
body[24..36].copy_from_slice(
&[2.0_f32, 4.0, 6.0]
.iter()
.flat_map(|value| value.to_le_bytes())
.collect::<Vec<_>>(),
);
body[36..48].copy_from_slice(
&[1.0_f32, 0.0, 0.0]
.iter()
.flat_map(|value| value.to_le_bytes())
.collect::<Vec<_>>(),
);
body[48..60].copy_from_slice(
&[0.0_f32, 1.0, 0.0]
.iter()
.flat_map(|value| value.to_le_bytes())
.collect::<Vec<_>>(),
);
body[60..76].copy_from_slice(
&[1.0_f32, 1.0, 1.0, 1.0]
.iter()
.flat_map(|value| value.to_le_bytes())
.collect::<Vec<_>>(),
);
body[76..92].copy_from_slice(
&[3.0_f32, 3.0, 3.0, 1.0]
.iter()
.flat_map(|value| value.to_le_bytes())
.collect::<Vec<_>>(),
);
body[108..112].copy_from_slice(&10.0_f32.to_le_bytes());
body[112..116].copy_from_slice(&20.0_f32.to_le_bytes());
body[116..120].copy_from_slice(&0.5_f32.to_le_bytes());
FxCommand {
word: 1,
opcode: FxOpcode::Op1,
native_flag: false,
raw_body: Arc::from(body.into_boxed_slice()),
resource_refs: Vec::new(),
}
}
fn evaluate_op1_point_light_identity(
command: &FxCommand,
command_time: f32,
origin: [f32; 3],
) -> Option<PointLightFrame> {
evaluate_op1_point_light_with_basis(command, command_time, origin, identity_basis())
}
#[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);
}
}