diff options
| -rw-r--r-- | crates/fparkan-fx/src/ambient_op3.rs | 984 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/lib.rs | 1 | ||||
| -rw-r--r-- | docs/reference/environment-effects.md | 10 |
3 files changed, 995 insertions, 0 deletions
diff --git a/crates/fparkan-fx/src/ambient_op3.rs b/crates/fparkan-fx/src/ambient_op3.rs new file mode 100644 index 0000000..5bd2881 --- /dev/null +++ b/crates/fparkan-fx/src/ambient_op3.rs @@ -0,0 +1,984 @@ +//! Native-shaped CPU evaluation for Types 0, 1, and 2 carried by opcode 3. +//! +//! The FX body and the command opcode are separate concepts: this evaluator +//! accepts opcode 3 only when its body discriminator matches the selected +//! evaluator. Types 0/1/2 share the position, scale, material-phase, and +//! opacity tracks; Types 1/2 additionally use an animated axis. Their render +//! basis is selected by the native body discriminator. + +use crate::environment::EffectResource; +use crate::fx_visual::{ + compose_native_effect_matrix, native_camera_facing_basis, native_d110_basis, + native_effect_local_point, transform_native_effect_quad, EffectQuadFrame, EFFECT_QUAD_UV, +}; +use crate::{FxCommand, FxOpcode}; + +const TYPE2_BODY_DISCRIMINATOR: u32 = 2; +const TYPE2_ACTIVE_START_OFFSET: usize = 0x1c; +const TYPE2_ACTIVE_END_OFFSET: usize = 0x20; +const TYPE2_PHASE_START_OFFSET: usize = 0x04; +const TYPE2_PHASE_END_OFFSET: usize = 0x08; +const TYPE2_PHASE_EXPONENT_OFFSET: usize = 0x0c; +const TYPE2_OPACITY_START_OFFSET: usize = 0x10; +const TYPE2_OPACITY_END_OFFSET: usize = 0x14; +const TYPE2_OPACITY_EXPONENT_OFFSET: usize = 0x18; +const TYPE2_POSITION_START_OFFSET: usize = 0x24; +const TYPE2_POSITION_END_OFFSET: usize = 0x30; +#[cfg(test)] +const TYPE2_POSITION_EXPONENT_OFFSET: usize = 0x3c; +const TYPE2_AXIS_START_OFFSET: usize = 0x48; +const TYPE2_AXIS_END_OFFSET: usize = 0x54; +const TYPE2_SCALE_START_OFFSET: usize = 0x60; +const TYPE2_SCALE_END_OFFSET: usize = 0x6c; +const TYPE2_RESOURCE_OFFSET: usize = 0x84; +const TYPE2_RESOURCE_BYTES: usize = 64; +const TYPE0_BODY_DISCRIMINATOR: u32 = 0; +const TYPE1_BODY_DISCRIMINATOR: u32 = 1; + +/// Per-frame values sampled from the verified Op3 Type0 body tracks. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Op3Type0Sample { + /// Animated local translation sampled from body+0x24/+0x30 with exponents + /// at body+0x3c/+0x40/+0x44. + position: [f32; 3], + /// Animated scale sampled from body+0x60/+0x6c with reverse-order + /// exponents at body+0x78/+0x7c/+0x80. + scale: [f32; 3], + /// Native command opacity after its authored curve. + opacity: f32, + /// Wrapped texture phase computed by the native Op3 time track. + material_phase: f32, +} + +/// Per-frame values sampled from the verified native Op3 Type2 body tracks. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Op3Type2Sample { + /// Animated local translation used by native helper 0xC5D0. + position: [f32; 3], + /// Animated local axis used by native helper 0xD110. + axis: [f32; 3], + /// Animated scale applied to the parent matrix basis. + scale: [f32; 3], + /// Opacity multiplier sent through native helper 0x9930 and clamped to + /// `[0, 1]` for the material descriptor. + opacity: f32, + /// Wrapped texture phase computed by the native Op3 time track. + material_phase: f32, +} + +/// Per-frame values sampled from the same track layout in a Type 1 body. +type Op3Type1Sample = Op3Type2Sample; + +/// Evaluates one FX opcode 3 / Type 0 fixed camera-facing command. +/// +/// Type 0 uses the shared animated position and scale tracks but forms its +/// local basis from the camera vector. The current caller supplies the +/// already-composed Control owner transform and header scale. +/// The supported caller path assumes native render-context index 0, which +/// satisfies Effect.dll's shared `context + 0x60 < 8` gate. +#[must_use] +pub fn evaluate_op3_type0_command( + command: &FxCommand, + command_index: usize, + world_from_effect: &[[f32; 4]; 4], + elapsed_seconds: f32, + effect_phase: f32, + camera_world: [f32; 3], +) -> Option<EffectQuadFrame> { + let body = &command.raw_body; + if command.opcode != FxOpcode::Op3 + || read_u32(body, 0)? != TYPE0_BODY_DISCRIMINATOR + || body.len() < TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES + || !world_from_effect + .iter() + .flatten() + .all(|value| value.is_finite()) + || !elapsed_seconds.is_finite() + || !effect_phase.is_finite() + || !camera_world.iter().all(|value| value.is_finite()) + { + return None; + } + + let start = read_f32(body, TYPE2_ACTIVE_START_OFFSET)?; + let end = read_f32(body, TYPE2_ACTIVE_END_OFFSET)?; + if !start.is_finite() || !end.is_finite() || start > end { + return None; + } + + let resource = read_resource(command)?; + let normalized_time = interval_phase(effect_phase, start, end); + let sample = sample_op3_type0_body(body, normalized_time, elapsed_seconds)?; + let camera_local = native_effect_local_point(world_from_effect, camera_world)?; + let view = sub3(camera_local, sample.position); + let local_basis = native_camera_facing_basis(view, sample.position)?; + let matrix = compose_native_effect_matrix( + world_from_effect, + &local_basis, + sample.position, + sample.scale, + )?; + let vertices = transform_native_effect_quad(&matrix)?; + + Some(EffectQuadFrame { + command_index, + opcode: FxOpcode::Op3, + material: resource, + vertices, + uv: EFFECT_QUAD_UV, + color: [1.0, 1.0, 1.0, sample.opacity], + elapsed_seconds, + normalized_time, + material_phase: sample.material_phase, + active: effect_phase >= start && effect_phase <= end, + native_flag_base: None, + }) +} + +/// Evaluates one FX opcode 3 / Type 2 command using native helper matrix rules. +/// +/// `world_from_effect` is the row-major parent descriptor already composed by +/// the caller through the selected CPT row, mission-root transform, resolved +/// owner pose (including a mounted component pose), and FX header scale. The +/// evaluator does not resolve those inputs itself. The matrix is the parent +/// supplied to native helper 0x93D0, not the final quad matrix. +/// +/// `effect_phase` is the header-transformed, command-clamped overall effect +/// phase in `[0, 1]`. The command's inclusive phase window is stored at +/// raw-body offsets `0x1c` and `0x20`. +/// `render_context` mirrors the native 0x106C0 gate (`context + 0x60 < 8`). +/// The function returns `None` for another command/body type, malformed +/// resources, non-finite inputs, or a degenerate non-finite transform. +#[must_use] +pub fn evaluate_op3_type2_command( + command: &FxCommand, + command_index: usize, + world_from_effect: &[[f32; 4]; 4], + elapsed_seconds: f32, + effect_phase: f32, + render_context: u32, +) -> Option<EffectQuadFrame> { + if command.opcode != FxOpcode::Op3 + || read_u32(&command.raw_body, 0)? != TYPE2_BODY_DISCRIMINATOR + || command.raw_body.len() < TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES + || !elapsed_seconds.is_finite() + || !effect_phase.is_finite() + { + return None; + } + + let start = read_f32(&command.raw_body, TYPE2_ACTIVE_START_OFFSET)?; + let end = read_f32(&command.raw_body, TYPE2_ACTIVE_END_OFFSET)?; + if !start.is_finite() || !end.is_finite() || start > end { + return None; + } + + let resource = read_resource(command)?; + let normalized_time = interval_phase(effect_phase, start, end); + let sample = sample_op3_type2_body(&command.raw_body, normalized_time, elapsed_seconds)?; + let matrix = native_op3_type2_matrix(world_from_effect, sample)?; + let vertices = transform_native_effect_quad(&matrix)?; + + Some(EffectQuadFrame { + command_index, + opcode: FxOpcode::Op3, + material: resource, + vertices, + uv: EFFECT_QUAD_UV, + color: [1.0, 1.0, 1.0, sample.opacity], + elapsed_seconds, + normalized_time, + material_phase: sample.material_phase, + active: render_context < 8 && effect_phase >= start && effect_phase <= end, + native_flag_base: None, + }) +} + +/// Evaluates one FX opcode 3 / Type 1 camera-facing command. +/// +/// Type 1 uses the native camera-to-position vector and sampled axis to form +/// its quad basis. The command's body tracks use the same offsets as Type 2. +/// The supported caller path assumes native render-context index 0, which +/// satisfies the Effect.dll `context + 0x60 < 8` gate. +#[must_use] +pub fn evaluate_op3_type1_command( + command: &FxCommand, + command_index: usize, + world_from_effect: &[[f32; 4]; 4], + elapsed_seconds: f32, + effect_phase: f32, + camera_world: [f32; 3], +) -> Option<EffectQuadFrame> { + let body = &command.raw_body; + if command.opcode != FxOpcode::Op3 + || read_u32(body, 0)? != TYPE1_BODY_DISCRIMINATOR + || body.len() < TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES + || !elapsed_seconds.is_finite() + || !effect_phase.is_finite() + || !camera_world.iter().all(|value| value.is_finite()) + { + return None; + } + + let start = read_f32(body, TYPE2_ACTIVE_START_OFFSET)?; + let end = read_f32(body, TYPE2_ACTIVE_END_OFFSET)?; + if !start.is_finite() || !end.is_finite() || start > end { + return None; + } + + let resource = read_resource(command)?; + let normalized_time = interval_phase(effect_phase, start, end); + let sample = sample_op3_type1_body(body, normalized_time, elapsed_seconds)?; + let matrix = native_op3_type1_matrix(world_from_effect, sample, camera_world)?; + let vertices = transform_native_effect_quad(&matrix)?; + + Some(EffectQuadFrame { + command_index, + opcode: FxOpcode::Op3, + material: resource, + vertices, + uv: EFFECT_QUAD_UV, + color: [1.0, 1.0, 1.0, sample.opacity], + elapsed_seconds, + normalized_time, + material_phase: sample.material_phase, + active: effect_phase >= start && effect_phase <= end, + native_flag_base: None, + }) +} + +/// Samples the verified Type2 body tracks used by native Op3 update/render. +/// +/// `normalized_active_time` is the value produced by the inclusive command +/// interval gate. Position and scale use component-wise powers; axis is a +/// linear start/end track. The separate phase track uses elapsed seconds when +/// its raw start is negative, matching native 0x105F0. +#[must_use] +fn sample_op3_type2_body( + body: &[u8], + normalized_active_time: f32, + elapsed_seconds: f32, +) -> Option<Op3Type2Sample> { + if read_u32(body, 0)? != TYPE2_BODY_DISCRIMINATOR + || body.len() < TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES + { + return None; + } + + sample_op3_body_tracks(body, normalized_active_time, elapsed_seconds) +} + +/// Samples the verified Op3 Type0 position/scale and common command tracks. +#[must_use] +fn sample_op3_type0_body( + body: &[u8], + normalized_active_time: f32, + elapsed_seconds: f32, +) -> Option<Op3Type0Sample> { + if read_u32(body, 0)? != TYPE0_BODY_DISCRIMINATOR + || body.len() < TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES + || !normalized_active_time.is_finite() + || !elapsed_seconds.is_finite() + { + return None; + } + + // Native 0x106C0 samples the record's position into helper+0x78 from the + // body position endpoints and exponents. The body tail at +0x78 is the + // reverse-ordered scale exponent vector, not a fixed position. + let position = sample_power_vector( + body, + TYPE2_POSITION_START_OFFSET, + TYPE2_POSITION_END_OFFSET, + [0x3c, 0x40, 0x44], + normalized_active_time, + )?; + let scale = sample_power_vector( + body, + TYPE2_SCALE_START_OFFSET, + TYPE2_SCALE_END_OFFSET, + [0x80, 0x7c, 0x78], + normalized_active_time, + )?; + let (material_phase, opacity) = + sample_op3_material_phase_and_opacity(body, normalized_active_time, elapsed_seconds)?; + Some(Op3Type0Sample { + position, + scale, + opacity, + material_phase, + }) +} + +/// Samples the verified Op3 Type 1 body tracks. +#[must_use] +fn sample_op3_type1_body( + body: &[u8], + normalized_active_time: f32, + elapsed_seconds: f32, +) -> Option<Op3Type1Sample> { + if read_u32(body, 0)? != TYPE1_BODY_DISCRIMINATOR + || body.len() < TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES + { + return None; + } + + sample_op3_body_tracks(body, normalized_active_time, elapsed_seconds) +} + +fn sample_op3_body_tracks( + body: &[u8], + normalized_active_time: f32, + elapsed_seconds: f32, +) -> Option<Op3Type2Sample> { + if !normalized_active_time.is_finite() || !elapsed_seconds.is_finite() { + return None; + } + + let position = sample_power_vector( + body, + TYPE2_POSITION_START_OFFSET, + TYPE2_POSITION_END_OFFSET, + [0x3c, 0x40, 0x44], + normalized_active_time, + )?; + let axis = sample_linear_vector( + body, + TYPE2_AXIS_START_OFFSET, + TYPE2_AXIS_END_OFFSET, + normalized_active_time, + )?; + let scale = sample_power_vector( + body, + TYPE2_SCALE_START_OFFSET, + TYPE2_SCALE_END_OFFSET, + // 0x106C0 loads Z then Y then X from the packed reverse-order + // exponent vector at body+0x78. + [0x80, 0x7c, 0x78], + normalized_active_time, + )?; + + let (material_phase, opacity) = + sample_op3_material_phase_and_opacity(body, normalized_active_time, elapsed_seconds)?; + if !material_phase.is_finite() || !opacity.is_finite() { + return None; + } + + Some(Op3Type2Sample { + position, + axis, + scale, + opacity, + material_phase, + }) +} + +fn sample_op3_material_phase_and_opacity( + body: &[u8], + normalized_active_time: f32, + elapsed_seconds: f32, +) -> Option<(f32, f32)> { + let raw_phase_start = read_f32(body, TYPE2_PHASE_START_OFFSET)?; + let phase_end = read_f32(body, TYPE2_PHASE_END_OFFSET)?; + let phase_exponent = read_f32(body, TYPE2_PHASE_EXPONENT_OFFSET)?; + let opacity_start = read_f32(body, TYPE2_OPACITY_START_OFFSET)?; + let opacity_end = read_f32(body, TYPE2_OPACITY_END_OFFSET)?; + let opacity_exponent = read_f32(body, TYPE2_OPACITY_EXPONENT_OFFSET)?; + if ![ + raw_phase_start, + phase_end, + phase_exponent, + opacity_start, + opacity_end, + opacity_exponent, + normalized_active_time, + elapsed_seconds, + ] + .iter() + .all(|value| value.is_finite()) + { + return None; + } + + let phase_start = raw_phase_start.max(0.0); + let phase_delta = phase_end - phase_start; + let phase_timebase = if raw_phase_start < 0.0 { + elapsed_seconds + } else { + normalized_active_time + }; + let material_phase = fract(phase_start + phase_delta * phase_timebase.powf(phase_exponent)); + let opacity = (opacity_start + + (opacity_end - opacity_start) * normalized_active_time.powf(opacity_exponent)) + .clamp(0.0, 1.0); + (material_phase.is_finite() && opacity.is_finite()).then_some((material_phase, opacity)) +} + +/// Builds the native Type2 Op3 quad matrix from its parent descriptor and +/// already-sampled animated vectors. +/// +/// This matches 0x93D0's order: scale the parent basis by animated scale, +/// multiply by D110's local basis, and then replace the translation with the +/// unscaled parent transform of the animated position. +#[must_use] +fn native_op3_type2_matrix( + world_from_effect: &[[f32; 4]; 4], + sample: Op3Type2Sample, +) -> Option<[[f32; 4]; 4]> { + if !world_from_effect + .iter() + .flatten() + .all(|value| value.is_finite()) + || !sample_is_finite(sample) + { + return None; + } + + let local = native_d110_basis(sample.axis, sample.position)?; + compose_native_effect_matrix(world_from_effect, &local, sample.position, sample.scale) +} + +/// Builds the native Type 1 Op3 quad matrix from the parent transform, sampled +/// tracks, and camera query point. +#[must_use] +fn native_op3_type1_matrix( + world_from_effect: &[[f32; 4]; 4], + sample: Op3Type1Sample, + camera_world: [f32; 3], +) -> Option<[[f32; 4]; 4]> { + if !world_from_effect + .iter() + .flatten() + .all(|value| value.is_finite()) + || !camera_world.iter().all(|value| value.is_finite()) + || !sample_is_finite(sample) + { + return None; + } + + let camera_local = native_effect_local_point(world_from_effect, camera_world)?; + let view = sub3(camera_local, sample.position); + let local_basis = native_op3_type1_basis(view, sample.axis, sample.position)?; + compose_native_effect_matrix( + world_from_effect, + &local_basis, + sample.position, + sample.scale, + ) +} + +/// Native Effect.dll Type 1 basis. The parallel gate compares the raw +/// `view · axis` value to exactly ±1 before building the cross-product frame; +/// neither input vector is normalized. The native path normalizes only +/// `view × axis`, then computes `axis × U` without normalizing the result. +#[must_use] +fn native_op3_type1_basis( + view: [f32; 3], + axis: [f32; 3], + position: [f32; 3], +) -> Option<[[f32; 4]; 4]> { + if !view + .iter() + .chain(axis.iter()) + .chain(position.iter()) + .all(|value| value.is_finite()) + { + return None; + } + + let dot = view[0] * axis[0] + view[1] * axis[1] + view[2] * axis[2]; + if !dot.is_finite() || dot.abs() == 1.0 { + return None; + } + + let mut u = cross3(view, axis); + let length = length3(u)?; + if length == 0.0 { + return None; + } + for value in &mut u { + *value /= length; + } + let w = cross3(axis, u); + let result = [ + [u[0], w[0], axis[0], position[0]], + [u[1], w[1], axis[1], position[1]], + [u[2], w[2], axis[2], position[2]], + [0.0, 0.0, 0.0, 1.0], + ]; + result + .iter() + .flatten() + .all(|value| value.is_finite()) + .then_some(result) +} + +fn interval_phase(phase: f32, start: f32, end: f32) -> f32 { + if phase < start { + return 0.0; + } + if phase > end { + return 1.0; + } + if end == start { + 1.0 + } else if phase == start { + 0.0 + } else if phase == end { + 1.0 + } else { + ((phase - start) / (end - start)).clamp(0.0, 1.0) + } +} + +fn sample_is_finite(sample: Op3Type2Sample) -> bool { + sample + .position + .iter() + .chain(sample.axis.iter()) + .chain(sample.scale.iter()) + .chain([&sample.opacity, &sample.material_phase]) + .all(|value| value.is_finite()) +} + +fn sample_linear_vector(body: &[u8], start: usize, end: usize, time: f32) -> Option<[f32; 3]> { + let start = read_vec3(body, start)?; + let end = read_vec3(body, end)?; + if !start + .iter() + .chain(end.iter()) + .all(|value| value.is_finite()) + || !time.is_finite() + { + return None; + } + let values = std::array::from_fn(|axis| start[axis] + (end[axis] - start[axis]) * time); + values + .iter() + .all(|value| value.is_finite()) + .then_some(values) +} + +fn sample_power_vector( + body: &[u8], + start: usize, + end: usize, + exponent_offsets: [usize; 3], + time: f32, +) -> Option<[f32; 3]> { + let start = read_vec3(body, start)?; + let end = read_vec3(body, end)?; + let exponents = [ + read_f32(body, exponent_offsets[0])?, + read_f32(body, exponent_offsets[1])?, + read_f32(body, exponent_offsets[2])?, + ]; + if !start + .iter() + .chain(end.iter()) + .chain(exponents.iter()) + .all(|value| value.is_finite()) + || !time.is_finite() + { + return None; + } + let values = std::array::from_fn(|axis| { + let amount = time.powf(exponents[axis]); + start[axis] + (end[axis] - start[axis]) * amount + }); + values + .iter() + .all(|value| value.is_finite()) + .then_some(values) +} + +fn read_vec3(body: &[u8], offset: usize) -> Option<[f32; 3]> { + Some([ + read_f32(body, offset)?, + read_f32(body, offset + 4)?, + read_f32(body, offset + 8)?, + ]) +} + +fn cross3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [ + left[1] * right[2] - left[2] * right[1], + left[2] * right[0] - left[0] * right[2], + left[0] * right[1] - left[1] * right[0], + ] +} + +fn sub3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] { + [left[0] - right[0], left[1] - right[1], left[2] - right[2]] +} + +fn length3(vector: [f32; 3]) -> Option<f32> { + let length = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt(); + length.is_finite().then_some(length) +} + +fn fract(value: f32) -> f32 { + value - value.floor() +} + +fn read_resource(command: &FxCommand) -> Option<EffectResource> { + let bytes = command + .raw_body + .get(TYPE2_RESOURCE_OFFSET..TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES)?; + let archive = bounded_name(&bytes[..32])?; + let name = bounded_name(&bytes[32..])?; + (!archive.is_empty() && !name.is_empty()).then_some(EffectResource { archive, name }) +} + +fn bounded_name(bytes: &[u8]) -> Option<String> { + let end = bytes + .iter() + .position(|byte| *byte == 0) + .unwrap_or(bytes.len()); + let value = std::str::from_utf8(&bytes[..end]).ok()?.trim(); + (!value.is_empty()).then(|| value.to_owned()) +} + +fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> { + Some(u32::from_le_bytes( + bytes.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +fn read_f32(bytes: &[u8], offset: usize) -> Option<f32> { + Some(f32::from_le_bytes( + bytes.get(offset..offset + 4)?.try_into().ok()?, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + #[test] + fn type0_command_samples_position_and_scale_tracks_for_camera_facing_quad() { + let mut body = vec![0_u8; 196]; + write_u32(&mut body, 0, TYPE0_BODY_DISCRIMINATOR); + write_f32(&mut body, TYPE2_PHASE_START_OFFSET, -1.0); + write_f32(&mut body, TYPE2_PHASE_END_OFFSET, 1.0); + write_f32(&mut body, TYPE2_PHASE_EXPONENT_OFFSET, 1.0); + write_f32(&mut body, TYPE2_OPACITY_START_OFFSET, 1.0); + write_f32(&mut body, TYPE2_OPACITY_END_OFFSET, 0.0); + write_f32(&mut body, TYPE2_OPACITY_EXPONENT_OFFSET, 1.0); + write_f32(&mut body, TYPE2_ACTIVE_START_OFFSET, 0.0); + write_f32(&mut body, TYPE2_ACTIVE_END_OFFSET, 1.0); + write_vec3(&mut body, TYPE2_POSITION_START_OFFSET, [0.0, 0.0, 0.0]); + write_vec3(&mut body, TYPE2_POSITION_END_OFFSET, [2.0, 4.0, 6.0]); + write_vec3(&mut body, TYPE2_POSITION_EXPONENT_OFFSET, [1.0; 3]); + write_vec3(&mut body, TYPE2_SCALE_START_OFFSET, [1.0; 3]); + write_vec3(&mut body, TYPE2_SCALE_END_OFFSET, [3.0, 4.0, 5.0]); + write_vec3(&mut body, 0x78, [3.0, 2.0, 1.0]); + body[TYPE2_RESOURCE_OFFSET..TYPE2_RESOURCE_OFFSET + 11].copy_from_slice(b"effects.rlb"); + body[TYPE2_RESOURCE_OFFSET + 32..TYPE2_RESOURCE_OFFSET + 32 + 3].copy_from_slice(b"sun"); + + let command = FxCommand { + word: 3, + opcode: FxOpcode::Op3, + native_flag: false, + raw_body: Arc::from(body.into_boxed_slice()), + resource_refs: Vec::new(), + }; + let identity = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + let sample = sample_op3_type0_body(&command.raw_body, 0.5, 0.25).unwrap(); + assert_eq!(sample.position, [1.0, 2.0, 3.0]); + assert_eq!(sample.scale, [2.0, 1.75, 1.5]); + assert_eq!(sample.opacity, 0.5); + assert_eq!(sample.material_phase, 0.25); + + let frame = evaluate_op3_type0_command(&command, 7, &identity, 0.25, 0.5, [1.0, 2.0, 13.0]) + .unwrap(); + assert_eq!(frame.command_index, 7); + assert_eq!(frame.material.name, "sun"); + assert_eq!(frame.normalized_time, 0.5); + assert_eq!(frame.material_phase, 0.25); + assert_eq!(frame.color[3], 0.5); + assert!(frame.active); + let center = std::array::from_fn::<_, 3, _>(|axis| { + frame + .vertices + .iter() + .map(|vertex| vertex[axis]) + .sum::<f32>() + / 4.0 + }); + assert_eq!(center, [1.0, 2.0, 3.0]); + assert_ne!(frame.vertices[0], frame.vertices[1]); + + assert!( + evaluate_op3_type0_command(&command, 7, &identity, 0.25, 1.1, [1.0, 2.0, 13.0],) + .is_some_and(|frame| !frame.active) + ); + assert!( + evaluate_op3_type0_command(&command, 7, &identity, 0.25, 0.5, [1.0, 2.0, 3.0],) + .is_none() + ); + } + + #[test] + fn type2_matrix_scales_parent_basis_then_overrides_translation() { + let parent = [ + [2.0, 0.0, 0.0, 10.0], + [0.0, 3.0, 0.0, 20.0], + [0.0, 0.0, 4.0, 30.0], + [0.0, 0.0, 0.0, 1.0], + ]; + let sample = Op3Type2Sample { + position: [1.0, 2.0, 3.0], + axis: [1.0, 2.0, 2.0], + scale: [2.0, 3.0, 4.0], + opacity: 1.0, + material_phase: 0.0, + }; + let result = native_op3_type2_matrix(&parent, sample).unwrap(); + assert_eq!(result[0][3], 12.0); + assert_eq!(result[1][3], 26.0); + assert_eq!(result[2][3], 42.0); + assert_eq!(result[0][0], -8.0 / 5.0_f32.sqrt()); + assert_eq!(result[1][0], 9.0 / 5.0_f32.sqrt()); + assert_eq!(result[3], [0.0, 0.0, 0.0, 1.0]); + } + + #[test] + fn type2_command_keeps_inclusive_single_phase_endpoint() { + let mut body = vec![0_u8; 196]; + body[0..4].copy_from_slice(&TYPE2_BODY_DISCRIMINATOR.to_le_bytes()); + body[TYPE2_ACTIVE_START_OFFSET..TYPE2_ACTIVE_START_OFFSET + 4] + .copy_from_slice(&1.0_f32.to_le_bytes()); + body[TYPE2_ACTIVE_END_OFFSET..TYPE2_ACTIVE_END_OFFSET + 4] + .copy_from_slice(&1.0_f32.to_le_bytes()); + body[TYPE2_RESOURCE_OFFSET..TYPE2_RESOURCE_OFFSET + 11].copy_from_slice(b"effects.rlb"); + body[TYPE2_RESOURCE_OFFSET + 32..TYPE2_RESOURCE_OFFSET + 36].copy_from_slice(b"lamp"); + let command = FxCommand { + word: 3, + opcode: FxOpcode::Op3, + native_flag: false, + raw_body: Arc::from(body.into_boxed_slice()), + resource_refs: Vec::new(), + }; + let identity = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + let frame = evaluate_op3_type2_command(&command, 0, &identity, 1.0, 1.0, 0).unwrap(); + assert_eq!( + frame.material, + EffectResource { + archive: "effects.rlb".to_owned(), + name: "lamp".to_owned(), + } + ); + assert!(frame.active); + assert_eq!(frame.normalized_time, 1.0); + + let before = evaluate_op3_type2_command(&command, 0, &identity, 0.99, 0.99, 0).unwrap(); + assert!(!before.active); + } + + #[test] + fn samples_verified_type2_position_axis_scale_phase_and_opacity_tracks() { + let mut body = vec![0_u8; 196]; + write_u32(&mut body, 0, TYPE2_BODY_DISCRIMINATOR); + write_f32(&mut body, TYPE2_PHASE_START_OFFSET, -1.0); + write_f32(&mut body, TYPE2_PHASE_END_OFFSET, 9.0); + write_f32(&mut body, TYPE2_PHASE_EXPONENT_OFFSET, 1.0); + write_f32(&mut body, TYPE2_OPACITY_START_OFFSET, 2.0); + write_f32(&mut body, TYPE2_OPACITY_END_OFFSET, -1.0); + write_f32(&mut body, TYPE2_OPACITY_EXPONENT_OFFSET, 1.0); + write_vec3(&mut body, TYPE2_POSITION_START_OFFSET, [0.0, 0.0, 0.0]); + write_vec3(&mut body, TYPE2_POSITION_END_OFFSET, [8.0, 16.0, 24.0]); + write_vec3(&mut body, TYPE2_POSITION_EXPONENT_OFFSET, [1.0, 2.0, 3.0]); + write_vec3(&mut body, TYPE2_AXIS_START_OFFSET, [1.0, 2.0, 3.0]); + write_vec3(&mut body, TYPE2_AXIS_END_OFFSET, [5.0, 6.0, 7.0]); + write_vec3(&mut body, TYPE2_SCALE_START_OFFSET, [1.0; 3]); + write_vec3(&mut body, TYPE2_SCALE_END_OFFSET, [3.0; 3]); + write_f32(&mut body, 0x78, 3.0); + write_f32(&mut body, 0x7c, 2.0); + write_f32(&mut body, 0x80, 1.0); + + let sample = sample_op3_type2_body(&body, 0.5, 0.25).unwrap(); + assert_eq!(sample.position, [4.0, 4.0, 3.0]); + assert_eq!(sample.axis, [3.0, 4.0, 5.0]); + assert_eq!(sample.scale, [2.0, 1.5, 1.25]); + assert_eq!(sample.opacity, 0.5); + assert_eq!(sample.material_phase, 0.25); + } + + #[test] + fn type1_basis_uses_raw_exact_dot_gate_and_native_cross_order() { + // The native comparison is against the raw dot product, so this + // oblique pair is rejected even though the vectors are not parallel. + assert_eq!( + native_op3_type1_basis([1.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0; 3]), + None + ); + assert_eq!( + native_op3_type1_basis([1.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0; 3]), + None + ); + assert_eq!( + native_op3_type1_basis([1.0, 0.0, 0.0], [-1.0, 0.0, 0.0], [0.0; 3]), + None + ); + + // A non-unit view gives a raw dot of 2 and proceeds to the native + // cross-product frame instead of being normalized before the gate. + let frame = + native_op3_type1_basis([2.0, 1.0, 0.0], [1.0, 0.0, 0.0], [3.0, 4.0, 5.0]).unwrap(); + assert_eq!(frame[0], [0.0, 0.0, 1.0, 3.0]); + assert_eq!(frame[1], [0.0, 1.0, 0.0, 4.0]); + assert_eq!(frame[2], [-1.0, 0.0, 0.0, 5.0]); + + // The axis is not normalized: W and the axis column keep its scale. + let axis_scaled = + native_op3_type1_basis([2.0, 1.0, 0.0], [0.0, 0.0, 2.0], [0.0; 3]).unwrap(); + let inv_sqrt_5 = 1.0 / 5.0_f32.sqrt(); + assert_eq!(axis_scaled[0][0], inv_sqrt_5); + assert_eq!(axis_scaled[1][0], -2.0 * inv_sqrt_5); + assert_eq!(axis_scaled[0][1], 4.0 * inv_sqrt_5); + assert_eq!(axis_scaled[1][1], 2.0 * inv_sqrt_5); + assert_eq!(axis_scaled[2][2], 2.0); + + assert_eq!( + native_op3_type1_basis([f32::NAN, 0.0, 0.0], [0.0, 0.0, 1.0], [0.0; 3]), + None + ); + } + + #[test] + fn type1_command_samples_authored_tracks_and_camera_basis() { + let mut body = vec![0_u8; 196]; + write_u32(&mut body, 0, TYPE1_BODY_DISCRIMINATOR); + write_f32(&mut body, TYPE2_PHASE_START_OFFSET, -1.0); + write_f32(&mut body, TYPE2_PHASE_END_OFFSET, 2.0); + write_f32(&mut body, TYPE2_PHASE_EXPONENT_OFFSET, 1.0); + write_f32(&mut body, TYPE2_OPACITY_START_OFFSET, 1.0); + write_f32(&mut body, TYPE2_OPACITY_END_OFFSET, 0.0); + write_f32(&mut body, TYPE2_OPACITY_EXPONENT_OFFSET, 1.0); + write_f32(&mut body, TYPE2_ACTIVE_START_OFFSET, 0.0); + write_f32(&mut body, TYPE2_ACTIVE_END_OFFSET, 1.0); + write_vec3(&mut body, TYPE2_POSITION_START_OFFSET, [0.0, 0.0, 0.0]); + write_vec3(&mut body, TYPE2_POSITION_END_OFFSET, [2.0, 0.0, 0.0]); + write_vec3(&mut body, TYPE2_POSITION_EXPONENT_OFFSET, [1.0; 3]); + write_vec3(&mut body, TYPE2_AXIS_START_OFFSET, [0.0, 0.0, 1.0]); + write_vec3(&mut body, TYPE2_AXIS_END_OFFSET, [0.0, 0.0, 1.0]); + write_vec3(&mut body, TYPE2_SCALE_START_OFFSET, [1.0; 3]); + write_vec3(&mut body, TYPE2_SCALE_END_OFFSET, [1.0; 3]); + write_vec3(&mut body, 0x78, [1.0; 3]); + body[TYPE2_RESOURCE_OFFSET..TYPE2_RESOURCE_OFFSET + 12].copy_from_slice(b"material.lib"); + body[TYPE2_RESOURCE_OFFSET + 32..TYPE2_RESOURCE_OFFSET + 37].copy_from_slice(b"b_p08"); + + let command = FxCommand { + word: 3, + opcode: FxOpcode::Op3, + native_flag: false, + raw_body: Arc::from(body.into_boxed_slice()), + resource_refs: Vec::new(), + }; + let identity = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + let frame = + evaluate_op3_type1_command(&command, 4, &identity, 0.25, 0.5, [2.0, 1.0, 3.0]).unwrap(); + assert_eq!(frame.command_index, 4); + assert_eq!( + frame.material, + EffectResource { + archive: "material.lib".to_owned(), + name: "b_p08".to_owned(), + } + ); + assert!(frame.active); + assert_eq!(frame.normalized_time, 0.5); + assert_eq!(frame.color, [1.0, 1.0, 1.0, 0.5]); + assert_eq!(frame.material_phase, 0.5); + assert!(evaluate_op3_type1_command( + &command, + 4, + &identity, + 0.25, + 0.5, + [f32::NAN, 1.0, 3.0], + ) + .is_none()); + } + + #[test] + fn rejects_truncated_bodies_and_non_finite_tracks_or_inputs() { + let identity = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + [0.0, 0.0, 0.0, 1.0], + ]; + let truncated = FxCommand { + word: 3, + opcode: FxOpcode::Op3, + native_flag: false, + raw_body: Arc::from( + vec![0_u8; TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES - 1].into_boxed_slice(), + ), + resource_refs: Vec::new(), + }; + assert!( + evaluate_op3_type0_command(&truncated, 0, &identity, 0.0, 0.0, [0.0; 3],).is_none() + ); + + let mut type0_body = vec![0_u8; TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES]; + write_u32(&mut type0_body, 0, TYPE0_BODY_DISCRIMINATOR); + write_f32( + &mut type0_body, + TYPE2_POSITION_EXPONENT_OFFSET, + f32::INFINITY, + ); + assert!(sample_op3_type0_body(&type0_body, 0.5, 0.25).is_none()); + + let mut type2_body = vec![0_u8; TYPE2_RESOURCE_OFFSET + TYPE2_RESOURCE_BYTES]; + write_u32(&mut type2_body, 0, TYPE2_BODY_DISCRIMINATOR); + let non_finite_elapsed = FxCommand { + word: 3, + opcode: FxOpcode::Op3, + native_flag: false, + raw_body: Arc::from(type2_body.into_boxed_slice()), + resource_refs: Vec::new(), + }; + assert!(evaluate_op3_type2_command( + &non_finite_elapsed, + 0, + &identity, + f32::INFINITY, + 0.5, + 0, + ) + .is_none()); + } + + fn write_u32(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn write_f32(bytes: &mut [u8], offset: usize, value: f32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn write_vec3(bytes: &mut [u8], offset: usize, value: [f32; 3]) { + for (axis, component) in value.into_iter().enumerate() { + write_f32(bytes, offset + axis * 4, component); + } + } +} diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs index c08eec5..1772d1c 100644 --- a/crates/fparkan-fx/src/lib.rs +++ b/crates/fparkan-fx/src/lib.rs @@ -6,6 +6,7 @@ //! reference stub until opcode timing, gates, RNG, and command-body semantics //! are backed by runtime-captured evidence. +pub mod ambient_op3; pub mod atmosphere; pub mod environment; pub mod fx_visual; diff --git a/docs/reference/environment-effects.md b/docs/reference/environment-effects.md index 6c45038..60a900d 100644 --- a/docs/reference/environment-effects.md +++ b/docs/reference/environment-effects.md @@ -255,6 +255,16 @@ global RNG остаются задачей владельца. `fparkan-game` п категорию 0 с наибольшей детализацией; выбор категории по projected size ещё не реализован. +Для Op3 поддерживаются body-типы 0, 1 и 2. Поля отсчитываются от +`FxCommand.raw_body`, то есть после 4-байтового command word. Типы 0 и 1 +используют общие position/scale/phase/opacity tracks и camera-facing основу; +тип 1 и тип 2 также интерполируют ось, а тип 2 строит basis через native +D110-порядок. Все три evaluator-а принимают уже собранную матрицу владельца и +не выбирают Control row или attachment. Текущий путь для типов 0 и 1 +предполагает native render context 0: `Effect.dll` проверяет общий gate +`context + 0x60 < 8`, а API этих evaluator-ов не выбирает другой context. +Evaluator типа 2 принимает индекс context явно и учитывает этот gate в `active`. + ## Что остаётся за владельцем `EnvironmentSystem` не извлекает камеру из renderer, не разрешает материалы |
