//! 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>, visibility_profile: Option, particles: Vec, last_clock: Option, group_clocks: Vec, } 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 { 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> { 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(¶ms, 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 { // 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 { 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 { 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 { 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 { Some(u32::from_le_bytes( body.get(offset..offset + 4)?.try_into().ok()?, )) } fn read_f32(body: &[u8], offset: usize) -> Option { 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::() / 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::() / 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); } }