aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/fparkan-game/src/main.rs22
-rw-r--r--crates/fparkan-fx/src/environment.rs402
-rw-r--r--crates/fparkan-fx/src/lib.rs1
-rw-r--r--crates/fparkan-fx/src/smoke.rs1360
-rw-r--r--docs/reference/environment-effects.md50
-rw-r--r--docs/tomes/05-render.md21
6 files changed, 1818 insertions, 38 deletions
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs
index 3fc12a7..3b3969e 100644
--- a/apps/fparkan-game/src/main.rs
+++ b/apps/fparkan-game/src/main.rs
@@ -3841,6 +3841,11 @@ const PROTOTYPE_TYPE_FORT: u32 = u32::from_le_bytes(*b"FORT");
const PROTOTYPE_TYPE_BTLU: u32 = u32::from_le_bytes(*b"BTLU");
const PROTOTYPE_TYPE_STAT: u32 = u32::from_le_bytes(*b"STAT");
+// shortcut: the preview does not load shade.cfg yet, so use CShade's native
+// defaults until the settings loader is implemented.
+const NATIVE_DEFAULT_ROB_SHADOWS_ON: bool = true;
+const NATIVE_DEFAULT_BLD_SHADOWS_ON: bool = false;
+
/// Maps the resolved registry type to the native object-kind selector used by
/// CSun/CShade. The mission object's raw kind is an unrelated TMA field; the
/// renderer receives the registry provenance from the prepared visual.
@@ -3853,6 +3858,14 @@ fn native_shadow_kind(source_type: Option<u32>) -> Option<u32> {
}
}
+fn native_shadow_caster_enabled(native_kind: u32) -> bool {
+ if native_kind == 4 {
+ NATIVE_DEFAULT_ROB_SHADOWS_ON
+ } else {
+ NATIVE_DEFAULT_BLD_SHADOWS_ON
+ }
+}
+
fn compose_unit_pose(parent: VulkanNodePose, child: VulkanNodePose) -> VulkanNodePose {
let rotated = rotate_unit_vector(child.translation, parent.rotation);
VulkanNodePose {
@@ -4288,7 +4301,9 @@ fn static_preview_mesh_and_materials(
mesh_components += 1;
}
}
- if let Some(native_kind) = root_native_kind {
+ if let Some(native_kind) =
+ root_native_kind.filter(|kind| native_shadow_caster_enabled(*kind))
+ {
let caster_id = u32::try_from(shadow_casters.len())
.map_err(|_| "shadow caster id exceeds u32".to_string())?;
if let Some(caster) = aggregate_shadow_caster(
@@ -6439,10 +6454,13 @@ mod tests {
}
#[test]
- fn native_sun_object_source_types_map_to_world_kinds() {
+ fn native_shadow_kind_mapping_keeps_sun_eligibility_separate_from_caster_defaults() {
assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_FORT)), Some(3));
assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_BTLU)), Some(4));
assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_STAT)), Some(10));
+ assert!(!native_shadow_caster_enabled(3));
+ assert!(native_shadow_caster_enabled(4));
+ assert!(!native_shadow_caster_enabled(10));
assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_FORT)));
assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_BTLU)));
assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_STAT)));
diff --git a/crates/fparkan-fx/src/environment.rs b/crates/fparkan-fx/src/environment.rs
index babbd0f..a1f6c13 100644
--- a/crates/fparkan-fx/src/environment.rs
+++ b/crates/fparkan-fx/src/environment.rs
@@ -7,7 +7,7 @@
//! loading behind the simulation.
use crate::sky::SkyMaterials;
-use crate::{FxDocument, FxError, FxOpcode};
+use crate::{FxCommand, FxDocument, FxError, FxOpcode};
use std::sync::Arc;
// Terrain.dll writes these velocity vectors into the shared precipitation
@@ -597,12 +597,14 @@ impl LightningBounds {
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,
+ /// 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,
- /// 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.
@@ -616,10 +618,146 @@ pub struct PointLightFrame {
/// 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`.
+ /// 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
@@ -959,32 +1097,24 @@ impl LightningEffect {
}
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]));
+ 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,
- elapsed_seconds,
+ effect_type: sample.effect_type,
+ light_selector,
+ native_flags,
+ command_time: elapsed_seconds,
normalized_time,
- duration_seconds: self.duration_seconds,
- position: add3(origin, transform3(basis, local_position)),
+ position: add3(origin, transform3(basis, sample.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),
+ 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,
})
}
@@ -1565,7 +1695,9 @@ impl PrecipitationEmitter {
let size = camera.precipitation_size_scalar() * size_factor;
for index in 0..desired {
let particle = &mut self.particles[index];
- particle.previous_position = particle.position;
+ 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;
@@ -1960,6 +2092,31 @@ fn active_interval(body: &[u8], elapsed: f32, duration: f32) -> Option<(f32, f32
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)?;
@@ -2315,6 +2472,29 @@ mod tests {
}
#[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);
@@ -2593,6 +2773,172 @@ mod tests {
}
#[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());
diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs
index 04e54ad..4125a68 100644
--- a/crates/fparkan-fx/src/lib.rs
+++ b/crates/fparkan-fx/src/lib.rs
@@ -13,6 +13,7 @@ pub mod fx_visual;
pub mod op7;
pub mod shadow;
pub mod sky;
+pub mod smoke;
/// Advances the native FX 16-bit-pair random state and returns its new high half.
///
diff --git a/crates/fparkan-fx/src/smoke.rs b/crates/fparkan-fx/src/smoke.rs
new file mode 100644
index 0000000..116dcb7
--- /dev/null
+++ b/crates/fparkan-fx/src/smoke.rs
@@ -0,0 +1,1360 @@
+//! Deterministic evaluator for verified native opcode-8 particle pools.
+
+use crate::environment::EffectResource;
+use crate::fx_visual::{
+ compose_native_effect_matrix, native_camera_facing_basis, native_effect_local_point,
+ transform_native_effect_quad, EffectQuadFrame, EFFECT_QUAD_UV,
+};
+use crate::{FxCommand, FxOpcode};
+use std::sync::Arc;
+
+const RESOURCE_OFFSET: usize = 0xB4;
+const RESOURCE_BYTES: usize = 64;
+const MIN_BODY_SIZE: usize = RESOURCE_OFFSET + RESOURCE_BYTES;
+const MAX_POOL_SIZE: usize = 4096;
+const MAX_CATCH_UP: usize = 100_000;
+const U16_TO_UNIT: f32 = 1.0 / 65_536.0;
+
+/// Bounds for the native per-particle detail threshold.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Op8DetailThresholdRange {
+ /// Lower native threshold bound.
+ min: f32,
+ /// Upper native threshold bound.
+ max: f32,
+}
+
+impl Op8DetailThresholdRange {
+ /// Returns a range only when both endpoints are finite and ordered.
+ #[must_use]
+ pub fn new(min: f32, max: f32) -> Option<Self> {
+ (min.is_finite() && max.is_finite() && min <= max).then_some(Self { min, max })
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+struct NativeLfsr16Pair {
+ state: u32,
+}
+
+impl NativeLfsr16Pair {
+ fn from_word(state: u32) -> Self {
+ Self { state }
+ }
+
+ fn next_u16(&mut self) -> u16 {
+ crate::advance_native_fx_random_state(&mut self.state)
+ }
+
+ fn next_unit(&mut self) -> f32 {
+ f32::from(self.next_u16()) * U16_TO_UNIT
+ }
+
+ fn next_signed(&mut self, width: f32) -> f32 {
+ (self.next_unit() - 0.5) * width
+ }
+}
+
+#[derive(Clone, Copy, Debug)]
+struct Op8Track {
+ start: [f32; 3],
+ end: [f32; 3],
+ exponent: [f32; 3],
+}
+
+#[derive(Clone, Debug)]
+struct ParticleSlot {
+ born_at: f32,
+ threshold: f32,
+ random_phase: f32,
+ owner_matrix: [[f32; 4]; 4],
+ position: Op8Track,
+ scale: Op8Track,
+}
+
+/// Mutable state for one Op8 command. Keep one state per command instance.
+#[derive(Clone, Debug)]
+pub struct SmokeEmitterState {
+ initial_emitter_rng: NativeLfsr16Pair,
+ initial_signed_rng: NativeLfsr16Pair,
+ initial_material_rng: NativeLfsr16Pair,
+ emitter_rng: NativeLfsr16Pair,
+ signed_rng: NativeLfsr16Pair,
+ material_rng: NativeLfsr16Pair,
+ body: Option<Arc<[u8]>>,
+ emitter_position: Option<Op8Track>,
+ stored_anchor: Option<[f32; 3]>,
+ cursor: usize,
+ last_spawn: f32,
+ interval: f32,
+ slots: Vec<Option<ParticleSlot>>,
+}
+
+impl SmokeEmitterState {
+ /// Creates an evaluator with explicit emitter, signed-range, and material seeds.
+ #[must_use]
+ pub fn new(emitter_seed: u32, signed_seed: u32, material_seed: u32) -> Self {
+ let initial_emitter_rng = NativeLfsr16Pair::from_word(emitter_seed);
+ let initial_signed_rng = NativeLfsr16Pair::from_word(signed_seed);
+ let initial_material_rng = NativeLfsr16Pair::from_word(material_seed);
+ Self {
+ initial_emitter_rng,
+ initial_signed_rng,
+ initial_material_rng,
+ emitter_rng: initial_emitter_rng,
+ signed_rng: initial_signed_rng,
+ material_rng: initial_material_rng,
+ body: None,
+ emitter_position: None,
+ stored_anchor: None,
+ cursor: 0,
+ last_spawn: 0.0,
+ interval: 0.0,
+ slots: Vec::new(),
+ }
+ }
+
+ /// Resets the three streams and the native pool state.
+ fn reset(&mut self) {
+ self.emitter_rng = self.initial_emitter_rng;
+ self.signed_rng = self.initial_signed_rng;
+ self.material_rng = self.initial_material_rng;
+ self.body = None;
+ self.emitter_position = None;
+ self.stored_anchor = None;
+ self.cursor = 0;
+ self.last_spawn = 0.0;
+ self.interval = 0.0;
+ self.slots.clear();
+ }
+
+ /// Number of slots retained by the native ring.
+ #[must_use]
+ #[cfg(test)]
+ fn pool_len(&self) -> usize {
+ self.slots.len()
+ }
+
+ /// Native ring cursor. A scheduled event advances this index before access.
+ #[must_use]
+ #[cfg(test)]
+ fn next_slot(&self) -> usize {
+ self.cursor
+ }
+
+ fn material_threshold(&mut self, range: Op8DetailThresholdRange) -> f32 {
+ range.min + self.material_rng.next_unit() * (range.max - range.min)
+ }
+}
+
+/// Tests the native inclusive detail gate.
+#[must_use]
+fn detail_class_passes_threshold(detail_class: f32, threshold: f32) -> bool {
+ detail_class.is_finite() && threshold.is_finite() && detail_class <= threshold
+}
+
+/// Evaluates the native Op8 pool and returns one frame per slot in ring order.
+///
+/// The body is parsed from its verified shared offsets, so material names and
+/// pool sizes can vary. The caller supplies the native update clock, normalized
+/// effect phase, detail threshold bounds, owner transform, camera position,
+/// and explicit RNG state. The first in-window call captures the owner pose
+/// and emits nothing, matching the native update path. Each emitted particle
+/// keeps its interpolated birth anchor and a motion-oriented slot basis.
+#[must_use]
+#[allow(clippy::too_many_arguments)]
+pub fn evaluate_op8_emitter(
+ command: &FxCommand,
+ command_index: usize,
+ world_from_effect: &[[f32; 4]; 4],
+ camera_world: [f32; 3],
+ update_clock: f32,
+ effect_phase: f32,
+ detail_class: u32,
+ detail_threshold_range: Op8DetailThresholdRange,
+ state: &mut SmokeEmitterState,
+) -> Option<Vec<EffectQuadFrame>> {
+ if command.opcode != FxOpcode::Op8
+ || !world_from_effect
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ || !camera_world.iter().all(|value| value.is_finite())
+ || !update_clock.is_finite()
+ || !effect_phase.is_finite()
+ || !detail_threshold_range.min.is_finite()
+ || !detail_threshold_range.max.is_finite()
+ || detail_threshold_range.min > detail_threshold_range.max
+ || !(detail_threshold_range.max - detail_threshold_range.min).is_finite()
+ {
+ return None;
+ }
+
+ let params = Op8Params::parse(command)?;
+ if state
+ .body
+ .as_deref()
+ .is_some_and(|body| body != command.raw_body.as_ref())
+ {
+ state.reset();
+ }
+ state.body = Some(Arc::clone(&command.raw_body));
+
+ if state.slots.len() != params.pool_count {
+ if !state.slots.is_empty() {
+ state.reset();
+ state.body = Some(Arc::clone(&command.raw_body));
+ }
+ state.slots.resize_with(params.pool_count, || None);
+ state.cursor = 0;
+ }
+ if state.emitter_position.is_none() {
+ state.emitter_position = Some(sample_emitter_position(&params, state)?);
+ }
+
+ let in_window = effect_phase >= params.window_start && effect_phase <= params.window_end;
+ let interval_phase =
+ normalized_window_phase(effect_phase, params.window_start, params.window_end);
+ let due_interval =
+ params.interval_start + (params.interval_end - params.interval_start) * interval_phase;
+ let schedule_interval =
+ params.interval_start + (params.interval_end - params.interval_start) * effect_phase;
+ if !due_interval.is_finite() || due_interval <= 0.0 {
+ return None;
+ }
+ let emitter_offset = sample_track(state.emitter_position?, interval_phase)?;
+ let current_anchor = apply_emitter_offset(world_from_effect, emitter_offset)?;
+
+ if !in_window && !state.slots.iter().any(Option::is_some) {
+ // The native update returns early once its live-particle count is zero.
+ // A later in-window update captures a fresh owner anchor before emitting.
+ state.stored_anchor = None;
+ state.last_spawn = 0.0;
+ state.interval = due_interval;
+ } else if state.last_spawn == 0.0 {
+ // Native uses a zero schedule base as its initialization sentinel.
+ // If the first update clock is zero, the next update captures again.
+ state.stored_anchor = Some(matrix_translation(&current_anchor));
+ state.last_spawn = update_clock;
+ state.interval = due_interval;
+ } else {
+ // Native clamps only the schedule base on a time rewind. Existing
+ // particles and all three random streams remain intact.
+ if state.last_spawn > update_clock {
+ state.last_spawn = update_clock;
+ }
+ let catch_up = catch_up_count(
+ state.last_spawn,
+ update_clock,
+ due_interval,
+ schedule_interval,
+ )?;
+ let schedule_origin = state.last_spawn;
+ let mut stored_anchor = state.stored_anchor?;
+ let velocity = if catch_up == 0 {
+ [0.0; 3]
+ } else {
+ owner_anchor_velocity(
+ &current_anchor,
+ stored_anchor,
+ update_clock,
+ schedule_origin,
+ )?
+ };
+ for _ in 0..catch_up {
+ let schedule_base = state.last_spawn;
+ let born_at = schedule_base + due_interval;
+ let next_schedule_base = schedule_base + schedule_interval;
+ if !born_at.is_finite() {
+ return None;
+ }
+ if !next_schedule_base.is_finite() || next_schedule_base <= schedule_base {
+ return None;
+ }
+ let (owner_matrix, next_anchor) =
+ advance_owner_anchor(&current_anchor, stored_anchor, velocity, due_interval)?;
+ if in_window {
+ spawn_particle(
+ &params,
+ detail_threshold_range,
+ born_at,
+ owner_matrix,
+ state,
+ )?;
+ } else {
+ retire_particle(params.pool_count, state);
+ }
+ stored_anchor = next_anchor;
+ state.stored_anchor = Some(next_anchor);
+ state.last_spawn = next_schedule_base;
+ }
+ state.interval = due_interval;
+ }
+
+ let interval_period = state.interval * params.pool_count as f32;
+ if !interval_period.is_finite() || interval_period <= 0.0 {
+ return None;
+ }
+ let mut frames = Vec::with_capacity(params.pool_count);
+
+ for (slot_index, slot) in state.slots.iter().enumerate() {
+ let Some(particle) = slot else {
+ frames.push(empty_frame(command_index, &params.resource));
+ continue;
+ };
+ let mut raw_phase = (update_clock - state.last_spawn) / interval_period
+ + (state.cursor as f32 - slot_index as f32) / params.pool_count as f32;
+ if raw_phase < 0.0 {
+ raw_phase += 1.0;
+ }
+ if !raw_phase.is_finite() {
+ return None;
+ }
+
+ let position = sample_track(particle.position, raw_phase)?;
+ let scale = sample_track(particle.scale, raw_phase)?;
+ let opacity = params.opacity_start
+ + (params.opacity_end - params.opacity_start) * raw_phase.powf(params.opacity_exponent);
+ let material_phase = if params.material_phase_exponent < 0.0 {
+ particle.random_phase
+ } else {
+ raw_phase.powf(params.material_phase_exponent)
+ };
+ if !opacity.is_finite() || !material_phase.is_finite() {
+ return None;
+ }
+
+ let local_position = position;
+ let detail_passes = detail_class_passes_threshold(detail_class as f32, particle.threshold);
+ let camera_local = native_effect_local_point(&particle.owner_matrix, camera_world)?;
+ let view = subtract3(camera_local, local_position);
+ let Some(local_basis) = native_camera_facing_basis(view, local_position) else {
+ frames.push(EffectQuadFrame {
+ command_index,
+ opcode: FxOpcode::Op8,
+ material: params.resource.clone(),
+ vertices: [[0.0; 3]; 4],
+ uv: EFFECT_QUAD_UV,
+ color: [1.0, 1.0, 1.0, 0.0],
+ material_phase,
+ elapsed_seconds: (update_clock - particle.born_at).max(0.0),
+ normalized_time: raw_phase,
+ active: false,
+ native_flag_base: None,
+ });
+ continue;
+ };
+ let matrix = compose_native_effect_matrix(
+ &particle.owner_matrix,
+ &local_basis,
+ local_position,
+ scale,
+ )?;
+ let vertices = transform_native_effect_quad(&matrix)?;
+ let visible = detail_passes;
+ frames.push(EffectQuadFrame {
+ command_index,
+ opcode: FxOpcode::Op8,
+ material: params.resource.clone(),
+ vertices,
+ uv: EFFECT_QUAD_UV,
+ color: [1.0, 1.0, 1.0, if visible { opacity } else { 0.0 }],
+ material_phase,
+ elapsed_seconds: (update_clock - particle.born_at).max(0.0),
+ normalized_time: raw_phase,
+ active: visible,
+ native_flag_base: None,
+ });
+ }
+ Some(frames)
+}
+
+#[derive(Clone, Debug)]
+struct Op8Params {
+ resource: EffectResource,
+ pool_count: usize,
+ window_start: f32,
+ window_end: f32,
+ interval_start: f32,
+ interval_end: f32,
+ material_phase_exponent: f32,
+ opacity_start: f32,
+ opacity_end: f32,
+ opacity_exponent: f32,
+ emitter_position_start: [f32; 3],
+ emitter_position_end_center: [f32; 3],
+ emitter_position_width: [f32; 3],
+ emitter_position_exponent: [f32; 3],
+ position_start: [f32; 3],
+ position_end_center: [f32; 3],
+ position_width: [f32; 3],
+ position_exponent: [f32; 3],
+ scale_start: [f32; 3],
+ scale_end_center: [f32; 3],
+ scale_width: [f32; 3],
+ scale_exponent: [f32; 3],
+}
+
+impl Op8Params {
+ fn parse(command: &FxCommand) -> Option<Self> {
+ let body = &command.raw_body;
+ if body.len() < MIN_BODY_SIZE {
+ return None;
+ }
+ let pool_count = read_u32(body, 0x20)? as usize;
+ let window_start = read_f32(body, 0x0C)?;
+ let window_end = read_f32(body, 0x10)?;
+ let interval_start = read_f32(body, 0x14)?;
+ let interval_end = read_f32(body, 0x18)?;
+ let material_phase_exponent = read_f32(body, 0x1C)?;
+ let opacity_start = read_f32(body, 0x00)?;
+ let opacity_end = read_f32(body, 0x04)?;
+ let opacity_exponent = read_f32(body, 0x08)?;
+ let emitter_position_start = read_vec3(body, 0x24)?;
+ let emitter_position_end_center = read_vec3(body, 0x30)?;
+ let emitter_position_width = read_vec3(body, 0x3C)?;
+ let emitter_position_exponent = read_vec3(body, 0x48)?;
+ let position_start = read_vec3(body, 0x54)?;
+ let position_end_center = read_vec3(body, 0x60)?;
+ let position_width = read_vec3(body, 0x6C)?;
+ let position_exponent = read_vec3(body, 0x78)?;
+ let scale_start = read_vec3(body, 0x84)?;
+ let scale_end_center = read_vec3(body, 0x90)?;
+ let scale_width = read_vec3(body, 0x9C)?;
+ let scale_exponent = read_vec3(body, 0xA8)?;
+ if pool_count == 0
+ || pool_count > MAX_POOL_SIZE
+ || ![
+ window_start,
+ window_end,
+ interval_start,
+ interval_end,
+ material_phase_exponent,
+ opacity_start,
+ opacity_end,
+ opacity_exponent,
+ ]
+ .iter()
+ .all(|value| value.is_finite())
+ || window_start > window_end
+ || interval_start <= 0.0
+ || interval_end <= 0.0
+ || !emitter_position_start
+ .iter()
+ .chain(emitter_position_end_center.iter())
+ .chain(emitter_position_width.iter())
+ .chain(emitter_position_exponent.iter())
+ .chain(position_start.iter())
+ .chain(position_end_center.iter())
+ .chain(position_width.iter())
+ .chain(position_exponent.iter())
+ .chain(scale_start.iter())
+ .chain(scale_end_center.iter())
+ .chain(scale_width.iter())
+ .chain(scale_exponent.iter())
+ .all(|value| value.is_finite())
+ {
+ return None;
+ }
+
+ Some(Self {
+ resource: read_resource(body)?,
+ pool_count,
+ window_start,
+ window_end,
+ interval_start,
+ interval_end,
+ material_phase_exponent,
+ opacity_start,
+ opacity_end,
+ opacity_exponent,
+ emitter_position_start,
+ emitter_position_end_center,
+ emitter_position_width,
+ emitter_position_exponent,
+ position_start,
+ position_end_center,
+ position_width,
+ position_exponent,
+ scale_start,
+ scale_end_center,
+ scale_width,
+ scale_exponent,
+ })
+ }
+}
+
+fn normalized_window_phase(phase: f32, start: f32, end: f32) -> f32 {
+ let phase = (phase - start) / (end - start);
+ if !(phase >= 0.0) {
+ 0.0
+ } else if phase > 1.0 {
+ 1.0
+ } else {
+ phase
+ }
+}
+
+fn catch_up_count(
+ last_spawn: f32,
+ update_clock: f32,
+ due_interval: f32,
+ schedule_interval: f32,
+) -> Option<usize> {
+ if !last_spawn.is_finite() || !update_clock.is_finite() || !due_interval.is_finite() {
+ return None;
+ }
+ if due_interval <= 0.0 {
+ return None;
+ }
+
+ let mut scheduled = last_spawn;
+ let mut count = 0;
+ loop {
+ let due_at = scheduled + due_interval;
+ if !due_at.is_finite() {
+ return None;
+ }
+ if !(due_at < update_clock) {
+ return Some(count);
+ }
+ if count == MAX_CATCH_UP || !schedule_interval.is_finite() || schedule_interval <= 0.0 {
+ return None;
+ }
+ let next_base = scheduled + schedule_interval;
+ if !next_base.is_finite() || next_base <= scheduled {
+ return None;
+ }
+ scheduled = next_base;
+ count += 1;
+ }
+}
+
+fn sample_emitter_position(params: &Op8Params, state: &mut SmokeEmitterState) -> Option<Op8Track> {
+ // Native 11343..113A4 draws Z from the uniform stream first and centers
+ // it around zero, then draws centered Y and X from the signed-range stream.
+ let mut end = params.emitter_position_end_center;
+ end[2] += state.emitter_rng.next_unit() * params.emitter_position_width[2]
+ - params.emitter_position_width[2] * 0.5;
+ end[1] += state
+ .signed_rng
+ .next_signed(params.emitter_position_width[1]);
+ end[0] += state
+ .signed_rng
+ .next_signed(params.emitter_position_width[0]);
+ end.iter()
+ .all(|value| value.is_finite())
+ .then_some(Op8Track {
+ start: params.emitter_position_start,
+ end,
+ exponent: params.emitter_position_exponent,
+ })
+}
+
+fn apply_emitter_offset(owner: &[[f32; 4]; 4], offset: [f32; 3]) -> Option<[[f32; 4]; 4]> {
+ let mut anchor = *owner;
+ for axis in 0..3 {
+ anchor[axis][3] += offset[axis];
+ }
+ anchor
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ .then_some(anchor)
+}
+
+fn spawn_particle(
+ params: &Op8Params,
+ threshold_range: Op8DetailThresholdRange,
+ born_at: f32,
+ owner_matrix: [[f32; 4]; 4],
+ state: &mut SmokeEmitterState,
+) -> Option<()> {
+ let threshold = state.material_threshold(threshold_range);
+ let random_phase = state.emitter_rng.next_unit();
+ let position_z = params.position_end_center[2]
+ + state.emitter_rng.next_unit() * params.position_width[2]
+ - params.position_width[2] * 0.5;
+ let position_y =
+ params.position_end_center[1] + state.signed_rng.next_signed(params.position_width[1]);
+ let position_x =
+ params.position_end_center[0] + state.signed_rng.next_signed(params.position_width[0]);
+ let scale_z = params.scale_end_center[2] + state.signed_rng.next_signed(params.scale_width[2]);
+ let scale_y = params.scale_end_center[1] + state.signed_rng.next_signed(params.scale_width[1]);
+ let scale_x = params.scale_end_center[0] + state.signed_rng.next_signed(params.scale_width[0]);
+ let position_end = [position_x, position_y, position_z];
+ let scale_end = [scale_x, scale_y, scale_z];
+ if !threshold.is_finite()
+ || !position_end.iter().all(|value| value.is_finite())
+ || !scale_end.iter().all(|value| value.is_finite())
+ || !born_at.is_finite()
+ {
+ return None;
+ }
+ // The native ring increments its cursor before using the slot.
+ state.cursor = (state.cursor + 1) % params.pool_count;
+ state.slots[state.cursor] = Some(ParticleSlot {
+ born_at,
+ threshold,
+ random_phase,
+ owner_matrix,
+ position: Op8Track {
+ start: params.position_start,
+ end: position_end,
+ exponent: params.position_exponent,
+ },
+ scale: Op8Track {
+ start: params.scale_start,
+ end: scale_end,
+ exponent: params.scale_exponent,
+ },
+ });
+ Some(())
+}
+
+fn retire_particle(pool_count: usize, state: &mut SmokeEmitterState) {
+ // Out-of-window ticks follow the same cursor cadence, but mark the slot
+ // reached by that tick inactive instead of emitting a replacement.
+ state.cursor = (state.cursor + 1) % pool_count;
+ state.slots[state.cursor] = None;
+}
+
+fn matrix_translation(matrix: &[[f32; 4]; 4]) -> [f32; 3] {
+ [matrix[0][3], matrix[1][3], matrix[2][3]]
+}
+
+fn owner_anchor_velocity(
+ current: &[[f32; 4]; 4],
+ stored_anchor: [f32; 3],
+ update_clock: f32,
+ schedule_base: f32,
+) -> Option<[f32; 3]> {
+ let elapsed = update_clock - schedule_base;
+ if !elapsed.is_finite() || elapsed <= 0.0 {
+ return None;
+ }
+ let velocity = std::array::from_fn(|axis| (current[axis][3] - stored_anchor[axis]) / elapsed);
+ velocity
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some(velocity)
+}
+
+fn advance_owner_anchor(
+ current: &[[f32; 4]; 4],
+ stored_anchor: [f32; 3],
+ velocity: [f32; 3],
+ due_interval: f32,
+) -> Option<([[f32; 4]; 4], [f32; 3])> {
+ let mut velocity = velocity;
+ if vector_length_squared(velocity)? == 0.0 {
+ // Native replaces the zero velocity vector with the current owner's X
+ // column before both stepping the anchor and building the slot frame.
+ velocity = [current[0][0], current[1][0], current[2][0]];
+ }
+ let translation =
+ std::array::from_fn(|axis| stored_anchor[axis] + velocity[axis] * due_interval);
+ let tangent = normalize_vector(velocity)?;
+
+ let owner_up = [current[0][2], current[1][2], current[2][2]];
+ let side_candidate = cross(owner_up, tangent);
+ let side = if vector_length_squared(side_candidate)? == 0.0 {
+ native_fallback_side(tangent)?
+ } else {
+ normalize_vector(side_candidate)?
+ };
+ let up = cross(tangent, side);
+ let matrix = [
+ [tangent[0], side[0], up[0], translation[0]],
+ [tangent[1], side[1], up[1], translation[1]],
+ [tangent[2], side[2], up[2], translation[2]],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+ (matrix.iter().flatten().all(|value| value.is_finite())
+ && translation.iter().all(|value| value.is_finite()))
+ .then_some((matrix, translation))
+}
+
+fn vector_length_squared(vector: [f32; 3]) -> Option<f32> {
+ let length_squared = vector
+ .iter()
+ .map(|component| component * component)
+ .sum::<f32>();
+ length_squared.is_finite().then_some(length_squared)
+}
+
+fn normalize_vector(vector: [f32; 3]) -> Option<[f32; 3]> {
+ let length_squared = vector_length_squared(vector)?;
+ if length_squared == 0.0 {
+ return None;
+ }
+ let inverse_length = length_squared.sqrt().recip();
+ let normalized = vector.map(|component| component * inverse_length);
+ normalized
+ .iter()
+ .all(|component| component.is_finite())
+ .then_some(normalized)
+}
+
+fn native_fallback_side(tangent: [f32; 3]) -> Option<[f32; 3]> {
+ // Match 38D0: choose X when tangent X is zero, otherwise Y when tangent
+ // Y is zero, otherwise use (-Y, X, 0) and normalize only when Z is nonzero.
+ let mut side = if tangent[0] == 0.0 {
+ [1.0, 0.0, 0.0]
+ } else if tangent[1] == 0.0 {
+ [0.0, 1.0, 0.0]
+ } else {
+ [-tangent[1], tangent[0], 0.0]
+ };
+ if tangent[0] != 0.0 && tangent[1] != 0.0 && tangent[2] != 0.0 {
+ side = normalize_vector(side)?;
+ }
+ Some(side)
+}
+
+fn cross(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [
+ left[1] * right[2] - left[2] * right[1],
+ left[2] * right[0] - left[0] * right[2],
+ left[0] * right[1] - left[1] * right[0],
+ ]
+}
+
+fn sample_track(track: Op8Track, phase: f32) -> Option<[f32; 3]> {
+ let sample = std::array::from_fn(|axis| {
+ track.start[axis] + (track.end[axis] - track.start[axis]) * phase.powf(track.exponent[axis])
+ });
+ sample
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some(sample)
+}
+
+fn empty_frame(command_index: usize, resource: &EffectResource) -> EffectQuadFrame {
+ EffectQuadFrame {
+ command_index,
+ opcode: FxOpcode::Op8,
+ material: resource.clone(),
+ vertices: [[0.0; 3]; 4],
+ uv: EFFECT_QUAD_UV,
+ color: [1.0, 1.0, 1.0, 0.0],
+ material_phase: 0.0,
+ elapsed_seconds: 0.0,
+ normalized_time: 0.0,
+ active: false,
+ native_flag_base: None,
+ }
+}
+
+fn read_resource(body: &[u8]) -> Option<EffectResource> {
+ let bytes = body.get(RESOURCE_OFFSET..RESOURCE_OFFSET + RESOURCE_BYTES)?;
+ Some(EffectResource {
+ archive: read_name(&bytes[..32])?,
+ name: read_name(&bytes[32..])?,
+ })
+}
+
+fn read_name(bytes: &[u8]) -> Option<String> {
+ let end = bytes
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(bytes.len());
+ let name = std::str::from_utf8(&bytes[..end]).ok()?.trim();
+ (!name.is_empty()).then(|| name.to_owned())
+}
+
+fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
+ Some(u32::from_le_bytes(
+ bytes.get(offset..offset + 4)?.try_into().ok()?,
+ ))
+}
+
+fn read_f32(bytes: &[u8], offset: usize) -> Option<f32> {
+ Some(f32::from_le_bytes(
+ bytes.get(offset..offset + 4)?.try_into().ok()?,
+ ))
+}
+
+fn read_vec3(bytes: &[u8], offset: usize) -> Option<[f32; 3]> {
+ Some([
+ read_f32(bytes, offset)?,
+ read_f32(bytes, offset + 4)?,
+ read_f32(bytes, offset + 8)?,
+ ])
+}
+
+fn subtract3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ std::array::from_fn(|axis| left[axis] - right[axis])
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ detail_class_passes_threshold, evaluate_op8_emitter, Op8DetailThresholdRange,
+ SmokeEmitterState,
+ };
+ use crate::{fx_visual::EffectQuadFrame, FxCommand, FxOpcode};
+ use std::sync::Arc;
+
+ const IDENTITY: [[f32; 4]; 4] = [
+ [1.0, 0.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+
+ fn command(resource: &str, pool_count: u32, native_flag: bool) -> FxCommand {
+ let mut body = vec![0_u8; 0xF4];
+ put_f32(&mut body, 0x00, 1.0);
+ put_f32(&mut body, 0x04, 0.0);
+ put_f32(&mut body, 0x08, 1.0);
+ put_f32(&mut body, 0x0C, 0.0);
+ put_f32(&mut body, 0x10, 1.0);
+ put_f32(&mut body, 0x14, 0.08);
+ put_f32(&mut body, 0x18, 0.08);
+ put_f32(&mut body, 0x1C, 1.0);
+ put_u32(&mut body, 0x20, pool_count);
+ put_vec3(&mut body, 0x24, [0.0; 3]);
+ put_vec3(&mut body, 0x30, [0.0; 3]);
+ put_vec3(&mut body, 0x3C, [0.0; 3]);
+ put_vec3(&mut body, 0x48, [1.0; 3]);
+ put_vec3(&mut body, 0x54, [0.0; 3]);
+ put_vec3(&mut body, 0x60, [0.0, 0.0, 50.0]);
+ put_vec3(&mut body, 0x6C, [10.0, 30.0, 30.0]);
+ put_vec3(&mut body, 0x78, [1.0; 3]);
+ put_vec3(&mut body, 0x84, [10.0; 3]);
+ put_vec3(&mut body, 0x90, [30.0; 3]);
+ put_vec3(&mut body, 0x9C, [0.0, 1.0, 1.0]);
+ put_vec3(&mut body, 0xA8, [1.0; 3]);
+ put_name(&mut body, 0xB4, "material.lib");
+ put_name(&mut body, 0xD4, resource);
+ FxCommand {
+ word: if native_flag { 0x108 } else { 8 },
+ opcode: FxOpcode::Op8,
+ native_flag,
+ raw_body: Arc::from(body),
+ resource_refs: Vec::new(),
+ }
+ }
+
+ fn evaluate(
+ command: &FxCommand,
+ clock: f32,
+ phase: f32,
+ detail_class: u32,
+ state: &mut SmokeEmitterState,
+ ) -> Vec<EffectQuadFrame> {
+ evaluate_with_owner(command, clock, phase, detail_class, &IDENTITY, state)
+ }
+
+ fn evaluate_with_owner(
+ command: &FxCommand,
+ clock: f32,
+ phase: f32,
+ detail_class: u32,
+ owner: &[[f32; 4]; 4],
+ state: &mut SmokeEmitterState,
+ ) -> Vec<EffectQuadFrame> {
+ evaluate_op8_emitter(
+ command,
+ 0,
+ owner,
+ [0.0, 0.0, 500.0],
+ clock,
+ phase,
+ detail_class,
+ Op8DetailThresholdRange::new(1.0, 1.0).unwrap(),
+ state,
+ )
+ .unwrap()
+ }
+
+ fn put_u32(body: &mut [u8], offset: usize, value: u32) {
+ body[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ }
+
+ fn put_f32(body: &mut [u8], offset: usize, value: f32) {
+ body[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ }
+
+ fn put_vec3(body: &mut [u8], offset: usize, value: [f32; 3]) {
+ for (axis, component) in value.into_iter().enumerate() {
+ put_f32(body, offset + axis * 4, component);
+ }
+ }
+
+ fn put_name(body: &mut [u8], offset: usize, value: &str) {
+ body[offset..offset + value.len()].copy_from_slice(value.as_bytes());
+ }
+
+ fn centroid(frame: &EffectQuadFrame) -> [f32; 3] {
+ std::array::from_fn(|axis| {
+ frame
+ .vertices
+ .iter()
+ .map(|vertex| vertex[axis])
+ .sum::<f32>()
+ / 4.0
+ })
+ }
+
+ fn distance(left: [f32; 3], right: [f32; 3]) -> f32 {
+ let delta = std::array::from_fn::<_, 3, _>(|axis| left[axis] - right[axis]);
+ (delta[0] * delta[0] + delta[1] * delta[1] + delta[2] * delta[2]).sqrt()
+ }
+
+ #[test]
+ fn first_update_strict_interval_and_catch_up_match_native_schedule() {
+ let command = command("fire_smoke", 30, false);
+ let mut state = SmokeEmitterState::new(13, 13, 13);
+ assert!(evaluate(&command, 0.0, 0.0, 0, &mut state)
+ .iter()
+ .all(|frame| !frame.active));
+ assert_eq!(state.next_slot(), 0);
+ assert_eq!(state.last_spawn, 0.0);
+ assert_eq!(
+ evaluate(&command, 0.08, 0.0, 0, &mut state)
+ .iter()
+ .filter(|frame| frame.active)
+ .count(),
+ 0
+ );
+ assert_eq!(state.last_spawn, 0.08);
+ assert_eq!(
+ evaluate(&command, 0.16, 0.0, 0, &mut state)
+ .iter()
+ .filter(|frame| frame.active)
+ .count(),
+ 0
+ );
+ assert_eq!(
+ evaluate(&command, 0.160_001, 0.0, 0, &mut state)
+ .iter()
+ .filter(|frame| frame.active)
+ .count(),
+ 1
+ );
+ assert_eq!(
+ evaluate(&command, 0.320_001, 0.0, 0, &mut state)
+ .iter()
+ .filter(|frame| frame.active)
+ .count(),
+ 3
+ );
+ assert_eq!(state.next_slot(), 3);
+ }
+
+ #[test]
+ fn b_sphere_start_uses_normalized_due_interval_and_raw_schedule_step() {
+ let mut command = command("B_Sphere_Start", 6, true);
+ let body = Arc::make_mut(&mut command.raw_body);
+ // These values are from effects.rlb / B_Sphere_Start command 14.
+ put_f32(body, 0x0C, 0.269);
+ put_f32(body, 0x10, 0.885);
+ put_f32(body, 0x14, 0.05);
+ put_f32(body, 0x18, 0.01);
+ let mut state = SmokeEmitterState::new(17, 29, 43);
+
+ evaluate(&command, 0.01, 0.5, 0, &mut state);
+ evaluate(&command, 0.09, 0.5, 0, &mut state);
+
+ // At phase .5, native normalizes to .375 and tests events at .035,
+ // then .065 relative to the captured .01 base. After each event it
+ // advances the schedule base by the raw-phase interval .03.
+ assert!((state.last_spawn - 0.07).abs() < 1e-6);
+ assert_eq!(state.next_slot(), 2);
+ assert!((state.slots[1].as_ref().unwrap().born_at - 0.045).abs() < 1e-6);
+ assert!((state.slots[2].as_ref().unwrap().born_at - 0.075).abs() < 1e-6);
+ }
+
+ #[test]
+ fn equal_window_phase_matches_native_unordered_and_clamp_comparisons() {
+ assert_eq!(super::normalized_window_phase(0.269, 0.269, 0.269), 0.0);
+ assert_eq!(super::normalized_window_phase(0.268, 0.269, 0.269), 0.0);
+ assert_eq!(super::normalized_window_phase(0.270, 0.269, 0.269), 1.0);
+ }
+
+ #[test]
+ fn clock_rewind_clamps_only_schedule_base() {
+ let command = command("smoke_yg", 6, true);
+ let mut state = SmokeEmitterState::new(17, 29, 43);
+ evaluate(&command, 0.01, 0.5, 0, &mut state);
+ evaluate(&command, 0.090_001, 0.5, 0, &mut state);
+
+ let cursor = state.cursor;
+ let born_at = state.slots[1].as_ref().unwrap().born_at;
+ let emitter_rng = state.emitter_rng;
+ let signed_rng = state.signed_rng;
+ let material_rng = state.material_rng;
+ assert!((state.last_spawn - 0.09).abs() < 1e-6);
+
+ evaluate(&command, 0.04, 0.5, 0, &mut state);
+
+ assert_eq!(state.last_spawn, 0.04);
+ assert_eq!(state.cursor, cursor);
+ assert_eq!(state.slots[1].as_ref().unwrap().born_at, born_at);
+ assert_eq!(state.emitter_rng, emitter_rng);
+ assert_eq!(state.signed_rng, signed_rng);
+ assert_eq!(state.material_rng, material_rng);
+ }
+
+ #[test]
+ fn catch_up_requires_advancing_base_and_honors_validation_bound() {
+ assert_eq!(super::catch_up_count(0.0, 1.0, 0.5, 0.25), Some(2));
+ assert!(super::catch_up_count(0.0, 200_001.0, 1.0, 1.0).is_none());
+ assert!(super::catch_up_count(16_777_216.0, 16_777_220.0, 0.01, 0.01).is_none());
+ }
+
+ #[test]
+ fn moving_owner_advances_from_the_last_emitted_anchor() {
+ let command = command("smoke_yg", 6, true);
+ let owner_start = IDENTITY;
+ let mut owner_end = [
+ [0.0, -1.0, 0.0, 0.0],
+ [1.0, 0.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+ let mut owner_later = IDENTITY;
+ owner_end[0][3] = 10.0;
+ owner_later[0][3] = 20.0;
+ let mut state = SmokeEmitterState::new(17, 17, 17);
+
+ evaluate_with_owner(&command, 0.01, 0.0, 0, &owner_start, &mut state);
+ evaluate_with_owner(&command, 0.05, 0.5, 0, &owner_end, &mut state);
+ assert_eq!(state.stored_anchor.unwrap(), [0.0, 0.0, 0.0]);
+
+ // No scheduled event has happened, so the stored anchor stays at the
+ // initialization pose. Native divides the full displacement by the
+ // time from that anchor's schedule base when an event becomes due.
+ evaluate_with_owner(&command, 0.110_001, 0.5, 0, &owner_end, &mut state);
+ let slot = state.slots[1].as_ref().unwrap();
+ assert!((slot.owner_matrix[0][3] - 8.0).abs() < 1e-4);
+ assert_eq!(
+ state.stored_anchor.unwrap(),
+ [slot.owner_matrix[0][3], 0.0, 0.0]
+ );
+ // The native slot frame uses motion direction and current owner Z.
+ // The yawed owner with X-directed motion yields an identity basis.
+ assert_eq!(
+ slot.owner_matrix[0],
+ [1.0, 0.0, 0.0, slot.owner_matrix[0][3]]
+ );
+ assert_eq!(slot.owner_matrix[1], [0.0, 1.0, 0.0, 0.0]);
+ assert_eq!(slot.owner_matrix[2], [0.0, 0.0, 1.0, 0.0]);
+
+ let frames = evaluate_with_owner(&command, 0.12, 0.6, 0, &owner_later, &mut state);
+ let slot = state.slots[1].as_ref().unwrap();
+ let local_position = super::sample_track(slot.position, frames[1].normalized_time).unwrap();
+ // A no-event update leaves the previously emitted anchor unchanged.
+ assert!((slot.owner_matrix[0][3] - 8.0).abs() < 1e-4);
+ assert!((state.stored_anchor.unwrap()[0] - 8.0).abs() < 1e-4);
+ let center_x = centroid(&frames[1])[0];
+ assert!((center_x - (slot.owner_matrix[0][3] + local_position[0])).abs() < 1e-4);
+ assert!((center_x - (owner_later[0][3] + local_position[0])).abs() > 10.0);
+
+ let frames = evaluate_with_owner(&command, 0.250_001, 0.7, 0, &owner_later, &mut state);
+ assert!((state.slots[2].as_ref().unwrap().born_at - 0.17).abs() < 1e-6);
+ assert!((state.slots[3].as_ref().unwrap().born_at - 0.25).abs() < 1e-6);
+ // Both catch-up particles use the single velocity computed from the
+ // stored X=8 anchor and the original schedule base, stepping by .08.
+ assert!((state.slots[2].as_ref().unwrap().owner_matrix[0][3] - 14.0).abs() < 1e-3);
+ assert!((state.slots[3].as_ref().unwrap().owner_matrix[0][3] - 20.0).abs() < 1e-3);
+ assert!(frames[2].active && frames[3].active);
+ }
+
+ #[test]
+ fn moving_owner_uses_native_zero_motion_and_parallel_axis_fallbacks() {
+ let command = command("smoke_yg", 6, true);
+ let previous = IDENTITY;
+ let mut current = IDENTITY;
+ // Tiny nonzero movement must remain a velocity, not take the exact
+ // zero-motion branch. A parallel owner-up axis then exercises 38D0.
+ current[0][3] = 0.000_01;
+ current[0][2] = 1.0;
+ current[2][2] = 0.0;
+ current[0][0] = 0.0;
+ current[1][0] = 1.0;
+ current[2][0] = 0.0;
+ let mut state = SmokeEmitterState::new(19, 19, 19);
+ evaluate_with_owner(&command, 0.01, 0.0, 0, &previous, &mut state);
+ evaluate_with_owner(&command, 0.090_001, 0.5, 0, &current, &mut state);
+ let matrix = state.slots[1].as_ref().unwrap().owner_matrix;
+ assert_eq!([matrix[0][0], matrix[1][0], matrix[2][0]], [1.0, 0.0, 0.0]);
+ assert_eq!([matrix[0][1], matrix[1][1], matrix[2][1]], [0.0, 1.0, 0.0]);
+ assert_eq!([matrix[0][2], matrix[1][2], matrix[2][2]], [0.0, 0.0, 1.0]);
+
+ // Exactly zero movement uses the current owner X column, matching the
+ // separate 11B83 branch before the 38D0 parallel-axis fallback.
+ let mut static_owner = IDENTITY;
+ static_owner[0][0] = 0.0;
+ static_owner[1][0] = 1.0;
+ static_owner[2][0] = 0.0;
+ let mut stationary = SmokeEmitterState::new(23, 23, 23);
+ evaluate_with_owner(&command, 0.01, 0.0, 0, &previous, &mut stationary);
+ evaluate_with_owner(&command, 0.090_001, 0.5, 0, &static_owner, &mut stationary);
+ let matrix = stationary.slots[1].as_ref().unwrap().owner_matrix;
+ assert_eq!([matrix[0][0], matrix[1][0], matrix[2][0]], [0.0, 1.0, 0.0]);
+ assert_eq!([matrix[0][1], matrix[1][1], matrix[2][1]], [-1.0, 0.0, 0.0]);
+ assert_eq!([matrix[0][2], matrix[1][2], matrix[2][2]], [0.0, 0.0, 1.0]);
+ assert!((matrix[1][3] - 0.08).abs() < 1e-6);
+ assert_eq!(stationary.stored_anchor.unwrap(), [0.0, 0.08, 0.0]);
+ }
+
+ #[test]
+ fn particles_drain_through_the_ring_and_keep_fading_after_the_window() {
+ let mut command = command("smoke_yg", 6, true);
+ put_f32(Arc::make_mut(&mut command.raw_body), 0x10, 0.5);
+ let mut state = SmokeEmitterState::new(31, 31, 31);
+
+ evaluate(&command, 0.01, 0.25, 0, &mut state);
+ assert_eq!(
+ evaluate(&command, 0.090_001, 0.25, 0, &mut state)
+ .iter()
+ .filter(|frame| frame.active)
+ .count(),
+ 1
+ );
+
+ let tail = evaluate(&command, 0.170_001, 0.75, 0, &mut state);
+ assert_eq!(tail.iter().filter(|frame| frame.active).count(), 1);
+ assert!(tail[1].color[3] > 0.0 && tail[1].color[3] < 1.0);
+
+ // Out-of-window events still advance the native ring. This particle
+ // remains alive until the cursor reaches its slot on the sixth tick.
+ let before_wrap = evaluate(&command, 0.490_001, 0.75, 0, &mut state);
+ assert_eq!(before_wrap.iter().filter(|frame| frame.active).count(), 1);
+ assert!(before_wrap[1].active);
+ assert!(before_wrap[1].color[3] < tail[1].color[3]);
+
+ let drained = evaluate(&command, 0.570_001, 0.75, 0, &mut state);
+ assert!(drained.iter().all(|frame| !frame.active));
+ assert!(state.slots.iter().all(Option::is_none));
+ }
+
+ #[test]
+ fn generic_six_slot_smoke_yg_body_samples_tracks_opacity_and_phase() {
+ let mut command = command("smoke_yg", 6, true);
+ let body = Arc::make_mut(&mut command.raw_body);
+ put_f32(body, 0x00, 0.25);
+ put_f32(body, 0x04, 0.75);
+ put_f32(body, 0x08, 2.0);
+ put_f32(body, 0x1C, -1.0);
+ put_f32(body, 0x14, 0.08);
+ put_f32(body, 0x18, 0.08);
+ put_f32(body, 0x44, 0.0);
+ put_vec3(body, 0x48, [1.0; 3]);
+ put_vec3(body, 0x24, [0.0, 0.0, 3.0]);
+ put_vec3(body, 0x30, [0.0, 0.0, 3.0]);
+ put_vec3(body, 0x54, [0.0, 0.0, 3.0]);
+ put_vec3(body, 0x60, [0.0, 0.0, 13.0]);
+ put_vec3(body, 0x6C, [0.0, 0.0, 6.0]);
+ put_vec3(body, 0x78, [1.0, 1.0, 0.5]);
+ put_vec3(body, 0x84, [0.5, 0.75, 1.0]);
+ put_vec3(body, 0x90, [1.5, 1.75, 2.0]);
+ put_vec3(body, 0x9C, [0.0; 3]);
+ put_vec3(body, 0xA8, [1.0, 2.0, 3.0]);
+
+ let mut state = SmokeEmitterState::new(17, 29, 43);
+ let first = evaluate(&command, 0.01, 0.0, 1, &mut state);
+ assert_eq!(first.len(), 6);
+ assert!(first.iter().all(|frame| !frame.active));
+ let frames = evaluate(&command, 0.090_001, 0.5, 1, &mut state);
+ let frame = &frames[1];
+ let particle = state.slots[1].as_ref().unwrap();
+ let phase: f32 = (0.090_001 - 0.09) / (0.08 * 6.0);
+ let position: [f32; 3] = std::array::from_fn(|axis| {
+ particle.position.start[axis]
+ + (particle.position.end[axis] - particle.position.start[axis])
+ * phase.powf(particle.position.exponent[axis])
+ });
+ let scale: [f32; 3] = std::array::from_fn(|axis| {
+ particle.scale.start[axis]
+ + (particle.scale.end[axis] - particle.scale.start[axis])
+ * phase.powf(particle.scale.exponent[axis])
+ });
+ let expected_center =
+ crate::fx_visual::transform_native_effect_point(&particle.owner_matrix, position)
+ .unwrap();
+ let expected_opacity = 0.25 + (0.75 - 0.25) * phase.powf(2.0);
+ let camera_local =
+ crate::fx_visual::native_effect_local_point(&particle.owner_matrix, [0.0, 0.0, 500.0])
+ .unwrap();
+ let view = std::array::from_fn(|axis| camera_local[axis] - position[axis]);
+ let local_basis = super::native_camera_facing_basis(view, position).unwrap();
+ let expected_edge_length = |basis_column| {
+ let edge: [f32; 3] = std::array::from_fn(|row| {
+ (0..3)
+ .map(|axis| {
+ particle.owner_matrix[row][axis]
+ * scale[axis]
+ * local_basis[axis][basis_column]
+ })
+ .sum::<f32>()
+ });
+ (edge[0] * edge[0] + edge[1] * edge[1] + edge[2] * edge[2]).sqrt()
+ };
+
+ assert!(frame.active);
+ assert_eq!(frame.material.name, "smoke_yg");
+ assert!((frame.normalized_time - phase).abs() < 1e-6);
+ assert!((frame.material_phase - particle.random_phase).abs() < 1e-6);
+ assert!((frame.color[3] - expected_opacity).abs() < 1e-6);
+ for (actual, expected) in centroid(frame).into_iter().zip(expected_center) {
+ assert!(
+ (actual - expected).abs() < 1e-4,
+ "actual={actual}, expected={expected}"
+ );
+ }
+ assert!(
+ (distance(frame.vertices[0], frame.vertices[3]) - expected_edge_length(0)).abs() < 1e-4
+ );
+ assert!(
+ (distance(frame.vertices[0], frame.vertices[1]) - expected_edge_length(1)).abs() < 1e-4
+ );
+ }
+
+ #[test]
+ fn mission02_breath_anl_03_op8_fixture_uses_shared_body_fields() {
+ let mut command = command("smoke_yg", 6, true);
+ let body = Arc::make_mut(&mut command.raw_body);
+ put_f32(body, 0x44, 0.0);
+ put_vec3(body, 0x54, [0.0, 0.0, 1.0]);
+ put_vec3(body, 0x60, [0.0, 0.0, 3.0]);
+ put_vec3(body, 0x6C, [3.0, 2.0, 1.0]);
+ put_vec3(body, 0x78, [1.0; 3]);
+ put_vec3(body, 0x84, [4.0; 3]);
+ put_vec3(body, 0x90, [5.0; 3]);
+ put_vec3(body, 0x9C, [1.0; 3]);
+ put_vec3(body, 0xA8, [1.0; 3]);
+
+ let mut state = SmokeEmitterState::new(17, 29, 43);
+ evaluate(&command, 0.01, 0.0, 1, &mut state);
+ let frames = evaluate(&command, 0.090_001, 0.5, 1, &mut state);
+ let frame = &frames[1];
+ let particle = state.slots[1].as_ref().unwrap();
+ let phase: f32 = (0.090_001 - 0.09) / (0.08 * 6.0);
+ let position = super::sample_track(particle.position, phase).unwrap();
+
+ assert!(frame.active);
+ assert_eq!(frame.material.name, "smoke_yg");
+ assert!((frame.normalized_time - phase).abs() < 1e-6);
+ assert!((frame.material_phase - phase).abs() < 1e-6);
+ assert!((frame.color[3] - (1.0 - phase)).abs() < 1e-6);
+ let expected_center =
+ crate::fx_visual::transform_native_effect_point(&particle.owner_matrix, position)
+ .unwrap();
+ for (actual, expected) in centroid(frame).into_iter().zip(expected_center) {
+ assert!((actual - expected).abs() < 1e-4);
+ }
+ }
+
+ #[test]
+ fn generic_body_uses_dynamic_resource_pool_and_wraps_ring() {
+ let command = command("cloud_B2_ADD", 6, false);
+ let mut left = SmokeEmitterState::new(77, 77, 77);
+ let mut right = SmokeEmitterState::new(77, 77, 77);
+ evaluate(&command, 0.01, 0.0, 0, &mut left);
+ evaluate(&command, 0.01, 0.0, 0, &mut right);
+ let left_frames = evaluate(&command, 0.570_001, 0.0, 0, &mut left);
+ let right_frames = evaluate(&command, 0.570_001, 0.0, 0, &mut right);
+ assert_eq!(left.pool_len(), 6);
+ assert_eq!(left.next_slot(), 1);
+ assert_eq!(left_frames, right_frames);
+ assert_eq!(left_frames[0].material.name, "cloud_B2_ADD");
+ }
+
+ #[test]
+ fn emitter_position_track_moves_birth_anchor_from_start_to_randomized_end() {
+ let mut command = command("smoke_yg", 6, true);
+ let body = Arc::make_mut(&mut command.raw_body);
+ put_vec3(body, 0x24, [1.0, 2.0, 3.0]);
+ put_vec3(body, 0x30, [11.0, 22.0, 33.0]);
+ put_vec3(body, 0x3C, [4.0, 6.0, 8.0]);
+ put_vec3(body, 0x48, [1.0, 2.0, 3.0]);
+
+ let uniform_seed = 0x0001_0000;
+ let signed_seed = 0x0002_0000;
+ let mut state = SmokeEmitterState::new(uniform_seed, signed_seed, 1);
+ evaluate(&command, 0.01, 0.0, 0, &mut state);
+ let track = state.emitter_position.unwrap();
+ let signed = |word: f32, width: f32| (word / 65_536.0 - 0.5) * width;
+ let expected_end = [
+ 11.0 + signed(6.0, 4.0),
+ 22.0 + signed(3.0, 6.0),
+ 33.0 + 1.0 / 65_536.0 * 8.0 - 4.0,
+ ];
+ assert_eq!(track.start, [1.0, 2.0, 3.0]);
+ for (actual, expected) in track.end.into_iter().zip(expected_end) {
+ assert!((actual - expected).abs() < 1e-6);
+ }
+ let mut expected_uniform_rng = super::NativeLfsr16Pair::from_word(uniform_seed);
+ let mut expected_signed_rng = super::NativeLfsr16Pair::from_word(signed_seed);
+ expected_uniform_rng.next_unit();
+ expected_signed_rng.next_signed(6.0);
+ expected_signed_rng.next_signed(4.0);
+ assert_eq!(state.emitter_rng, expected_uniform_rng);
+ assert_eq!(state.signed_rng, expected_signed_rng);
+
+ // The midpoint uses each component's own exponent, and the sampled
+ // offset is added to the owner translation before the slot is born.
+ let midpoint = super::sample_track(track, 0.5).unwrap();
+ let mut state = SmokeEmitterState::new(uniform_seed, signed_seed, 1);
+ evaluate(&command, 0.01, 0.0, 0, &mut state);
+ let mut owner = IDENTITY;
+ owner[0][3] = 5.0;
+ let frames = evaluate_with_owner(&command, 0.090_001, 0.5, 0, &owner, &mut state);
+ let particle = state.slots[1].as_ref().unwrap();
+ let expected_position_z = 50.0 + 4.0 / 65_536.0 * 30.0 - 15.0;
+ assert!((particle.position.end[2] - expected_position_z).abs() < 1e-6);
+ // The spawn is almost at the current update, whose owner anchor is
+ // the trajectory sample at phase 0.5.
+ for axis in 0..3 {
+ assert!(
+ (particle.owner_matrix[axis][3] - (owner[axis][3] + midpoint[axis])).abs() < 1e-3
+ );
+ }
+ let local_position =
+ super::sample_track(particle.position, frames[1].normalized_time).unwrap();
+ let expected_center =
+ crate::fx_visual::transform_native_effect_point(&particle.owner_matrix, local_position)
+ .unwrap();
+ for (actual, expected) in centroid(&frames[1]).into_iter().zip(expected_center) {
+ assert!((actual - expected).abs() < 1e-3);
+ }
+ }
+
+ #[test]
+ fn detail_gate_is_inclusive_and_rejects_the_next_float() {
+ assert!(detail_class_passes_threshold(3.0, 3.0));
+ let next_float = f32::from_bits(3.0_f32.to_bits() + 1);
+ assert!(!detail_class_passes_threshold(next_float, 3.0));
+ assert!(detail_class_passes_threshold(0.0, 0.0));
+ }
+
+ #[test]
+ fn rejects_malformed_or_oversized_op8_bodies_before_state_changes() {
+ let command = FxCommand {
+ word: 8,
+ opcode: FxOpcode::Op8,
+ native_flag: false,
+ raw_body: Arc::from(vec![0_u8; 32]),
+ resource_refs: Vec::new(),
+ };
+ let mut state = SmokeEmitterState::new(13, 13, 13);
+ assert!(evaluate_op8_emitter(
+ &command,
+ 0,
+ &IDENTITY,
+ [0.0, 0.0, 500.0],
+ 1.0,
+ 0.0,
+ 0,
+ Op8DetailThresholdRange::new(0.0, 3.0).unwrap(),
+ &mut state,
+ )
+ .is_none());
+ assert_eq!(state.pool_len(), 0);
+ assert_eq!(state.next_slot(), 0);
+ }
+}
diff --git a/docs/reference/environment-effects.md b/docs/reference/environment-effects.md
index 078a222..0435a6a 100644
--- a/docs/reference/environment-effects.md
+++ b/docs/reference/environment-effects.md
@@ -104,6 +104,10 @@ N = round_even(
координаты, и к каждой оси применяется floor-based wrap;
5. после wrap точка снова переводится в мир, а её хвост сбрасывается на голову.
+Повторное обновление на том же времени имеет нулевой `dt`: позиция капли и
+сохранённое начало хвоста остаются прежними. Хвост продвигается только при
+положительном шаге; wrap по-прежнему сбрасывает его на голову.
+
Скорости не принадлежат системе координат камеры:
```text
@@ -143,6 +147,24 @@ slot `+6c`): ширина `RECT` делится на горизонтальныÐ
исходную таблицу знаков и минимальный depth fade `0.1`. Renderer сам решает,
как загрузить этот квадрат и как смешать его материал.
+## Точечный свет из opcode 1
+
+Preview вычисляет generic Op1 отдельно от визуальных команд. Переданный
+владельцем `command_time` сравнивается с началом и концом интервала из body
+opcode 1; обе границы включены, а коэффициент выборки равен
+`(command_time - start) / (end - start)`. Заголовочная длительность FXID не
+заменяет этот интервал. Для нулевой ширины адаптер не создаёт кадр света,
+поскольку у команды нет конечного коэффициента интерполяции.
+
+Позиция, направление, цвет и range интерполируются по концам команды.
+Позиция и направление переводятся через основу родителя; направление после
+этого нормализуется, а нулевой вектор остаётся нулевым. Range сохраняет
+авторское значение без ограничения снизу. Типы Op1 `1`, `2`, `5`, `6` и `7`
+выбирают point-light selector `1`; их native flags сохраняются. Типы `3` и
+`4` выбирают другие классы записей LightManager и не попадают в generic список
+точечного света. У `LightningEffect` выборка тех же полей использует отдельные
+ограничение по длительности FXID и правила безопасной обработки.
+
## Молния и звуковые события
`decode_env_lightning_fxid` извлекает из FXID длительность, ссылки opcode 3 и
@@ -285,6 +307,34 @@ Evaluator типа 2 принимает индекс context явно и учиÑ
семена отдельных экземпляров лишь детерминированно заменяют process-global
состояние и не воспроизводят общий порядок вызовов.
+## Частицы Op8
+
+`fparkan-fx::smoke` разбирает body Op8, хранит кольцевой пул команды и выдаёт
+quad-ы с ориентацией к камере для активных слотов. Нативная нормализованная
+фаза окна ограничивается диапазоном 0–1 и задаёт интервал проверки следующего события;
+сравнение строгое (`schedule_base + interval < update_clock`). Все события
+catch-up в одном обновлении проверяются по тому же нормализованному интервалу;
+после события нативный код продвигает базу расписания интервалом, вычисленным
+по сырой фазе эффекта, а возраст слота считает по нормализованному интервалу.
+Когда фаза равна единственной границе окна, деление `0/0` даёт NaN, который
+нативный unordered-путь сравнения ограничивает нулём.
+
+Нулевая база расписания служит признаком начального состояния: обновление
+сохраняет текущую опору эмиттера и базу времени, не создавая частицу. Если
+первое время равно нулю, следующее обновление повторно захватывает опору. При
+событии скорость опоры считается от сохранённой позиции до текущей за время
+от базы расписания; слот смещает сохранённую опору на `скорость * due_interval`.
+Обновление без событий не переносит сохранённую опору к текущему владельцу.
+Откат часов корректирует только базу расписания до текущего времени: слоты,
+опора и три потока случайных
+чисел не сбрасываются.
+
+Evaluator принимает отдельные начальные состояния для эмиттера, знаковых
+отклонений и материала. Переход каждого потока совпадает с общим native LFSR,
+но эти локальные seed не воспроизводят порядок process-global вызовов игры.
+Предел 100000 событий catch-up — защитная граница evaluator-а; соответствующего
+лимита в исследованном native цикле не обнаружено.
+
## Что остаётся за владельцем
`EnvironmentSystem` не извлекает камеру из renderer, не разрешает материалы
diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md
index 15fb01a..c8bdf4e 100644
--- a/docs/tomes/05-render.md
+++ b/docs/tomes/05-render.md
@@ -625,15 +625,20 @@ Lightmap не является обычной diffuse texture. WEAR содерж
отдельным slot или texture stage. Замена lightmap предварительным умножением в
diffuse texture ломает LOD, atlas coordinates и динамическую модуляцию.
-Vulkan preview строит projected shadows для выбранных зданий и роботов,
-отбрасывает spheres вне шести clip-плоскостей с радиусом `sphere + 100` и
+Vulkan preview строит проецируемые тени для объектов, отбрасывающих тени,
+отбрасывает сферы вне шести clip-плоскостей с радиусом `sphere + 100` и
сохраняет порядок видимых кандидатов; frame builder обрабатывает не более 20
-casters. Текущая реализация выделяет страницы в atlas `256 x 256` с 28 page
-slots и использует одну smoothing pass. Вызов preview передаёт `fade = 1.0` и
-LOD defaults: minimum LOD `1` и detail multiplier `1.0` отдельно для зданий и
-роботов. Rust-путь не загружает `shade.cfg`: эти значения задаются кодом, а
-параметры файла пока не влияют на preview. Проекция учитывает вклад
-directional и point lights.
+объектов. Текущая реализация выделяет страницы в atlas `256 x 256` с 28 page
+slots и использует одно сглаживание. Preview применяет native defaults
+`RobShadowsOn = true` и `BldShadowsOn = false`: объект kind `4` проверяет
+первый флаг, остальные kinds — второй. Поэтому роботы BTLU остаются объектами,
+отбрасывающими тени, а объекты FORT и STAT их не создают. Preview пока не
+загружает `shade.cfg`, поэтому значения из пользовательского файла не меняют
+список объектов, отбрасывающих тени. Проверка действует только на проецируемые
+тени: отдельный список объектов для затенения солнца по-прежнему включает
+FORT, BTLU и STAT. Вызов preview передаёт `fade = 1.0` и LOD defaults:
+minimum LOD `1` и detail multiplier `1.0` отдельно для зданий и роботов.
+Проекция учитывает вклад directional и point lights.
Receiver projection обрезает projected quad по рёбрам треугольника. Native ray
query descriptor в `+0x10` требует наличия всех переданных bits, а descriptor