aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-fx/src/op7.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/fparkan-fx/src/op7.rs')
-rw-r--r--crates/fparkan-fx/src/op7.rs1443
1 files changed, 1443 insertions, 0 deletions
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);
+ }
+}