aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-fx
diff options
context:
space:
mode:
Diffstat (limited to 'crates/fparkan-fx')
-rw-r--r--crates/fparkan-fx/src/ambient_op3.rs984
-rw-r--r--crates/fparkan-fx/src/atmosphere.rs38
-rw-r--r--crates/fparkan-fx/src/environment.rs375
-rw-r--r--crates/fparkan-fx/src/fx_visual.rs1428
-rw-r--r--crates/fparkan-fx/src/lib.rs17
-rw-r--r--crates/fparkan-fx/src/op7.rs1443
-rw-r--r--crates/fparkan-fx/src/smoke.rs1360
7 files changed, 5611 insertions, 34 deletions
diff --git a/crates/fparkan-fx/src/ambient_op3.rs b/crates/fparkan-fx/src/ambient_op3.rs
new file mode 100644
index 0000000..5bd2881
--- /dev/null
+++ b/crates/fparkan-fx/src/ambient_op3.rs
@@ -0,0 +1,984 @@
+//! Native-shaped CPU evaluation for Types 0, 1, and 2 carried by opcode 3.
+//!
+//! The FX body and the command opcode are separate concepts: this evaluator
+//! accepts opcode 3 only when its body discriminator matches the selected
+//! evaluator. Types 0/1/2 share the position, scale, material-phase, and
+//! opacity tracks; Types 1/2 additionally use an animated axis. Their render
+//! basis is selected by the native body discriminator.
+
+use crate::environment::EffectResource;
+use crate::fx_visual::{
+ compose_native_effect_matrix, native_camera_facing_basis, native_d110_basis,
+ native_effect_local_point, transform_native_effect_quad, EffectQuadFrame, EFFECT_QUAD_UV,
+};
+use crate::{FxCommand, FxOpcode};
+
+const TYPE2_BODY_DISCRIMINATOR: u32 = 2;
+const TYPE2_ACTIVE_START_OFFSET: usize = 0x1c;
+const TYPE2_ACTIVE_END_OFFSET: usize = 0x20;
+const TYPE2_PHASE_START_OFFSET: usize = 0x04;
+const TYPE2_PHASE_END_OFFSET: usize = 0x08;
+const TYPE2_PHASE_EXPONENT_OFFSET: usize = 0x0c;
+const TYPE2_OPACITY_START_OFFSET: usize = 0x10;
+const TYPE2_OPACITY_END_OFFSET: usize = 0x14;
+const TYPE2_OPACITY_EXPONENT_OFFSET: usize = 0x18;
+const TYPE2_POSITION_START_OFFSET: usize = 0x24;
+const TYPE2_POSITION_END_OFFSET: usize = 0x30;
+#[cfg(test)]
+const TYPE2_POSITION_EXPONENT_OFFSET: usize = 0x3c;
+const TYPE2_AXIS_START_OFFSET: usize = 0x48;
+const TYPE2_AXIS_END_OFFSET: usize = 0x54;
+const TYPE2_SCALE_START_OFFSET: usize = 0x60;
+const TYPE2_SCALE_END_OFFSET: usize = 0x6c;
+const TYPE2_RESOURCE_OFFSET: usize = 0x84;
+const TYPE2_RESOURCE_BYTES: usize = 64;
+const TYPE0_BODY_DISCRIMINATOR: u32 = 0;
+const TYPE1_BODY_DISCRIMINATOR: u32 = 1;
+
+/// Per-frame values sampled from the verified Op3 Type0 body tracks.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct Op3Type0Sample {
+ /// Animated local translation sampled from body+0x24/+0x30 with exponents
+ /// at body+0x3c/+0x40/+0x44.
+ position: [f32; 3],
+ /// Animated scale sampled from body+0x60/+0x6c with reverse-order
+ /// exponents at body+0x78/+0x7c/+0x80.
+ scale: [f32; 3],
+ /// Native command opacity after its authored curve.
+ opacity: f32,
+ /// Wrapped texture phase computed by the native Op3 time track.
+ material_phase: f32,
+}
+
+/// Per-frame values sampled from the verified native Op3 Type2 body tracks.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct Op3Type2Sample {
+ /// Animated local translation used by native helper 0xC5D0.
+ position: [f32; 3],
+ /// Animated local axis used by native helper 0xD110.
+ axis: [f32; 3],
+ /// Animated scale applied to the parent matrix basis.
+ scale: [f32; 3],
+ /// Opacity multiplier sent through native helper 0x9930 and clamped to
+ /// `[0, 1]` for the material descriptor.
+ opacity: f32,
+ /// Wrapped texture phase computed by the native Op3 time track.
+ material_phase: f32,
+}
+
+/// Per-frame values sampled from the same track layout in a Type 1 body.
+type Op3Type1Sample = Op3Type2Sample;
+
+/// Evaluates one FX opcode 3 / Type 0 fixed camera-facing command.
+///
+/// Type 0 uses the shared animated position and scale tracks but forms its
+/// local basis from the camera vector. The current caller supplies the
+/// already-composed Control owner transform and header scale.
+/// The supported caller path assumes native render-context index 0, which
+/// satisfies Effect.dll's shared `context + 0x60 < 8` gate.
+#[must_use]
+pub fn evaluate_op3_type0_command(
+ command: &FxCommand,
+ command_index: usize,
+ world_from_effect: &[[f32; 4]; 4],
+ elapsed_seconds: f32,
+ effect_phase: f32,
+ camera_world: [f32; 3],
+) -> Option<EffectQuadFrame> {
+ let body = &command.raw_body;
+ if command.opcode != FxOpcode::Op3
+ || read_u32(body, 0)? != TYPE0_BODY_DISCRIMINATOR
+ || body.len() < TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES
+ || !world_from_effect
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ || !elapsed_seconds.is_finite()
+ || !effect_phase.is_finite()
+ || !camera_world.iter().all(|value| value.is_finite())
+ {
+ return None;
+ }
+
+ let start = read_f32(body, TYPE2_ACTIVE_START_OFFSET)?;
+ let end = read_f32(body, TYPE2_ACTIVE_END_OFFSET)?;
+ if !start.is_finite() || !end.is_finite() || start > end {
+ return None;
+ }
+
+ let resource = read_resource(command)?;
+ let normalized_time = interval_phase(effect_phase, start, end);
+ let sample = sample_op3_type0_body(body, normalized_time, elapsed_seconds)?;
+ let camera_local = native_effect_local_point(world_from_effect, camera_world)?;
+ let view = sub3(camera_local, sample.position);
+ let local_basis = native_camera_facing_basis(view, sample.position)?;
+ let matrix = compose_native_effect_matrix(
+ world_from_effect,
+ &local_basis,
+ sample.position,
+ sample.scale,
+ )?;
+ let vertices = transform_native_effect_quad(&matrix)?;
+
+ Some(EffectQuadFrame {
+ command_index,
+ opcode: FxOpcode::Op3,
+ material: resource,
+ vertices,
+ uv: EFFECT_QUAD_UV,
+ color: [1.0, 1.0, 1.0, sample.opacity],
+ elapsed_seconds,
+ normalized_time,
+ material_phase: sample.material_phase,
+ active: effect_phase >= start && effect_phase <= end,
+ native_flag_base: None,
+ })
+}
+
+/// Evaluates one FX opcode 3 / Type 2 command using native helper matrix rules.
+///
+/// `world_from_effect` is the row-major parent descriptor already composed by
+/// the caller through the selected CPT row, mission-root transform, resolved
+/// owner pose (including a mounted component pose), and FX header scale. The
+/// evaluator does not resolve those inputs itself. The matrix is the parent
+/// supplied to native helper 0x93D0, not the final quad matrix.
+///
+/// `effect_phase` is the header-transformed, command-clamped overall effect
+/// phase in `[0, 1]`. The command's inclusive phase window is stored at
+/// raw-body offsets `0x1c` and `0x20`.
+/// `render_context` mirrors the native 0x106C0 gate (`context + 0x60 < 8`).
+/// The function returns `None` for another command/body type, malformed
+/// resources, non-finite inputs, or a degenerate non-finite transform.
+#[must_use]
+pub fn evaluate_op3_type2_command(
+ command: &FxCommand,
+ command_index: usize,
+ world_from_effect: &[[f32; 4]; 4],
+ elapsed_seconds: f32,
+ effect_phase: f32,
+ render_context: u32,
+) -> Option<EffectQuadFrame> {
+ if command.opcode != FxOpcode::Op3
+ || read_u32(&command.raw_body, 0)? != TYPE2_BODY_DISCRIMINATOR
+ || command.raw_body.len() < TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES
+ || !elapsed_seconds.is_finite()
+ || !effect_phase.is_finite()
+ {
+ return None;
+ }
+
+ let start = read_f32(&command.raw_body, TYPE2_ACTIVE_START_OFFSET)?;
+ let end = read_f32(&command.raw_body, TYPE2_ACTIVE_END_OFFSET)?;
+ if !start.is_finite() || !end.is_finite() || start > end {
+ return None;
+ }
+
+ let resource = read_resource(command)?;
+ let normalized_time = interval_phase(effect_phase, start, end);
+ let sample = sample_op3_type2_body(&command.raw_body, normalized_time, elapsed_seconds)?;
+ let matrix = native_op3_type2_matrix(world_from_effect, sample)?;
+ let vertices = transform_native_effect_quad(&matrix)?;
+
+ Some(EffectQuadFrame {
+ command_index,
+ opcode: FxOpcode::Op3,
+ material: resource,
+ vertices,
+ uv: EFFECT_QUAD_UV,
+ color: [1.0, 1.0, 1.0, sample.opacity],
+ elapsed_seconds,
+ normalized_time,
+ material_phase: sample.material_phase,
+ active: render_context < 8 && effect_phase >= start && effect_phase <= end,
+ native_flag_base: None,
+ })
+}
+
+/// Evaluates one FX opcode 3 / Type 1 camera-facing command.
+///
+/// Type 1 uses the native camera-to-position vector and sampled axis to form
+/// its quad basis. The command's body tracks use the same offsets as Type 2.
+/// The supported caller path assumes native render-context index 0, which
+/// satisfies the Effect.dll `context + 0x60 < 8` gate.
+#[must_use]
+pub fn evaluate_op3_type1_command(
+ command: &FxCommand,
+ command_index: usize,
+ world_from_effect: &[[f32; 4]; 4],
+ elapsed_seconds: f32,
+ effect_phase: f32,
+ camera_world: [f32; 3],
+) -> Option<EffectQuadFrame> {
+ let body = &command.raw_body;
+ if command.opcode != FxOpcode::Op3
+ || read_u32(body, 0)? != TYPE1_BODY_DISCRIMINATOR
+ || body.len() < TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES
+ || !elapsed_seconds.is_finite()
+ || !effect_phase.is_finite()
+ || !camera_world.iter().all(|value| value.is_finite())
+ {
+ return None;
+ }
+
+ let start = read_f32(body, TYPE2_ACTIVE_START_OFFSET)?;
+ let end = read_f32(body, TYPE2_ACTIVE_END_OFFSET)?;
+ if !start.is_finite() || !end.is_finite() || start > end {
+ return None;
+ }
+
+ let resource = read_resource(command)?;
+ let normalized_time = interval_phase(effect_phase, start, end);
+ let sample = sample_op3_type1_body(body, normalized_time, elapsed_seconds)?;
+ let matrix = native_op3_type1_matrix(world_from_effect, sample, camera_world)?;
+ let vertices = transform_native_effect_quad(&matrix)?;
+
+ Some(EffectQuadFrame {
+ command_index,
+ opcode: FxOpcode::Op3,
+ material: resource,
+ vertices,
+ uv: EFFECT_QUAD_UV,
+ color: [1.0, 1.0, 1.0, sample.opacity],
+ elapsed_seconds,
+ normalized_time,
+ material_phase: sample.material_phase,
+ active: effect_phase >= start && effect_phase <= end,
+ native_flag_base: None,
+ })
+}
+
+/// Samples the verified Type2 body tracks used by native Op3 update/render.
+///
+/// `normalized_active_time` is the value produced by the inclusive command
+/// interval gate. Position and scale use component-wise powers; axis is a
+/// linear start/end track. The separate phase track uses elapsed seconds when
+/// its raw start is negative, matching native 0x105F0.
+#[must_use]
+fn sample_op3_type2_body(
+ body: &[u8],
+ normalized_active_time: f32,
+ elapsed_seconds: f32,
+) -> Option<Op3Type2Sample> {
+ if read_u32(body, 0)? != TYPE2_BODY_DISCRIMINATOR
+ || body.len() < TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES
+ {
+ return None;
+ }
+
+ sample_op3_body_tracks(body, normalized_active_time, elapsed_seconds)
+}
+
+/// Samples the verified Op3 Type0 position/scale and common command tracks.
+#[must_use]
+fn sample_op3_type0_body(
+ body: &[u8],
+ normalized_active_time: f32,
+ elapsed_seconds: f32,
+) -> Option<Op3Type0Sample> {
+ if read_u32(body, 0)? != TYPE0_BODY_DISCRIMINATOR
+ || body.len() < TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES
+ || !normalized_active_time.is_finite()
+ || !elapsed_seconds.is_finite()
+ {
+ return None;
+ }
+
+ // Native 0x106C0 samples the record's position into helper+0x78 from the
+ // body position endpoints and exponents. The body tail at +0x78 is the
+ // reverse-ordered scale exponent vector, not a fixed position.
+ let position = sample_power_vector(
+ body,
+ TYPE2_POSITION_START_OFFSET,
+ TYPE2_POSITION_END_OFFSET,
+ [0x3c, 0x40, 0x44],
+ normalized_active_time,
+ )?;
+ let scale = sample_power_vector(
+ body,
+ TYPE2_SCALE_START_OFFSET,
+ TYPE2_SCALE_END_OFFSET,
+ [0x80, 0x7c, 0x78],
+ normalized_active_time,
+ )?;
+ let (material_phase, opacity) =
+ sample_op3_material_phase_and_opacity(body, normalized_active_time, elapsed_seconds)?;
+ Some(Op3Type0Sample {
+ position,
+ scale,
+ opacity,
+ material_phase,
+ })
+}
+
+/// Samples the verified Op3 Type 1 body tracks.
+#[must_use]
+fn sample_op3_type1_body(
+ body: &[u8],
+ normalized_active_time: f32,
+ elapsed_seconds: f32,
+) -> Option<Op3Type1Sample> {
+ if read_u32(body, 0)? != TYPE1_BODY_DISCRIMINATOR
+ || body.len() < TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES
+ {
+ return None;
+ }
+
+ sample_op3_body_tracks(body, normalized_active_time, elapsed_seconds)
+}
+
+fn sample_op3_body_tracks(
+ body: &[u8],
+ normalized_active_time: f32,
+ elapsed_seconds: f32,
+) -> Option<Op3Type2Sample> {
+ if !normalized_active_time.is_finite() || !elapsed_seconds.is_finite() {
+ return None;
+ }
+
+ let position = sample_power_vector(
+ body,
+ TYPE2_POSITION_START_OFFSET,
+ TYPE2_POSITION_END_OFFSET,
+ [0x3c, 0x40, 0x44],
+ normalized_active_time,
+ )?;
+ let axis = sample_linear_vector(
+ body,
+ TYPE2_AXIS_START_OFFSET,
+ TYPE2_AXIS_END_OFFSET,
+ normalized_active_time,
+ )?;
+ let scale = sample_power_vector(
+ body,
+ TYPE2_SCALE_START_OFFSET,
+ TYPE2_SCALE_END_OFFSET,
+ // 0x106C0 loads Z then Y then X from the packed reverse-order
+ // exponent vector at body+0x78.
+ [0x80, 0x7c, 0x78],
+ normalized_active_time,
+ )?;
+
+ let (material_phase, opacity) =
+ sample_op3_material_phase_and_opacity(body, normalized_active_time, elapsed_seconds)?;
+ if !material_phase.is_finite() || !opacity.is_finite() {
+ return None;
+ }
+
+ Some(Op3Type2Sample {
+ position,
+ axis,
+ scale,
+ opacity,
+ material_phase,
+ })
+}
+
+fn sample_op3_material_phase_and_opacity(
+ body: &[u8],
+ normalized_active_time: f32,
+ elapsed_seconds: f32,
+) -> Option<(f32, f32)> {
+ let raw_phase_start = read_f32(body, TYPE2_PHASE_START_OFFSET)?;
+ let phase_end = read_f32(body, TYPE2_PHASE_END_OFFSET)?;
+ let phase_exponent = read_f32(body, TYPE2_PHASE_EXPONENT_OFFSET)?;
+ let opacity_start = read_f32(body, TYPE2_OPACITY_START_OFFSET)?;
+ let opacity_end = read_f32(body, TYPE2_OPACITY_END_OFFSET)?;
+ let opacity_exponent = read_f32(body, TYPE2_OPACITY_EXPONENT_OFFSET)?;
+ if ![
+ raw_phase_start,
+ phase_end,
+ phase_exponent,
+ opacity_start,
+ opacity_end,
+ opacity_exponent,
+ normalized_active_time,
+ elapsed_seconds,
+ ]
+ .iter()
+ .all(|value| value.is_finite())
+ {
+ return None;
+ }
+
+ let phase_start = raw_phase_start.max(0.0);
+ let phase_delta = phase_end - phase_start;
+ let phase_timebase = if raw_phase_start < 0.0 {
+ elapsed_seconds
+ } else {
+ normalized_active_time
+ };
+ let material_phase = fract(phase_start + phase_delta * phase_timebase.powf(phase_exponent));
+ let opacity = (opacity_start
+ + (opacity_end - opacity_start) * normalized_active_time.powf(opacity_exponent))
+ .clamp(0.0, 1.0);
+ (material_phase.is_finite() && opacity.is_finite()).then_some((material_phase, opacity))
+}
+
+/// Builds the native Type2 Op3 quad matrix from its parent descriptor and
+/// already-sampled animated vectors.
+///
+/// This matches 0x93D0's order: scale the parent basis by animated scale,
+/// multiply by D110's local basis, and then replace the translation with the
+/// unscaled parent transform of the animated position.
+#[must_use]
+fn native_op3_type2_matrix(
+ world_from_effect: &[[f32; 4]; 4],
+ sample: Op3Type2Sample,
+) -> Option<[[f32; 4]; 4]> {
+ if !world_from_effect
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ || !sample_is_finite(sample)
+ {
+ return None;
+ }
+
+ let local = native_d110_basis(sample.axis, sample.position)?;
+ compose_native_effect_matrix(world_from_effect, &local, sample.position, sample.scale)
+}
+
+/// Builds the native Type 1 Op3 quad matrix from the parent transform, sampled
+/// tracks, and camera query point.
+#[must_use]
+fn native_op3_type1_matrix(
+ world_from_effect: &[[f32; 4]; 4],
+ sample: Op3Type1Sample,
+ camera_world: [f32; 3],
+) -> Option<[[f32; 4]; 4]> {
+ if !world_from_effect
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ || !camera_world.iter().all(|value| value.is_finite())
+ || !sample_is_finite(sample)
+ {
+ return None;
+ }
+
+ let camera_local = native_effect_local_point(world_from_effect, camera_world)?;
+ let view = sub3(camera_local, sample.position);
+ let local_basis = native_op3_type1_basis(view, sample.axis, sample.position)?;
+ compose_native_effect_matrix(
+ world_from_effect,
+ &local_basis,
+ sample.position,
+ sample.scale,
+ )
+}
+
+/// Native Effect.dll Type 1 basis. The parallel gate compares the raw
+/// `view · axis` value to exactly ±1 before building the cross-product frame;
+/// neither input vector is normalized. The native path normalizes only
+/// `view × axis`, then computes `axis × U` without normalizing the result.
+#[must_use]
+fn native_op3_type1_basis(
+ view: [f32; 3],
+ axis: [f32; 3],
+ position: [f32; 3],
+) -> Option<[[f32; 4]; 4]> {
+ if !view
+ .iter()
+ .chain(axis.iter())
+ .chain(position.iter())
+ .all(|value| value.is_finite())
+ {
+ return None;
+ }
+
+ let dot = view[0] * axis[0] + view[1] * axis[1] + view[2] * axis[2];
+ if !dot.is_finite() || dot.abs() == 1.0 {
+ return None;
+ }
+
+ let mut u = cross3(view, axis);
+ let length = length3(u)?;
+ if length == 0.0 {
+ return None;
+ }
+ for value in &mut u {
+ *value /= length;
+ }
+ let w = cross3(axis, u);
+ let result = [
+ [u[0], w[0], axis[0], position[0]],
+ [u[1], w[1], axis[1], position[1]],
+ [u[2], w[2], axis[2], position[2]],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+ result
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ .then_some(result)
+}
+
+fn interval_phase(phase: f32, start: f32, end: f32) -> f32 {
+ if phase < start {
+ return 0.0;
+ }
+ if phase > end {
+ return 1.0;
+ }
+ if end == start {
+ 1.0
+ } else if phase == start {
+ 0.0
+ } else if phase == end {
+ 1.0
+ } else {
+ ((phase - start) / (end - start)).clamp(0.0, 1.0)
+ }
+}
+
+fn sample_is_finite(sample: Op3Type2Sample) -> bool {
+ sample
+ .position
+ .iter()
+ .chain(sample.axis.iter())
+ .chain(sample.scale.iter())
+ .chain([&sample.opacity, &sample.material_phase])
+ .all(|value| value.is_finite())
+}
+
+fn sample_linear_vector(body: &[u8], start: usize, end: usize, time: f32) -> Option<[f32; 3]> {
+ let start = read_vec3(body, start)?;
+ let end = read_vec3(body, end)?;
+ if !start
+ .iter()
+ .chain(end.iter())
+ .all(|value| value.is_finite())
+ || !time.is_finite()
+ {
+ return None;
+ }
+ let values = std::array::from_fn(|axis| start[axis] + (end[axis] - start[axis]) * time);
+ values
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some(values)
+}
+
+fn sample_power_vector(
+ body: &[u8],
+ start: usize,
+ end: usize,
+ exponent_offsets: [usize; 3],
+ time: f32,
+) -> Option<[f32; 3]> {
+ let start = read_vec3(body, start)?;
+ let end = read_vec3(body, end)?;
+ let exponents = [
+ read_f32(body, exponent_offsets[0])?,
+ read_f32(body, exponent_offsets[1])?,
+ read_f32(body, exponent_offsets[2])?,
+ ];
+ if !start
+ .iter()
+ .chain(end.iter())
+ .chain(exponents.iter())
+ .all(|value| value.is_finite())
+ || !time.is_finite()
+ {
+ return None;
+ }
+ let values = std::array::from_fn(|axis| {
+ let amount = time.powf(exponents[axis]);
+ start[axis] + (end[axis] - start[axis]) * amount
+ });
+ values
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some(values)
+}
+
+fn read_vec3(body: &[u8], offset: usize) -> Option<[f32; 3]> {
+ Some([
+ read_f32(body, offset)?,
+ read_f32(body, offset + 4)?,
+ read_f32(body, offset + 8)?,
+ ])
+}
+
+fn cross3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [
+ left[1] * right[2] - left[2] * right[1],
+ left[2] * right[0] - left[0] * right[2],
+ left[0] * right[1] - left[1] * right[0],
+ ]
+}
+
+fn sub3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [left[0] - right[0], left[1] - right[1], left[2] - right[2]]
+}
+
+fn length3(vector: [f32; 3]) -> Option<f32> {
+ let length = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt();
+ length.is_finite().then_some(length)
+}
+
+fn fract(value: f32) -> f32 {
+ value - value.floor()
+}
+
+fn read_resource(command: &FxCommand) -> Option<EffectResource> {
+ let bytes = command
+ .raw_body
+ .get(TYPE2_RESOURCE_OFFSET..TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES)?;
+ let archive = bounded_name(&bytes[..32])?;
+ let name = bounded_name(&bytes[32..])?;
+ (!archive.is_empty() && !name.is_empty()).then_some(EffectResource { archive, name })
+}
+
+fn bounded_name(bytes: &[u8]) -> Option<String> {
+ let end = bytes
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(bytes.len());
+ let value = std::str::from_utf8(&bytes[..end]).ok()?.trim();
+ (!value.is_empty()).then(|| value.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()?,
+ ))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::Arc;
+
+ #[test]
+ fn type0_command_samples_position_and_scale_tracks_for_camera_facing_quad() {
+ let mut body = vec![0_u8; 196];
+ write_u32(&mut body, 0, TYPE0_BODY_DISCRIMINATOR);
+ write_f32(&mut body, TYPE2_PHASE_START_OFFSET, -1.0);
+ write_f32(&mut body, TYPE2_PHASE_END_OFFSET, 1.0);
+ write_f32(&mut body, TYPE2_PHASE_EXPONENT_OFFSET, 1.0);
+ write_f32(&mut body, TYPE2_OPACITY_START_OFFSET, 1.0);
+ write_f32(&mut body, TYPE2_OPACITY_END_OFFSET, 0.0);
+ write_f32(&mut body, TYPE2_OPACITY_EXPONENT_OFFSET, 1.0);
+ write_f32(&mut body, TYPE2_ACTIVE_START_OFFSET, 0.0);
+ write_f32(&mut body, TYPE2_ACTIVE_END_OFFSET, 1.0);
+ write_vec3(&mut body, TYPE2_POSITION_START_OFFSET, [0.0, 0.0, 0.0]);
+ write_vec3(&mut body, TYPE2_POSITION_END_OFFSET, [2.0, 4.0, 6.0]);
+ write_vec3(&mut body, TYPE2_POSITION_EXPONENT_OFFSET, [1.0; 3]);
+ write_vec3(&mut body, TYPE2_SCALE_START_OFFSET, [1.0; 3]);
+ write_vec3(&mut body, TYPE2_SCALE_END_OFFSET, [3.0, 4.0, 5.0]);
+ write_vec3(&mut body, 0x78, [3.0, 2.0, 1.0]);
+ body[TYPE2_RESOURCE_OFFSET..TYPE2_RESOURCE_OFFSET + 11].copy_from_slice(b"effects.rlb");
+ body[TYPE2_RESOURCE_OFFSET + 32..TYPE2_RESOURCE_OFFSET + 32 + 3].copy_from_slice(b"sun");
+
+ let command = FxCommand {
+ word: 3,
+ opcode: FxOpcode::Op3,
+ native_flag: false,
+ raw_body: Arc::from(body.into_boxed_slice()),
+ resource_refs: Vec::new(),
+ };
+ let identity = [
+ [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],
+ ];
+ let sample = sample_op3_type0_body(&command.raw_body, 0.5, 0.25).unwrap();
+ assert_eq!(sample.position, [1.0, 2.0, 3.0]);
+ assert_eq!(sample.scale, [2.0, 1.75, 1.5]);
+ assert_eq!(sample.opacity, 0.5);
+ assert_eq!(sample.material_phase, 0.25);
+
+ let frame = evaluate_op3_type0_command(&command, 7, &identity, 0.25, 0.5, [1.0, 2.0, 13.0])
+ .unwrap();
+ assert_eq!(frame.command_index, 7);
+ assert_eq!(frame.material.name, "sun");
+ assert_eq!(frame.normalized_time, 0.5);
+ assert_eq!(frame.material_phase, 0.25);
+ assert_eq!(frame.color[3], 0.5);
+ assert!(frame.active);
+ let center = std::array::from_fn::<_, 3, _>(|axis| {
+ frame
+ .vertices
+ .iter()
+ .map(|vertex| vertex[axis])
+ .sum::<f32>()
+ / 4.0
+ });
+ assert_eq!(center, [1.0, 2.0, 3.0]);
+ assert_ne!(frame.vertices[0], frame.vertices[1]);
+
+ assert!(
+ evaluate_op3_type0_command(&command, 7, &identity, 0.25, 1.1, [1.0, 2.0, 13.0],)
+ .is_some_and(|frame| !frame.active)
+ );
+ assert!(
+ evaluate_op3_type0_command(&command, 7, &identity, 0.25, 0.5, [1.0, 2.0, 3.0],)
+ .is_none()
+ );
+ }
+
+ #[test]
+ fn type2_matrix_scales_parent_basis_then_overrides_translation() {
+ let parent = [
+ [2.0, 0.0, 0.0, 10.0],
+ [0.0, 3.0, 0.0, 20.0],
+ [0.0, 0.0, 4.0, 30.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+ let sample = Op3Type2Sample {
+ position: [1.0, 2.0, 3.0],
+ axis: [1.0, 2.0, 2.0],
+ scale: [2.0, 3.0, 4.0],
+ opacity: 1.0,
+ material_phase: 0.0,
+ };
+ let result = native_op3_type2_matrix(&parent, sample).unwrap();
+ assert_eq!(result[0][3], 12.0);
+ assert_eq!(result[1][3], 26.0);
+ assert_eq!(result[2][3], 42.0);
+ assert_eq!(result[0][0], -8.0 / 5.0_f32.sqrt());
+ assert_eq!(result[1][0], 9.0 / 5.0_f32.sqrt());
+ assert_eq!(result[3], [0.0, 0.0, 0.0, 1.0]);
+ }
+
+ #[test]
+ fn type2_command_keeps_inclusive_single_phase_endpoint() {
+ let mut body = vec![0_u8; 196];
+ body[0..4].copy_from_slice(&TYPE2_BODY_DISCRIMINATOR.to_le_bytes());
+ body[TYPE2_ACTIVE_START_OFFSET..TYPE2_ACTIVE_START_OFFSET + 4]
+ .copy_from_slice(&1.0_f32.to_le_bytes());
+ body[TYPE2_ACTIVE_END_OFFSET..TYPE2_ACTIVE_END_OFFSET + 4]
+ .copy_from_slice(&1.0_f32.to_le_bytes());
+ body[TYPE2_RESOURCE_OFFSET..TYPE2_RESOURCE_OFFSET + 11].copy_from_slice(b"effects.rlb");
+ body[TYPE2_RESOURCE_OFFSET + 32..TYPE2_RESOURCE_OFFSET + 36].copy_from_slice(b"lamp");
+ let command = FxCommand {
+ word: 3,
+ opcode: FxOpcode::Op3,
+ native_flag: false,
+ raw_body: Arc::from(body.into_boxed_slice()),
+ resource_refs: Vec::new(),
+ };
+ let identity = [
+ [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],
+ ];
+ let frame = evaluate_op3_type2_command(&command, 0, &identity, 1.0, 1.0, 0).unwrap();
+ assert_eq!(
+ frame.material,
+ EffectResource {
+ archive: "effects.rlb".to_owned(),
+ name: "lamp".to_owned(),
+ }
+ );
+ assert!(frame.active);
+ assert_eq!(frame.normalized_time, 1.0);
+
+ let before = evaluate_op3_type2_command(&command, 0, &identity, 0.99, 0.99, 0).unwrap();
+ assert!(!before.active);
+ }
+
+ #[test]
+ fn samples_verified_type2_position_axis_scale_phase_and_opacity_tracks() {
+ let mut body = vec![0_u8; 196];
+ write_u32(&mut body, 0, TYPE2_BODY_DISCRIMINATOR);
+ write_f32(&mut body, TYPE2_PHASE_START_OFFSET, -1.0);
+ write_f32(&mut body, TYPE2_PHASE_END_OFFSET, 9.0);
+ write_f32(&mut body, TYPE2_PHASE_EXPONENT_OFFSET, 1.0);
+ write_f32(&mut body, TYPE2_OPACITY_START_OFFSET, 2.0);
+ write_f32(&mut body, TYPE2_OPACITY_END_OFFSET, -1.0);
+ write_f32(&mut body, TYPE2_OPACITY_EXPONENT_OFFSET, 1.0);
+ write_vec3(&mut body, TYPE2_POSITION_START_OFFSET, [0.0, 0.0, 0.0]);
+ write_vec3(&mut body, TYPE2_POSITION_END_OFFSET, [8.0, 16.0, 24.0]);
+ write_vec3(&mut body, TYPE2_POSITION_EXPONENT_OFFSET, [1.0, 2.0, 3.0]);
+ write_vec3(&mut body, TYPE2_AXIS_START_OFFSET, [1.0, 2.0, 3.0]);
+ write_vec3(&mut body, TYPE2_AXIS_END_OFFSET, [5.0, 6.0, 7.0]);
+ write_vec3(&mut body, TYPE2_SCALE_START_OFFSET, [1.0; 3]);
+ write_vec3(&mut body, TYPE2_SCALE_END_OFFSET, [3.0; 3]);
+ write_f32(&mut body, 0x78, 3.0);
+ write_f32(&mut body, 0x7c, 2.0);
+ write_f32(&mut body, 0x80, 1.0);
+
+ let sample = sample_op3_type2_body(&body, 0.5, 0.25).unwrap();
+ assert_eq!(sample.position, [4.0, 4.0, 3.0]);
+ assert_eq!(sample.axis, [3.0, 4.0, 5.0]);
+ assert_eq!(sample.scale, [2.0, 1.5, 1.25]);
+ assert_eq!(sample.opacity, 0.5);
+ assert_eq!(sample.material_phase, 0.25);
+ }
+
+ #[test]
+ fn type1_basis_uses_raw_exact_dot_gate_and_native_cross_order() {
+ // The native comparison is against the raw dot product, so this
+ // oblique pair is rejected even though the vectors are not parallel.
+ assert_eq!(
+ native_op3_type1_basis([1.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0; 3]),
+ None
+ );
+ assert_eq!(
+ native_op3_type1_basis([1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0; 3]),
+ None
+ );
+ assert_eq!(
+ native_op3_type1_basis([1.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [0.0; 3]),
+ None
+ );
+
+ // A non-unit view gives a raw dot of 2 and proceeds to the native
+ // cross-product frame instead of being normalized before the gate.
+ let frame =
+ native_op3_type1_basis([2.0, 1.0, 0.0], [1.0, 0.0, 0.0], [3.0, 4.0, 5.0]).unwrap();
+ assert_eq!(frame[0], [0.0, 0.0, 1.0, 3.0]);
+ assert_eq!(frame[1], [0.0, 1.0, 0.0, 4.0]);
+ assert_eq!(frame[2], [-1.0, 0.0, 0.0, 5.0]);
+
+ // The axis is not normalized: W and the axis column keep its scale.
+ let axis_scaled =
+ native_op3_type1_basis([2.0, 1.0, 0.0], [0.0, 0.0, 2.0], [0.0; 3]).unwrap();
+ let inv_sqrt_5 = 1.0 / 5.0_f32.sqrt();
+ assert_eq!(axis_scaled[0][0], inv_sqrt_5);
+ assert_eq!(axis_scaled[1][0], -2.0 * inv_sqrt_5);
+ assert_eq!(axis_scaled[0][1], 4.0 * inv_sqrt_5);
+ assert_eq!(axis_scaled[1][1], 2.0 * inv_sqrt_5);
+ assert_eq!(axis_scaled[2][2], 2.0);
+
+ assert_eq!(
+ native_op3_type1_basis([f32::NAN, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0; 3]),
+ None
+ );
+ }
+
+ #[test]
+ fn type1_command_samples_authored_tracks_and_camera_basis() {
+ let mut body = vec![0_u8; 196];
+ write_u32(&mut body, 0, TYPE1_BODY_DISCRIMINATOR);
+ write_f32(&mut body, TYPE2_PHASE_START_OFFSET, -1.0);
+ write_f32(&mut body, TYPE2_PHASE_END_OFFSET, 2.0);
+ write_f32(&mut body, TYPE2_PHASE_EXPONENT_OFFSET, 1.0);
+ write_f32(&mut body, TYPE2_OPACITY_START_OFFSET, 1.0);
+ write_f32(&mut body, TYPE2_OPACITY_END_OFFSET, 0.0);
+ write_f32(&mut body, TYPE2_OPACITY_EXPONENT_OFFSET, 1.0);
+ write_f32(&mut body, TYPE2_ACTIVE_START_OFFSET, 0.0);
+ write_f32(&mut body, TYPE2_ACTIVE_END_OFFSET, 1.0);
+ write_vec3(&mut body, TYPE2_POSITION_START_OFFSET, [0.0, 0.0, 0.0]);
+ write_vec3(&mut body, TYPE2_POSITION_END_OFFSET, [2.0, 0.0, 0.0]);
+ write_vec3(&mut body, TYPE2_POSITION_EXPONENT_OFFSET, [1.0; 3]);
+ write_vec3(&mut body, TYPE2_AXIS_START_OFFSET, [0.0, 0.0, 1.0]);
+ write_vec3(&mut body, TYPE2_AXIS_END_OFFSET, [0.0, 0.0, 1.0]);
+ write_vec3(&mut body, TYPE2_SCALE_START_OFFSET, [1.0; 3]);
+ write_vec3(&mut body, TYPE2_SCALE_END_OFFSET, [1.0; 3]);
+ write_vec3(&mut body, 0x78, [1.0; 3]);
+ body[TYPE2_RESOURCE_OFFSET..TYPE2_RESOURCE_OFFSET + 12].copy_from_slice(b"material.lib");
+ body[TYPE2_RESOURCE_OFFSET + 32..TYPE2_RESOURCE_OFFSET + 37].copy_from_slice(b"b_p08");
+
+ let command = FxCommand {
+ word: 3,
+ opcode: FxOpcode::Op3,
+ native_flag: false,
+ raw_body: Arc::from(body.into_boxed_slice()),
+ resource_refs: Vec::new(),
+ };
+ let identity = [
+ [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],
+ ];
+ let frame =
+ evaluate_op3_type1_command(&command, 4, &identity, 0.25, 0.5, [2.0, 1.0, 3.0]).unwrap();
+ assert_eq!(frame.command_index, 4);
+ assert_eq!(
+ frame.material,
+ EffectResource {
+ archive: "material.lib".to_owned(),
+ name: "b_p08".to_owned(),
+ }
+ );
+ assert!(frame.active);
+ assert_eq!(frame.normalized_time, 0.5);
+ assert_eq!(frame.color, [1.0, 1.0, 1.0, 0.5]);
+ assert_eq!(frame.material_phase, 0.5);
+ assert!(evaluate_op3_type1_command(
+ &command,
+ 4,
+ &identity,
+ 0.25,
+ 0.5,
+ [f32::NAN, 1.0, 3.0],
+ )
+ .is_none());
+ }
+
+ #[test]
+ fn rejects_truncated_bodies_and_non_finite_tracks_or_inputs() {
+ let identity = [
+ [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],
+ ];
+ let truncated = FxCommand {
+ word: 3,
+ opcode: FxOpcode::Op3,
+ native_flag: false,
+ raw_body: Arc::from(
+ vec![0_u8; TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES - 1].into_boxed_slice(),
+ ),
+ resource_refs: Vec::new(),
+ };
+ assert!(
+ evaluate_op3_type0_command(&truncated, 0, &identity, 0.0, 0.0, [0.0; 3],).is_none()
+ );
+
+ let mut type0_body = vec![0_u8; TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES];
+ write_u32(&mut type0_body, 0, TYPE0_BODY_DISCRIMINATOR);
+ write_f32(
+ &mut type0_body,
+ TYPE2_POSITION_EXPONENT_OFFSET,
+ f32::INFINITY,
+ );
+ assert!(sample_op3_type0_body(&type0_body, 0.5, 0.25).is_none());
+
+ let mut type2_body = vec![0_u8; TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES];
+ write_u32(&mut type2_body, 0, TYPE2_BODY_DISCRIMINATOR);
+ let non_finite_elapsed = FxCommand {
+ word: 3,
+ opcode: FxOpcode::Op3,
+ native_flag: false,
+ raw_body: Arc::from(type2_body.into_boxed_slice()),
+ resource_refs: Vec::new(),
+ };
+ assert!(evaluate_op3_type2_command(
+ &non_finite_elapsed,
+ 0,
+ &identity,
+ f32::INFINITY,
+ 0.5,
+ 0,
+ )
+ .is_none());
+ }
+
+ fn write_u32(bytes: &mut [u8], offset: usize, value: u32) {
+ bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ }
+
+ fn write_f32(bytes: &mut [u8], offset: usize, value: f32) {
+ bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ }
+
+ fn write_vec3(bytes: &mut [u8], offset: usize, value: [f32; 3]) {
+ for (axis, component) in value.into_iter().enumerate() {
+ write_f32(bytes, offset + axis * 4, component);
+ }
+ }
+}
diff --git a/crates/fparkan-fx/src/atmosphere.rs b/crates/fparkan-fx/src/atmosphere.rs
index 2681b00..da3a98b 100644
--- a/crates/fparkan-fx/src/atmosphere.rs
+++ b/crates/fparkan-fx/src/atmosphere.rs
@@ -1139,8 +1139,7 @@ fn sky_sample(key: &InterpolatedKey) -> SkySample {
fn sun_sample(key: &InterpolatedKey, birth: Option<SunBirth>) -> SunSample {
let source = key.colors[12].rgba();
let intensity = key.parameters[2];
- // Native selector-0 outputs 5/6 are palette[9]/[14] (Terrain.dll RVA 0x3DFB0);
- // CSun uses slot 5 alpha for flare opacity (+0x4B3AB) and slot 6 RGB for secondary light (+0x4B4BE).
+ // CSun's sprite and flare opacity use palette[10]; its secondary light uses palette[14].
SunSample {
active: birth.is_some(),
rgb: [
@@ -1149,7 +1148,7 @@ fn sun_sample(key: &InterpolatedKey, birth: Option<SunBirth>) -> SunSample {
source[2] * intensity,
],
values: [key.parameters[0], key.parameters[1]],
- packed: [key.colors[9], key.colors[14]],
+ packed: [key.colors[10], key.colors[14]],
intensity,
birth,
}
@@ -1590,8 +1589,8 @@ mod tests {
#[test]
fn sun_packed_slots_feed_native_flare_alpha_and_secondary_light() {
let mut palette = [0xFF00_0000_u32; KEY_COLOR_COUNT];
- palette[9] = 0x4011_2233;
- palette[10] = 0xFFAA_BBCC;
+ palette[9] = 0xFF00_0000;
+ palette[10] = 0x4011_2233;
palette[12] = 0xFFFF_FFFF;
palette[14] = 0x8044_5566;
let bytes = document(2, |out| {
@@ -1608,7 +1607,7 @@ mod tests {
let sampled = parsed.sample_day(0, 0.0);
assert!(sampled.sun.active);
- assert_eq!(sampled.sun.packed[0], PackedColor(palette[9]));
+ assert_eq!(sampled.sun.packed[0], PackedColor(palette[10]));
assert_eq!(sampled.sun.packed[1], PackedColor(palette[14]));
let birth = sampled.sun.birth.expect("sun birth");
@@ -1617,7 +1616,7 @@ mod tests {
0.0,
crate::sky::SunConfig::from_birth(birth),
);
- assert_eq!(sun.sprite_color, PackedColor(palette[9]).rgba());
+ assert_eq!(sun.sprite_color, PackedColor(palette[10]).rgba());
assert_eq!(sun.secondary_color, PackedColor(palette[14]).rgba()[..3]);
let optics = crate::sky::sun_optics(
@@ -1635,6 +1634,31 @@ mod tests {
}
#[test]
+ fn sun_sprite_uses_interpolated_palette_ten() {
+ let mut first = [0xFF00_0000_u32; KEY_COLOR_COUNT];
+ first[9] = 0xFF00_0000;
+ first[10] = 0x32FF_2800;
+ first[12] = 0xFFFF_FFFF;
+ let mut second = first;
+ second[10] = 0x4BFF_4A00;
+ let bytes = document(2, |out| {
+ key(out, 0, 0, 0, first, [0.0, 0.0, 1.0, 0.0], &[]);
+ key(out, 12, 0, 1, second, [0.0, 0.0, 1.0, 0.0], &[]);
+ });
+ let mut parsed = TypedAtmosphere::parse(&bytes).expect("parse");
+ for key in &mut parsed.tracks[0].keys {
+ key.strings[0] = AtmosphereString {
+ raw: b"sun".to_vec(),
+ text: "sun".to_owned(),
+ };
+ }
+
+ let sampled = parsed.sample_day(0, 7.2 * 3_600.0);
+ assert_eq!(sampled.sun.packed[0], PackedColor(0x41FF_3C00));
+ assert_ne!(sampled.sun.packed[0], PackedColor(first[9]));
+ }
+
+ #[test]
fn wraps_last_key_to_first_and_emits_events() {
let palette = [0xFF00_0000_u32; KEY_COLOR_COUNT];
let bytes = document(2, |out| {
diff --git a/crates/fparkan-fx/src/environment.rs b/crates/fparkan-fx/src/environment.rs
index babbd0f..a298f24 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,
})
}
@@ -1960,6 +2090,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)?;
@@ -2593,6 +2748,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/fx_visual.rs b/crates/fparkan-fx/src/fx_visual.rs
new file mode 100644
index 0000000..0c09d4a
--- /dev/null
+++ b/crates/fparkan-fx/src/fx_visual.rs
@@ -0,0 +1,1428 @@
+//! Shared, native-shaped output for FX commands that render one four-vertex
+//! effect primitive.
+//!
+//! This module keeps the small affine and frame contracts separate from the
+//! environment-specific lightning emitter. Command evaluators only belong
+//! here once their native inputs and output fields are established.
+
+use crate::environment::EffectResource;
+use crate::{FxCommand, FxOpcode};
+
+/// Canonical native four-vertex topology in helper-local coordinates.
+///
+/// The consumer's triangle list is `[0, 1, 2, 2, 3, 0]`.
+const EFFECT_QUAD_CORNERS: [[f32; 3]; 4] = [
+ [-0.5, 0.5, 0.0],
+ [-0.5, -0.5, 0.0],
+ [0.5, -0.5, 0.0],
+ [0.5, 0.5, 0.0],
+];
+
+/// Native full-texture quad coordinates, with the 0.99 edge inset.
+pub(crate) const EFFECT_QUAD_UV: [[f32; 2]; 4] =
+ [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]];
+
+/// One evaluated native-style FX quad.
+#[derive(Clone, Debug, PartialEq)]
+pub struct EffectQuadFrame {
+ /// Source command index in the FXID.
+ pub command_index: usize,
+ /// Source command opcode.
+ pub opcode: FxOpcode,
+ /// MAT0 resource selected by the command.
+ pub material: EffectResource,
+ /// World-space vertices in native corner order.
+ pub vertices: [[f32; 3]; 4],
+ /// Native texture coordinates in matching corner order.
+ pub uv: [[f32; 2]; 4],
+ /// Per-vertex RGBA value supplied by the native command evaluator.
+ ///
+ /// The alpha is the native FX opacity value. Native render assignment
+ /// replaces MAT0 phase alpha with this value rather than multiplying it.
+ pub color: [f32; 4],
+ /// Wrapped command phase supplied to the material's MAT0 animation.
+ pub material_phase: f32,
+ /// Effect age in seconds.
+ pub elapsed_seconds: f32,
+ /// Normalized command time over its native active interval.
+ pub normalized_time: f32,
+ /// Whether the native command is active at `elapsed_seconds`.
+ pub active: bool,
+ /// Category-independent native draw-flag base selected by Op9 body C4.
+ ///
+ /// The native renderer combines this base with render-context category
+ /// bits and a trailing command flag. Those inputs are not part of this
+ /// geometry evaluator's interface yet, so this stores only the proven
+ /// selector mapping (C4 0/1/2 -> 3/5/6).
+ pub native_flag_base: Option<u32>,
+}
+
+/// One evaluated indexed mesh emitted by a native FX command.
+///
+/// Position and UV arrays use the native element order; `indices` expands
+/// those elements into triangles for renderers that consume indexed lists.
+#[derive(Clone, Debug, PartialEq)]
+pub struct EffectMeshFrame {
+ /// Source command index in the FXID.
+ pub command_index: usize,
+ /// Source command opcode.
+ pub opcode: FxOpcode,
+ /// MAT0 resource selected by the command.
+ pub material: EffectResource,
+ /// World-space mesh positions in native element order.
+ pub vertices: Vec<[f32; 3]>,
+ /// Texture coordinates paired with `vertices`.
+ pub uv: Vec<[f32; 2]>,
+ /// Triangle-list indices into `vertices` and `uv`.
+ pub indices: Vec<u16>,
+ /// Per-vertex RGBA value supplied by the native command evaluator.
+ pub color: [f32; 4],
+ /// Wrapped command phase supplied to the material's MAT0 animation.
+ pub material_phase: f32,
+ /// Effect age in seconds.
+ pub elapsed_seconds: f32,
+ /// Normalized command time over its native active interval.
+ pub normalized_time: f32,
+ /// Whether the native command is active at `elapsed_seconds`.
+ pub active: bool,
+ /// Category-independent selector base from Op9 body+C4.
+ pub native_flag_base: u32,
+ /// Native category selected by the top two bits of the renderer flags.
+ pub native_category: u8,
+}
+
+const F_GENER_BALL_RESOURCE_OFFSET: usize = 0x84;
+const F_GENER_BALL_RESOURCE_BYTES: usize = 64;
+
+struct FGenerBallSample {
+ opcode: FxOpcode,
+ material: EffectResource,
+ matrix: [[f32; 4]; 4],
+ color: [f32; 4],
+ material_phase: f32,
+ elapsed_seconds: f32,
+ normalized_time: f32,
+ active: bool,
+ native_flag_base: Option<u32>,
+}
+
+/// Evaluates a supported opcode-4 `f_gener_ball` billboard quad.
+///
+/// `world_from_effect` is the row-major native parent matrix after owner
+/// transforms and the FX header scale have been applied. `effect_phase` is the
+/// normalized overall effect age; `elapsed_seconds` is command age in seconds.
+/// `camera_world` is the render-context query point consumed by the native
+/// opcode-4 path. Opcode 9 emits generated hemisphere geometry and must use
+/// [`evaluate_f_gener_ball_mesh`] instead.
+///
+/// Opcode 4 body mode 0 is supported when its distance denominator at
+/// body+0xC4 is positive. Other mode/body combinations return `None` until
+/// their native branches are verified.
+#[must_use]
+pub fn evaluate_f_gener_ball(
+ command: &FxCommand,
+ command_index: usize,
+ world_from_effect: &[[f32; 4]; 4],
+ effect_phase: f32,
+ elapsed_seconds: f32,
+ camera_world: [f32; 3],
+) -> Option<EffectQuadFrame> {
+ if command.opcode != FxOpcode::Op4 {
+ return None;
+ }
+ let sample = evaluate_f_gener_ball_sample(
+ command,
+ world_from_effect,
+ effect_phase,
+ elapsed_seconds,
+ camera_world,
+ )?;
+ let vertices = transform_native_effect_quad(&sample.matrix)?;
+
+ Some(EffectQuadFrame {
+ command_index,
+ opcode: sample.opcode,
+ material: sample.material,
+ vertices,
+ uv: EFFECT_QUAD_UV,
+ color: sample.color,
+ elapsed_seconds: sample.elapsed_seconds,
+ normalized_time: sample.normalized_time,
+ material_phase: sample.material_phase,
+ active: sample.active,
+ native_flag_base: sample.native_flag_base,
+ })
+}
+
+/// Evaluates an Op9 `f_gener_ball` command as the native generated hemisphere.
+///
+/// `native_category` is `(renderer_flags >> 30)`, the native category index
+/// consumed by Terrain's geometry selector. The three C4 selectors map to
+/// generated-mesh modes 0, 1, and 2. Body+0xC8 selects the native UV path:
+/// zero uses per-vertex projected XY coordinates; nonzero uses Terrain's
+/// static quad/triangle UV layouts. Op4 remains a billboard quad and is not
+/// accepted here.
+#[must_use]
+pub fn evaluate_f_gener_ball_mesh(
+ command: &FxCommand,
+ command_index: usize,
+ world_from_effect: &[[f32; 4]; 4],
+ effect_phase: f32,
+ elapsed_seconds: f32,
+ camera_world: [f32; 3],
+ native_category: u8,
+) -> Option<EffectMeshFrame> {
+ if command.opcode != FxOpcode::Op9 || native_category > 3 {
+ return None;
+ }
+ let sample = evaluate_f_gener_ball_sample(
+ command,
+ world_from_effect,
+ effect_phase,
+ elapsed_seconds,
+ camera_world,
+ )?;
+ let native_flag_base = sample.native_flag_base?;
+ let static_uv = read_u32(&command.raw_body, 0xC8)? != 0;
+ let (vertices, uv, indices) =
+ generated_op9_hemisphere(native_flag_base, native_category, static_uv)?;
+ let vertices = vertices
+ .into_iter()
+ .map(|vertex| transform_native_effect_point(&sample.matrix, vertex))
+ .collect::<Option<Vec<_>>>()?;
+
+ Some(EffectMeshFrame {
+ command_index,
+ opcode: sample.opcode,
+ material: sample.material,
+ vertices,
+ uv,
+ indices,
+ color: sample.color,
+ material_phase: sample.material_phase,
+ elapsed_seconds: sample.elapsed_seconds,
+ normalized_time: sample.normalized_time,
+ active: sample.active,
+ native_flag_base,
+ native_category,
+ })
+}
+
+fn evaluate_f_gener_ball_sample(
+ command: &FxCommand,
+ world_from_effect: &[[f32; 4]; 4],
+ effect_phase: f32,
+ elapsed_seconds: f32,
+ camera_world: [f32; 3],
+) -> Option<FGenerBallSample> {
+ if !world_from_effect
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ || !effect_phase.is_finite()
+ || !elapsed_seconds.is_finite()
+ {
+ return None;
+ }
+ if !matches!(command.opcode, FxOpcode::Op9 | FxOpcode::Op4) {
+ return None;
+ }
+
+ let body = &command.raw_body;
+ let mode = read_u32(body, 0x00)?;
+ let (billboard, distance_denominator, native_flag_base) = match command.opcode {
+ FxOpcode::Op9 if mode == 2 => {
+ let selector = read_u32(body, 0xC4)?;
+ let flag_base = match selector {
+ 0 => 3,
+ 1 => 5,
+ 2 => 6,
+ _ => return None,
+ };
+ (false, None, Some(flag_base))
+ }
+ FxOpcode::Op4 if mode == 0 => {
+ let denominator = read_f32(body, 0xC4)?;
+ if !denominator.is_finite() || denominator <= 0.0 {
+ return None;
+ }
+ (true, Some(denominator), None)
+ }
+ _ => return None,
+ };
+
+ let resource = read_effect_resource(body)?;
+ let start = read_f32(body, 0x1C)?;
+ let end = read_f32(body, 0x20)?;
+ let phase_start_raw = read_f32(body, 0x04)?;
+ let phase_end = read_f32(body, 0x08)?;
+ let phase_exponent = read_f32(body, 0x0C)?;
+ let opacity_start = read_f32(body, 0x10)?;
+ let opacity_end = read_f32(body, 0x14)?;
+ let opacity_exponent = read_f32(body, 0x18)?;
+ if ![
+ start,
+ end,
+ phase_start_raw,
+ phase_end,
+ phase_exponent,
+ opacity_start,
+ opacity_end,
+ opacity_exponent,
+ ]
+ .iter()
+ .all(|value| value.is_finite())
+ || start > end
+ {
+ return None;
+ }
+
+ let interval_time = command_interval_phase(effect_phase, start, end);
+ let animation_time = if let Some(denominator) = distance_denominator {
+ if !camera_world.iter().all(|value| value.is_finite()) {
+ return None;
+ }
+ let camera_local = native_effect_local_point(world_from_effect, camera_world)?;
+ let distance = vector_length(camera_local)?;
+ (interval_time * distance / denominator).min(1.0)
+ } else {
+ interval_time
+ };
+ if !animation_time.is_finite() {
+ return None;
+ }
+
+ let position_start = read_vec3(body, 0x24)?;
+ let position_end = read_vec3(body, 0x30)?;
+ let position_exponents = read_vec3(body, 0x3C)?;
+ let axis_start = read_vec3(body, 0x48)?;
+ let axis_end = read_vec3(body, 0x54)?;
+ let scale_start = read_vec3(body, 0x60)?;
+ let scale_end = read_vec3(body, 0x6C)?;
+ // Native pushes these exponent fields in reverse stack order; their
+ // resulting XYZ pairing is body+0x80, +0x7C, +0x78.
+ let scale_exponents = [
+ read_f32(body, 0x80)?,
+ read_f32(body, 0x7C)?,
+ read_f32(body, 0x78)?,
+ ];
+
+ let position = curve_vector(
+ position_start,
+ position_end,
+ position_exponents,
+ animation_time,
+ )?;
+ let axis = lerp_vector(axis_start, axis_end, animation_time);
+ let scale = curve_vector(scale_start, scale_end, scale_exponents, animation_time)?;
+ let opacity_curve_time = animation_time.powf(opacity_exponent);
+ let opacity =
+ (opacity_start + (opacity_end - opacity_start) * opacity_curve_time).clamp(0.0, 1.0);
+
+ let material_timebase = if phase_start_raw < 0.0 {
+ elapsed_seconds
+ } else {
+ interval_time
+ };
+ let phase_start = phase_start_raw.max(0.0);
+ let phase = phase_start + (phase_end - phase_start) * material_timebase.powf(phase_exponent);
+ let material_phase = phase.rem_euclid(1.0);
+ if !opacity.is_finite() || !material_phase.is_finite() {
+ return None;
+ }
+
+ let local_basis = if billboard {
+ let camera_local = native_effect_local_point(world_from_effect, camera_world)?;
+ let view = subtract_vector(camera_local, position);
+ native_camera_facing_basis(view, position)?
+ } else {
+ native_d110_basis(axis, position)?
+ };
+ let matrix = compose_native_effect_matrix(world_from_effect, &local_basis, position, scale)?;
+ let active = effect_phase >= start && effect_phase <= end;
+
+ Some(FGenerBallSample {
+ opcode: command.opcode,
+ material: resource,
+ matrix,
+ color: [1.0, 1.0, 1.0, opacity],
+ elapsed_seconds,
+ normalized_time: animation_time,
+ material_phase,
+ active,
+ native_flag_base,
+ })
+}
+
+// Terrain::18520's native mode/category tables, indexed by
+// `mode * 4 + (renderer_flags >> 30)`. These values are assigned from the
+// Terrain globals at initialization and drive both 18D40 draw loops.
+const OP9_SECTOR_COUNTS: [[usize; 4]; 3] = [[8, 6, 5, 4], [16, 12, 10, 8], [24, 20, 16, 10]];
+const OP9_RING_COUNTS: [[usize; 4]; 3] = [[3, 3, 2, 1], [6, 5, 4, 3], [9, 8, 6, 4]];
+
+fn generated_op9_hemisphere(
+ native_flag_base: u32,
+ native_category: u8,
+ static_uv: bool,
+) -> Option<(Vec<[f32; 3]>, Vec<[f32; 2]>, Vec<u16>)> {
+ let mode = match native_flag_base {
+ 3 => 0,
+ 5 => 1,
+ 6 => 2,
+ _ => return None,
+ };
+ let category = usize::from(native_category);
+ if category >= 4 {
+ return None;
+ }
+
+ let sectors = OP9_SECTOR_COUNTS[mode][category];
+ let rings = OP9_RING_COUNTS[mode][category];
+ let quad_count = sectors.checked_mul(rings.checked_sub(1)?)?;
+ let vertex_count = quad_count
+ .checked_mul(4)?
+ .checked_add(sectors.checked_mul(3)?)?;
+ let index_count = quad_count
+ .checked_mul(6)?
+ .checked_add(sectors.checked_mul(3)?)?;
+ let mut vertices = Vec::with_capacity(vertex_count);
+ let mut uv = Vec::with_capacity(vertex_count);
+ let mut indices = Vec::with_capacity(index_count);
+ let latitude_step = std::f32::consts::FRAC_PI_2 / rings as f32;
+
+ // 18310 stores quads sector-major, then ring-major. Each cell's order is
+ // top/current-azimuth, top/next-azimuth, bottom/next, bottom/current.
+ for sector in 0..sectors {
+ let phi0 = std::f32::consts::TAU * sector as f32 / sectors as f32;
+ let phi1 = std::f32::consts::TAU * (sector + 1) as f32 / sectors as f32;
+ for ring in 0..rings.saturating_sub(1) {
+ let theta0 = latitude_step * (ring + 1) as f32;
+ let theta1 = latitude_step * (ring + 2) as f32;
+ let element_start = u16::try_from(vertices.len()).ok()?;
+ let element = [
+ hemisphere_point(theta0, phi0),
+ hemisphere_point(theta0, phi1),
+ hemisphere_point(theta1, phi1),
+ hemisphere_point(theta1, phi0),
+ ];
+ for (corner, point) in element.into_iter().enumerate() {
+ uv.push(if static_uv {
+ EFFECT_QUAD_UV[corner]
+ } else {
+ [(point[0] + 1.0) * 0.5, (point[1] + 1.0) * 0.5]
+ });
+ vertices.push(point);
+ }
+ indices.extend_from_slice(&[
+ element_start,
+ element_start + 1,
+ element_start + 2,
+ element_start + 2,
+ element_start + 3,
+ element_start,
+ ]);
+ }
+ }
+
+ // 18190 appends the cap fan after all quads, ordered pole, next sector,
+ // current sector. The UVs are the same native XY projection as 18310.
+ for sector in 0..sectors {
+ let phi0 = std::f32::consts::TAU * sector as f32 / sectors as f32;
+ let phi1 = std::f32::consts::TAU * (sector + 1) as f32 / sectors as f32;
+ let theta = latitude_step;
+ let element_start = u16::try_from(vertices.len()).ok()?;
+ let element = [
+ [0.0, 0.0, 1.0],
+ hemisphere_point(theta, phi1),
+ hemisphere_point(theta, phi0),
+ ];
+ let static_triangle_uv = [[0.5, 0.99], [0.0, 0.0], [0.99, 0.0]];
+ for (corner, point) in element.into_iter().enumerate() {
+ uv.push(if static_uv {
+ static_triangle_uv[corner]
+ } else {
+ [(point[0] + 1.0) * 0.5, (point[1] + 1.0) * 0.5]
+ });
+ vertices.push(point);
+ }
+ indices.extend_from_slice(&[element_start, element_start + 1, element_start + 2]);
+ }
+
+ (vertices.len() == vertex_count && indices.len() == index_count)
+ .then_some((vertices, uv, indices))
+}
+
+fn hemisphere_point(theta: f32, phi: f32) -> [f32; 3] {
+ let radial = theta.sin();
+ [radial * phi.sin(), radial * phi.cos(), theta.cos()]
+}
+
+pub(crate) fn native_camera_facing_basis(
+ view: [f32; 3],
+ position: [f32; 3],
+) -> Option<[[f32; 4]; 4]> {
+ if !view
+ .iter()
+ .chain(position.iter())
+ .all(|value| value.is_finite())
+ {
+ return None;
+ }
+ let length = vector_length(view)?;
+ if length == 0.0 {
+ // The native 93F4 branch clears its active byte and returns false when
+ // camera and animated position coincide.
+ return None;
+ }
+ let view = [view[0] / length, view[1] / length, view[2] / length];
+ let mut u = if view[0] == 0.0 {
+ [1.0, 0.0, 0.0]
+ } else if view[1] == 0.0 {
+ [0.0, 1.0, 0.0]
+ } else {
+ [-view[1], view[0], 0.0]
+ };
+ if view[2] != 0.0 && view[0] != 0.0 && view[1] != 0.0 {
+ let u_length = vector_length(u)?;
+ if u_length == 0.0 {
+ return None;
+ }
+ for component in &mut u {
+ *component /= u_length;
+ }
+ }
+ let v = cross(view, u);
+ Some([
+ [u[0], v[0], view[0], position[0]],
+ [u[1], v[1], view[1], position[1]],
+ [u[2], v[2], view[2], position[2]],
+ [0.0, 0.0, 0.0, 1.0],
+ ])
+}
+
+fn multiply_affine(left: &[[f32; 4]; 4], right: &[[f32; 4]; 4]) -> Option<[[f32; 4]; 4]> {
+ let result = std::array::from_fn(|row| {
+ std::array::from_fn(|column| {
+ (0..4)
+ .map(|inner| left[row][inner] * right[inner][column])
+ .sum::<f32>()
+ })
+ });
+ result
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ .then_some(result)
+}
+
+fn read_effect_resource(body: &[u8]) -> Option<EffectResource> {
+ let bytes = body.get(
+ F_GENER_BALL_RESOURCE_OFFSET..F_GENER_BALL_RESOURCE_OFFSET + F_GENER_BALL_RESOURCE_BYTES,
+ )?;
+ let archive = bounded_name(&bytes[..32])?;
+ let name = bounded_name(&bytes[32..])?;
+ (!archive.is_empty() && !name.is_empty()).then_some(EffectResource { archive, name })
+}
+
+fn bounded_name(bytes: &[u8]) -> Option<String> {
+ let end = bytes
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(bytes.len());
+ let value = std::str::from_utf8(&bytes[..end]).ok()?.trim();
+ (!value.is_empty()).then(|| value.to_owned())
+}
+
+fn read_u32(body: &[u8], offset: usize) -> Option<u32> {
+ Some(u32::from_le_bytes(
+ body.get(offset..offset + 4)?.try_into().ok()?,
+ ))
+}
+
+fn read_f32(body: &[u8], offset: usize) -> Option<f32> {
+ Some(f32::from_le_bytes(
+ body.get(offset..offset + 4)?.try_into().ok()?,
+ ))
+}
+
+fn read_vec3(body: &[u8], offset: usize) -> Option<[f32; 3]> {
+ Some([
+ read_f32(body, offset)?,
+ read_f32(body, offset + 4)?,
+ read_f32(body, offset + 8)?,
+ ])
+}
+
+fn command_interval_phase(phase: f32, start: f32, end: f32) -> f32 {
+ if phase < start {
+ 0.0
+ } else if end == start {
+ 1.0
+ } else if phase >= end {
+ 1.0
+ } else {
+ ((phase - start) / (end - start)).clamp(0.0, 1.0)
+ }
+}
+
+/// Builds the native D110 Type2 local basis in row-major matrix form.
+///
+/// The X/Y degeneracies use native constant fallbacks. For a general axis U
+/// is normalized only when its Z component is nonzero; V is `axis × U`, and
+/// the axis vector itself remains unnormalized.
+#[must_use]
+pub(crate) fn native_d110_basis(axis: [f32; 3], position: [f32; 3]) -> Option<[[f32; 4]; 4]> {
+ if !axis
+ .iter()
+ .chain(position.iter())
+ .all(|value| value.is_finite())
+ {
+ return None;
+ }
+
+ let mut u = if axis[0] == 0.0 {
+ [1.0, 0.0, 0.0]
+ } else if axis[1] == 0.0 {
+ [0.0, 1.0, 0.0]
+ } else {
+ [-axis[1], axis[0], 0.0]
+ };
+ if axis[2] != 0.0 && axis[0] != 0.0 && axis[1] != 0.0 {
+ let length = vector_length(u)?;
+ if length == 0.0 {
+ return None;
+ }
+ for value in &mut u {
+ *value /= length;
+ }
+ }
+
+ let v = cross(axis, u);
+ let result = [
+ [u[0], v[0], axis[0], position[0]],
+ [u[1], v[1], axis[1], position[1]],
+ [u[2], v[2], axis[2], position[2]],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+ result
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ .then_some(result)
+}
+
+fn curve_vector(start: [f32; 3], end: [f32; 3], exponent: [f32; 3], time: f32) -> Option<[f32; 3]> {
+ let result = std::array::from_fn(|axis| {
+ start[axis] + (end[axis] - start[axis]) * time.powf(exponent[axis])
+ });
+ result
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some(result)
+}
+
+fn lerp_vector(start: [f32; 3], end: [f32; 3], time: f32) -> [f32; 3] {
+ std::array::from_fn(|axis| start[axis] + (end[axis] - start[axis]) * time)
+}
+
+fn vector_length(vector: [f32; 3]) -> Option<f32> {
+ let length = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt();
+ length.is_finite().then_some(length)
+}
+
+fn subtract_vector(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [left[0] - right[0], left[1] - right[1], left[2] - right[2]]
+}
+
+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],
+ ]
+}
+
+/// Applies the native affine point transform used by the quad helper.
+///
+/// The matrix is row-major. Each output component is the dot product of one
+/// of the first three rows with `[x, y, z, 1]`.
+#[must_use]
+pub(crate) fn transform_native_effect_point(
+ matrix: &[[f32; 4]; 4],
+ point: [f32; 3],
+) -> Option<[f32; 3]> {
+ if !matrix.iter().flatten().all(|value| value.is_finite())
+ || !point.iter().all(|value| value.is_finite())
+ {
+ return None;
+ }
+ let result = std::array::from_fn(|row| {
+ matrix[row][0] * point[0]
+ + matrix[row][1] * point[1]
+ + matrix[row][2] * point[2]
+ + matrix[row][3]
+ });
+ result
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some(result)
+}
+
+/// Transforms the native canonical quad through the final helper matrix.
+#[must_use]
+pub(crate) fn transform_native_effect_quad(matrix: &[[f32; 4]; 4]) -> Option<[[f32; 3]; 4]> {
+ let vertices = EFFECT_QUAD_CORNERS.map(|corner| transform_native_effect_point(matrix, corner));
+ if vertices.iter().any(Option::is_none) {
+ return None;
+ }
+ Some(vertices.map(Option::unwrap))
+}
+
+/// Applies the native `g_FastProc + 0x68` point operation.
+///
+/// This is the native component-wise projection: subtract matrix translation,
+/// dot the delta with each basis column, and divide by that column's squared
+/// length. It is the `g_FastProc + 0x68` contract used by effect callers.
+#[must_use]
+pub(crate) fn native_effect_local_point(
+ matrix: &[[f32; 4]; 4],
+ point: [f32; 3],
+) -> Option<[f32; 3]> {
+ if !matrix.iter().flatten().all(|value| value.is_finite())
+ || !point.iter().all(|value| value.is_finite())
+ {
+ return None;
+ }
+ let delta = [
+ point[0] - matrix[0][3],
+ point[1] - matrix[1][3],
+ point[2] - matrix[2][3],
+ ];
+ let mut result = [0.0; 3];
+ for axis in 0..3 {
+ let basis = [matrix[0][axis], matrix[1][axis], matrix[2][axis]];
+ let squared_length = basis
+ .iter()
+ .map(|component| component * component)
+ .sum::<f32>();
+ if !squared_length.is_finite() || squared_length == 0.0 {
+ return None;
+ }
+ result[axis] =
+ (basis[0] * delta[0] + basis[1] * delta[1] + basis[2] * delta[2]) / squared_length;
+ }
+ result
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some(result)
+}
+
+/// Composes a native FX local basis and translation with the effect parent.
+///
+/// This matches the shared `93D0` finalization: scale the parent basis columns
+/// by the animated scale, multiply by the local basis, then replace the
+/// translation with the unscaled parent transform of the animated position.
+#[must_use]
+pub(crate) fn compose_native_effect_matrix(
+ world_from_effect: &[[f32; 4]; 4],
+ local_basis: &[[f32; 4]; 4],
+ position: [f32; 3],
+ scale: [f32; 3],
+) -> Option<[[f32; 4]; 4]> {
+ if !world_from_effect
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ || !local_basis.iter().flatten().all(|value| value.is_finite())
+ || !position
+ .iter()
+ .chain(scale.iter())
+ .all(|value| value.is_finite())
+ {
+ return None;
+ }
+ let mut scaled_parent = *world_from_effect;
+ for row in 0..3 {
+ for column in 0..3 {
+ scaled_parent[row][column] *= scale[column];
+ }
+ }
+ let mut result = multiply_affine(&scaled_parent, local_basis)?;
+ let world_position = transform_native_effect_point(world_from_effect, position)?;
+ for axis in 0..3 {
+ result[axis][3] = world_position[axis];
+ }
+ result[3] = [0.0, 0.0, 0.0, 1.0];
+ result
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ .then_some(result)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::Arc;
+
+ #[test]
+ fn d110_builds_general_axis_basis_and_preserves_native_axis_length() {
+ let matrix = native_d110_basis([1.0, 2.0, 2.0], [3.0, 4.0, 5.0]).unwrap();
+ let inv_sqrt_5 = 1.0 / 5.0_f32.sqrt();
+ assert_eq!(matrix[0], [-2.0 * inv_sqrt_5, -2.0 * inv_sqrt_5, 1.0, 3.0]);
+ assert_eq!(matrix[1], [inv_sqrt_5, -4.0 * inv_sqrt_5, 2.0, 4.0]);
+ assert_eq!(matrix[2], [0.0, 5.0 / 5.0_f32.sqrt(), 2.0, 5.0]);
+ }
+
+ #[test]
+ fn d110_uses_native_x_then_y_degenerate_fallbacks() {
+ let x_fallback = native_d110_basis([0.0, 1.0, 0.0], [0.0; 3]).unwrap();
+ assert_eq!(x_fallback[0][0..3], [1.0, 0.0, 0.0]);
+ assert_eq!(x_fallback[1][0..3], [0.0, 0.0, 1.0]);
+
+ let y_fallback = native_d110_basis([1.0, 0.0, 0.0], [0.0; 3]).unwrap();
+ assert_eq!(y_fallback[0][0..3], [0.0, 0.0, 1.0]);
+ assert_eq!(y_fallback[1][0..3], [1.0, 0.0, 0.0]);
+ }
+
+ #[test]
+ fn native_point_operation_projects_each_translated_basis_column() {
+ // Deliberately non-symmetric, translated, and non-orthogonal. The
+ // native helper projects independently onto each column and divides
+ // by that column's squared length; this is not a full matrix inverse.
+ let matrix = [
+ [2.0, 3.0, 5.0, 7.0],
+ [11.0, 13.0, 17.0, 19.0],
+ [23.0, 29.0, 31.0, 37.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+ assert_eq!(
+ native_effect_local_point(&matrix, [41.0, 43.0, 47.0]),
+ Some([562.0 / 654.0, 704.0 / 1019.0, 888.0 / 1275.0])
+ );
+
+ let nonuniform_translated = [
+ [2.0, 0.0, 0.0, 1.0],
+ [0.0, 3.0, 0.0, 2.0],
+ [0.0, 0.0, 4.0, 3.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+ assert_eq!(
+ native_effect_local_point(&nonuniform_translated, [6.0, 8.0, 8.0]),
+ Some([2.5, 2.0, 1.25])
+ );
+
+ let tiny_basis = [
+ [0.0001, 0.0, 0.0, 0.0],
+ [0.0, 2.0, 0.0, 0.0],
+ [0.0, 0.0, 3.0, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+ assert_eq!(
+ native_effect_local_point(&tiny_basis, [0.0002, 2.0, 3.0]),
+ Some([2.0, 1.0, 1.0])
+ );
+ }
+
+ #[test]
+ fn native_quad_uses_canonical_corner_order_and_affine_rows() {
+ let matrix = [
+ [2.0, 3.0, 5.0, 7.0],
+ [11.0, 13.0, 17.0, 19.0],
+ [23.0, 29.0, 31.0, 37.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+ assert_eq!(
+ transform_native_effect_quad(&matrix),
+ Some([
+ [7.5, 20.0, 40.0],
+ [4.5, 7.0, 11.0],
+ [6.5, 18.0, 34.0],
+ [9.5, 31.0, 63.0],
+ ])
+ );
+ assert_eq!(
+ EFFECT_QUAD_UV,
+ [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]]
+ );
+ }
+
+ #[test]
+ fn affine_helpers_reject_non_finite_inputs_and_results() {
+ let mut matrix = [[0.0; 4]; 4];
+ matrix[3][3] = 1.0;
+ assert_eq!(
+ transform_native_effect_point(&matrix, [f32::NAN, 0.0, 0.0]),
+ None
+ );
+ matrix[0][0] = f32::INFINITY;
+ assert_eq!(transform_native_effect_quad(&matrix), None);
+ }
+
+ #[test]
+ fn f_gener_ball_snapshot_emits_generated_op9_meshes_and_an_op4_quad() {
+ // Provenance: decoded `effects.rlb/f_gener_ball` snapshot in
+ // target/research-playground/generator-orb/fx-bodies.txt. The source
+ // archive SHA-256 was
+ // 3DAF49A8A4DA2FBC181F86822E125E47EB18F431BB5DF10EC53AC2EDC29C0A89.
+ // Keep only the known command fields here; no licensed archive bytes
+ // or unneeded Op1 semantics are copied into the test.
+ let commands = f_gener_ball_snapshot_commands();
+ assert_eq!(commands.len(), 4);
+ assert_eq!(
+ commands
+ .iter()
+ .map(|command| command.opcode)
+ .collect::<Vec<_>>(),
+ [FxOpcode::Op9, FxOpcode::Op9, FxOpcode::Op4, FxOpcode::Op1]
+ );
+
+ // The decoded FX header carries a uniform 0.75 parent scale. That
+ // scale belongs to the input matrix, not the command body.
+ let parent = uniform_scale_matrix(0.75);
+ let camera = [0.0, 0.0, 500.0 / 0.75];
+ let op9_meshes = commands[..2]
+ .iter()
+ .enumerate()
+ .map(|(index, command)| {
+ evaluate_f_gener_ball_mesh(command, index, &parent, 0.875, 0.3, camera, 0)
+ .expect("native Op9 mode emits a generated hemisphere")
+ })
+ .collect::<Vec<_>>();
+ assert_eq!(op9_meshes.len(), 2);
+ assert_eq!(op9_meshes[0].command_index, 0);
+ assert_eq!(op9_meshes[1].command_index, 1);
+
+ for frame in &op9_meshes {
+ assert_eq!(
+ frame.material,
+ EffectResource {
+ archive: "material.lib".to_owned(),
+ name: "laser_y_hit".to_owned(),
+ }
+ );
+ assert_eq!(frame.normalized_time, 0.5);
+ assert!((frame.color[3] - 0.5).abs() < 1e-6);
+ assert!((frame.material_phase - 0.3).abs() < 1e-6);
+ assert!(frame.active);
+ assert_eq!(frame.native_flag_base, 3);
+ assert_eq!(frame.native_category, 0);
+ assert_eq!(frame.vertices.len(), 88);
+ assert_eq!(frame.uv.len(), 88);
+ assert_eq!(frame.indices.len(), 120);
+ }
+ assert_ne!(op9_meshes[0].vertices, op9_meshes[1].vertices);
+ assert!(evaluate_f_gener_ball(&commands[0], 0, &parent, 0.875, 0.3, camera).is_none());
+
+ let frame = evaluate_f_gener_ball(&commands[2], 2, &parent, 0.875, 0.3, camera)
+ .expect("Op4 remains a billboard quad");
+ assert_eq!(
+ frame.material,
+ EffectResource {
+ archive: "material.lib".to_owned(),
+ name: "glow_y".to_owned(),
+ }
+ );
+ // FastProc projects through the parent basis and divides by its
+ // squared length, so the uniform 0.75 parent scale maps the adjusted
+ // world camera to local distance 500 / (0.75 * 0.75) before Op4 rescales time.
+ assert!((frame.normalized_time - 8.0 / 9.0).abs() < 1e-6);
+ assert!((frame.color[3] - 17.0 / 18.0).abs() < 1e-6);
+ assert!((frame.material_phase - 0.7).abs() < 1e-6);
+ assert!(frame.active);
+ assert_vec3_close(frame.vertices[0], [-3.008_333_4, 3.008_333_4, 0.0]);
+ }
+
+ #[test]
+ fn f_gener_ball_samples_position_scale_and_opacity_curves() {
+ let mut command = f_gener_ball_command(FxOpcode::Op9);
+ let body = Arc::make_mut(&mut command.raw_body);
+ put_vec3(body, 0x30, [2.0, 4.0, 8.0]);
+ put_vec3(body, 0x3C, [1.0, 2.0, 3.0]);
+ put_vec3(body, 0x48, [0.0, 1.0, 0.0]);
+ put_vec3(body, 0x54, [0.0, 0.0, 1.0]);
+ put_vec3(body, 0x60, [1.0, 2.0, 3.0]);
+ put_vec3(body, 0x6C, [3.0, 6.0, 11.0]);
+ // Native XYZ exponent order is body+0x80, +0x7C, +0x78.
+ put_vec3(body, 0x78, [3.0, 2.0, 1.0]);
+ put_f32(body, 0x10, 0.2);
+ put_f32(body, 0x14, 1.0);
+ put_f32(body, 0x18, 2.0);
+
+ let identity = identity_matrix();
+ let sample = evaluate_f_gener_ball_sample(&command, &identity, 0.875, 0.35, [0.0; 3])
+ .expect("curve fields are valid");
+ assert_eq!(sample.normalized_time, 0.5);
+ assert_vec3_close(
+ transform_native_effect_point(&sample.matrix, [0.0; 3]).unwrap(),
+ [1.0, 1.0, 1.0],
+ );
+ for (column, expected_length) in [2.0, 2.5, 2.5].into_iter().enumerate() {
+ let basis = [
+ sample.matrix[0][column],
+ sample.matrix[1][column],
+ sample.matrix[2][column],
+ ];
+ assert!((vector_length(basis).unwrap() - expected_length).abs() < 1e-6);
+ }
+ assert!((sample.color[3] - 0.4).abs() < 1e-6);
+ }
+
+ #[test]
+ fn f_gener_ball_op4_uses_local_camera_distance_and_billboard_basis() {
+ let command = f_gener_ball_snapshot_commands().remove(2);
+ let identity = identity_matrix();
+ let frame = evaluate_f_gener_ball(&command, 2, &identity, 0.875, 0.3, [0.0, 0.0, 500.0])
+ .expect("supported Op4 fixture command");
+
+ assert_eq!(frame.command_index, 2);
+ assert_eq!(
+ frame.material,
+ EffectResource {
+ archive: "material.lib".to_owned(),
+ name: "glow_y".to_owned(),
+ }
+ );
+ assert_eq!(frame.normalized_time, 0.5);
+ assert!((frame.material_phase - 0.7).abs() < 1e-6);
+ assert_eq!(frame.color, [1.0, 1.0, 1.0, 0.75]);
+ assert!(frame.active);
+ let expected_extent = 4.6 * 0.5;
+ assert_eq!(
+ frame.vertices,
+ [
+ [-expected_extent, expected_extent, 0.0],
+ [-expected_extent, -expected_extent, 0.0],
+ [expected_extent, -expected_extent, 0.0],
+ [expected_extent, expected_extent, 0.0],
+ ]
+ );
+
+ let mut far_camera = [0.0, 0.0, 1000.0];
+ let capped = evaluate_f_gener_ball(&command, 2, &identity, 0.875, 0.3, far_camera)
+ .expect("camera distance caps Op4 progress");
+ assert_eq!(capped.normalized_time, 1.0);
+ far_camera[2] = 0.0;
+ assert!(evaluate_f_gener_ball(&command, 2, &identity, 0.875, 0.3, far_camera).is_none());
+
+ let nonuniform_parent = [
+ [2.0, 0.0, 0.0, 1.0],
+ [0.0, 3.0, 0.0, 2.0],
+ [0.0, 0.0, 4.0, 3.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ];
+ let camera_world = [6.0, 8.0, 8.0];
+ let camera_local = native_effect_local_point(&nonuniform_parent, camera_world).unwrap();
+ let expected_time = 0.5 * vector_length(camera_local).unwrap() / 500.0;
+ let transformed =
+ evaluate_f_gener_ball(&command, 2, &nonuniform_parent, 0.875, 0.3, camera_world)
+ .expect("Op4 uses the native column-projection camera query");
+ assert!((transformed.normalized_time - expected_time).abs() < 1e-6);
+ }
+
+ #[test]
+ fn f_gener_ball_phase_uses_interval_age_before_op4_camera_rescaling() {
+ let mut command = f_gener_ball_command(FxOpcode::Op4);
+ let body = Arc::make_mut(&mut command.raw_body);
+ put_f32(body, 0x04, 0.5);
+ let identity = identity_matrix();
+
+ // The command interval maps effect phase 0.875 into age 0.5. Op4's
+ // 250/500 render-distance factor reduces track time to 0.25, while
+ // 105F0/108F0 has already computed MAT0 phase from the unscaled 0.5.
+ let frame = evaluate_f_gener_ball(&command, 2, &identity, 0.875, 0.3, [0.0, 0.0, 250.0])
+ .expect("valid Op4 command");
+ assert_eq!(frame.normalized_time, 0.25);
+ assert_eq!(frame.material_phase, 0.75);
+ }
+
+ #[test]
+ fn f_gener_ball_rejects_unverified_modes_and_non_finite_state() {
+ let mut command = f_gener_ball_command(FxOpcode::Op9);
+ let identity = identity_matrix();
+ Arc::make_mut(&mut command.raw_body)[..4].copy_from_slice(&1u32.to_le_bytes());
+ assert!(
+ evaluate_f_gener_ball_mesh(&command, 0, &identity, 0.875, 0.35, [0.0; 3], 0).is_none()
+ );
+
+ let mut invalid_selector = f_gener_ball_command(FxOpcode::Op9);
+ put_u32(Arc::make_mut(&mut invalid_selector.raw_body), 0xC4, 3);
+ assert!(evaluate_f_gener_ball_mesh(
+ &invalid_selector,
+ 0,
+ &identity,
+ 0.875,
+ 0.35,
+ [0.0; 3],
+ 0
+ )
+ .is_none());
+
+ let command = f_gener_ball_command(FxOpcode::Op9);
+ let mut invalid_parent = identity;
+ invalid_parent[1][2] = f32::NAN;
+ assert!(
+ evaluate_f_gener_ball_mesh(&command, 0, &invalid_parent, 0.875, 0.35, [0.0; 3], 0)
+ .is_none()
+ );
+ }
+
+ #[test]
+ fn op9_c4_selector_maps_to_native_draw_flag_base() {
+ let identity = identity_matrix();
+ for (selector, expected) in [(0, 3), (1, 5), (2, 6)] {
+ let mut command = f_gener_ball_command(FxOpcode::Op9);
+ put_u32(Arc::make_mut(&mut command.raw_body), 0xC4, selector);
+ let frame = evaluate_f_gener_ball_mesh(
+ &command,
+ 0,
+ &identity,
+ 0.875,
+ 0.35,
+ [0.0, 0.0, 500.0],
+ 0,
+ )
+ .expect("native Op9 C4 selector 0..=2");
+ assert_eq!(frame.native_flag_base, expected);
+ assert_eq!(frame.native_category, 0);
+ let (sectors, rings) = match selector {
+ 0 => (8, 3),
+ 1 => (16, 6),
+ 2 => (24, 9),
+ _ => unreachable!(),
+ };
+ let quads = sectors * (rings - 1);
+ assert_eq!(frame.vertices.len(), quads * 4 + sectors * 3);
+ assert_eq!(frame.indices.len(), quads * 6 + sectors * 3);
+ }
+ }
+
+ #[test]
+ fn op9_mesh_uses_native_mode_category_counts_and_projected_uvs() {
+ let identity = identity_matrix();
+ let command = f_gener_ball_command(FxOpcode::Op9);
+ let expected = [
+ [(8_usize, 3_usize), (6, 3), (5, 2), (4, 1)],
+ [(16, 6), (12, 5), (10, 4), (8, 3)],
+ [(24, 9), (20, 8), (16, 6), (10, 4)],
+ ];
+
+ for (selector, mode) in [(0, 0), (1, 1), (2, 2)] {
+ let mut command = command.clone();
+ put_u32(Arc::make_mut(&mut command.raw_body), 0xC4, selector);
+ for category in 0..4_u8 {
+ let mesh = evaluate_f_gener_ball_mesh(
+ &command,
+ 0,
+ &identity,
+ 0.875,
+ 0.35,
+ [0.0, 0.0, 500.0],
+ category,
+ )
+ .expect("all native mode/category combinations have a mesh");
+ let (sectors, rings) = expected[mode][usize::from(category)];
+ let quads = sectors * (rings - 1);
+ assert_eq!(mesh.vertices.len(), quads * 4 + sectors * 3);
+ assert_eq!(mesh.uv.len(), mesh.vertices.len());
+ assert_eq!(mesh.indices.len(), quads * 6 + sectors * 3);
+ assert!(mesh
+ .vertices
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite()));
+ assert!(mesh.uv.iter().flatten().all(|value| value.is_finite()));
+ assert!(mesh
+ .indices
+ .iter()
+ .all(|index| usize::from(*index) < mesh.vertices.len()));
+ }
+ }
+
+ let mut mode0_command = command.clone();
+ put_u32(Arc::make_mut(&mut mode0_command.raw_body), 0xC4, 0);
+ put_vec3(
+ Arc::make_mut(&mut mode0_command.raw_body),
+ 0x48,
+ [0.0, 0.0, 1.0],
+ );
+ put_vec3(
+ Arc::make_mut(&mut mode0_command.raw_body),
+ 0x54,
+ [0.0, 0.0, 1.0],
+ );
+ let mesh = evaluate_f_gener_ball_mesh(
+ &mode0_command,
+ 0,
+ &identity,
+ 0.875,
+ 0.35,
+ [0.0, 0.0, 500.0],
+ 0,
+ )
+ .expect("last selector remains valid");
+ // Native triangle cap starts after all sector-major quads. Sector 0
+ // is pole, next azimuth, current azimuth with XY-projected UVs.
+ let cap_start = (8 * (3 - 1)) * 4;
+ assert_vec3_close(mesh.vertices[cap_start], [0.0, 0.0, 0.4]);
+ assert_eq!(mesh.uv[cap_start], [0.5, 0.5]);
+ let next = mesh.vertices[cap_start + 1];
+ assert!((next[0] - 0.10606602).abs() < 1e-6);
+ assert!((next[1] - 0.14142136).abs() < 1e-6);
+ assert!((next[2] - 0.34641016).abs() < 1e-6);
+ let cap_index_start = (8 * (3 - 1)) * 6;
+ assert_eq!(mesh.indices[cap_index_start], cap_start as u16);
+ assert_eq!(mesh.indices[cap_index_start + 1], cap_start as u16 + 1);
+ assert_eq!(mesh.indices[cap_index_start + 2], cap_start as u16 + 2);
+
+ let mut static_uv_command = mode0_command.clone();
+ put_u32(Arc::make_mut(&mut static_uv_command.raw_body), 0xC8, 1);
+ let static_uv_mesh = evaluate_f_gener_ball_mesh(
+ &static_uv_command,
+ 0,
+ &identity,
+ 0.875,
+ 0.35,
+ [0.0, 0.0, 500.0],
+ 0,
+ )
+ .expect("nonzero native UV flag selects static UV arrays");
+ assert_eq!(&static_uv_mesh.uv[..4], &EFFECT_QUAD_UV);
+ assert_eq!(
+ &static_uv_mesh.uv[cap_start..cap_start + 3],
+ &[[0.5, 0.99], [0.0, 0.0], [0.99, 0.0]]
+ );
+ assert!(evaluate_f_gener_ball_mesh(
+ &command,
+ 0,
+ &identity,
+ 0.875,
+ 0.35,
+ [0.0, 0.0, 500.0],
+ 4,
+ )
+ .is_none());
+ }
+
+ #[test]
+ fn op9_generated_mesh_triangles_keep_native_outward_winding() {
+ for (mode, flag_base) in [(0, 3), (1, 5), (2, 6)] {
+ for category in 0..4_u8 {
+ let (vertices, _, indices) =
+ generated_op9_hemisphere(flag_base, category, false).unwrap();
+ assert!(!vertices.is_empty());
+ assert_eq!(indices.len() % 3, 0);
+ for triangle in indices.chunks_exact(3) {
+ let a = vertices[usize::from(triangle[0])];
+ let b = vertices[usize::from(triangle[1])];
+ let c = vertices[usize::from(triangle[2])];
+ let normal = cross(subtract_vector(b, a), subtract_vector(c, a));
+ let centroid = [
+ (a[0] + b[0] + c[0]) / 3.0,
+ (a[1] + b[1] + c[1]) / 3.0,
+ (a[2] + b[2] + c[2]) / 3.0,
+ ];
+ let outward =
+ normal[0] * centroid[0] + normal[1] * centroid[1] + normal[2] * centroid[2];
+ assert!(
+ outward > 0.0,
+ "mode {mode}, category {category}, triangle {triangle:?}"
+ );
+ }
+ }
+ }
+ }
+
+ #[test]
+ #[ignore = "requires the original GOG installation"]
+ fn original_gog_recharge_and_tree_plate_op9_modes_evaluate() {
+ let root = std::env::var_os("PARKAN_GOG_ROOT")
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|| std::path::PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy"));
+ let archive_bytes: Arc<[u8]> = std::fs::read(root.join("effects.rlb"))
+ .expect("read effects.rlb")
+ .into();
+ let archive = fparkan_nres::decode(archive_bytes, fparkan_nres::ReadProfile::Compatible)
+ .expect("decode effects.rlb");
+ let identity = identity_matrix();
+
+ for name in ["f_recharge_r", "tree_plate_58"] {
+ let entry_id = archive
+ .find(name)
+ .unwrap_or_else(|| panic!("missing {name}"));
+ let document = crate::decode_fxid(Arc::from(
+ archive
+ .payload(entry_id)
+ .expect("FXID payload")
+ .to_vec()
+ .into_boxed_slice(),
+ ))
+ .unwrap_or_else(|error| panic!("decode {name}: {error}"));
+ let mut seen = false;
+ for (index, command) in document.commands().iter().enumerate() {
+ if command.opcode != FxOpcode::Op9
+ || read_u32(&command.raw_body, 0)
+ .unwrap_or_else(|| panic!("{name} malformed Op9 mode"))
+ != 2
+ {
+ continue;
+ }
+ let selector = read_u32(&command.raw_body, 0xC4)
+ .unwrap_or_else(|| panic!("{name} Op9 missing C4 selector"));
+ if !(0..=2).contains(&selector) {
+ continue;
+ }
+ let phase_start = read_f32(&command.raw_body, 0x1C)
+ .unwrap_or_else(|| panic!("{name} Op9 missing phase start"));
+ let phase_end = read_f32(&command.raw_body, 0x20)
+ .unwrap_or_else(|| panic!("{name} Op9 missing phase end"));
+ let frame = evaluate_f_gener_ball_mesh(
+ command,
+ index,
+ &identity,
+ (phase_start + phase_end) * 0.5,
+ 0.0,
+ [0.0, 0.0, 500.0],
+ 0,
+ )
+ .unwrap_or_else(|| panic!("{name} Op9 selector {selector} did not evaluate"));
+ assert_eq!(frame.native_flag_base, [3, 5, 6][selector as usize]);
+ let (sectors, rings) = match selector {
+ 0 => (8, 3),
+ 1 => (16, 6),
+ 2 => (24, 9),
+ _ => unreachable!(),
+ };
+ assert_eq!(
+ frame.vertices.len(),
+ sectors * (rings - 1) * 4 + sectors * 3
+ );
+ seen = true;
+ }
+ assert!(seen, "{name} has no supported Op9 mode");
+ }
+ }
+
+ fn f_gener_ball_snapshot_commands() -> Vec<FxCommand> {
+ let mut first_op9 = f_gener_ball_command(FxOpcode::Op9);
+ let mut second_op9 = f_gener_ball_command(FxOpcode::Op9);
+ put_vec3(
+ Arc::make_mut(&mut second_op9.raw_body),
+ 0x48,
+ [1.0, 0.0, 0.0],
+ );
+ put_vec3(
+ Arc::make_mut(&mut second_op9.raw_body),
+ 0x54,
+ [1.0, 0.0, 0.0],
+ );
+ // Both checked Op9 commands have opacity 0 -> 1 and opposite X axes.
+ put_f32(Arc::make_mut(&mut first_op9.raw_body), 0x10, 0.0);
+ put_f32(Arc::make_mut(&mut second_op9.raw_body), 0x10, 0.0);
+
+ let mut op4 = f_gener_ball_command(FxOpcode::Op4);
+ put_f32(Arc::make_mut(&mut op4.raw_body), 0x10, 0.5);
+
+ // The checked decoder confirms only that the fourth row is Op1. Its
+ // raw body is intentionally absent: this evaluator does not claim its
+ // layout or behavior.
+ let op1 = FxCommand {
+ word: 1,
+ opcode: FxOpcode::Op1,
+ native_flag: false,
+ raw_body: Arc::from([]),
+ resource_refs: Vec::new(),
+ };
+ vec![first_op9, second_op9, op4, op1]
+ }
+
+ fn f_gener_ball_command(opcode: FxOpcode) -> FxCommand {
+ let is_op9 = opcode == FxOpcode::Op9;
+ let mut body = vec![0u8; if is_op9 { 0xCC } else { 0xC8 }];
+ let mode = if is_op9 { 2 } else { 0 };
+ put_u32(&mut body, 0x00, mode);
+ put_f32(&mut body, 0x04, -1.0);
+ put_f32(&mut body, 0x08, if is_op9 { 11.0 } else { 9.0 });
+ put_f32(&mut body, 0x0C, 1.0);
+ put_f32(&mut body, 0x10, 0.0);
+ put_f32(&mut body, 0x14, 1.0);
+ put_f32(&mut body, 0x18, 1.0);
+ put_f32(&mut body, 0x1C, 0.75);
+ put_f32(&mut body, 0x20, 1.0);
+ put_vec3(&mut body, 0x24, [0.0; 3]);
+ put_vec3(&mut body, 0x30, [0.0; 3]);
+ put_vec3(&mut body, 0x3C, [1.0; 3]);
+ let axis = if is_op9 {
+ [-1.0, 0.0, 0.0]
+ } else {
+ [1.0, 0.0, 0.0]
+ };
+ put_vec3(&mut body, 0x48, axis);
+ put_vec3(&mut body, 0x54, axis);
+ let scale_start = if is_op9 { [0.3, 0.4, 0.4] } else { [0.2; 3] };
+ let scale_end = if is_op9 { [0.3, 0.4, 0.4] } else { [9.0; 3] };
+ put_vec3(&mut body, 0x60, scale_start);
+ put_vec3(&mut body, 0x6C, scale_end);
+ put_vec3(&mut body, 0x78, [1.0; 3]);
+ put_name(&mut body, 0x84, "material.lib");
+ put_name(
+ &mut body,
+ 0xA4,
+ if is_op9 { "laser_y_hit" } else { "glow_y" },
+ );
+ if is_op9 {
+ put_u32(&mut body, 0xC4, 0);
+ } else {
+ put_f32(&mut body, 0xC4, 500.0);
+ }
+ FxCommand {
+ word: if is_op9 { 9 } else { 4 },
+ opcode,
+ native_flag: false,
+ raw_body: Arc::from(body),
+ resource_refs: Vec::new(),
+ }
+ }
+
+ 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 (index, component) in value.into_iter().enumerate() {
+ put_f32(body, offset + index * 4, component);
+ }
+ }
+
+ fn put_name(body: &mut [u8], offset: usize, value: &str) {
+ body[offset..offset + value.len()].copy_from_slice(value.as_bytes());
+ }
+
+ fn identity_matrix() -> [[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 uniform_scale_matrix(scale: f32) -> [[f32; 4]; 4] {
+ [
+ [scale, 0.0, 0.0, 0.0],
+ [0.0, scale, 0.0, 0.0],
+ [0.0, 0.0, scale, 0.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ]
+ }
+
+ fn assert_vec3_close(actual: [f32; 3], expected: [f32; 3]) {
+ for (actual, expected) in actual.into_iter().zip(expected) {
+ assert!((actual - expected).abs() < 1e-6, "{actual} != {expected}");
+ }
+ }
+}
diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs
index 2ef5b56..4125a68 100644
--- a/crates/fparkan-fx/src/lib.rs
+++ b/crates/fparkan-fx/src/lib.rs
@@ -6,10 +6,27 @@
//! reference stub until opcode timing, gates, RNG, and command-body semantics
//! are backed by runtime-captured evidence.
+pub mod ambient_op3;
pub mod atmosphere;
pub mod environment;
+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.
+///
+/// Native helpers store `low' = low * 2 ^ high` and `high' = high >> 1 ^ low'`.
+#[must_use]
+pub fn advance_native_fx_random_state(state: &mut u32) -> u16 {
+ let low = *state as u16;
+ let high = (*state >> 16) as u16;
+ let next_low = low.wrapping_shl(1) ^ high;
+ let next_high = (high >> 1) ^ next_low;
+ *state = u32::from(next_low) | (u32::from(next_high) << 16);
+ next_high
+}
use fparkan_binary::{Cursor, DecodeError};
use std::sync::Arc;
diff --git a/crates/fparkan-fx/src/op7.rs b/crates/fparkan-fx/src/op7.rs
new file mode 100644
index 0000000..ac31f44
--- /dev/null
+++ b/crates/fparkan-fx/src/op7.rs
@@ -0,0 +1,1443 @@
+//! Native-shaped evaluation for opcode-7 particle pools.
+//!
+//! Op7 is a fixed pool of independently randomized position and scale tracks.
+//! It has a different lifecycle from the scheduled ring used by Op8.
+
+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 = 0x8C;
+const RESOURCE_BYTES: usize = 64;
+const MIN_BODY_SIZE: usize = RESOURCE_OFFSET + RESOURCE_BYTES;
+const MAX_PARTICLES: usize = 4096;
+const U16_TO_UNIT: f32 = 1.0 / 65_536.0;
+
+#[derive(Clone, Copy, Debug)]
+struct Track {
+ start: [f32; 3],
+ end: [f32; 3],
+ exponent: [f32; 3],
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+struct Lfsr16Pair {
+ state: u32,
+}
+
+impl Lfsr16Pair {
+ fn from_word(state: u32) -> Self {
+ Self { state }
+ }
+
+ fn next_unit(&mut self) -> f32 {
+ f32::from(crate::advance_native_fx_random_state(&mut self.state)) * U16_TO_UNIT
+ }
+
+ fn next_signed(&mut self, width: f32) -> f32 {
+ (self.next_unit() - 0.5) * width
+ }
+}
+
+#[derive(Clone, Debug)]
+struct Particle {
+ group: usize,
+ helper_mode: u32,
+ visibility_cutoff: f32,
+ position: Track,
+ scale: Track,
+}
+
+/// Mutable deterministic state for one Op7 command instance.
+///
+/// Native Op7 initializes all slots once and then samples their fixed tracks;
+/// retain one state per command instance and call [`evaluate_op7_emitter`] on
+/// each update. The seed only supplies deterministic stand-ins for the
+/// process-global native random streams.
+#[derive(Clone, Debug)]
+pub struct Op7EmitterState {
+ initial_particle_rng: Lfsr16Pair,
+ initial_visibility_rng: Lfsr16Pair,
+ particle_rng: Lfsr16Pair,
+ visibility_rng: Lfsr16Pair,
+ body: Option<Arc<[u8]>>,
+ visibility_profile: Option<Op7VisibilityProfile>,
+ particles: Vec<Particle>,
+ last_clock: Option<f32>,
+ group_clocks: Vec<f32>,
+}
+
+impl Op7EmitterState {
+ /// Creates deterministic evaluator state from particle and visibility seeds.
+ #[must_use]
+ pub fn new(particle_seed: u32, visibility_seed: u32) -> Self {
+ let initial_particle_rng = Lfsr16Pair::from_word(particle_seed);
+ let initial_visibility_rng = Lfsr16Pair::from_word(visibility_seed);
+ Self {
+ initial_particle_rng,
+ initial_visibility_rng,
+ particle_rng: initial_particle_rng,
+ visibility_rng: initial_visibility_rng,
+ body: None,
+ visibility_profile: None,
+ particles: Vec::new(),
+ last_clock: None,
+ group_clocks: Vec::new(),
+ }
+ }
+
+ /// Clears initialized slots and restores the initial random streams.
+ fn reset(&mut self) {
+ self.particle_rng = self.initial_particle_rng;
+ self.visibility_rng = self.initial_visibility_rng;
+ self.body = None;
+ self.visibility_profile = None;
+ self.particles.clear();
+ self.last_clock = None;
+ self.group_clocks.clear();
+ }
+}
+
+/// Native Op7's per-slot visibility range from the effect settings tables.
+///
+/// `width` and `bias` are the pair selected by the caller's native settings
+/// context (`settings_id >> 8`) for helper 0xEC50. The sampled cutoff is
+/// `random_unit * width - bias`; this is a detail cutoff, not a time offset.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Op7VisibilityProfile {
+ /// Width from the native settings table.
+ pub width: f32,
+ /// Bias from the native settings table.
+ pub bias: f32,
+}
+
+/// Per-render context consumed by the native Op7 visibility gate.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Op7RenderContext {
+ /// Ordinal returned by the native render-context detail selector.
+ /// The native outer dispatcher bypasses rendering for values >= 8.
+ pub detail_level: u32,
+ /// Native settings-table pair used to initialize fixed slot cutoffs.
+ pub visibility: Op7VisibilityProfile,
+ /// Native world scalar returned by Terrain.dll's GetWorld vslot +0x10.
+ /// Effect.dll multiplies Op7 body B88 by this value and by 0.5.
+ pub world_scalar: f32,
+}
+
+#[derive(Clone, Debug)]
+struct Params {
+ material: EffectResource,
+ helper_mode: u32,
+ pool_count: usize,
+ copies_per_group: usize,
+ start: f32,
+ end: f32,
+ lifetime: f32,
+ material_phase_factor: f32,
+ vertical_acceleration: f32,
+ opacity_start: f32,
+ opacity_end: f32,
+ opacity_exponent: f32,
+ position: Track,
+ position_width: [f32; 3],
+ scale: Track,
+ scale_width: [f32; 3],
+}
+
+impl Params {
+ fn parse(command: &FxCommand, world_scalar: f32) -> Option<Self> {
+ let body = &command.raw_body;
+ if body.len() < MIN_BODY_SIZE {
+ return None;
+ }
+ let start = read_f32(body, 0x10)?;
+ let end = read_f32(body, 0x14)?;
+ let lifetime = read_f32(body, 0x18)?;
+ let material_phase_factor = read_f32(body, 0x1C)?;
+ let vertical_acceleration = read_f32(body, 0x88)? * world_scalar * 0.5;
+ let pool_count = read_u32(body, 0x20)? as usize;
+ let copies_per_group = read_u32(body, 0x24)? as usize;
+ let opacity_start = read_f32(body, 0x04)?;
+ let opacity_end = read_f32(body, 0x08)?;
+ let opacity_exponent = read_f32(body, 0x0C)?;
+ let position = Track {
+ start: read_vec3(body, 0x28)?,
+ end: read_vec3(body, 0x34)?,
+ exponent: read_vec3(body, 0x4C)?,
+ };
+ let position_width = read_vec3(body, 0x40)?;
+ let scale = Track {
+ start: read_vec3(body, 0x58)?,
+ end: read_vec3(body, 0x64)?,
+ exponent: read_vec3(body, 0x7C)?,
+ };
+ let scale_width = read_vec3(body, 0x70)?;
+ let particle_count = pool_count.checked_mul(copies_per_group)?;
+ let mode_field = read_u32(body, 0x00)?;
+ let helper_mode = if mode_field == 1 { 3 } else { 0 };
+ if start >= end
+ || !lifetime.is_finite()
+ || lifetime <= 0.0
+ || !material_phase_factor.is_finite()
+ || material_phase_factor == 0.0
+ || !world_scalar.is_finite()
+ || !vertical_acceleration.is_finite()
+ || pool_count == 0
+ || copies_per_group == 0
+ || particle_count > MAX_PARTICLES
+ || ![start, end, opacity_start, opacity_end, opacity_exponent]
+ .iter()
+ .all(|value| value.is_finite())
+ || !position
+ .start
+ .iter()
+ .chain(position.end.iter())
+ .chain(position.exponent.iter())
+ .chain(position_width.iter())
+ .chain(scale.start.iter())
+ .chain(scale.end.iter())
+ .chain(scale.exponent.iter())
+ .chain(scale_width.iter())
+ .all(|value| value.is_finite())
+ {
+ return None;
+ }
+ Some(Self {
+ material: read_resource(body)?,
+ helper_mode,
+ pool_count,
+ copies_per_group,
+ start,
+ end,
+ lifetime,
+ material_phase_factor,
+ vertical_acceleration,
+ opacity_start,
+ opacity_end,
+ opacity_exponent,
+ position,
+ position_width,
+ scale,
+ scale_width,
+ })
+ }
+}
+
+/// Evaluates an Op7 fixed particle pool at one effect phase.
+///
+/// `effect_phase` is the native command clock used by the inclusive body
+/// window. `world_from_effect` is the owner transform after the FX header
+/// transform, and `camera_world` is the render context point used by the
+/// particle billboard. Frames are returned only for slots whose staggered
+/// lifetime is currently active.
+#[must_use]
+pub fn evaluate_op7_emitter(
+ command: &FxCommand,
+ command_index: usize,
+ world_from_effect: &[[f32; 4]; 4],
+ camera_world: [f32; 3],
+ effect_phase: f32,
+ elapsed_seconds: f32,
+ render_context: Op7RenderContext,
+ state: &mut Op7EmitterState,
+) -> Option<Vec<EffectQuadFrame>> {
+ if command.opcode != FxOpcode::Op7
+ || !world_from_effect
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ || !camera_world.iter().all(|value| value.is_finite())
+ || !effect_phase.is_finite()
+ || !elapsed_seconds.is_finite()
+ || !render_context.visibility.width.is_finite()
+ || !render_context.visibility.bias.is_finite()
+ || !render_context.world_scalar.is_finite()
+ || render_context.visibility.width < 0.0
+ {
+ return None;
+ }
+ let params = Params::parse(command, render_context.world_scalar)?;
+ if state
+ .body
+ .as_deref()
+ .is_some_and(|body| body != command.raw_body.as_ref())
+ {
+ state.reset();
+ }
+ if state
+ .visibility_profile
+ .is_some_and(|profile| profile != render_context.visibility)
+ {
+ // The native base class samples these cutoffs once during object
+ // initialization. A caller changing the settings pair mid-emitter is
+ // not the same native state, so reject that frame instead of silently
+ // rerolling particle and visibility RNGs.
+ return None;
+ }
+ state.body = Some(Arc::clone(&command.raw_body));
+ if state.particles.is_empty() {
+ initialize_particles(&params, render_context.visibility, state)?;
+ state.visibility_profile = Some(render_context.visibility);
+ }
+
+ let frame_delta = elapsed_seconds - state.last_clock.unwrap_or(0.0);
+ if !frame_delta.is_finite() {
+ return None;
+ }
+ let in_window = effect_phase >= params.start && effect_phase <= params.end;
+ state.last_clock = Some(elapsed_seconds);
+
+ if render_context.detail_level >= 8 || !in_window {
+ return Some(Vec::new());
+ }
+ let window_phase = (effect_phase - params.start) / (params.end - params.start);
+ let mut frames = Vec::with_capacity(params.particles_len());
+ let mut group_contexts = Vec::with_capacity(params.pool_count);
+ for group in 0..params.pool_count {
+ let stagger = (1.0 - params.lifetime) * group as f32 / params.pool_count as f32;
+ let group_age = (window_phase - stagger) / params.lifetime;
+ if !(0.0..=1.0).contains(&group_age) {
+ group_contexts.push(None);
+ continue;
+ }
+ let mut owner_matrix = *world_from_effect;
+ if params.vertical_acceleration != 0.0 {
+ let group_clock = *state.group_clocks.get(group)?;
+ let delta_z = group_clock * group_clock * params.vertical_acceleration;
+ if !delta_z.is_finite() {
+ return None;
+ }
+ // The native update accumulator updates the emitter's bounds
+ // position, while the renderer applies only this group's clock
+ // to its copied owner matrix.
+ owner_matrix[2][3] -= delta_z;
+ let group_clock = state.group_clocks.get_mut(group)?;
+ *group_clock += frame_delta;
+ if !group_clock.is_finite() {
+ return None;
+ }
+ }
+ let camera_local = native_effect_local_point(&owner_matrix, camera_world)?;
+ group_contexts.push(Some((owner_matrix, camera_local)));
+ }
+ for particle in &state.particles {
+ // Renderer 01300 converts context+0x60's DWORD ordinal with FILD and
+ // skips this slot only when the ordinal compares greater than the
+ // native per-slot float in state+0x2c.
+ if render_context.detail_level as f32 > particle.visibility_cutoff {
+ continue;
+ }
+ // Native 01000 spaces group starts by (1 - lifetime) / pool_count;
+ // the second pool dimension shares its group's start phase.
+ let stagger = (1.0 - params.lifetime) * particle.group as f32 / params.pool_count as f32;
+ let age = (window_phase - stagger) / params.lifetime;
+ if !(0.0..=1.0).contains(&age) {
+ continue;
+ }
+ let Some(position) = sample_track(particle.position, age) else {
+ return None;
+ };
+ let Some(scale) = sample_track(particle.scale, age) else {
+ return None;
+ };
+ let Some((owner_matrix, camera_local)) = group_contexts.get(particle.group)?.as_ref()
+ else {
+ continue;
+ };
+ let camera_local = *camera_local;
+ let opacity = params.opacity_start
+ + (params.opacity_end - params.opacity_start) * age.powf(params.opacity_exponent);
+ if !opacity.is_finite() {
+ return None;
+ }
+ let Some(local_basis) =
+ native_mode_basis(particle.helper_mode, owner_matrix, camera_local, position)
+ else {
+ continue;
+ };
+ let matrix = compose_native_effect_matrix(owner_matrix, &local_basis, position, scale)?;
+ let vertices = transform_native_effect_quad(&matrix)?;
+ frames.push(EffectQuadFrame {
+ command_index,
+ opcode: FxOpcode::Op7,
+ material: params.material.clone(),
+ vertices,
+ uv: EFFECT_QUAD_UV,
+ color: [1.0, 1.0, 1.0, opacity.clamp(0.0, 1.0)],
+ // Native material timing uses the normalized effect-window phase
+ // minus the group offset. It does not reuse the lifetime-scaled
+ // particle age used by position, scale, and opacity.
+ material_phase: native_material_phase(
+ window_phase - stagger,
+ params.material_phase_factor,
+ )?,
+ elapsed_seconds,
+ normalized_time: age,
+ active: true,
+ native_flag_base: None,
+ });
+ }
+ Some(frames)
+}
+
+impl Params {
+ fn particles_len(&self) -> usize {
+ self.pool_count * self.copies_per_group
+ }
+}
+
+fn initialize_particles(
+ params: &Params,
+ visibility: Op7VisibilityProfile,
+ state: &mut Op7EmitterState,
+) -> Option<()> {
+ state.particles.reserve(params.particles_len());
+ state.group_clocks.resize(params.pool_count, 0.0);
+ for group in 0..params.pool_count {
+ for _ in 0..params.copies_per_group {
+ state.particles.push(Particle {
+ group,
+ helper_mode: params.helper_mode,
+ visibility_cutoff: state.visibility_rng.next_unit() * visibility.width
+ - visibility.bias,
+ position: randomize_track(
+ params.position,
+ params.position_width,
+ &mut state.particle_rng,
+ )?,
+ scale: randomize_track(params.scale, params.scale_width, &mut state.particle_rng)?,
+ });
+ }
+ }
+ Some(())
+}
+
+fn randomize_track(
+ mut track: Track,
+ width: [f32; 3],
+ particle_rng: &mut Lfsr16Pair,
+) -> Option<Track> {
+ // Native 01720 advances the same process-global state for Z, Y, and X;
+ // scale randomization continues that stream after position randomization.
+ track.end[2] += particle_rng.next_unit() * width[2] - width[2] * 0.5;
+ track.end[1] += particle_rng.next_signed(width[1]);
+ track.end[0] += particle_rng.next_signed(width[0]);
+ track
+ .start
+ .iter()
+ .chain(track.end.iter())
+ .chain(track.exponent.iter())
+ .all(|value| value.is_finite())
+ .then_some(track)
+}
+
+fn native_material_phase(window_phase: f32, factor: f32) -> Option<f32> {
+ let phase = if factor > 1.0 {
+ (window_phase * factor).fract()
+ } else {
+ window_phase / factor
+ };
+ phase
+ .is_finite()
+ .then_some(if phase >= 1.0 { 0.99 } else { phase })
+}
+
+fn sample_track(track: Track, phase: f32) -> Option<[f32; 3]> {
+ if !phase.is_finite() || !(0.0..=1.0).contains(&phase) {
+ return None;
+ }
+ let mut result = [0.0; 3];
+ for axis in 0..3 {
+ let curve = phase.powf(track.exponent[axis]);
+ result[axis] = track.start[axis] + (track.end[axis] - track.start[axis]) * curve;
+ }
+ result
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some(result)
+}
+
+fn native_mode_basis(
+ helper_mode: u32,
+ world_from_effect: &[[f32; 4]; 4],
+ camera_local: [f32; 3],
+ position: [f32; 3],
+) -> Option<[[f32; 4]; 4]> {
+ match helper_mode {
+ 0 => {
+ let view = subtract(camera_local, position);
+ native_camera_facing_basis(view, position)
+ }
+ 3 => native_mode_three_basis(world_from_effect, camera_local, position),
+ _ => None,
+ }
+}
+
+/// Reproduces Op7's native helper-mode-3 basis selection (Effect.dll 9575,
+/// with 038D0 as its degenerate-cross fallback).
+fn native_mode_three_basis(
+ world_from_effect: &[[f32; 4]; 4],
+ camera_local: [f32; 3],
+ position: [f32; 3],
+) -> Option<[[f32; 4]; 4]> {
+ // The slot helper's +0x54 point is initialized to zero by 092D0. Native
+ // FastProc+0x68 projects that world point through the owner matrix before
+ // using it as the forward-vector origin.
+ let origin_local = native_effect_local_point(world_from_effect, [0.0; 3])?;
+ let forward = normalize(subtract(position, origin_local))?;
+ let to_camera = subtract(camera_local, position);
+ let dot = dot(forward, to_camera);
+ if !dot.is_finite() || dot.abs() == 1.0 {
+ // The x87 comparison at 9667..9683 clears the helper's active byte for
+ // the exact-equality or unordered path.
+ return None;
+ }
+
+ let right = cross(to_camera, forward);
+ let local_right = match normalize(right) {
+ Some(right) => right,
+ None => mode_three_fallback_right(forward)?,
+ };
+ Some(matrix_from_forward_right(forward, local_right, position))
+}
+
+fn mode_three_fallback_right(forward: [f32; 3]) -> Option<[f32; 3]> {
+ let right = if forward[0] == 0.0 {
+ [0.0, 1.0, 0.0]
+ } else if forward[1] == 0.0 {
+ [1.0, 0.0, 0.0]
+ } else {
+ [-forward[1], forward[0], 0.0]
+ };
+ if forward[2] != 0.0 && forward[0] != 0.0 && forward[1] != 0.0 {
+ normalize(right)
+ } else {
+ Some(right)
+ }
+}
+
+fn matrix_from_forward_right(
+ forward: [f32; 3],
+ right: [f32; 3],
+ position: [f32; 3],
+) -> [[f32; 4]; 4] {
+ let up = cross(forward, right);
+ [
+ [forward[0], right[0], up[0], position[0]],
+ [forward[1], right[1], up[1], position[1]],
+ [forward[2], right[2], up[2], position[2]],
+ [0.0, 0.0, 0.0, 1.0],
+ ]
+}
+
+fn subtract(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [left[0] - right[0], left[1] - right[1], left[2] - right[2]]
+}
+
+fn dot(left: [f32; 3], right: [f32; 3]) -> f32 {
+ left[0] * right[0] + left[1] * right[1] + left[2] * right[2]
+}
+
+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 normalize(vector: [f32; 3]) -> Option<[f32; 3]> {
+ if !vector.iter().all(|component| component.is_finite()) {
+ return None;
+ }
+ let length = dot(vector, vector).sqrt();
+ if !length.is_finite() || length == 0.0 {
+ return None;
+ }
+ Some([vector[0] / length, vector[1] / length, vector[2] / length])
+}
+
+fn read_resource(body: &[u8]) -> Option<EffectResource> {
+ let bytes = body.get(RESOURCE_OFFSET..RESOURCE_OFFSET + RESOURCE_BYTES)?;
+ let archive = read_name(&bytes[..32])?;
+ let name = read_name(&bytes[32..])?;
+ (!archive.is_empty() && !name.is_empty()).then_some(EffectResource { archive, name })
+}
+
+fn read_name(bytes: &[u8]) -> Option<String> {
+ let end = bytes
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(bytes.len());
+ Some(std::str::from_utf8(&bytes[..end]).ok()?.trim().to_owned())
+}
+
+fn read_u32(body: &[u8], offset: usize) -> Option<u32> {
+ Some(u32::from_le_bytes(
+ body.get(offset..offset + 4)?.try_into().ok()?,
+ ))
+}
+
+fn read_f32(body: &[u8], offset: usize) -> Option<f32> {
+ Some(f32::from_le_bytes(
+ body.get(offset..offset + 4)?.try_into().ok()?,
+ ))
+}
+
+fn read_vec3(body: &[u8], offset: usize) -> Option<[f32; 3]> {
+ Some([
+ read_f32(body, offset)?,
+ read_f32(body, offset + 4)?,
+ read_f32(body, offset + 8)?,
+ ])
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ evaluate_op7_emitter, Lfsr16Pair, Op7EmitterState, Op7RenderContext, Op7VisibilityProfile,
+ };
+ use crate::{decode_fxid, FxCommand, FxOpcode, FXID_KIND};
+ use fparkan_nres::{decode, ReadProfile};
+ use std::path::PathBuf;
+ use std::sync::Arc;
+
+ fn command() -> FxCommand {
+ let mut body = vec![0; 0xCC];
+ put_f32(&mut body, 0x04, 1.0);
+ put_f32(&mut body, 0x08, 0.0);
+ put_f32(&mut body, 0x0C, 1.0);
+ put_f32(&mut body, 0x10, 0.05);
+ put_f32(&mut body, 0x14, 0.5);
+ put_f32(&mut body, 0x18, 0.5);
+ put_f32(&mut body, 0x1C, 1.5);
+ put_u32(&mut body, 0x20, 6);
+ put_u32(&mut body, 0x24, 1);
+ put_vec3(&mut body, 0x28, [0.0, 0.0, 0.0]);
+ put_vec3(&mut body, 0x34, [1.0, 0.0, 6.0]);
+ put_vec3(&mut body, 0x40, [1.0, 1.0, 2.0]);
+ put_vec3(&mut body, 0x4C, [1.0; 3]);
+ put_vec3(&mut body, 0x58, [2.5; 3]);
+ put_vec3(&mut body, 0x64, [2.0; 3]);
+ put_vec3(&mut body, 0x70, [1.5; 3]);
+ put_vec3(&mut body, 0x7C, [1.0; 3]);
+ body[0x8C..0x98].copy_from_slice(b"material.lib");
+ body[0xAC..0xB4].copy_from_slice(b"smoke_yg");
+ FxCommand {
+ word: 0x107,
+ opcode: FxOpcode::Op7,
+ native_flag: true,
+ raw_body: Arc::from(body.into_boxed_slice()),
+ resource_refs: Vec::new(),
+ }
+ }
+
+ fn b88_command(pool_count: u32, lifetime: f32) -> FxCommand {
+ let mut command = command();
+ let mut body = command.raw_body.to_vec();
+ put_f32(&mut body, 0x10, 0.0);
+ put_f32(&mut body, 0x14, 1.0);
+ put_f32(&mut body, 0x18, lifetime);
+ put_u32(&mut body, 0x20, pool_count);
+ put_u32(&mut body, 0x24, 1);
+ put_vec3(&mut body, 0x28, [0.0; 3]);
+ put_vec3(&mut body, 0x34, [0.0; 3]);
+ put_vec3(&mut body, 0x40, [0.0; 3]);
+ put_vec3(&mut body, 0x58, [1.0; 3]);
+ put_vec3(&mut body, 0x64, [1.0; 3]);
+ put_vec3(&mut body, 0x70, [0.0; 3]);
+ put_f32(&mut body, 0x88, 1.0);
+ command.raw_body = Arc::from(body.into_boxed_slice());
+ command
+ }
+
+ fn render_context(detail_level: u32) -> Op7RenderContext {
+ Op7RenderContext {
+ detail_level,
+ // Synthetic profile used to exercise the native ordinal gate.
+ // Production callers pass their resolved runtime settings pair.
+ visibility: Op7VisibilityProfile {
+ width: 3.0,
+ bias: 0.0,
+ },
+ world_scalar: 10.0,
+ }
+ }
+
+ fn matrix_center_z(frame: &crate::fx_visual::EffectQuadFrame) -> f32 {
+ frame.vertices.iter().map(|vertex| vertex[2]).sum::<f32>() / 4.0
+ }
+
+ #[test]
+ fn op7_lfsr_matches_native_two_half_transition() {
+ let mut rng = Lfsr16Pair { state: 0x1234_5678 };
+ assert_eq!(rng.next_unit(), f32::from(0xB7DE_u16) / 65_536.0);
+ assert_eq!(rng, Lfsr16Pair { state: 0xB7DE_BEC4 });
+ assert_eq!(rng.next_unit(), f32::from(0x91B9_u16) / 65_536.0);
+ assert_eq!(rng, Lfsr16Pair { state: 0x91B9_CA56 });
+ }
+
+ 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 (index, component) in value.into_iter().enumerate() {
+ put_f32(body, offset + index * 4, component);
+ }
+ }
+
+ fn centroid(frame: &crate::fx_visual::EffectQuadFrame) -> [f32; 3] {
+ std::array::from_fn(|axis| {
+ frame
+ .vertices
+ .iter()
+ .map(|vertex| vertex[axis])
+ .sum::<f32>()
+ / 4.0
+ })
+ }
+
+ #[test]
+ fn op7_samples_randomized_tracks_and_native_group_stagger() {
+ let command = command();
+ let identity = [
+ [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],
+ ];
+ let mut state = Op7EmitterState::new(17, 29);
+
+ assert!(
+ evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ [0.0, 0.0, -20.0],
+ 0.05,
+ 0.0,
+ render_context(0),
+ &mut state,
+ )
+ .unwrap()
+ .len()
+ <= 1
+ );
+ let frames = evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ [0.0, 0.0, -20.0],
+ 0.25,
+ 0.5,
+ render_context(0),
+ &mut state,
+ )
+ .unwrap();
+ assert_eq!(frames.len(), 6);
+ assert!(frames.iter().all(|frame| frame.opcode == FxOpcode::Op7));
+ assert!(frames.iter().all(|frame| frame.material.name == "smoke_yg"));
+ let first_age = frames[0].normalized_time;
+ assert!((first_age - 0.888_888_9).abs() < 1e-5);
+ for (left, right) in frames.iter().zip(frames.iter().skip(1)) {
+ assert!((left.normalized_time - right.normalized_time - 1.0 / 6.0).abs() < 1e-5);
+ }
+ assert!((frames[0].color[3] - (1.0 - first_age)).abs() < 1e-5);
+ let window_phase = (0.25_f32 - 0.05) / (0.5 - 0.05);
+ let native_material_phase = (window_phase * 1.5).fract();
+ assert!((frames[0].material_phase - native_material_phase).abs() < 1e-5);
+ // Native material timing uses the window phase (about 0.444), not
+ // the lifetime-scaled particle age (about 0.889).
+ assert!((frames[0].material_phase - first_age).abs() > 0.1);
+ assert!(centroid(&frames[0])[2] > 0.0);
+ }
+
+ #[test]
+ fn op7_b88_uses_world_scalar_and_moves_owner_and_camera_basis() {
+ let command = b88_command(1, 1.0);
+ let mut unaccelerated = command.clone();
+ Arc::make_mut(&mut unaccelerated.raw_body)[0x88..0x8C]
+ .copy_from_slice(&0.0_f32.to_le_bytes());
+ let identity = [
+ [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],
+ ];
+ let camera = [10.0, 2.0, 10.0];
+
+ let mut default_state = Op7EmitterState::new(31, 31);
+ let default_context = render_context(0);
+ let default_first = evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ camera,
+ 0.25,
+ 0.2,
+ default_context,
+ &mut default_state,
+ )
+ .expect("native default world scalar");
+ assert_eq!(default_first.len(), 1);
+ assert!(matrix_center_z(&default_first[0]).abs() < 1e-6);
+ assert!((default_state.group_clocks[0] - 0.2).abs() < 1e-6);
+ let default_frames = evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ camera,
+ 0.3,
+ 0.4,
+ default_context,
+ &mut default_state,
+ )
+ .expect("second update renders with prior group clock only");
+ assert!((matrix_center_z(&default_frames[0]) + 0.2).abs() < 1e-6);
+ assert!((default_state.group_clocks[0] - 0.4).abs() < 1e-6);
+
+ let mut alternate_state = Op7EmitterState::new(31, 31);
+ let mut alternate_context = render_context(0);
+ alternate_context.world_scalar = 4.0;
+ let alternate_first = evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ camera,
+ 0.25,
+ 0.2,
+ alternate_context,
+ &mut alternate_state,
+ )
+ .expect("alternate world scalar");
+ assert!(matrix_center_z(&alternate_first[0]).abs() < 1e-6);
+ let alternate_frames = evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ camera,
+ 0.3,
+ 0.4,
+ alternate_context,
+ &mut alternate_state,
+ )
+ .expect("alternate scalar renders with prior group clock only");
+ assert!((matrix_center_z(&alternate_frames[0]) + 0.08).abs() < 1e-6);
+
+ let mut unaccelerated_state = Op7EmitterState::new(31, 31);
+ let unaccelerated_first = evaluate_op7_emitter(
+ &unaccelerated,
+ 0,
+ &identity,
+ camera,
+ 0.25,
+ 0.2,
+ default_context,
+ &mut unaccelerated_state,
+ )
+ .expect("zero B88 command");
+ let unaccelerated_frames = evaluate_op7_emitter(
+ &unaccelerated,
+ 0,
+ &identity,
+ camera,
+ 0.3,
+ 0.4,
+ default_context,
+ &mut unaccelerated_state,
+ )
+ .expect("zero B88 command on second update");
+ assert!(matrix_center_z(&unaccelerated_first[0]).abs() < 1e-6);
+ let edge = |frame: &crate::fx_visual::EffectQuadFrame| {
+ std::array::from_fn::<_, 3, _>(|axis| frame.vertices[3][axis] - frame.vertices[0][axis])
+ };
+ assert_ne!(edge(&default_frames[0]), edge(&unaccelerated_frames[0]));
+ }
+
+ #[test]
+ fn op7_group_clocks_advance_only_for_active_groups_and_remain_separate() {
+ let command = b88_command(2, 0.5);
+ let identity = [
+ [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],
+ ];
+ let mut state = Op7EmitterState::new(37, 37);
+ let context = render_context(0);
+
+ let first = evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ [10.0, 2.0, 10.0],
+ 0.2,
+ 0.2,
+ context,
+ &mut state,
+ )
+ .expect("first active phase");
+ assert_eq!(first.len(), 1);
+ assert_eq!(state.group_clocks, [0.2, 0.0]);
+ assert!(matrix_center_z(&first[0]).abs() < 1e-6);
+
+ let inactive = evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ [10.0, 2.0, 10.0],
+ 1.2,
+ 0.6,
+ context,
+ &mut state,
+ )
+ .expect("outside the active window");
+ assert!(inactive.is_empty());
+ assert_eq!(state.group_clocks, [0.2, 0.0]);
+
+ let resumed = evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ [10.0, 2.0, 10.0],
+ 0.4,
+ 0.7,
+ context,
+ &mut state,
+ )
+ .expect("re-enter the active window");
+ assert_eq!(resumed.len(), 2);
+ assert!((state.group_clocks[0] - 0.3).abs() < 1e-6);
+ assert!((state.group_clocks[1] - 0.1).abs() < 1e-6);
+ assert!((matrix_center_z(&resumed[0]) + 0.2).abs() < 1e-5);
+ assert!(matrix_center_z(&resumed[1]).abs() < 1e-6);
+ }
+
+ #[test]
+ fn op7_active_group_clocks_advance_before_detail_cutoff() {
+ let command = b88_command(2, 0.5);
+ let identity = [
+ [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],
+ ];
+ let mut context = render_context(0);
+ context.visibility = Op7VisibilityProfile {
+ width: 0.0,
+ bias: 1.0,
+ };
+ let mut state = Op7EmitterState::new(37, 37);
+ let frames = evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ [10.0, 2.0, 10.0],
+ 0.3,
+ 0.3,
+ context,
+ &mut state,
+ )
+ .unwrap();
+
+ assert!(frames.is_empty());
+ assert_eq!(state.group_clocks, [0.3, 0.3]);
+ }
+
+ #[test]
+ fn op7_group_phases_match_native_activity_at_zero_and_one() {
+ let mut command = command();
+ let mut body = command.raw_body.to_vec();
+ put_f32(&mut body, 0x10, 0.0);
+ put_f32(&mut body, 0x14, 1.0);
+ put_u32(&mut body, 0x20, 4);
+ command.raw_body = Arc::from(body.into_boxed_slice());
+ let identity = [
+ [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],
+ ];
+ let mut state = Op7EmitterState::new(17, 17);
+ let mut context = render_context(0);
+ context.visibility = Op7VisibilityProfile {
+ width: 0.0,
+ bias: -1.0,
+ };
+
+ let at_zero = evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ [0.0, 0.0, -20.0],
+ 0.0,
+ 0.0,
+ context,
+ &mut state,
+ )
+ .unwrap();
+ assert_eq!(at_zero.len(), 1);
+ assert!((at_zero[0].normalized_time - 0.0).abs() < 1e-6);
+
+ let before_last_group = evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ [0.0, 0.0, -20.0],
+ 0.3749,
+ 0.0,
+ context,
+ &mut state,
+ )
+ .unwrap();
+ assert_eq!(before_last_group.len(), 3);
+
+ let at_last_group = evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ [0.0, 0.0, -20.0],
+ 0.375,
+ 0.0,
+ context,
+ &mut state,
+ )
+ .unwrap();
+ assert_eq!(at_last_group.len(), 4);
+ assert!(at_last_group
+ .iter()
+ .any(|frame| frame.normalized_time.abs() < 1e-6));
+
+ let at_one = evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ [0.0, 0.0, -20.0],
+ 0.5,
+ 0.0,
+ context,
+ &mut state,
+ )
+ .unwrap();
+ assert_eq!(at_one.len(), 4);
+ assert!((at_one[0].normalized_time - 1.0).abs() < 1e-6);
+
+ let after_first_group = evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ [0.0, 0.0, -20.0],
+ 0.5001,
+ 0.0,
+ context,
+ &mut state,
+ )
+ .unwrap();
+ assert_eq!(after_first_group.len(), 3);
+ assert!(after_first_group
+ .iter()
+ .all(|frame| frame.normalized_time <= 1.0));
+ }
+
+ #[test]
+ fn op7_position_and_scale_randomization_share_the_native_lfsr_stream() {
+ let mut command = command();
+ let mut body = command.raw_body.to_vec();
+ put_u32(&mut body, 0x20, 1);
+ command.raw_body = Arc::from(body.into_boxed_slice());
+ let identity = [
+ [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],
+ ];
+ let mut state = Op7EmitterState::new(0x1234_5678, 0xBEEF);
+ let _ = evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ [0.0, 0.0, -20.0],
+ 0.25,
+ 0.5,
+ render_context(0),
+ &mut state,
+ )
+ .unwrap();
+ let particle = &state.particles[0];
+ assert!((particle.position.end[2] - 6.436_462_4).abs() < 1e-6);
+ assert!((particle.position.end[1] - 0.069_229_126).abs() < 1e-6);
+ assert!((particle.position.end[0] - 0.803_848_27).abs() < 1e-6);
+ assert!((particle.scale.end[2] - 1.818_519_6).abs() < 1e-6);
+ assert!((particle.scale.end[1] - 2.552_292).abs() < 1e-6);
+ assert!((particle.scale.end[0] - 1.887_962_3).abs() < 1e-6);
+ assert_eq!(state.particle_rng, super::Lfsr16Pair { state: 0x6CE1_03C0 });
+ }
+
+ #[test]
+ fn op7_rejects_non_finite_world_scalar() {
+ let identity = [
+ [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],
+ ];
+ let acceleration = b88_command(1, 1.0);
+ let mut context = render_context(0);
+ context.world_scalar = f32::NAN;
+ let mut state = Op7EmitterState::new(11, 11);
+ assert!(evaluate_op7_emitter(
+ &acceleration,
+ 0,
+ &identity,
+ [0.0, 0.0, -20.0],
+ 0.25,
+ 2.0,
+ context,
+ &mut state,
+ )
+ .is_none());
+ }
+
+ #[test]
+ fn op7_rejects_truncated_body() {
+ let mut command = command();
+ command.raw_body = Arc::from(
+ command.raw_body[..super::MIN_BODY_SIZE - 1]
+ .to_vec()
+ .into_boxed_slice(),
+ );
+ let identity = [
+ [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],
+ ];
+ let mut state = Op7EmitterState::new(11, 11);
+ assert!(evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ [0.0, 0.0, -20.0],
+ 0.25,
+ 2.0,
+ render_context(0),
+ &mut state,
+ )
+ .is_none());
+ }
+
+ #[test]
+ fn op7_mode_three_deactivates_on_native_exact_dot_one_precheck() {
+ let identity = [
+ [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],
+ ];
+ // F=(1,0,0), D=(1,1,0): abs(F·D)==1 although D×F is nonzero.
+ // Native 9667..9683 deactivates before reaching its cross fallback.
+ assert!(
+ super::native_mode_three_basis(&identity, [2.0, 1.0, 0.0], [1.0, 0.0, 0.0]).is_none()
+ );
+ }
+
+ #[test]
+ fn op7_mode_three_uses_038d0_fallback_when_camera_cross_is_zero() {
+ let identity = [
+ [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],
+ ];
+ // F=(0,0,1), D=(0,0,2), so abs(dot)=2 (past the early gate),
+ // but D×F is zero. 038D0 chooses +Y as the right axis and F×R as up.
+ assert_eq!(
+ super::native_mode_three_basis(&identity, [0.0, 0.0, 4.0], [0.0, 0.0, 2.0]),
+ Some([
+ [0.0, 0.0, -1.0, 0.0],
+ [0.0, 1.0, 0.0, 0.0],
+ [1.0, 0.0, 0.0, 2.0],
+ [0.0, 0.0, 0.0, 1.0],
+ ])
+ );
+ }
+
+ #[test]
+ fn op7_mode_three_uses_camera_cross_basis_for_non_degenerate_view() {
+ let identity = [
+ [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],
+ ];
+ let matrix =
+ super::native_mode_three_basis(&identity, [0.0, 0.0, -10.0], [1.0, 1.0, 1.0]).unwrap();
+ let inv_sqrt_two = std::f32::consts::FRAC_1_SQRT_2;
+ assert!((matrix[0][0] - 1.0 / 3.0_f32.sqrt()).abs() < 1e-6);
+ assert!((matrix[0][1] - inv_sqrt_two).abs() < 1e-6);
+ assert!((matrix[0][2] - 1.0 / (6.0_f32.sqrt() * 1.0)).abs() < 1e-6);
+ assert!((matrix[1][0] - 1.0 / 3.0_f32.sqrt()).abs() < 1e-6);
+ assert!((matrix[1][1] + inv_sqrt_two).abs() < 1e-6);
+ assert!((matrix[1][2] - 1.0 / (6.0_f32.sqrt() * 1.0)).abs() < 1e-6);
+ assert!((matrix[2][0] - 1.0 / 3.0_f32.sqrt()).abs() < 1e-6);
+ assert!(matrix[2][2] + 2.0 / 6.0_f32.sqrt() < 1e-6);
+ assert_eq!([matrix[0][3], matrix[1][3], matrix[2][3]], [1.0, 1.0, 1.0]);
+ }
+
+ #[test]
+ fn op7_mode_three_projects_the_zero_helper_point_through_owner_matrix() {
+ let identity = [
+ [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],
+ ];
+ let translated = [
+ [1.0, 0.0, 0.0, 10.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],
+ ];
+ let local =
+ super::native_mode_three_basis(&identity, [0.0, 0.0, -10.0], [1.0, 1.0, 1.0]).unwrap();
+ // FastProc+68 maps world origin to owner-local [-10,0,0]. Pairing it
+ // with this local slot point and the same owner-local camera yields
+ // the same forward vector as the untransformed reference case.
+ let translated_local =
+ super::native_mode_three_basis(&translated, [0.0, 0.0, -10.0], [-9.0, 1.0, 1.0])
+ .unwrap();
+ for row in 0..3 {
+ assert!((translated_local[row][0] - local[row][0]).abs() < 1e-6);
+ }
+ }
+
+ #[test]
+ fn op7_mode_three_evaluator_renders_supported_slots() {
+ let mut command = command();
+ let mut body = command.raw_body.to_vec();
+ put_u32(&mut body, 0x00, 1);
+ put_f32(&mut body, 0x10, 0.0);
+ put_f32(&mut body, 0x14, 1.0);
+ put_f32(&mut body, 0x18, 1.0);
+ put_u32(&mut body, 0x20, 1);
+ put_vec3(&mut body, 0x28, [1.0, 1.0, 1.0]);
+ put_vec3(&mut body, 0x34, [1.0, 1.0, 1.0]);
+ put_vec3(&mut body, 0x40, [0.0; 3]);
+ put_vec3(&mut body, 0x4C, [1.0; 3]);
+ put_vec3(&mut body, 0x58, [1.0; 3]);
+ put_vec3(&mut body, 0x64, [1.0; 3]);
+ put_vec3(&mut body, 0x70, [0.0; 3]);
+ put_vec3(&mut body, 0x7C, [1.0; 3]);
+ command.raw_body = Arc::from(body.into_boxed_slice());
+ let identity = [
+ [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],
+ ];
+ let mut context = render_context(0);
+ context.visibility = Op7VisibilityProfile {
+ width: 0.0,
+ bias: -1.0,
+ };
+ let mut state = Op7EmitterState::new(9, 9);
+ let frames = evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ [0.0, 0.0, -10.0],
+ 0.5,
+ 0.0,
+ context,
+ &mut state,
+ )
+ .unwrap();
+ assert_eq!(frames.len(), 1);
+ let center = centroid(&frames[0]);
+ for (actual, expected) in center.into_iter().zip([1.0, 1.0, 1.0]) {
+ assert!((actual - expected).abs() < 1e-6);
+ }
+ }
+
+ #[test]
+ fn op7_billboard_uses_owner_local_camera_point_for_translated_owner() {
+ let mut command = command();
+ let mut body = command.raw_body.to_vec();
+ put_f32(&mut body, 0x10, 0.0);
+ put_f32(&mut body, 0x14, 1.0);
+ put_f32(&mut body, 0x18, 1.0);
+ put_vec3(&mut body, 0x28, [0.0; 3]);
+ put_vec3(&mut body, 0x34, [0.0; 3]);
+ put_vec3(&mut body, 0x40, [0.0; 3]);
+ put_vec3(&mut body, 0x4C, [1.0; 3]);
+ put_vec3(&mut body, 0x58, [1.0; 3]);
+ put_vec3(&mut body, 0x64, [1.0; 3]);
+ put_vec3(&mut body, 0x70, [0.0; 3]);
+ put_vec3(&mut body, 0x7C, [1.0; 3]);
+ put_u32(&mut body, 0x20, 1);
+ command.raw_body = Arc::from(body.into_boxed_slice());
+
+ let translated_owner = [
+ [1.0, 0.0, 0.0, 10.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],
+ ];
+ let mut context = render_context(0);
+ context.visibility = Op7VisibilityProfile {
+ width: 0.0,
+ bias: -1.0,
+ };
+ let frames = evaluate_op7_emitter(
+ &command,
+ 0,
+ &translated_owner,
+ [10.0, 0.0, -10.0],
+ 0.5,
+ 0.5,
+ context,
+ &mut Op7EmitterState::new(7, 7),
+ )
+ .unwrap();
+
+ assert_eq!(frames.len(), 1);
+ // Native 07D10 transforms context+48 through the inverse owner matrix
+ // before 01300 passes it to 93D0. With a translation-only owner that
+ // leaves a camera directly behind the particle, so the quad is axis
+ // aligned; raw world coordinates would rotate it around the view ray.
+ assert_eq!(frames[0].vertices[0], [9.5, -0.5, 0.0]);
+ assert_eq!(frames[0].vertices[2], [10.5, 0.5, 0.0]);
+ }
+
+ #[test]
+ fn op7_is_inactive_outside_its_native_window() {
+ let command = command();
+ let identity = [
+ [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],
+ ];
+ let mut state = Op7EmitterState::new(7, 7);
+ let frames = evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ [0.0, 0.0, -1.0],
+ 0.51,
+ 0.8,
+ render_context(0),
+ &mut state,
+ )
+ .unwrap();
+ assert!(frames.is_empty());
+ }
+
+ #[test]
+ fn op7_detail_ordinal_filters_fixed_cutoffs_not_particle_age() {
+ let command = command();
+ let identity = [
+ [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],
+ ];
+ // This stream places five of six native cutoffs below level 1 and
+ // one above it, so the test observes ordinal filtering directly.
+ let mut state = Op7EmitterState::new(17, 0x0156);
+ let run = |detail_level, state: &mut Op7EmitterState| {
+ evaluate_op7_emitter(
+ &command,
+ 0,
+ &identity,
+ [0.0, 0.0, -20.0],
+ 0.25,
+ 0.5,
+ render_context(detail_level),
+ state,
+ )
+ .unwrap()
+ };
+ let best = run(0, &mut state);
+ let lower = run(1, &mut state);
+ let lowest = run(2, &mut state);
+ assert_eq!(best.len(), 6);
+ assert!(lower.len() < best.len());
+ assert!(lowest.len() <= lower.len());
+ assert!(lower.iter().all(|frame| best
+ .iter()
+ .any(|full| frame.normalized_time == full.normalized_time)));
+ }
+
+ #[test]
+ #[ignore = "requires the original GOG installation"]
+ fn original_gog_breath_anl_03a_b_c_load_as_op7() {
+ let root = std::env::var_os("PARKAN_GOG_ROOT")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy"));
+ let bytes = std::fs::read(root.join("effects.rlb")).expect("read effects.rlb");
+ let archive = decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
+ .expect("decode effects.rlb");
+ let identity = [
+ [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],
+ ];
+ for name in ["breath_anl_03a", "breath_anl_03b", "breath_anl_03c"] {
+ let id = archive.find(name).expect("FXID entry");
+ let entry = archive.entry(id).expect("FXID metadata");
+ assert_eq!(entry.meta().type_id, FXID_KIND);
+ let bytes = archive.payload(id).expect("FXID payload");
+ let document = decode_fxid(Arc::from(bytes.to_vec().into_boxed_slice()))
+ .unwrap_or_else(|error| panic!("{name}: {error}"));
+ let command = document.commands().first().expect("Op7 command");
+ assert_eq!(command.opcode, FxOpcode::Op7, "{name}");
+ assert_eq!(
+ super::read_u32(&command.raw_body, 0x00),
+ Some(0),
+ "{name} B0"
+ );
+ assert_eq!(
+ super::read_f32(&command.raw_body, 0x1C),
+ Some(1.5),
+ "{name} B1C"
+ );
+ assert_eq!(
+ super::read_f32(&command.raw_body, 0x88),
+ Some(0.0),
+ "{name} B88"
+ );
+ let mut state = Op7EmitterState::new(7, 7);
+ let frames = evaluate_op7_emitter(
+ command,
+ 0,
+ &identity,
+ [0.0, 0.0, -20.0],
+ 0.275,
+ 0.5,
+ render_context(0),
+ &mut state,
+ )
+ .unwrap_or_else(|| panic!("{name}: native Op7 fields should parse"));
+ assert_eq!(state.particles.len(), 6, "{name} pool slots");
+ assert!(frames.len() <= 6, "{name} visible slots");
+ assert!(frames.iter().all(|frame| frame.material.name == "smoke_yg"));
+ }
+
+ let id = archive.find("breath_anl_03").expect("Op8 FXID entry");
+ let bytes = archive.payload(id).expect("Op8 FXID payload");
+ let document = decode_fxid(Arc::from(bytes.to_vec().into_boxed_slice()))
+ .expect("decode breath_anl_03");
+ assert_eq!(document.commands()[0].opcode, FxOpcode::Op8);
+ }
+}
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);
+ }
+}