diff options
Diffstat (limited to 'crates/fparkan-fx')
| -rw-r--r-- | crates/fparkan-fx/src/clock.rs | 190 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/environment.rs | 154 | ||||
| -rw-r--r-- | crates/fparkan-fx/src/lib.rs | 17 |
3 files changed, 357 insertions, 4 deletions
diff --git a/crates/fparkan-fx/src/clock.rs b/crates/fparkan-fx/src/clock.rs new file mode 100644 index 0000000..9382b33 --- /dev/null +++ b/crates/fparkan-fx/src/clock.rs @@ -0,0 +1,190 @@ +//! Per-instance time for the owner-independent Effect.dll phase modes. + +/// The clock fields read by Effect.dll +0x5C60 and set by +0x7B00/+0x7B40. +#[derive(Clone, Debug)] +pub struct FxInstanceClock { + header_mode: u32, + mode: u32, + header_flags: u32, + duration_ms: f32, + origin_ms: f32, + end_ms: f32, + constant_phase: f32, + restart_requested: bool, +} + +impl FxInstanceClock { + /// Creates an instance at its own origin, using the document's default mode. + /// Modes requiring an owner remain unsupported by this clock. + pub fn new( + mode: u32, + header_flags: u32, + duration_seconds: f32, + origin_ms: f32, + ) -> Option<Self> { + let duration_ms = duration_seconds * 1000.0; + if mode > 3 + || !duration_ms.is_finite() + || duration_ms < 0.0 + || !origin_ms.is_finite() + || (mode != 0 && duration_ms == 0.0) + || !(origin_ms + duration_ms).is_finite() + { + return None; + } + Some(Self { + header_mode: mode, + mode, + header_flags, + duration_ms, + origin_ms, + end_ms: origin_ms + duration_ms, + constant_phase: 0.0, + restart_requested: false, + }) + } + + /// Changes the caller-selected mode and epoch without changing the document. + pub fn set_time(&mut self, now_ms: f32, mode: u32) -> Option<()> { + if mode > 3 + || !now_ms.is_finite() + || !(now_ms + self.duration_ms).is_finite() + || (mode != 0 && self.duration_ms == 0.0) + { + return None; + } + self.origin_ms = now_ms; + self.end_ms = now_ms + self.duration_ms; + self.mode = mode; + Some(()) + } + + /// Sets the value used by mode zero; command evaluation clamps it later. + pub fn set_constant_phase(&mut self, phase: f32) -> Option<()> { + if !phase.is_finite() { + return None; + } + self.constant_phase = phase; + Some(()) + } + + /// Requests the native deferred restart: the current lifetime finishes first. + pub fn request_restart(&mut self) { + self.restart_requested = true; + } + + /// Applies the restart portion of Effect.dll +0x8120 before phase sampling. + /// The new lifetime starts at the update time rather than retaining overshoot. + pub fn update(&mut self, now_ms: f32) -> Option<()> { + if !now_ms.is_finite() { + return None; + } + if self.restart_requested && self.end_ms <= now_ms { + self.set_time(now_ms, self.header_mode)?; + self.restart_requested = false; + } + Some(()) + } + + /// Elapsed instance time for command simulation, independent of app startup. + pub fn elapsed_seconds(&self, now_ms: f32) -> Option<f32> { + now_ms + .is_finite() + .then_some((now_ms - self.origin_ms) * 0.001) + } + + /// Samples the raw phase before command clamping and random phase jitter. + /// `manager_flag_a8` is the native owner bit tested by header gates 0x80/0x100. + pub fn phase(&self, now_ms: f32, manager_flag_a8: bool) -> Option<f32> { + if !now_ms.is_finite() { + return None; + } + if (self.header_flags & 0x80 != 0 && !manager_flag_a8) + || (self.header_flags & 0x100 != 0 && manager_flag_a8) + { + return Some(0.0); + } + let ratio = (now_ms - self.origin_ms) / (self.end_ms - self.origin_ms); + let mut phase = match self.mode { + 0 => self.constant_phase, + 1 => ratio, + 2 => ratio - ratio.floor(), + 3 => 1.0 - ratio, + _ => return None, + }; + if self.header_flags & 0x200 != 0 { + phase *= ratio; + } + if self.header_flags & 0x20 != 0 { + if phase >= 0.5 { + phase = 1.0 - phase; + } + phase *= 2.0; + } + phase.is_finite().then_some(phase) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_epoch_modes_and_unclamped_header_transforms() { + // Effect.dll GOG +0x5C60, x87 CW 007F; native oracle uses these epochs. + for (mode, expected) in [ + (0, [0.375, 0.375, 0.375, 0.375, 0.375]), + (1, [0.0, 0.25, 0.5, 1.0, 1.25]), + (2, [0.0, 0.25, 0.5, 0.0, 0.25]), + (3, [1.0, 0.75, 0.5, 0.0, -0.25]), + ] { + let mut clock = FxInstanceClock::new(mode, 0, 4.0, 135_000.0).unwrap(); + clock.set_constant_phase(0.375).unwrap(); + for (now, phase) in [135_000.0, 136_000.0, 137_000.0, 139_000.0, 140_000.0] + .into_iter() + .zip(expected) + { + assert_eq!(clock.phase(now, false), Some(phase)); + } + } + for (flags, expected) in [(0x200, 2.25), (0x20, -1.0), (0x220, -2.5)] { + let clock = FxInstanceClock::new(1, flags, 4.0, 135_000.0).unwrap(); + assert_eq!(clock.phase(141_000.0, false), Some(expected)); + } + } + + #[test] + fn caller_mode_and_deferred_restart_use_the_instance_origin() { + let mut clock = FxInstanceClock::new(2, 0, 4.0, 135_000.0).unwrap(); + clock.set_time(136_000.0, 3).unwrap(); + clock.request_restart(); + clock.update(139_999.0).unwrap(); + assert!(clock.phase(139_999.0, false).unwrap() < 0.001); + clock.update(140_250.0).unwrap(); + assert_eq!(clock.phase(140_250.0, false), Some(0.0)); + assert_eq!(clock.elapsed_seconds(140_750.0), Some(0.5)); + assert_eq!(clock.phase(141_250.0, false), Some(0.25)); + } + + #[test] + fn manager_header_gates_precede_phase_transforms() { + for (flags, manager, expected) in [ + (0x80, false, 0.0), + (0x80, true, 0.5), + (0x100, false, 0.5), + (0x100, true, 0.0), + ] { + let clock = FxInstanceClock::new(1, flags, 2.0, 20_000.0).unwrap(); + assert_eq!(clock.phase(21_000.0, manager), Some(expected)); + } + assert_eq!( + FxInstanceClock::new(0, 0, 0.0, 0.0) + .unwrap() + .phase(100.0, false), + Some(0.0) + ); + assert!(FxInstanceClock::new(1, 0, 0.0, 0.0).is_none()); + assert!(FxInstanceClock::new(0, 0, f32::MAX, 0.0).is_none()); + assert!(FxInstanceClock::new(4, 0, 2.0, 0.0).is_none()); + } +} diff --git a/crates/fparkan-fx/src/environment.rs b/crates/fparkan-fx/src/environment.rs index 713a3f9..b0f9d83 100644 --- a/crates/fparkan-fx/src/environment.rs +++ b/crates/fparkan-fx/src/environment.rs @@ -615,7 +615,7 @@ pub struct PointLightFrame { pub range: f32, /// Native attenuation coefficients `(a0, a1, a2)`. pub attenuation: [f32; 3], - /// Radius jitter amplitude at body offset 116; retained but not sampled yet. + /// Radius jitter amplitude at body offset 116 (legacy field name). pub effect_factor: f32, /// Whether the opcode 1 command is active at `command_time`. pub active: bool, @@ -627,6 +627,8 @@ pub struct PointLightFrame { /// `command_time` is resolved by the caller; FX header duration and lifecycle /// remain with that caller. Native Op1 scales the radius by the transformed /// direction length and replaces a non-positive result with 0.01. +/// This samples the authored curves; Control FX use +/// [`evaluate_op1_point_light_with_basis_and_jitter`] for stochastic additions. #[must_use] pub fn evaluate_op1_point_light_with_basis( command: &FxCommand, @@ -634,6 +636,51 @@ pub fn evaluate_op1_point_light_with_basis( origin: [f32; 3], basis: [[f32; 3]; 3], ) -> Option<PointLightFrame> { + evaluate_op1_point_light(command, command_time, origin, basis, None) +} + +/// The two native streams used by Op1 color and radius jitter. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Op1JitterState { + general: u32, + light: u32, +} + +impl Op1JitterState { + #[must_use] + pub const fn new(general: u32, light: u32) -> Self { + Self { general, light } + } + + fn sample(&mut self) -> ([u16; 4], u16) { + let alpha = crate::advance_native_fx_random_state(&mut self.light); + let blue = crate::advance_native_fx_random_state(&mut self.light); + let green = crate::advance_native_fx_random_state(&mut self.light); + let red = crate::advance_native_fx_random_state(&mut self.general); + let radius = crate::advance_native_fx_random_state(&mut self.light); + ([red, green, blue, alpha], radius) + } +} + +/// Adds the native signed uniform jitter before transforming the light radius. +/// Streams advance even outside the command interval and with zero amplitudes. +pub fn evaluate_op1_point_light_with_basis_and_jitter( + command: &FxCommand, + command_time: f32, + origin: [f32; 3], + basis: [[f32; 3]; 3], + jitter: &mut Op1JitterState, +) -> Option<PointLightFrame> { + evaluate_op1_point_light(command, command_time, origin, basis, Some(jitter)) +} + +fn evaluate_op1_point_light( + command: &FxCommand, + command_time: f32, + origin: [f32; 3], + basis: [[f32; 3]; 3], + jitter: Option<&mut Op1JitterState>, +) -> Option<PointLightFrame> { if command.opcode != FxOpcode::Op1 || !command_time.is_finite() || !origin @@ -653,14 +700,27 @@ pub fn evaluate_op1_point_light_with_basis( || FLOAT_OFFSETS .iter() .any(|&offset| read_f32(body, offset).is_none_or(|value| !value.is_finite())) + || (jitter.is_some() + && (92..108) + .step_by(4) + .any(|offset| read_f32(body, offset).is_none_or(|value| !value.is_finite()))) { return None; } + let jitter = jitter.map(Op1JitterState::sample); let normalized_time = native_opcode1_interval(body, command_time)?; if !normalized_time.is_finite() { return None; } - let sample = sample_op1_point_light(body, normalized_time)?; + let mut sample = sample_op1_point_light(body, normalized_time)?; + if let Some((color, radius)) = jitter { + let centered = + |word, amplitude| f32::from(word) * (amplitude * (1.0 / 65_536.0)) - amplitude * 0.5; + for (axis, word) in color.into_iter().enumerate() { + sample.color[axis] += centered(word, read_f32(body, 92 + axis * 4)?); + } + sample.range += centered(radius, read_f32(body, 116)?); + } let (light_selector, native_flags) = opcode1_light_manager_mapping(sample.effect_type)?; if light_selector != 1 { return None; @@ -681,8 +741,6 @@ pub fn evaluate_op1_point_light_with_basis( .map(|component| component * component) .sum::<f32>() .sqrt(); - // shortcut: Op1 color/radius jitter is omitted; add native RNG sampling - // when the reported randomized Control lights are handled in phase R4. let scaled_range = sample.range * direction_length; if !direction_length.is_finite() || !scaled_range.is_finite() { return None; @@ -2982,6 +3040,94 @@ mod tests { } #[test] + fn opcode1_jitter_matches_native_color_words_and_advances_outside_the_interval() { + let mut command = op1_test_command(7, 0.0, 1.0); + let mut body = command.raw_body.to_vec(); + for (start, values) in [ + (60, [0.25_f32, 0.5, 0.75, 1.0]), + (76, [0.75, 1.0, 1.25, 1.5]), + (92, [0.5, 1.0, 2.0, 4.0]), + ] { + body[start..start + 16].copy_from_slice(&values.map(f32::to_le_bytes).concat()); + } + body[48..60].copy_from_slice(&[1.0_f32, 0.0, 0.0].map(f32::to_le_bytes).concat()); + body[116..120].copy_from_slice(&4.0_f32.to_le_bytes()); + command.raw_body = Arc::from(body); + let mut state = super::Op1JitterState::new(0x12345678, 0x89abcdef); + // Effect.dll+F6E0, CW007F, identity owner; native FastProc length is approximate. + let native = [ + ( + [0x3ef7de00, 0x3f310300, 0x3f123400, 0x3ef50000], + 0x4150a0fb, + 0xb7debec4, + 0xa29eea1f, + ), + ( + [0x3f08dc80, 0x3ec15400, 0x3fd95800, 0xbe011000], + 0x417a34ee, + 0x91b9ca56, + 0xa8f3b8a6, + ), + ( + [0x3f06e480, 0x3f190600, 0x3f885b00, 0x3fcb8c00], + 0x4181f650, + 0x4dc90515, + 0x2fd33350, + ), + ( + [0x3f508380, 0x3f2a5100, 0x40099880, 0x3f8d3400], + 0x41c31747, + 0x610747e3, + 0xf8ebfdc3, + ), + ( + [0x3f2f2100, 0x3f56ed00, 0x3f0c9c00, 0x3f9e3000], + 0x417d7bd4, + 0xde42eec1, + 0xb60ffd79, + ), + ]; + for (phase, (color, radius, general, light)) in + [0.25, 0.5, 0.75, 1.25, 0.5].into_iter().zip(native) + { + let frame = super::evaluate_op1_point_light_with_basis_and_jitter( + &command, + phase, + [0.0; 3], + identity_basis(), + &mut state, + ); + if phase > 1.0 { + assert!(frame.is_none()); + } else { + let frame = frame.unwrap(); + assert_eq!(frame.color.map(f32::to_bits), color); + let native_radius = f32::from_bits(radius); + assert!((frame.range - native_radius).abs() < native_radius * 0.00013); + } + assert_eq!((state.general, state.light), (general, light)); + } + } + + #[test] + fn opcode1_jitter_rejects_nonfinite_amplitudes_without_consuming_random_state() { + let mut command = op1_test_command(7, 0.0, 1.0); + let mut body = command.raw_body.to_vec(); + body[92..96].copy_from_slice(&f32::NAN.to_le_bytes()); + command.raw_body = Arc::from(body); + let mut state = super::Op1JitterState::new(123, 456); + assert!(super::evaluate_op1_point_light_with_basis_and_jitter( + &command, + 0.5, + [0.0; 3], + identity_basis(), + &mut state + ) + .is_none()); + assert_eq!((state.general, state.light), (123, 456)); + } + + #[test] fn generic_opcode1_keeps_zero_direction_zero() { let mut command = op1_test_command(5, 0.0, 1.0); let mut body = command.raw_body.to_vec(); diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs index af7408a..4201ffc 100644 --- a/crates/fparkan-fx/src/lib.rs +++ b/crates/fparkan-fx/src/lib.rs @@ -9,6 +9,7 @@ pub mod ambient_op3; pub mod ambient_sound; pub mod atmosphere; +pub mod clock; pub mod environment; pub mod fx_visual; pub mod op7; @@ -67,6 +68,12 @@ pub struct FxHeader { pub scale: [f32; 3], } +/// Effect.dll RVA 0x5C60 reads an owner/provider for modes 4 through 17, including mode 14. +#[must_use] +pub const fn fx_time_mode_requires_owner_state(mode: u32) -> bool { + matches!(mode, 4..=17) +} + /// FX opcode. #[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)] pub enum FxOpcode { @@ -624,6 +631,16 @@ mod tests { use std::path::{Path, PathBuf}; #[test] + fn phase_sources_distinguish_elapsed_damage_and_progress_modes() { + for mode in [0, 1, 2, 3, 18, u32::MAX] { + assert!(!fx_time_mode_requires_owner_state(mode)); + } + for mode in [4, 5, 9, 13, 14, 15, 16, 17] { + assert!(fx_time_mode_requires_owner_state(mode)); + } + } + + #[test] fn decodes_synthetic_opcodes_and_refs() { let mut bytes = header(2); bytes.extend_from_slice(&command_with_ref_at( |
