//! 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 { (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>, emitter_position: Option, stored_anchor: Option<[f32; 3]>, cursor: usize, last_spawn: f32, interval: f32, slots: Vec>, } 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> { 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(¶ms, 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(¤t_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( ¤t_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(¤t_anchor, stored_anchor, velocity, due_interval)?; if in_window { spawn_particle( ¶ms, 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, ¶ms.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 { 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 { 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 { // 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 { let length_squared = vector .iter() .map(|component| component * component) .sum::(); 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 { 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 { 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 { Some(u32::from_le_bytes( bytes.get(offset..offset + 4)?.try_into().ok()?, )) } fn read_f32(bytes: &[u8], offset: usize) -> Option { 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 { 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 { 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::() / 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, ¤t, &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::() }); (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); } }