diff options
| author | Valentin Popov <valentin@popov.link> | 2026-10-18 07:55:47 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-10-18 07:55:47 +0300 |
| commit | 737e429cb109446ec008c4dcf91d4f04faccd64a (patch) | |
| tree | d8f4455b0462ec2dc8073f7eba64cc82d03d6f2d /crates | |
| parent | 2e02325bf0762a9ac89f0b66773e3bbd7ceb7fe7 (diff) | |
| download | fparkan-737e429cb109446ec008c4dcf91d4f04faccd64a.tar.xz fparkan-737e429cb109446ec008c4dcf91d4f04faccd64a.zip | |
feat(fx): evaluate native Op7 particle poolscodex/op7-evaluator
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/fparkan-fx/src/lib.rs | 14 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/op7.rs | 1443 |
2 files changed, 1457 insertions, 0 deletions
diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs index 1772d1c..04e54ad 100644 --- a/crates/fparkan-fx/src/lib.rs +++ b/crates/fparkan-fx/src/lib.rs @@ -10,9 +10,23 @@ pub mod ambient_op3; pub mod atmosphere; pub mod environment; pub mod fx_visual; +pub mod op7; pub mod shadow; pub mod sky; +/// Advances the native FX 16-bit-pair random state and returns its new high half. +/// +/// Native helpers store `low' = low * 2 ^ high` and `high' = high >> 1 ^ low'`. +#[must_use] +pub fn advance_native_fx_random_state(state: &mut u32) -> u16 { + let low = *state as u16; + let high = (*state >> 16) as u16; + let next_low = low.wrapping_shl(1) ^ high; + let next_high = (high >> 1) ^ next_low; + *state = u32::from(next_low) | (u32::from(next_high) << 16); + next_high +} + use fparkan_binary::{Cursor, DecodeError}; use std::sync::Arc; diff --git a/crates/fparkan-fx/src/op7.rs b/crates/fparkan-fx/src/op7.rs new file mode 100644 index 0000000..ac31f44 --- /dev/null +++ b/crates/fparkan-fx/src/op7.rs @@ -0,0 +1,1443 @@ +//! Native-shaped evaluation for opcode-7 particle pools. +//! +//! Op7 is a fixed pool of independently randomized position and scale tracks. +//! It has a different lifecycle from the scheduled ring used by Op8. + +use crate::environment::EffectResource; +use crate::fx_visual::{ + compose_native_effect_matrix, native_camera_facing_basis, native_effect_local_point, + transform_native_effect_quad, EffectQuadFrame, EFFECT_QUAD_UV, +}; +use crate::{FxCommand, FxOpcode}; +use std::sync::Arc; + +const RESOURCE_OFFSET: usize = 0x8C; +const RESOURCE_BYTES: usize = 64; +const MIN_BODY_SIZE: usize = RESOURCE_OFFSET + RESOURCE_BYTES; +const MAX_PARTICLES: usize = 4096; +const U16_TO_UNIT: f32 = 1.0 / 65_536.0; + +#[derive(Clone, Copy, Debug)] +struct Track { + start: [f32; 3], + end: [f32; 3], + exponent: [f32; 3], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Lfsr16Pair { + state: u32, +} + +impl Lfsr16Pair { + fn from_word(state: u32) -> Self { + Self { state } + } + + fn next_unit(&mut self) -> f32 { + f32::from(crate::advance_native_fx_random_state(&mut self.state)) * U16_TO_UNIT + } + + fn next_signed(&mut self, width: f32) -> f32 { + (self.next_unit() - 0.5) * width + } +} + +#[derive(Clone, Debug)] +struct Particle { + group: usize, + helper_mode: u32, + visibility_cutoff: f32, + position: Track, + scale: Track, +} + +/// Mutable deterministic state for one Op7 command instance. +/// +/// Native Op7 initializes all slots once and then samples their fixed tracks; +/// retain one state per command instance and call [`evaluate_op7_emitter`] on +/// each update. The seed only supplies deterministic stand-ins for the +/// process-global native random streams. +#[derive(Clone, Debug)] +pub struct Op7EmitterState { + initial_particle_rng: Lfsr16Pair, + initial_visibility_rng: Lfsr16Pair, + particle_rng: Lfsr16Pair, + visibility_rng: Lfsr16Pair, + body: Option<Arc<[u8]>>, + visibility_profile: Option<Op7VisibilityProfile>, + particles: Vec<Particle>, + last_clock: Option<f32>, + group_clocks: Vec<f32>, +} + +impl Op7EmitterState { + /// Creates deterministic evaluator state from particle and visibility seeds. + #[must_use] + pub fn new(particle_seed: u32, visibility_seed: u32) -> Self { + let initial_particle_rng = Lfsr16Pair::from_word(particle_seed); + let initial_visibility_rng = Lfsr16Pair::from_word(visibility_seed); + Self { + initial_particle_rng, + initial_visibility_rng, + particle_rng: initial_particle_rng, + visibility_rng: initial_visibility_rng, + body: None, + visibility_profile: None, + particles: Vec::new(), + last_clock: None, + group_clocks: Vec::new(), + } + } + + /// Clears initialized slots and restores the initial random streams. + fn reset(&mut self) { + self.particle_rng = self.initial_particle_rng; + self.visibility_rng = self.initial_visibility_rng; + self.body = None; + self.visibility_profile = None; + self.particles.clear(); + self.last_clock = None; + self.group_clocks.clear(); + } +} + +/// Native Op7's per-slot visibility range from the effect settings tables. +/// +/// `width` and `bias` are the pair selected by the caller's native settings +/// context (`settings_id >> 8`) for helper 0xEC50. The sampled cutoff is +/// `random_unit * width - bias`; this is a detail cutoff, not a time offset. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Op7VisibilityProfile { + /// Width from the native settings table. + pub width: f32, + /// Bias from the native settings table. + pub bias: f32, +} + +/// Per-render context consumed by the native Op7 visibility gate. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Op7RenderContext { + /// Ordinal returned by the native render-context detail selector. + /// The native outer dispatcher bypasses rendering for values >= 8. + pub detail_level: u32, + /// Native settings-table pair used to initialize fixed slot cutoffs. + pub visibility: Op7VisibilityProfile, + /// Native world scalar returned by Terrain.dll's GetWorld vslot +0x10. + /// Effect.dll multiplies Op7 body B88 by this value and by 0.5. + pub world_scalar: f32, +} + +#[derive(Clone, Debug)] +struct Params { + material: EffectResource, + helper_mode: u32, + pool_count: usize, + copies_per_group: usize, + start: f32, + end: f32, + lifetime: f32, + material_phase_factor: f32, + vertical_acceleration: f32, + opacity_start: f32, + opacity_end: f32, + opacity_exponent: f32, + position: Track, + position_width: [f32; 3], + scale: Track, + scale_width: [f32; 3], +} + +impl Params { + fn parse(command: &FxCommand, world_scalar: f32) -> Option<Self> { + let body = &command.raw_body; + if body.len() < MIN_BODY_SIZE { + return None; + } + let start = read_f32(body, 0x10)?; + let end = read_f32(body, 0x14)?; + let lifetime = read_f32(body, 0x18)?; + let material_phase_factor = read_f32(body, 0x1C)?; + let vertical_acceleration = read_f32(body, 0x88)? * world_scalar * 0.5; + let pool_count = read_u32(body, 0x20)? as usize; + let copies_per_group = read_u32(body, 0x24)? as usize; + let opacity_start = read_f32(body, 0x04)?; + let opacity_end = read_f32(body, 0x08)?; + let opacity_exponent = read_f32(body, 0x0C)?; + let position = Track { + start: read_vec3(body, 0x28)?, + end: read_vec3(body, 0x34)?, + exponent: read_vec3(body, 0x4C)?, + }; + let position_width = read_vec3(body, 0x40)?; + let scale = Track { + start: read_vec3(body, 0x58)?, + end: read_vec3(body, 0x64)?, + exponent: read_vec3(body, 0x7C)?, + }; + let scale_width = read_vec3(body, 0x70)?; + let particle_count = pool_count.checked_mul(copies_per_group)?; + let mode_field = read_u32(body, 0x00)?; + let helper_mode = if mode_field == 1 { 3 } else { 0 }; + if start >= end + || !lifetime.is_finite() + || lifetime <= 0.0 + || !material_phase_factor.is_finite() + || material_phase_factor == 0.0 + || !world_scalar.is_finite() + || !vertical_acceleration.is_finite() + || pool_count == 0 + || copies_per_group == 0 + || particle_count > MAX_PARTICLES + || ![start, end, opacity_start, opacity_end, opacity_exponent] + .iter() + .all(|value| value.is_finite()) + || !position + .start + .iter() + .chain(position.end.iter()) + .chain(position.exponent.iter()) + .chain(position_width.iter()) + .chain(scale.start.iter()) + .chain(scale.end.iter()) + .chain(scale.exponent.iter()) + .chain(scale_width.iter()) + .all(|value| value.is_finite()) + { + return None; + } + Some(Self { + material: read_resource(body)?, + helper_mode, + pool_count, + copies_per_group, + start, + end, + lifetime, + material_phase_factor, + vertical_acceleration, + opacity_start, + opacity_end, + opacity_exponent, + position, + position_width, + scale, + scale_width, + }) + } +} + +/// Evaluates an Op7 fixed particle pool at one effect phase. +/// +/// `effect_phase` is the native command clock used by the inclusive body +/// window. `world_from_effect` is the owner transform after the FX header +/// transform, and `camera_world` is the render context point used by the +/// particle billboard. Frames are returned only for slots whose staggered +/// lifetime is currently active. +#[must_use] +pub fn evaluate_op7_emitter( + command: &FxCommand, + command_index: usize, + world_from_effect: &[[f32; 4]; 4], + camera_world: [f32; 3], + effect_phase: f32, + elapsed_seconds: f32, + render_context: Op7RenderContext, + state: &mut Op7EmitterState, +) -> Option<Vec<EffectQuadFrame>> { + if command.opcode != FxOpcode::Op7 + || !world_from_effect + .iter() + .flatten() + .all(|value| value.is_finite()) + || !camera_world.iter().all(|value| value.is_finite()) + || !effect_phase.is_finite() + || !elapsed_seconds.is_finite() + || !render_context.visibility.width.is_finite() + || !render_context.visibility.bias.is_finite() + || !render_context.world_scalar.is_finite() + || render_context.visibility.width < 0.0 + { + return None; + } + let params = Params::parse(command, render_context.world_scalar)?; + if state + .body + .as_deref() + .is_some_and(|body| body != command.raw_body.as_ref()) + { + state.reset(); + } + if state + .visibility_profile + .is_some_and(|profile| profile != render_context.visibility) + { + // The native base class samples these cutoffs once during object + // initialization. A caller changing the settings pair mid-emitter is + // not the same native state, so reject that frame instead of silently + // rerolling particle and visibility RNGs. + return None; + } + state.body = Some(Arc::clone(&command.raw_body)); + if state.particles.is_empty() { + initialize_particles(¶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<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); + } +} |
