feat(fx): play ambient Control sound commands
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
This commit is contained in:
7 files changed
+573
-24
No files matched your search
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user