aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-fx
diff options
context:
space:
mode:
Diffstat (limited to 'crates/fparkan-fx')
-rw-r--r--crates/fparkan-fx/src/ambient_sound.rs288
-rw-r--r--crates/fparkan-fx/src/lib.rs1
-rw-r--r--crates/fparkan-fx/src/sky.rs67
3 files changed, 348 insertions, 8 deletions
diff --git a/crates/fparkan-fx/src/ambient_sound.rs b/crates/fparkan-fx/src/ambient_sound.rs
new file mode 100644
index 0000000..bcc1814
--- /dev/null
+++ b/crates/fparkan-fx/src/ambient_sound.rs
@@ -0,0 +1,288 @@
+//! Opcode-2 sound commands, independent of an audio device.
+
+use crate::environment::{SoundAction, SoundEvent};
+use crate::{FxCommand, FxOpcode};
+
+/// A validated ordinary Op2 command. Modes 1/3 request an unverified backend
+/// flag and are left to the caller's unsupported-command report.
+#[derive(Clone, Debug)]
+pub struct Op2SoundCommand {
+ looping: bool,
+ start: f32,
+ end: f32,
+ position: [f32; 3],
+ position_delta: [f32; 3],
+ min_distance: f32,
+ max_distance: f32,
+ frequency: f32,
+ frequency_delta: f32,
+ archive: String,
+ name: String,
+}
+
+/// Playback crossing state belongs to a command instance, even when several
+/// instances share the same sample.
+#[derive(Clone, Debug, Default)]
+pub struct Op2SoundState {
+ previous_phase: f32,
+}
+
+impl Op2SoundCommand {
+ /// Decodes the supported native modes 0 (once) and 2 (loop).
+ #[must_use]
+ pub fn from_command(command: &FxCommand) -> Option<Self> {
+ let body = command.raw_body.as_ref();
+ if command.opcode != FxOpcode::Op2 || body.len() != 144 {
+ return None;
+ }
+ let mode = u32::from_le_bytes(body[..4].try_into().ok()?);
+ // shortcut: backend flag 0x200 and body+76 need a native playback
+ // comparison before modes 1/3 or nonzero backend parameters are enabled.
+ if !matches!(mode, 0 | 2) || body[76..80] != [0; 4] {
+ return None;
+ }
+ let float = |offset| {
+ let value = f32::from_le_bytes(body[offset..offset + 4].try_into().ok()?);
+ value.is_finite().then_some(value)
+ };
+ let vector = |offset| Some([float(offset)?, float(offset + 4)?, float(offset + 8)?]);
+ let start = float(4)?;
+ let end = float(8)?;
+ let position = vector(12)?;
+ let position_end = vector(24)?;
+ let min_distance = float(60)?;
+ let max_distance = float(64)?;
+ let frequency = float(68)?;
+ let frequency_end = float(72)?;
+ let position_delta = std::array::from_fn(|axis| position_end[axis] - position[axis]);
+ let frequency_delta = frequency_end - frequency;
+ if end <= start
+ || !(end - start).is_finite()
+ || !position_delta.iter().all(|value: &f32| value.is_finite())
+ || !frequency_delta.is_finite()
+ || min_distance < 0.0
+ || max_distance < min_distance
+ || frequency <= 0.0
+ || frequency_end <= 0.0
+ {
+ return None;
+ }
+ let text = |bytes: &[u8]| {
+ let end = bytes
+ .iter()
+ .position(|&byte| byte == 0)
+ .unwrap_or(bytes.len());
+ (!bytes[..end].is_empty()).then(|| String::from_utf8_lossy(&bytes[..end]).into_owned())
+ };
+ Some(Self {
+ looping: mode == 2,
+ start,
+ end,
+ position,
+ position_delta,
+ min_distance,
+ max_distance,
+ frequency,
+ frequency_delta,
+ archive: text(&body[80..112])?,
+ name: text(&body[112..144])?,
+ })
+ }
+
+ /// Evaluates Effect.dll's phase crossings and current source parameters.
+ /// `playing` comes from the audio owner, since a one-shot can finish between
+ /// updates. The matrix maps local command coordinates into world space.
+ #[must_use]
+ pub fn update(
+ &self,
+ state: &mut Op2SoundState,
+ phase: f32,
+ world_from_effect: [[f32; 4]; 4],
+ playing: bool,
+ ) -> Option<SoundEvent> {
+ if !phase.is_finite() || !world_from_effect.iter().flatten().all(|v| v.is_finite()) {
+ return None;
+ }
+ let action = if self.looping {
+ if phase >= self.start && phase <= self.end {
+ if playing {
+ SoundAction::SetLoopVolume
+ } else {
+ SoundAction::StartLoop
+ }
+ } else if playing {
+ SoundAction::StopLoop
+ } else {
+ return None;
+ }
+ } else {
+ let crossing_phase = if phase == 1.0 { 0.0 } else { phase };
+ let crossed = state.previous_phase <= crossing_phase
+ && state.previous_phase <= self.start
+ && self.start < crossing_phase;
+ state.previous_phase = crossing_phase;
+ if crossed {
+ SoundAction::OneShot
+ } else if playing {
+ SoundAction::SetLoopVolume
+ } else {
+ return None;
+ }
+ };
+ let t = (phase - self.start) / (self.end - self.start);
+ let local: [f32; 3] =
+ std::array::from_fn(|axis| self.position[axis] + t * self.position_delta[axis]);
+ let position = std::array::from_fn(|axis| {
+ let row = world_from_effect[axis];
+ row[0] * local[0] + row[1] * local[1] + row[2] * local[2] + row[3]
+ });
+ let frequency_ratio = self.frequency + t * self.frequency_delta;
+ if !position.iter().all(|value| value.is_finite()) || !frequency_ratio.is_finite() {
+ return None;
+ }
+ Some(SoundEvent {
+ archive: self.archive.clone(),
+ name: self.name.clone(),
+ position,
+ volume: 1.0,
+ min_distance: self.min_distance,
+ max_distance: self.max_distance,
+ frequency_ratio,
+ action,
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ 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(mode: u32) -> FxCommand {
+ let mut body = vec![0; 144];
+ body[..4].copy_from_slice(&mode.to_le_bytes());
+ for (offset, value) in [
+ (4, 0.25_f32),
+ (8, 0.75),
+ (60, 12.0),
+ (64, 80.0),
+ (68, 1.0),
+ (72, 2.0),
+ ] {
+ body[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ }
+ body[80..90].copy_from_slice(b"sounds.lib");
+ body[112..122].copy_from_slice(b"f_defl.wav");
+ FxCommand {
+ word: 2,
+ opcode: FxOpcode::Op2,
+ native_flag: false,
+ raw_body: Arc::from(body),
+ resource_refs: vec![],
+ }
+ }
+
+ #[test]
+ fn loop_has_inclusive_boundaries_and_updates_without_restarting() {
+ let command = Op2SoundCommand::from_command(&command(2)).unwrap();
+ let mut state = Op2SoundState::default();
+ assert!(command.update(&mut state, 0.0, IDENTITY, false).is_none());
+ assert_eq!(
+ command
+ .update(&mut state, 0.25, IDENTITY, false)
+ .unwrap()
+ .action,
+ SoundAction::StartLoop
+ );
+ assert_eq!(
+ command
+ .update(&mut state, 0.75, IDENTITY, true)
+ .unwrap()
+ .action,
+ SoundAction::SetLoopVolume
+ );
+ assert_eq!(
+ command
+ .update(&mut state, 0.8, IDENTITY, true)
+ .unwrap()
+ .action,
+ SoundAction::StopLoop
+ );
+ assert!(command.update(&mut state, 0.8, IDENTITY, false).is_none());
+ }
+
+ #[test]
+ fn one_shot_crossing_is_strict_and_rearms_after_phase_one() {
+ let command = Op2SoundCommand::from_command(&command(0)).unwrap();
+ let mut state = Op2SoundState::default();
+ assert!(command.update(&mut state, 0.25, IDENTITY, false).is_none());
+ assert_eq!(
+ command
+ .update(&mut state, 0.3, IDENTITY, false)
+ .unwrap()
+ .action,
+ SoundAction::OneShot
+ );
+ assert_eq!(
+ command
+ .update(&mut state, 0.5, IDENTITY, true)
+ .unwrap()
+ .action,
+ SoundAction::SetLoopVolume
+ );
+ assert!(command.update(&mut state, 0.6, IDENTITY, false).is_none());
+ assert!(command.update(&mut state, 1.0, IDENTITY, false).is_none());
+ assert_eq!(
+ command
+ .update(&mut state, 0.3, IDENTITY, false)
+ .unwrap()
+ .action,
+ SoundAction::OneShot
+ );
+ }
+
+ #[test]
+ fn parameters_keep_native_constant_ranges_and_transform_the_position() {
+ let mut source = command(2);
+ let mut body = source.raw_body.to_vec();
+ body[24..28].copy_from_slice(&4.0_f32.to_le_bytes());
+ source.raw_body = Arc::from(body);
+ let command = Op2SoundCommand::from_command(&source).unwrap();
+ let mut matrix = IDENTITY;
+ matrix[0] = [0.0, -1.0, 0.0, 10.0];
+ matrix[1] = [2.0, 0.0, 0.0, 20.0];
+ let event = command
+ .update(&mut Op2SoundState::default(), 0.5, matrix, false)
+ .unwrap();
+ assert_eq!(event.position, [10.0, 24.0, 0.0]);
+ assert_eq!(
+ (
+ event.min_distance,
+ event.max_distance,
+ event.frequency_ratio
+ ),
+ (12.0, 80.0, 1.5)
+ );
+ assert_eq!(
+ (event.archive.as_str(), event.name.as_str()),
+ ("sounds.lib", "f_defl.wav")
+ );
+ }
+
+ #[test]
+ fn backend_modes_and_malformed_commands_are_explicitly_unsupported() {
+ assert!(Op2SoundCommand::from_command(&command(1)).is_none());
+ let mut source = command(2);
+ let mut body = source.raw_body.to_vec();
+ body[8..12].copy_from_slice(&f32::NAN.to_le_bytes());
+ source.raw_body = Arc::from(body);
+ assert!(Op2SoundCommand::from_command(&source).is_none());
+ }
+}
diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs
index 4125a68..af7408a 100644
--- a/crates/fparkan-fx/src/lib.rs
+++ b/crates/fparkan-fx/src/lib.rs
@@ -7,6 +7,7 @@
//! are backed by runtime-captured evidence.
pub mod ambient_op3;
+pub mod ambient_sound;
pub mod atmosphere;
pub mod environment;
pub mod fx_visual;
diff --git a/crates/fparkan-fx/src/sky.rs b/crates/fparkan-fx/src/sky.rs
index 26e68ab..1b793f0 100644
--- a/crates/fparkan-fx/src/sky.rs
+++ b/crates/fparkan-fx/src/sky.rs
@@ -328,6 +328,24 @@ pub struct SkyMesh {
}
impl SkyMesh {
+ /// Updates native nebula, star and cloud scroll from the sky object's elapsed milliseconds.
+ pub fn update_uvs(&mut self, elapsed_ms: u32) {
+ let offsets = [
+ [(elapsed_ms % 300_000) as f32 * 3.333_333_3e-6; 2],
+ [(elapsed_ms % 100_000) as f32 * 1.0e-5; 2],
+ [(elapsed_ms % 50_000) as f32 * 2.0e-5, 0.0],
+ ];
+ let inverse_radius = self.radius.recip();
+ for vertex in &mut self.vertices {
+ for stage in 0..3 {
+ let scale = inverse_radius * SKY_UV_SCALES[stage];
+ vertex.uv[stage] = std::array::from_fn(|axis| {
+ scale * vertex.position[axis] + offsets[stage][axis]
+ });
+ }
+ }
+ }
+
/// Number of triangles in the mesh.
#[must_use]
pub fn triangle_count(&self) -> usize {
@@ -1143,12 +1161,11 @@ impl SunFrame {
/// Converts an interpolated atmosphere sun sample to a renderer-facing frame.
///
/// The original update computes `orbit_direction = [cos(theta), 0,
-/// -sin(theta)]`, then applies the row-vector product of the two birth-angle
+/// -sin(theta)]`, then applies the column-vector product of the two birth-angle
/// matrices. With `A` and `B` in radians, the resulting direction is:
///
/// ```text
-/// [x*cos(A), -x*sin(A)*cos(B) + z*sin(B),
-/// x*sin(A)*sin(B) + z*cos(B)]
+/// [x*cos(A) + z*sin(A)*sin(B), x*sin(A) - z*cos(A)*sin(B), z*cos(B)]
/// ```
///
/// where `x` and `z` are the two non-zero orbit components.
@@ -1178,9 +1195,9 @@ pub fn sun_frame(sample: &SunSample, now_seconds: f32, config: SunConfig) -> Sun
let x = orbit_direction[0];
let z = orbit_direction[2];
let direction = normalize3([
- x * cos_a,
- -x * sin_a * cos_b + z * sin_b,
- x * sin_a * sin_b + z * cos_b,
+ x * cos_a + z * sin_a * sin_b,
+ x * sin_a - z * cos_a * sin_b,
+ z * cos_b,
]);
SunFrame {
active: true,
@@ -1788,6 +1805,26 @@ mod tests {
}
#[test]
+ fn scrolling_uvs_match_native_periods_and_wrapped_elapsed_clock() {
+ let mut mesh = SkyGeometryConfig::default().try_mesh(&sample()).unwrap();
+ let topology = mesh.indices.clone();
+ for elapsed in [0, 25_000, 49_999, 50_000, 100_000, 300_000, 4_051_271_258] {
+ mesh.update_uvs(elapsed);
+ let zenith = mesh.vertices[0].uv;
+ assert_eq!(zenith[0], [(elapsed % 300_000) as f32 * 3.333_333_3e-6; 2]);
+ assert_eq!(zenith[1], [(elapsed % 100_000) as f32 * 1e-5; 2]);
+ assert_eq!(zenith[2], [(elapsed % 50_000) as f32 * 2e-5, 0.0]);
+ }
+ // Live GOG sky render: now 265584022, object origin 509280060.
+ mesh.update_uvs(265_584_022_u32.wrapping_sub(509_280_060));
+ assert!((mesh.vertices[0].uv[2][0] - 0.42516).abs() < 1e-7);
+ let once = mesh.vertices.clone();
+ mesh.update_uvs(4_051_271_258);
+ assert_eq!(mesh.vertices, once, "updates must not accumulate drift");
+ assert_eq!(mesh.indices, topology);
+ }
+
+ #[test]
fn geometry_matches_original_counts_positions_uv_and_winding() {
let config = SkyGeometryConfig::new(10.0, std::f32::consts::FRAC_PI_2, 2.0, 4, 3);
let mesh = config.try_mesh(&sample()).expect("valid mesh");
@@ -1892,16 +1929,30 @@ mod tests {
let sample = sun(true);
let at_start = sun_frame(&sample, 0.0, SunConfig::new(0.0, 1.0, [90.0, 0.0]));
assert!((at_start.direction[0]).abs() < 1.0e-5);
- assert!((at_start.direction[1] + at_start.orbit_direction[0]).abs() < 1.0e-5);
+ assert!((at_start.direction[1] - at_start.orbit_direction[0]).abs() < 1.0e-5);
assert!((at_start.direction[2] - at_start.orbit_direction[2]).abs() < 1.0e-5);
let side = sun_frame(&sample, 0.0, SunConfig::new(0.0, 1.0, [0.0, 90.0]));
assert!((side.direction[0] - side.orbit_direction[0]).abs() < 1.0e-5);
- assert!((side.direction[1] - side.orbit_direction[2]).abs() < 1.0e-5);
+ assert!((side.direction[1] + side.orbit_direction[2]).abs() < 1.0e-5);
assert!(side.direction[2].abs() < 1.0e-5);
}
#[test]
+ fn sun_direction_matches_live_native_birth_matrix() {
+ // Terrain CSun::Update, elapsed 282184 ms, lifetime 525000 ms.
+ let frame = sun_frame(
+ &sun(true),
+ 282.184,
+ SunConfig::new(0.0, 525.0, [90.0, 30.0]),
+ );
+ let expected = [0xbefd_7269, 0xbe10_4168, 0xbf5b_7dda].map(f32::from_bits);
+ for (actual, expected) in frame.direction.into_iter().zip(expected) {
+ assert!((actual - expected).abs() < 3.0e-6, "{actual} != {expected}");
+ }
+ }
+
+ #[test]
fn native_birth_keeps_the_real_start_time() {
assert!(!celestial_frame(&sun(false), 100.0).active);
let birth = SunBirth {