feat(fx): play ambient Control sound commands
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s

This commit is contained in:
Valentin Popov committed 2026-10-31 22:06:49 +04:00
1 parent 3f4059b726
commit 7ba8a0d84e
7 files changed
+573 -24

No files matched your search

+288
View File
@@ -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());
}
}
+1
View File
@@ -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;