Files
fparkan/apps/fparkan-game/src/audio.rs
T
Valentin Popov 7ba8a0d84e
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s
feat(fx): play ambient Control sound commands
2026-10-31 22:06:49 +04:00

1913 lines
65 KiB
Rust

//! Audio playback for a running mission.
//!
//! The game keeps this module deliberately small. Mission ambience is read
//! from the names in mission.cfg, samples are resolved through the same VFS
//! and resource archive formats as the rest of the game, and rodio owns the
//! output thread. A sample is decoded once to validate it before it is
//! queued, then its bytes are retained so a new source can be created when a
//! loop or one-shot starts.
use fparkan_fx::environment::{SoundAction, SoundEvent};
use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy};
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
use fparkan_vfs::Vfs;
use rodio::mixer::Mixer;
use rodio::source::{ChannelVolume, SeekError};
use rodio::{
ChannelCount, Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, SampleRate, Source,
};
use std::collections::BTreeMap;
use std::io::Cursor;
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
const SWAV_TYPE: u32 = u32::from_le_bytes(*b"SWAV");
const DEFAULT_MUSIC_TRACK: u8 = 2;
const FIRST_MUSIC_TRACK: u8 = 2;
const LAST_MUSIC_TRACK: u8 = 10;
const EAR_HALF_WIDTH: f32 = 0.15;
const VALIDATION_SAMPLES: usize = 64;
const AMBIENT_NIGHT_LENGTH: f32 = 1.1;
const AMBIENT_MIN_DELAY_SECONDS: f32 = 10.0;
const AMBIENT_DELAY_VARIANTS: u32 = 10;
/// Position and orientation of the listener in world coordinates.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Listener {
/// Listener position.
pub position: [f32; 3],
/// Direction in which the camera looks.
pub forward: [f32; 3],
/// Camera up direction.
pub up: [f32; 3],
}
impl Default for Listener {
fn default() -> Self {
Self {
position: [0.0; 3],
forward: [0.0, 1.0, 0.0],
up: [0.0, 0.0, 1.0],
}
}
}
/// Audio loading/playback failure.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum AudioError {
/// Mission path or archive path is not a valid relative resource path.
InvalidPath { path: String, message: String },
/// Mission configuration could not be read.
MissionConfigRead { path: String, message: String },
/// Mission configuration does not contain the required audio object.
MissionConfig { path: String, message: String },
/// A declared archive could not be opened.
Archive { path: String, message: String },
/// A declared archive entry is absent.
MissingEntry { archive: String, name: String },
/// A declared entry exists but contains no payload.
EmptyEntry { archive: String, name: String },
/// A payload is not the expected SWAV entry type.
WrongEntryType {
archive: String,
name: String,
type_id: Option<u32>,
},
/// The payload has a valid container but cannot be decoded by rodio.
Decode { resource: String, message: String },
/// A decoded container produced no samples.
NoSamples { resource: String },
/// A direct VFS file such as a music track is missing or unreadable.
FileRead { path: String, message: String },
/// Track selection is outside the names shipped by the original game.
UnsupportedMusicTrack(u8),
/// The operating system has no usable output device.
DeviceUnavailable { message: String },
/// A listener direction is non-finite or has zero length.
InvalidListener { field: &'static str },
/// A listener or emitter position is non-finite.
InvalidPosition { field: &'static str },
}
impl std::fmt::Display for AudioError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidPath { path, message } => {
write!(f, "invalid audio path {path}: {message}")
}
Self::MissionConfigRead { path, message } => {
write!(f, "failed to read mission audio config {path}: {message}")
}
Self::MissionConfig { path, message } => {
write!(f, "invalid mission audio config {path}: {message}")
}
Self::Archive { path, message } => {
write!(f, "failed to open audio archive {path}: {message}")
}
Self::MissingEntry { archive, name } => {
write!(
f,
"declared audio resource {name} is missing from {archive}"
)
}
Self::EmptyEntry { archive, name } => {
write!(f, "declared audio resource {name} in {archive} is empty")
}
Self::WrongEntryType {
archive,
name,
type_id,
} => write!(
f,
"audio resource {name} in {archive} has type {type_id:?}, expected SWAV"
),
Self::Decode { resource, message } => {
write!(f, "failed to decode audio resource {resource}: {message}")
}
Self::NoSamples { resource } => {
write!(f, "audio resource {resource} contains no samples")
}
Self::FileRead { path, message } => {
write!(f, "failed to read audio file {path}: {message}")
}
Self::UnsupportedMusicTrack(track) => {
write!(
f,
"music track Track{track:02}.ogg is outside Track02..Track10"
)
}
Self::DeviceUnavailable { message } => {
write!(f, "audio output device is unavailable: {message}")
}
Self::InvalidListener { field } => {
write!(
f,
"audio listener direction {field} must be finite and non-zero"
)
}
Self::InvalidPosition { field } => {
write!(f, "audio {field} position must be finite")
}
}
}
}
impl std::error::Error for AudioError {}
/// Parsed names from the two mission audio objects.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct MissionAudioConfig {
/// Archive named by ambient_music_loop.
pub library: String,
/// Archive named by ambient_music_variation, or the loop archive when it
/// is omitted.
pub variation_library: String,
/// Theme loop name from THEME.
pub theme: String,
/// Ambient default variation names in numeric key order.
pub default_variations: Vec<String>,
/// Ambient day variation names in numeric key order.
pub day_variations: Vec<String>,
/// Ambient night variation names in numeric key order.
pub night_variations: Vec<String>,
}
#[derive(Clone, Debug)]
struct SoundAsset {
archive: String,
name: String,
bytes: Arc<[u8]>,
}
impl SoundAsset {
fn resource_name(&self) -> String {
format!("{}:{}", self.archive, self.name)
}
}
struct AudioLibrary {
vfs: Arc<dyn Vfs>,
repository: Arc<CachedResourceRepository>,
theme: SoundAsset,
default_variations: Vec<SoundAsset>,
day_variations: Vec<SoundAsset>,
night_variations: Vec<SoundAsset>,
music: BTreeMap<u8, Arc<[u8]>>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum VariationPool {
Default,
Day,
Night,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct VariationSelection {
pool: VariationPool,
index: Option<usize>,
}
/// The two 16-bit states used by the original ambient variation selector.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct VariationRng {
a: u16,
b: u16,
}
impl VariationRng {
fn from_clock(clock: u32) -> Self {
Self {
a: clock as u16,
b: (clock >> 16) as u16,
}
}
fn step(&mut self) {
self.a = self.a.wrapping_shl(1) ^ self.b;
self.b = self.b.wrapping_shr(1) ^ self.a;
}
fn choose(&mut self, length: usize, last_index: &mut i32) -> Option<usize> {
if length == 0 {
*last_index = -1;
return None;
}
if length == 1 {
*last_index = 0;
return Some(0);
}
for _ in 0..=u16::MAX {
self.step();
let candidate = usize::from(self.b) % length;
if candidate as i32 != *last_index {
*last_index = candidate as i32;
return Some(candidate);
}
}
// A zero state is not produced by the native clock seed, but keeping
// this fallback makes malformed or deterministic test seeds harmless.
let candidate = usize::from(self.b) % length;
*last_index = candidate as i32;
Some(candidate)
}
}
/// CRT-style delay generator kept separate from the ambient pool selector.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct CrtRand {
state: u32,
}
impl CrtRand {
fn new(state: u32) -> Self {
Self { state }
}
fn next_u15(&mut self) -> u32 {
self.state = self.state.wrapping_mul(214_013).wrapping_add(2_531_011);
(self.state >> 16) & 0x7fff
}
fn next_delay_seconds(&mut self) -> f32 {
AMBIENT_MIN_DELAY_SECONDS + (self.next_u15() % AMBIENT_DELAY_VARIANTS) as f32
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
struct AmbienceScheduler {
elapsed_seconds: f32,
next_delay_seconds: f32,
pool: VariationPool,
variation_rng: VariationRng,
delay_rng: CrtRand,
last_index: i32,
}
impl AmbienceScheduler {
fn new(clock_seed: u32, delay_seed: u32) -> Self {
Self {
elapsed_seconds: 0.0,
next_delay_seconds: 0.0,
pool: VariationPool::Default,
variation_rng: VariationRng::from_clock(clock_seed),
delay_rng: CrtRand::new(delay_seed),
last_index: -1,
}
}
fn update(
&mut self,
dt_seconds: f32,
primary_rgb: [f32; 3],
default_len: usize,
day_len: usize,
night_len: usize,
) -> Option<VariationSelection> {
self.pool = selected_pool(primary_rgb, day_len, night_len);
self.elapsed_seconds += finite_non_negative(dt_seconds);
if self.elapsed_seconds <= self.next_delay_seconds {
return None;
}
self.elapsed_seconds = 0.0;
let length = match self.pool {
VariationPool::Default => default_len,
VariationPool::Day => day_len,
VariationPool::Night => night_len,
};
let index = self.variation_rng.choose(length, &mut self.last_index);
self.next_delay_seconds = self.delay_rng.next_delay_seconds();
Some(VariationSelection {
pool: self.pool,
index,
})
}
}
fn finite_non_negative(value: f32) -> f32 {
if value.is_finite() {
value.max(0.0)
} else {
0.0
}
}
fn random_seed() -> u32 {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
(duration.as_nanos() as u64 as u32) ^ duration.subsec_nanos().rotate_left(13)
}
fn selected_pool(primary_rgb: [f32; 3], day_len: usize, night_len: usize) -> VariationPool {
if day_len == 0 && night_len == 0 {
return VariationPool::Default;
}
let length = primary_rgb
.iter()
.map(|value| value * value)
.sum::<f32>()
.sqrt();
if length.is_finite() && length <= AMBIENT_NIGHT_LENGTH {
VariationPool::Night
} else {
VariationPool::Day
}
}
struct SpatialOneShot {
player: NativeSpatialPlayer,
volume: f32,
}
#[derive(Clone, Copy, Debug)]
struct NativeSpatialState {
emitter_position: [f32; 3],
left_ear: [f32; 3],
right_ear: [f32; 3],
volume: f32,
min_distance: f32,
max_distance: f32,
}
/// A spatial player with the native DirectSound distance curve.
///
/// `rodio::SpatialPlayer` applies an inverse-square curve unconditionally.
/// Native FX sounds instead use the DirectSound min/max distance curve, so
/// this small wrapper keeps rodio's mono and stereo panning behavior while
/// supplying the native per-channel gains itself.
struct NativeSpatialPlayer {
player: Player,
state: Arc<Mutex<NativeSpatialState>>,
}
impl NativeSpatialPlayer {
fn connect_new(
mixer: &Mixer,
emitter_position: [f32; 3],
left_ear: [f32; 3],
right_ear: [f32; 3],
) -> Self {
Self {
player: Player::connect_new(mixer),
state: Arc::new(Mutex::new(NativeSpatialState {
emitter_position,
left_ear,
right_ear,
volume: 1.0,
min_distance: 0.0,
max_distance: f32::INFINITY,
})),
}
}
fn set_emitter_position(&self, position: [f32; 3]) {
self.state
.lock()
.expect("audio state poisoned")
.emitter_position = position;
}
fn set_left_ear_position(&self, position: [f32; 3]) {
self.state.lock().expect("audio state poisoned").left_ear = position;
}
fn set_right_ear_position(&self, position: [f32; 3]) {
self.state.lock().expect("audio state poisoned").right_ear = position;
}
fn set_sound_parameters(&self, min_distance: f32, max_distance: f32) {
let mut state = self.state.lock().expect("audio state poisoned");
state.min_distance = min_distance;
state.max_distance = max_distance;
}
fn append<S>(&self, source: S)
where
S: Source + Send + 'static,
{
self.player
.append(NativeSpatialSource::new(source, Arc::clone(&self.state)));
}
#[cfg(test)]
fn volume(&self) -> f32 {
self.state.lock().expect("audio state poisoned").volume
}
fn set_volume(&self, volume: f32) {
self.state.lock().expect("audio state poisoned").volume = volume;
}
fn set_speed(&self, speed: f32) {
self.player.set_speed(speed);
}
fn play(&self) {
self.player.play();
}
fn pause(&self) {
self.player.pause();
}
#[cfg(test)]
fn is_paused(&self) -> bool {
self.player.is_paused()
}
fn clear(&self) {
self.player.clear();
}
fn stop(&self) {
self.player.stop();
}
fn empty(&self) -> bool {
self.player.empty()
}
}
struct NativeSpatialSource<I>
where
I: Source,
{
input: ChannelVolume<I>,
state: Arc<Mutex<NativeSpatialState>>,
channel_gains: [f32; 2],
refresh_remaining: usize,
}
const NATIVE_SPATIAL_REFRESH_SAMPLES: usize = 256;
impl<I> NativeSpatialSource<I>
where
I: Source,
{
fn new(input: I, state: Arc<Mutex<NativeSpatialState>>) -> Self {
Self {
input: ChannelVolume::new(input, vec![0.0, 0.0]),
state,
channel_gains: [0.0, 0.0],
refresh_remaining: 0,
}
}
}
impl<I> Iterator for NativeSpatialSource<I>
where
I: Source,
{
type Item = f32;
fn next(&mut self) -> Option<Self::Item> {
if self.refresh_remaining == 0 {
if let Ok(state) = self.state.lock() {
self.channel_gains = native_spatial_channel_gains(*state);
}
self.refresh_remaining = NATIVE_SPATIAL_REFRESH_SAMPLES;
}
self.refresh_remaining -= 1;
self.input.set_volume(0, self.channel_gains[0]);
self.input.set_volume(1, self.channel_gains[1]);
self.input.next()
}
}
impl<I> Source for NativeSpatialSource<I>
where
I: Source,
{
fn current_span_len(&self) -> Option<usize> {
self.input.current_span_len()
}
fn channels(&self) -> ChannelCount {
self.input.channels()
}
fn sample_rate(&self) -> SampleRate {
self.input.sample_rate()
}
fn total_duration(&self) -> Option<std::time::Duration> {
self.input.total_duration()
}
fn try_seek(&mut self, position: std::time::Duration) -> Result<(), SeekError> {
self.input.try_seek(position)
}
}
impl AudioLibrary {
fn load(vfs: Arc<dyn Vfs>, mission_cfg: &NormalizedPath) -> Result<Self, AudioError> {
let repository = Arc::new(CachedResourceRepository::new(Arc::clone(&vfs)));
let cfg_bytes = vfs
.read(mission_cfg)
.map_err(|source| AudioError::MissionConfigRead {
path: mission_cfg.as_str().to_string(),
message: source.to_string(),
})?;
let config = parse_mission_audio_config(&cfg_bytes).map_err(|message| {
AudioError::MissionConfig {
path: mission_cfg.as_str().to_string(),
message,
}
})?;
let archive =
archive_path(config.library.as_bytes()).map_err(|source| AudioError::InvalidPath {
path: config.library.clone(),
message: source.to_string(),
})?;
let variation_archive =
archive_path(config.variation_library.as_bytes()).map_err(|source| {
AudioError::InvalidPath {
path: config.variation_library.clone(),
message: source.to_string(),
}
})?;
let theme = load_swav(&repository, &archive, &config.theme)?;
let default_variations = config
.default_variations
.iter()
.map(|name| load_swav(&repository, &variation_archive, name))
.collect::<Result<Vec<_>, _>>()?;
let day_variations = config
.day_variations
.iter()
.map(|name| load_swav(&repository, &variation_archive, name))
.collect::<Result<Vec<_>, _>>()?;
let night_variations = config
.night_variations
.iter()
.map(|name| load_swav(&repository, &variation_archive, name))
.collect::<Result<Vec<_>, _>>()?;
Ok(Self {
vfs,
repository,
theme,
default_variations,
day_variations,
night_variations,
music: BTreeMap::new(),
})
}
fn load_fx_sound(&self, archive: &str, name: &str) -> Result<SoundAsset, AudioError> {
let archive =
archive_path(archive.as_bytes()).map_err(|source| AudioError::InvalidPath {
path: archive.to_string(),
message: source.to_string(),
})?;
load_swav(&self.repository, &archive, name)
}
fn music_bytes(&mut self, track: u8) -> Result<Arc<[u8]>, AudioError> {
let path = music_path(track)?;
if let Some(bytes) = self.music.get(&track) {
return Ok(Arc::clone(bytes));
}
let bytes = self
.vfs
.read(&path)
.map_err(|source| AudioError::FileRead {
path: path.as_str().to_string(),
message: source.to_string(),
})?;
let bytes = validate_audio_bytes(path.as_str(), &bytes)?;
self.music.insert(track, Arc::clone(&bytes));
Ok(bytes)
}
}
/// Runtime audio state for one loaded mission.
pub(crate) struct GameAudio {
stream: MixerDeviceSink,
ambient: Player,
music: Player,
variations: Player,
weather_loop: NativeSpatialPlayer,
control_sounds: BTreeMap<usize, NativeSpatialPlayer>,
one_shots: Vec<SpatialOneShot>,
library: AudioLibrary,
ambience: AmbienceScheduler,
listener: Listener,
paused: bool,
dynamic_sounds: BTreeMap<String, SoundAsset>,
weather_volume: f32,
weather_position: [f32; 3],
}
impl GameAudio {
/// Opens and validates mission audio using the caller's VFS.
///
/// mission is the same relative path passed to the runtime, usually
/// MISSIONS/Autodemo.00/data.tma. Its neighbouring mission.cfg supplies
/// the ambient names. Only the declared samples and selected music tracks
/// are read.
///
/// Returns a resource/configuration error for missing or malformed audio,
/// or DeviceUnavailable when no output device can be opened. The caller
/// may report the latter and continue without audio.
pub(crate) fn new(vfs: Arc<dyn Vfs>, mission: &str) -> Result<Self, AudioError> {
let mission_cfg = mission_cfg_path(mission)?;
let library = AudioLibrary::load(vfs, &mission_cfg)?;
let mut stream = DeviceSinkBuilder::open_default_sink().map_err(|source| {
AudioError::DeviceUnavailable {
message: source.to_string(),
}
})?;
stream.log_on_drop(false);
let ambient = Player::connect_new(stream.mixer());
let music = Player::connect_new(stream.mixer());
let variations = Player::connect_new(stream.mixer());
let listener = Listener::default();
let (left, right) = ear_positions(listener)?;
let weather_loop =
NativeSpatialPlayer::connect_new(stream.mixer(), listener.position, left, right);
let mut audio = Self {
stream,
ambient,
music,
variations,
weather_loop,
control_sounds: BTreeMap::new(),
one_shots: Vec::new(),
library,
ambience: AmbienceScheduler::new(random_seed(), random_seed()),
listener,
paused: false,
dynamic_sounds: BTreeMap::new(),
weather_volume: 1.0,
weather_position: listener.position,
};
audio.start_theme()?;
audio.play_music_track(DEFAULT_MUSIC_TRACK)?;
Ok(audio)
}
/// Updates rodio's left/right ears from the current free camera.
pub(crate) fn update_listener(
&mut self,
position: [f32; 3],
forward: [f32; 3],
up: [f32; 3],
) -> Result<(), AudioError> {
let listener = Listener {
position,
forward,
up,
};
let (left, right) = ear_positions(listener)?;
self.listener = listener;
self.weather_loop.set_left_ear_position(left);
self.weather_loop.set_right_ear_position(right);
self.weather_loop
.set_emitter_position(self.weather_position);
self.retain_active_one_shots();
self.control_sounds.retain(|_, player| !player.empty());
for player in self.control_sounds.values() {
player.set_left_ear_position(left);
player.set_right_ear_position(right);
}
for one_shot in &self.one_shots {
one_shot.player.set_left_ear_position(left);
one_shot.player.set_right_ear_position(right);
one_shot.player.set_volume(one_shot.volume);
}
self.refresh_weather_volume();
Ok(())
}
/// Advances the native ambient variation timer and starts a selected
/// sample when its strict elapsed-time boundary is crossed.
///
/// `dt_seconds` is the non-negative delta for this frame, not an
/// absolute mission clock.
///
/// `primary_rgb` is the first active celestial light's base color before
/// any camera-dependent sun glare is applied. The original atmosphere
/// treats a color length at or below 1.1 as night.
pub(crate) fn update_ambience(
&mut self,
dt_seconds: f32,
primary_rgb: [f32; 3],
) -> Result<(), AudioError> {
if self.paused {
return Ok(());
}
let selection = self.ambience.update(
dt_seconds,
primary_rgb,
self.library.default_variations.len(),
self.library.day_variations.len(),
self.library.night_variations.len(),
);
let Some(selection) = selection else {
return Ok(());
};
let Some(index) = selection.index else {
return Ok(());
};
let asset = match selection.pool {
VariationPool::Default => self.library.default_variations.get(index),
VariationPool::Day => self.library.day_variations.get(index),
VariationPool::Night => self.library.night_variations.get(index),
}
.cloned()
.ok_or_else(|| AudioError::NoSamples {
resource: format!("ambient variation {:?}[{index}]", selection.pool),
})?;
self.variations.clear();
self.variations.append(decoder_for(&asset)?);
if self.paused {
self.variations.pause();
} else {
self.variations.play();
}
Ok(())
}
/// Applies one sound event emitted by the environment simulation.
///
/// Resource names are resolved lazily from the archive named by the event;
/// this keeps mission `sky.ske`/FX references data-driven and avoids
/// requiring every possible weather sample during mission startup.
pub(crate) fn handle_sound_event(&mut self, event: &SoundEvent) -> Result<(), AudioError> {
validate_point(event.position, "emitter")?;
let volume = if event.volume.is_finite() {
event.volume.max(0.0)
} else {
0.0
};
let min_distance = finite_non_negative(event.min_distance);
let max_distance = if event.max_distance.is_finite() {
event.max_distance.max(0.0)
} else {
f32::INFINITY
};
let frequency_ratio = if event.frequency_ratio.is_finite() && event.frequency_ratio > 0.0 {
event.frequency_ratio
} else {
1.0
};
match event.action {
SoundAction::StartLoop => {
let asset = self.dynamic_sound(&event.archive, &event.name)?;
self.weather_loop.clear();
self.weather_loop.set_emitter_position(event.position);
self.weather_position = event.position;
self.weather_volume = volume;
self.weather_loop
.set_sound_parameters(min_distance, max_distance);
self.weather_loop.set_volume(volume);
self.weather_loop.set_speed(frequency_ratio);
self.weather_loop
.append(decoder_for(&asset)?.repeat_infinite());
if self.paused {
self.weather_loop.pause();
} else {
self.weather_loop.play();
}
}
SoundAction::SetLoopVolume => {
self.weather_loop.set_emitter_position(event.position);
self.weather_position = event.position;
self.weather_volume = volume;
self.weather_loop
.set_sound_parameters(min_distance, max_distance);
self.weather_loop.set_volume(volume);
self.weather_loop.set_speed(frequency_ratio);
}
SoundAction::StopLoop => {
self.weather_loop.clear();
self.weather_loop.set_volume(1.0);
self.weather_volume = 1.0;
self.weather_position = self.listener.position;
self.weather_loop.set_sound_parameters(0.0, f32::INFINITY);
self.weather_loop.set_speed(1.0);
}
SoundAction::OneShot => {
self.retain_active_one_shots();
let asset = self.dynamic_sound(&event.archive, &event.name)?;
self.one_shots.push(self.new_spatial_voice(
&asset,
event.position,
volume,
min_distance,
max_distance,
frequency_ratio,
false,
)?);
}
}
Ok(())
}
/// Each Control command owns its source; it must not replace the rain
/// loop or another instance using the same sample.
pub(crate) fn handle_control_sound(
&mut self,
id: usize,
event: &SoundEvent,
) -> Result<(), AudioError> {
validate_point(event.position, "Control emitter")?;
let frequency_ratio = if event.frequency_ratio.is_finite() && event.frequency_ratio > 0.0 {
event.frequency_ratio
} else {
1.0
};
match event.action {
SoundAction::StartLoop | SoundAction::OneShot => {
let asset = self.dynamic_sound(&event.archive, &event.name)?;
let voice = self.new_spatial_voice(
&asset,
event.position,
event.volume,
event.min_distance,
event.max_distance,
frequency_ratio,
event.action == SoundAction::StartLoop,
)?;
if let Some(previous) = self.control_sounds.insert(id, voice.player) {
previous.stop();
}
eprintln!("control_sound_start id={id} resource={}:{} loop={} position={:?} range={}/{} frequency={}", event.archive, event.name, event.action == SoundAction::StartLoop, event.position, event.min_distance, event.max_distance, event.frequency_ratio);
}
SoundAction::SetLoopVolume => {
if let Some(player) = self.control_sounds.get(&id) {
player.set_emitter_position(event.position);
player.set_sound_parameters(event.min_distance, event.max_distance);
player.set_volume(event.volume);
player.set_speed(frequency_ratio);
}
}
SoundAction::StopLoop => {
if let Some(player) = self.control_sounds.remove(&id) {
player.stop();
eprintln!("control_sound_stop id={id}");
}
}
}
Ok(())
}
pub(crate) fn control_sound_is_playing(&self, id: usize) -> bool {
self.control_sounds
.get(&id)
.is_some_and(|player| !player.empty())
}
/// Selects one of the original MUSIC/Track02..10.ogg files and loops it.
pub(crate) fn play_music_track(&mut self, track: u8) -> Result<(), AudioError> {
let bytes = self.library.music_bytes(track)?;
self.music.clear();
self.music.append(
decoder_for_bytes(&format!("MUSIC/Track{track:02}.ogg"), &bytes)?.repeat_infinite(),
);
if self.paused {
self.music.pause();
} else {
self.music.play();
}
Ok(())
}
/// Pauses or resumes every live audio source without changing selection.
pub(crate) fn set_paused(&mut self, paused: bool) {
self.paused = paused;
for player in [&self.ambient, &self.music, &self.variations] {
if paused {
player.pause();
} else {
player.play();
}
}
if paused {
self.weather_loop.pause();
} else {
self.weather_loop.play();
}
for one_shot in &self.one_shots {
if paused {
one_shot.player.pause();
} else {
one_shot.player.play();
}
}
for player in self.control_sounds.values() {
if paused {
player.pause();
} else {
player.play();
}
}
}
/// Focus callback used by the window event loop.
pub(crate) fn on_focus_changed(&mut self, focused: bool) {
self.set_paused(!focused);
}
/// Stops all sources before the window/event loop exits.
pub(crate) fn shutdown(&mut self) {
self.ambient.stop();
self.music.stop();
self.variations.stop();
self.weather_loop.stop();
for player in self.control_sounds.values() {
player.stop();
}
for one_shot in &self.one_shots {
one_shot.player.stop();
}
}
fn start_theme(&self) -> Result<(), AudioError> {
self.ambient
.append(decoder_for(&self.library.theme)?.repeat_infinite());
if self.paused {
self.ambient.pause();
} else {
self.ambient.play();
}
Ok(())
}
fn new_spatial_voice(
&self,
asset: &SoundAsset,
emitter: [f32; 3],
volume: f32,
min_distance: f32,
max_distance: f32,
frequency_ratio: f32,
looping: bool,
) -> Result<SpatialOneShot, AudioError> {
validate_point(emitter, "emitter")?;
let (left, right) = ear_positions(self.listener)?;
let player = NativeSpatialPlayer::connect_new(self.stream.mixer(), emitter, left, right);
player.set_sound_parameters(min_distance, max_distance);
player.set_volume(volume);
player.set_speed(frequency_ratio);
let source = decoder_for(asset)?;
if looping {
player.append(source.repeat_infinite());
} else {
player.append(source);
}
if self.paused {
player.pause();
} else {
player.play();
}
Ok(SpatialOneShot { player, volume })
}
fn refresh_weather_volume(&self) {
self.weather_loop.set_volume(self.weather_volume);
}
fn dynamic_sound(&mut self, archive: &str, name: &str) -> Result<SoundAsset, AudioError> {
let archive = if archive.is_empty() {
self.library.theme.archive.as_str()
} else {
archive
};
let key = format!(
"{}:{}",
archive.to_ascii_lowercase(),
name.to_ascii_lowercase()
);
if let Some(asset) = self.dynamic_sounds.get(&key) {
return Ok(asset.clone());
}
let asset = self.library.load_fx_sound(archive, name)?;
self.dynamic_sounds.insert(key, asset.clone());
Ok(asset)
}
fn retain_active_one_shots(&mut self) {
self.one_shots.retain(|one_shot| !one_shot.player.empty());
}
}
impl Drop for GameAudio {
fn drop(&mut self) {
self.shutdown();
}
}
type AudioDecoder = Decoder<Cursor<Arc<[u8]>>>;
fn decoder_for(asset: &SoundAsset) -> Result<AudioDecoder, AudioError> {
decoder_for_bytes(&asset.resource_name(), &asset.bytes)
}
fn decoder_for_bytes(resource: &str, bytes: &Arc<[u8]>) -> Result<AudioDecoder, AudioError> {
Decoder::try_from(Cursor::new(Arc::clone(bytes))).map_err(|source| AudioError::Decode {
resource: resource.to_string(),
message: source.to_string(),
})
}
fn validate_audio_bytes(resource: &str, bytes: &Arc<[u8]>) -> Result<Arc<[u8]>, AudioError> {
if bytes.is_empty() {
return Err(AudioError::EmptyEntry {
archive: "VFS".to_string(),
name: resource.to_string(),
});
}
let decoder = decoder_for_bytes(resource, bytes)?;
if decoder.take(VALIDATION_SAMPLES).count() == 0 {
return Err(AudioError::NoSamples {
resource: resource.to_string(),
});
}
Ok(Arc::clone(bytes))
}
fn load_swav(
repository: &CachedResourceRepository,
archive: &NormalizedPath,
name: &str,
) -> Result<SoundAsset, AudioError> {
let display_archive = archive.as_str().to_string();
let resource = resource_name(name.as_bytes());
let archive_id = repository
.open_archive_unchanged(archive)
.map_err(|source| AudioError::Archive {
path: display_archive.clone(),
message: source.to_string(),
})?;
let entry = repository
.find(archive_id, &resource)
.map_err(|source| AudioError::Archive {
path: display_archive.clone(),
message: source.to_string(),
})?
.ok_or_else(|| AudioError::MissingEntry {
archive: display_archive.clone(),
name: name.to_string(),
})?;
let info = repository
.entry_info(entry)
.map_err(|source| AudioError::Archive {
path: display_archive.clone(),
message: source.to_string(),
})?;
if info.key.type_id != Some(SWAV_TYPE) {
return Err(AudioError::WrongEntryType {
archive: display_archive,
name: name.to_string(),
type_id: info.key.type_id,
});
}
let bytes = repository
.read(entry)
.map_err(|source| AudioError::Archive {
path: archive.as_str().to_string(),
message: source.to_string(),
})?;
if bytes.is_empty() {
return Err(AudioError::EmptyEntry {
archive: archive.as_str().to_string(),
name: name.to_string(),
});
}
let bytes = Arc::<[u8]>::from(bytes.as_slice());
let bytes = validate_audio_bytes(&format!("{}:{name}", archive.as_str()), &bytes)?;
Ok(SoundAsset {
archive: archive.as_str().to_string(),
name: name.to_string(),
bytes,
})
}
fn music_path(track: u8) -> Result<NormalizedPath, AudioError> {
if !(FIRST_MUSIC_TRACK..=LAST_MUSIC_TRACK).contains(&track) {
return Err(AudioError::UnsupportedMusicTrack(track));
}
let display = format!("MUSIC/Track{track:02}.ogg");
normalize_relative(display.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| {
AudioError::InvalidPath {
path: display,
message: source.to_string(),
}
})
}
/// Derives the sibling mission.cfg path from a mission TMA path.
pub(crate) fn mission_cfg_path(mission: &str) -> Result<NormalizedPath, AudioError> {
let mission =
normalize_relative(mission.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| {
AudioError::InvalidPath {
path: mission.to_string(),
message: source.to_string(),
}
})?;
let cfg = mission.as_str().rsplit_once('/').map_or_else(
|| "mission.cfg".to_string(),
|(parent, _)| format!("{parent}/mission.cfg"),
);
normalize_relative(cfg.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| {
AudioError::InvalidPath {
path: cfg,
message: source.to_string(),
}
})
}
fn ear_positions(listener: Listener) -> Result<([f32; 3], [f32; 3]), AudioError> {
validate_point(listener.position, "position")?;
let forward = unit(listener.forward, "forward")?;
let up = unit(listener.up, "up")?;
let right = unit(cross(forward, up), "forward/up")?;
let left_ear = sub(listener.position, scale(right, EAR_HALF_WIDTH));
let right_ear = add(listener.position, scale(right, EAR_HALF_WIDTH));
validate_point(left_ear, "position")?;
validate_point(right_ear, "position")?;
Ok((left_ear, right_ear))
}
const DIRECTSOUND_DB_COEFFICIENT: f32 = 3_321.927_978_515_625;
const DIRECTSOUND_MIN_VOLUME_DB: f32 = -10_000.0;
const DIRECTSOUND_PAN_UNITS: f32 = 4_000.0;
/// Converts a DirectSound distance range into a linear sample multiplier.
/// The native listener uses rolloff 1.0 and stores volume in hundredths of a
/// decibel. Its max-distance branch is strict (`distance > max`).
fn native_distance_gain(distance: f32, min_distance: f32, max_distance: f32) -> f32 {
if !distance.is_finite()
|| !min_distance.is_finite()
|| min_distance <= 0.0
|| !max_distance.is_finite()
|| max_distance <= min_distance
{
return 1.0;
}
if distance <= min_distance {
return 1.0;
}
if distance > max_distance {
return 10.0_f32.powf(DIRECTSOUND_MIN_VOLUME_DB / 2_000.0);
}
let ratio = min_distance / (min_distance + (distance - min_distance));
if !ratio.is_finite() || ratio <= 0.0 {
return 10.0_f32.powf(DIRECTSOUND_MIN_VOLUME_DB / 2_000.0);
}
let decibels = (ratio.log10() * DIRECTSOUND_DB_COEFFICIENT)
.round()
.max(DIRECTSOUND_MIN_VOLUME_DB);
10.0_f32.powf(decibels / 2_000.0)
}
fn native_spatial_channel_gains(state: NativeSpatialState) -> [f32; 2] {
let listener_position = midpoint(state.left_ear, state.right_ear);
let distance = squared_distance(state.emitter_position, listener_position).sqrt();
let attenuation = native_distance_gain(distance, state.min_distance, state.max_distance);
// Effect.dll computes software pan from the listener's right vector and
// the normalized emitter direction, then passes +/-4000 to DirectSound.
// The engine's ear positions already encode that right vector. Do not use
// the distance to each ear: that heuristic reverses the native pan when a
// source is placed to the listener's right.
let ear_axis = sub(state.right_ear, state.left_ear);
let ear_axis_length = squared_distance(ear_axis, [0.0; 3]).sqrt();
let emitter_direction = sub(state.emitter_position, listener_position);
let emitter_length = squared_distance(emitter_direction, [0.0; 3]).sqrt();
let pan = if ear_axis_length.is_finite()
&& ear_axis_length > f32::EPSILON
&& emitter_length.is_finite()
&& emitter_length > f32::EPSILON
{
let right = scale(ear_axis, 1.0 / ear_axis_length);
let direction = scale(emitter_direction, 1.0 / emitter_length);
(DIRECTSOUND_PAN_UNITS * dot(right, direction)).round()
} else {
0.0
};
let (left_pan, right_pan) = if pan > 0.0 {
(10.0_f32.powf(-pan / 2_000.0), 1.0)
} else if pan < 0.0 {
(1.0, 10.0_f32.powf(pan / 2_000.0))
} else {
(1.0, 1.0)
};
let volume = if state.volume.is_finite() {
state.volume.max(0.0)
} else {
0.0
} * attenuation;
[volume * left_pan, volume * right_pan]
}
fn dot(left: [f32; 3], right: [f32; 3]) -> f32 {
left[0].mul_add(right[0], left[1].mul_add(right[1], left[2] * right[2]))
}
fn squared_distance(left: [f32; 3], right: [f32; 3]) -> f32 {
left.into_iter()
.zip(right)
.map(|(left, right)| (left - right) * (left - right))
.sum()
}
fn midpoint(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
[
(left[0] + right[0]) * 0.5,
(left[1] + right[1]) * 0.5,
(left[2] + right[2]) * 0.5,
]
}
fn validate_point(point: [f32; 3], field: &'static str) -> Result<(), AudioError> {
if point.iter().all(|value| value.is_finite()) {
Ok(())
} else {
Err(AudioError::InvalidPosition { field })
}
}
fn unit(vector: [f32; 3], field: &'static str) -> Result<[f32; 3], AudioError> {
if !vector.iter().all(|value| value.is_finite()) {
return Err(AudioError::InvalidListener { field });
}
let length = vector[0]
.mul_add(
vector[0],
vector[1].mul_add(vector[1], vector[2] * vector[2]),
)
.sqrt();
if !length.is_finite() || length <= f32::EPSILON {
return Err(AudioError::InvalidListener { field });
}
Ok(vector.map(|value| value / length))
}
fn cross(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 add(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
[left[0] + right[0], left[1] + right[1], left[2] + right[2]]
}
fn sub(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
[left[0] - right[0], left[1] - right[1], left[2] - right[2]]
}
fn scale(vector: [f32; 3], factor: f32) -> [f32; 3] {
[vector[0] * factor, vector[1] * factor, vector[2] * factor]
}
fn parse_mission_audio_config(bytes: &[u8]) -> Result<MissionAudioConfig, String> {
#[derive(Default)]
struct Object {
library: Option<String>,
theme: Option<String>,
default: BTreeMap<usize, String>,
day: BTreeMap<usize, String>,
night: BTreeMap<usize, String>,
}
let text = String::from_utf8_lossy(bytes);
let mut loop_object = Object::default();
let mut variation_object = Object::default();
let mut current = None;
for raw_line in text.lines() {
let line = raw_line.split('#').next().unwrap_or_default().trim();
if line.is_empty() {
continue;
}
let mut tokens = line.split_whitespace();
if tokens
.next()
.is_some_and(|token| token.eq_ignore_ascii_case("object"))
{
current = tokens.next().map(|name| name.to_ascii_lowercase());
continue;
}
if line.eq_ignore_ascii_case("end") {
current = None;
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim().to_ascii_uppercase();
let value = parse_cfg_value(value.trim())?;
match current.as_deref() {
Some("ambient_music_loop") => {
if key == "LIBRARY" {
loop_object.library = Some(value);
} else if key == "THEME" {
loop_object.theme = Some(value);
}
}
Some("ambient_music_variation") => {
if key == "LIBRARY" {
variation_object.library = Some(value);
} else if let Some(index) = key.strip_prefix("DEFAULT_VARIATION") {
let index = index
.parse::<usize>()
.map_err(|_| format!("invalid default variation key {key}"))?;
if index == 0 {
return Err(format!("zero default variation key {key}"));
}
variation_object.default.insert(index, value);
} else if let Some(index) = key.strip_prefix("DAY_VARIATION") {
let index = index
.parse::<usize>()
.map_err(|_| format!("invalid day variation key {key}"))?;
if index == 0 {
return Err(format!("zero day variation key {key}"));
}
variation_object.day.insert(index, value);
} else if let Some(index) = key.strip_prefix("NIGHT_VARIATION") {
let index = index
.parse::<usize>()
.map_err(|_| format!("invalid night variation key {key}"))?;
if index == 0 {
return Err(format!("zero night variation key {key}"));
}
variation_object.night.insert(index, value);
}
}
_ => {}
}
}
let library = loop_object
.library
.ok_or_else(|| "ambient_music_loop.library is missing".to_string())?;
let theme = loop_object
.theme
.ok_or_else(|| "ambient_music_loop.THEME is missing".to_string())?;
let variation_library = variation_object.library.unwrap_or_else(|| library.clone());
Ok(MissionAudioConfig {
library,
variation_library,
theme,
default_variations: variation_object.default.into_values().collect(),
day_variations: variation_object.day.into_values().collect(),
night_variations: variation_object.night.into_values().collect(),
})
}
fn parse_cfg_value(value: &str) -> Result<String, String> {
if let Some(rest) = value.strip_prefix('"') {
let end = rest
.find('"')
.ok_or_else(|| "unterminated quoted value".to_string())?;
return Ok(rest[..end].to_string());
}
Ok(value
.split_whitespace()
.next()
.unwrap_or_default()
.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_original_ambient_names_in_numeric_order() {
let config = parse_mission_audio_config(
br#"
object ambient_music_loop
library = "sounds.lib"
THEME = "atm_c1_lp.wav"
end
object ambient_music_variation
library = "ambient.lib"
DEFAULT_VARIATION2 = "atm_default2.wav"
DEFAULT_VARIATION1 = "atm_default1.wav"
DAY_VARIATION2 = "atm_bird2.wav"
DAY_VARIATION1 = "atm_bees.wav"
NIGHT_VARIATION2 = "atm_frog2.wav"
NIGHT_VARIATION1 = "atm_frog1.wav"
end
"#,
)
.expect("config");
assert_eq!(config.library, "sounds.lib");
assert_eq!(config.variation_library, "ambient.lib");
assert_eq!(config.theme, "atm_c1_lp.wav");
assert_eq!(
config.default_variations,
["atm_default1.wav", "atm_default2.wav"]
);
assert_eq!(config.day_variations, ["atm_bees.wav", "atm_bird2.wav"]);
assert_eq!(config.night_variations, ["atm_frog1.wav", "atm_frog2.wav"]);
}
#[test]
fn omitted_variation_library_uses_loop_library() {
let config = parse_mission_audio_config(
br#"
object ambient_music_loop
library = "sounds.lib"
THEME = "atm_c1_lp.wav"
end
object ambient_music_variation
DEFAULT_VARIATION1 = "atm_c1_1.wav"
end
"#,
)
.expect("config");
assert_eq!(config.variation_library, "sounds.lib");
assert_eq!(config.default_variations, ["atm_c1_1.wav"]);
}
#[test]
fn duplicate_native_variation_key_keeps_last_definition() {
let config = parse_mission_audio_config(
br#"
object ambient_music_loop
library = "sounds.lib"
THEME = "atm_c1_lp.wav"
end
object ambient_music_variation
DAY_VARIATION1 = "old.wav"
DAY_VARIATION1 = "native-last.wav"
end
"#,
)
.expect("config");
assert_eq!(config.day_variations, ["native-last.wav"]);
}
#[test]
fn variation_rng_matches_native_two_state_step() {
let mut rng = VariationRng {
a: 0x1234,
b: 0xabcd,
};
rng.step();
assert_eq!((rng.a, rng.b), (0x8fa5, 0xda43));
rng.step();
assert_eq!((rng.a, rng.b), (0xc509, 0xa828));
rng.step();
assert_eq!((rng.a, rng.b), (0x223a, 0x762e));
}
#[test]
fn variation_rng_does_not_repeat_last_index_when_pool_has_choices() {
let mut rng = VariationRng {
a: 0x1234,
b: 0xabcd,
};
let mut last = -1;
let mut previous = None;
for _ in 0..64 {
let selected = rng.choose(4, &mut last).expect("non-empty pool");
assert_ne!(Some(selected), previous);
previous = Some(selected);
}
}
#[test]
fn variation_rng_clears_last_index_for_empty_pool() {
let mut rng = VariationRng {
a: 0x1234,
b: 0xabcd,
};
let mut last = 2;
assert_eq!(rng.choose(0, &mut last), None);
assert_eq!(last, -1);
assert_eq!(rng.choose(3, &mut last), Some(0));
assert_eq!(last, 0);
}
#[test]
fn crt_delay_uses_native_multiplier_and_ten_to_nineteen_seconds() {
let mut rng = CrtRand::new(1);
assert_eq!(rng.next_u15(), 41);
assert_eq!(rng.next_delay_seconds(), 17.0);
assert!((10.0..20.0).contains(&rng.next_delay_seconds()));
}
#[test]
fn ambience_timer_uses_strict_boundary_and_initial_positive_tick() {
let mut scheduler = AmbienceScheduler {
elapsed_seconds: 0.0,
next_delay_seconds: 0.0,
pool: VariationPool::Default,
variation_rng: VariationRng {
a: 0x1234,
b: 0xabcd,
},
delay_rng: CrtRand::new(16),
last_index: -1,
};
assert_eq!(scheduler.update(0.0, [2.0, 0.0, 0.0], 0, 1, 1), None);
let initial = scheduler
.update(0.001, [2.0, 0.0, 0.0], 0, 1, 1)
.expect("first positive tick selects a variation");
assert_eq!(initial.pool, VariationPool::Day);
assert_eq!(initial.index, Some(0));
assert_eq!(scheduler.next_delay_seconds, 10.0);
assert_eq!(scheduler.update(10.0, [2.0, 0.0, 0.0], 0, 1, 1), None);
assert!(scheduler.update(0.0001, [2.0, 0.0, 0.0], 0, 1, 1).is_some());
}
#[test]
fn ambience_pool_selection_has_no_cross_day_fallback() {
let mut scheduler = AmbienceScheduler::new(1, 1);
let selected = scheduler
.update(0.01, [0.0, 0.0, 0.0], 2, 1, 0)
.expect("night selection attempt");
assert_eq!(selected.pool, VariationPool::Night);
assert_eq!(selected.index, None);
let mut scheduler = AmbienceScheduler::new(1, 1);
let selected = scheduler
.update(0.01, [2.0, 0.0, 0.0], 2, 0, 0)
.expect("default selection");
assert_eq!(selected.pool, VariationPool::Default);
assert_eq!(selected.index, Some(0));
}
#[test]
fn night_threshold_is_native_primary_rgb_length() {
assert_eq!(selected_pool([1.1, 0.0, 0.0], 1, 1), VariationPool::Night);
assert_eq!(selected_pool([1.1001, 0.0, 0.0], 1, 1), VariationPool::Day);
assert_eq!(selected_pool([0.0, 0.0, 0.0], 0, 0), VariationPool::Default);
}
#[test]
fn derives_sibling_mission_config_path() {
assert_eq!(
mission_cfg_path("MISSIONS/Single.01/data.tma")
.expect("mission path")
.as_str(),
"MISSIONS/Single.01/mission.cfg"
);
}
#[test]
fn listener_ears_are_spaced_across_camera_right() {
let (left, right) = ear_positions(Listener::default()).expect("ears");
assert_eq!(left, [-EAR_HALF_WIDTH, 0.0, 0.0]);
assert_eq!(right, [EAR_HALF_WIDTH, 0.0, 0.0]);
}
#[test]
fn native_pan_uses_listener_right_axis_and_directsound_units() {
let state = NativeSpatialState {
emitter_position: [0.0, 0.0, 0.0],
left_ear: [-EAR_HALF_WIDTH, 0.0, 0.0],
right_ear: [EAR_HALF_WIDTH, 0.0, 0.0],
volume: 1.0,
min_distance: 0.0,
max_distance: f32::INFINITY,
};
assert_eq!(native_spatial_channel_gains(state), [1.0, 1.0]);
let right = NativeSpatialState {
emitter_position: [1.0, 0.0, 0.0],
..state
};
let right_gains = native_spatial_channel_gains(right);
assert!((right_gains[0] - 0.01).abs() < 1.0e-6);
assert_eq!(right_gains[1], 1.0);
let left = NativeSpatialState {
emitter_position: [-1.0, 0.0, 0.0],
..state
};
let left_gains = native_spatial_channel_gains(left);
assert_eq!(left_gains[0], 1.0);
assert!((left_gains[1] - 0.01).abs() < 1.0e-6);
}
#[test]
fn native_distance_gain_keeps_min_and_max_boundaries_native() {
let below_min = native_distance_gain(99.0, 100.0, 1_500.0);
let at_min = native_distance_gain(100.0, 100.0, 1_500.0);
let at_max = native_distance_gain(1_500.0, 100.0, 1_500.0);
let past_max = native_distance_gain(1_500.001, 100.0, 1_500.0);
assert_eq!(below_min, 1.0);
assert_eq!(at_min, 1.0);
assert!(at_max > past_max);
assert!(at_max < 1.0);
assert!((past_max - 1.0e-5).abs() < 1.0e-8);
}
#[test]
fn rejects_non_finite_or_unbounded_listener_inputs() {
assert!(ear_positions(Listener {
position: [f32::NAN, 0.0, 0.0],
..Listener::default()
})
.is_err());
assert!(ear_positions(Listener {
forward: [f32::MAX, f32::MAX, f32::MAX],
..Listener::default()
})
.is_err());
}
#[test]
#[ignore = "requires the installed GOG game files; set FPARKAN_GAME_ROOT"]
fn decodes_gog_pcm_adpcm_and_ogg_assets() {
let root = std::env::var_os("FPARKAN_GAME_ROOT")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy"));
assert!(
root.is_dir(),
"game root does not exist: {}",
root.display()
);
let vfs: Arc<dyn Vfs> = Arc::new(fparkan_vfs::DirectoryVfs::new(&root));
let mission_cfg = normalize_relative(
b"MISSIONS/Autodemo.00/mission.cfg",
PathPolicy::StrictLegacy,
)
.expect("mission cfg path");
let mut library = AudioLibrary::load(Arc::clone(&vfs), &mission_cfg).expect("audio assets");
assert_eq!(library.theme.name, "atm_c1_lp.wav");
assert!(!library.day_variations.is_empty());
assert!(!library.night_variations.is_empty());
assert_eq!(wav_format_tag(&library.theme.bytes), Some(1));
assert_decodes_samples(&library.theme.resource_name(), &library.theme.bytes, 256);
let adpcm = library
.day_variations
.iter()
.find(|asset| wav_format_tag(&asset.bytes) == Some(2))
.expect("an original day variation must use Microsoft ADPCM");
assert_eq!(wav_format_tag(&adpcm.bytes), Some(2));
assert_decodes_samples(&adpcm.resource_name(), &adpcm.bytes, 256);
let music = library
.music_bytes(DEFAULT_MUSIC_TRACK)
.expect("original Track02 OGG");
assert!(music.starts_with(b"OggS"));
assert_decodes_samples("MUSIC/Track02.ogg", &music, 256);
assert_eq!(library.music.len(), 1, "music must load lazily");
}
#[test]
#[ignore = "requires the installed GOG game files; set FPARKAN_GAME_ROOT"]
fn loads_every_gog_mission_variation_pool() {
let root = std::env::var_os("FPARKAN_GAME_ROOT")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy"));
assert!(
root.is_dir(),
"game root does not exist: {}",
root.display()
);
let mut paths = Vec::new();
collect_mission_configs(&root, &mut paths).expect("enumerate mission configs");
assert!(!paths.is_empty(), "GOG install has no mission.cfg files");
let vfs: Arc<dyn Vfs> = Arc::new(fparkan_vfs::DirectoryVfs::new(&root));
let mut default_pools = 0;
let mut day_pools = 0;
let mut night_pools = 0;
for path in paths {
let relative = path
.strip_prefix(&root)
.expect("mission config under game root")
.to_string_lossy()
.replace('\\', "/");
let mission_cfg = normalize_relative(relative.as_bytes(), PathPolicy::StrictLegacy)
.expect("mission cfg path");
let library = AudioLibrary::load(Arc::clone(&vfs), &mission_cfg)
.unwrap_or_else(|error| panic!("load {}: {error}", mission_cfg.as_str()));
default_pools += usize::from(!library.default_variations.is_empty());
day_pools += usize::from(!library.day_variations.is_empty());
night_pools += usize::from(!library.night_variations.is_empty());
}
assert!(default_pools > 0, "GOG missions must exercise DEFAULT pool");
assert!(day_pools > 0, "GOG missions must exercise DAY pool");
assert!(night_pools > 0, "GOG missions must exercise NIGHT pool");
}
#[test]
#[ignore = "requires installed GOG assets and a usable OS audio output"]
fn starts_pauses_and_drops_gog_audio() {
let root = std::env::var_os("FPARKAN_GAME_ROOT")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| std::path::PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy"));
assert!(
root.is_dir(),
"game root does not exist: {}",
root.display()
);
let vfs: Arc<dyn Vfs> = Arc::new(fparkan_vfs::DirectoryVfs::new(&root));
let mut audio =
GameAudio::new(vfs, "MISSIONS/Autodemo.00/data.tma").expect("open original game audio");
audio
.handle_sound_event(&SoundEvent {
archive: "sounds.lib".to_string(),
name: "atm_rain1.wav".to_string(),
position: [0.0; 3],
volume: 0.0,
min_distance: 0.0,
max_distance: f32::INFINITY,
frequency_ratio: 1.0,
action: SoundAction::StartLoop,
})
.expect("start original rain loop");
assert_eq!(audio.weather_loop.volume(), 0.0);
audio
.handle_sound_event(&SoundEvent {
archive: "sounds.lib".to_string(),
name: "atm_rain1.wav".to_string(),
position: [1.0, 2.0, 3.0],
volume: 0.5,
min_distance: 0.0,
max_distance: f32::INFINITY,
frequency_ratio: 1.0,
action: SoundAction::SetLoopVolume,
})
.expect("update original rain loop volume");
assert_eq!(audio.weather_loop.volume(), 0.5);
let mut machine = SoundEvent {
archive: "sounds.lib".to_string(),
name: "f_gener.wav".to_string(),
position: [1.0, 2.0, 3.0],
volume: 0.0,
min_distance: 15.0,
max_distance: 100.0,
frequency_ratio: 1.0,
action: SoundAction::StartLoop,
};
audio
.handle_control_sound(0, &machine)
.expect("first machine");
audio
.handle_control_sound(1, &machine)
.expect("second machine");
assert!(audio.control_sound_is_playing(0));
assert!(audio.control_sound_is_playing(1));
assert!(!audio.weather_loop.empty());
audio.set_paused(true);
assert!(audio
.control_sounds
.values()
.all(|player| player.is_paused()));
audio.set_paused(false);
assert!(audio
.control_sounds
.values()
.all(|player| !player.is_paused()));
machine.action = SoundAction::StopLoop;
audio
.handle_control_sound(0, &machine)
.expect("stop one machine");
assert!(!audio.control_sound_is_playing(0));
assert!(audio.control_sound_is_playing(1));
assert!(!audio.weather_loop.empty());
audio
.update_listener([10.0, 20.0, 30.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0])
.expect("move listener");
audio
.handle_sound_event(&SoundEvent {
archive: "sounds.lib".to_string(),
name: "atm_light1.wav".to_string(),
position: [3.0, 4.0, 5.0],
volume: 0.75,
min_distance: 100.0,
max_distance: 1500.0,
frequency_ratio: 1.0,
action: SoundAction::OneShot,
})
.expect("play original lightning sample");
assert_eq!(
audio
.one_shots
.last()
.unwrap()
.player
.state
.lock()
.unwrap()
.emitter_position,
[3.0, 4.0, 5.0]
);
audio
.handle_sound_event(&SoundEvent {
archive: "sounds.lib".to_string(),
name: "atm_rain1.wav".to_string(),
position: [10.0, 20.0, 30.0],
volume: 0.0,
min_distance: 0.0,
max_distance: f32::INFINITY,
frequency_ratio: 1.0,
action: SoundAction::StopLoop,
})
.expect("stop original rain loop");
assert!(audio.weather_loop.empty());
audio.set_paused(true);
assert!(audio.ambient.is_paused());
assert!(audio.music.is_paused());
assert!(audio.weather_loop.is_paused());
audio.set_paused(false);
assert!(!audio.ambient.is_paused());
assert!(!audio.music.is_paused());
audio.shutdown();
}
fn assert_decodes_samples(resource: &str, bytes: &Arc<[u8]>, minimum: usize) {
let decoded = decoder_for_bytes(resource, bytes)
.expect("decoder construction")
.take(minimum)
.count();
assert_eq!(decoded, minimum, "{resource} decoded too few samples");
}
fn wav_format_tag(bytes: &[u8]) -> Option<u16> {
if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
return None;
}
let mut offset = 12usize;
while offset.checked_add(8)? <= bytes.len() {
let chunk_len = usize::try_from(u32::from_le_bytes(
bytes[offset + 4..offset + 8].try_into().ok()?,
))
.ok()?;
let data_start = offset.checked_add(8)?;
let data_end = data_start.checked_add(chunk_len)?;
if data_end > bytes.len() {
return None;
}
if &bytes[offset..offset + 4] == b"fmt " && chunk_len >= 2 {
return Some(u16::from_le_bytes(
bytes[data_start..data_start + 2].try_into().ok()?,
));
}
offset = data_end.checked_add(chunk_len & 1)?;
}
None
}
fn collect_mission_configs(
directory: &std::path::Path,
output: &mut Vec<std::path::PathBuf>,
) -> std::io::Result<()> {
for entry in std::fs::read_dir(directory)? {
let path = entry?.path();
if path.is_dir() {
collect_mission_configs(&path, output)?;
} else if path
.file_name()
.is_some_and(|name| name.eq_ignore_ascii_case("mission.cfg"))
{
output.push(path);
}
}
Ok(())
}
}