326 lines
12 KiB
Rust
326 lines
12 KiB
Rust
//! Checked package-local PCM WAV and MUS loading, independent of SDL2.
|
||
//! RIFF chunks are little-endian and padded to a two-byte boundary.
|
||
use crate::{
|
||
assets::Catalog,
|
||
audio::{DigitalSample, Mixer, MusScore},
|
||
content::{ContentId, Registry},
|
||
simulation::SoundEvent,
|
||
};
|
||
use anyhow::{Context, Result, ensure};
|
||
use std::{collections::BTreeMap, io::Read, path::Path};
|
||
|
||
/// Original FX IDs name empty catalog markers. Their digital device variant is
|
||
/// four entries later, matching SND_Setup's SND_DIGITAL offset.
|
||
const DIGITAL_VARIANT: u32 = 4;
|
||
const MOD_CACHE_LIMIT: usize = 64 * 1024 * 1024;
|
||
pub const MAX_MUSIC_BYTES: usize = 128 * 1024;
|
||
const MAX_MUSIC_EVENTS: usize = 16384;
|
||
const MAX_MUSIC_TICKS: u32 = 30 * 60 * 70;
|
||
const MUSIC_CACHE_LIMIT: usize = 16 * 1024 * 1024;
|
||
|
||
#[derive(Default)]
|
||
pub struct SoundBank {
|
||
original: BTreeMap<u32, DigitalSample>,
|
||
mods: BTreeMap<ContentId, DigitalSample>,
|
||
music: BTreeMap<ContentId, MusScore>,
|
||
}
|
||
impl SoundBank {
|
||
pub fn preload_originals(&mut self, catalog: &Catalog) -> Result<()> {
|
||
for sound in crate::legacy_tables::sounds() {
|
||
self.original(catalog, sound.asset)?;
|
||
}
|
||
Ok(())
|
||
}
|
||
pub fn original(&mut self, catalog: &Catalog, marker: u32) -> Result<DigitalSample> {
|
||
if !self.original.contains_key(&marker) {
|
||
let is_marker = crate::legacy_tables::sounds()
|
||
.iter()
|
||
.any(|s| s.asset == marker);
|
||
let asset = if is_marker {
|
||
marker
|
||
.checked_add(DIGITAL_VARIANT)
|
||
.context("sound variant overflow")?
|
||
} else {
|
||
marker
|
||
};
|
||
let sample = DigitalSample::decode(&catalog.read(asset)?)
|
||
.with_context(|| format!("original sound {marker:#x}, digital asset {asset:#x}"))?;
|
||
self.original.insert(marker, sample);
|
||
}
|
||
Ok(self.original[&marker].clone())
|
||
}
|
||
pub fn queue_original(
|
||
&mut self,
|
||
catalog: &Catalog,
|
||
mixer: &mut Mixer,
|
||
event: &SoundEvent,
|
||
) -> Result<()> {
|
||
let sample = self.original(catalog, event.asset)?;
|
||
mixer.sample(sample, event.pan, event.pitch, event.volume, event.priority);
|
||
Ok(())
|
||
}
|
||
pub fn mod_sample(&mut self, registry: &Registry, id: &ContentId) -> Result<DigitalSample> {
|
||
let definition = registry
|
||
.sounds
|
||
.get(id)
|
||
.context("missing mod sound definition")?;
|
||
if !self.mods.contains_key(id) {
|
||
let owner = registry.owners.get(id).context("missing sound owner")?;
|
||
let root = registry
|
||
.asset_roots
|
||
.get(owner)
|
||
.context("missing sound asset root")?;
|
||
let path = crate::assets::contained(root, Path::new(&definition.file))?;
|
||
let sample =
|
||
decode(&std::fs::read(path)?).with_context(|| format!("mod sound {id}"))?;
|
||
if self.mods.values().map(|s| s.data.len()).sum::<usize>() + sample.data.len()
|
||
> MOD_CACHE_LIMIT
|
||
{
|
||
self.mods.clear();
|
||
}
|
||
self.mods.insert(id.clone(), sample);
|
||
}
|
||
Ok(self.mods[id].clone())
|
||
}
|
||
pub fn queue_mod(
|
||
&mut self,
|
||
registry: &Registry,
|
||
mixer: &mut Mixer,
|
||
id: &ContentId,
|
||
) -> Result<()> {
|
||
let sample = self.mod_sample(registry, id)?;
|
||
let definition = ®istry.sounds[id];
|
||
mixer.sample(
|
||
sample,
|
||
definition.pan,
|
||
definition.pitch,
|
||
definition.volume,
|
||
definition.priority as i32,
|
||
);
|
||
Ok(())
|
||
}
|
||
pub fn clear_mods(&mut self) {
|
||
self.mods.clear();
|
||
self.music.clear();
|
||
}
|
||
/// Cache validated package scores independently from the digital FX bus.
|
||
pub fn mod_music(&mut self, registry: &Registry, id: &ContentId) -> Result<MusScore> {
|
||
if !self.music.contains_key(id) {
|
||
let definition = registry
|
||
.music
|
||
.get(id)
|
||
.context("missing mod music definition")?;
|
||
let owner = registry.owners.get(id).context("missing music owner")?;
|
||
let root = registry
|
||
.asset_roots
|
||
.get(owner)
|
||
.context("missing music asset root")?;
|
||
let score =
|
||
load_music(root, &definition.file).with_context(|| format!("music {id}"))?;
|
||
if self.music.values().map(score_size).sum::<usize>() + score_size(&score)
|
||
> MUSIC_CACHE_LIMIT
|
||
{
|
||
self.music.clear();
|
||
}
|
||
self.music.insert(id.clone(), score);
|
||
}
|
||
Ok(self.music[id].clone())
|
||
}
|
||
pub fn original_count(&self) -> usize {
|
||
self.original.len()
|
||
}
|
||
}
|
||
fn score_size(score: &MusScore) -> usize {
|
||
score.groups.len() * std::mem::size_of::<(u32, Vec<crate::audio::MusicEvent>)>()
|
||
+ score
|
||
.groups
|
||
.iter()
|
||
.map(|(_, events)| events.len() * std::mem::size_of::<crate::audio::MusicEvent>())
|
||
.sum::<usize>()
|
||
}
|
||
|
||
/// Extra limits apply only to untrusted mod tracks; the legacy MUS decoder and
|
||
/// its native timing/controller compatibility remain unchanged.
|
||
pub fn decode_music(bytes: &[u8]) -> Result<MusScore> {
|
||
ensure!(bytes.len() <= MAX_MUSIC_BYTES, "music exceeds 128 KiB");
|
||
let score = MusScore::decode(bytes)?;
|
||
let mut count = 0;
|
||
let mut at_tick = 0;
|
||
let mut simultaneous = 0;
|
||
for (tick, events) in &score.groups {
|
||
ensure!(
|
||
*tick <= MAX_MUSIC_TICKS,
|
||
"music duration exceeds 30 minutes"
|
||
);
|
||
count += events.len();
|
||
ensure!(count <= MAX_MUSIC_EVENTS, "music exceeds 16384 events");
|
||
if *tick != at_tick {
|
||
at_tick = *tick;
|
||
simultaneous = 0;
|
||
}
|
||
simultaneous += events.len();
|
||
ensure!(
|
||
simultaneous <= 256,
|
||
"music exceeds 256 events on one service tick"
|
||
);
|
||
}
|
||
ensure!(
|
||
score.groups.last().is_some_and(|(tick, _)| *tick > 0),
|
||
"music must have positive duration"
|
||
);
|
||
Ok(score)
|
||
}
|
||
|
||
pub fn load_music(root: &Path, file: &str) -> Result<MusScore> {
|
||
let path = crate::assets::contained(root, Path::new(file))?;
|
||
let metadata = std::fs::metadata(&path)?;
|
||
ensure!(metadata.is_file(), "music must be a regular file");
|
||
ensure!(
|
||
metadata.len() <= MAX_MUSIC_BYTES as u64,
|
||
"music exceeds 128 KiB"
|
||
);
|
||
let mut bytes = Vec::with_capacity(metadata.len() as usize);
|
||
std::fs::File::open(path)?
|
||
.take(MAX_MUSIC_BYTES as u64 + 1)
|
||
.read_to_end(&mut bytes)?;
|
||
decode_music(&bytes)
|
||
}
|
||
|
||
/// UI cues use the native metadata without consuming gameplay RNG.
|
||
pub fn ui_event(name: &str) -> Result<SoundEvent> {
|
||
let sound = crate::legacy_tables::sounds()
|
||
.into_iter()
|
||
.find(|s| s.name == name)
|
||
.context("unknown original UI sound")?;
|
||
Ok(SoundEvent {
|
||
asset: sound.asset,
|
||
pan: 127,
|
||
pitch: sound.pitch,
|
||
volume: sound.volume,
|
||
priority: sound.priority,
|
||
})
|
||
}
|
||
|
||
/// Write a captured production stereo callback as a standard PCM16 WAV.
|
||
pub fn encode_capture(capture: &crate::audio::AudioCapture) -> Result<Vec<u8>> {
|
||
ensure!(
|
||
capture.samples.len() % 2 == 0,
|
||
"partial captured stereo frame"
|
||
);
|
||
let size = capture
|
||
.samples
|
||
.len()
|
||
.checked_mul(2)
|
||
.and_then(|n| u32::try_from(n).ok())
|
||
.context("audio capture too large")?;
|
||
let mut bytes = Vec::with_capacity(size as usize + 44);
|
||
bytes.extend_from_slice(b"RIFF");
|
||
bytes.extend_from_slice(&(36 + size).to_le_bytes());
|
||
bytes.extend_from_slice(b"WAVEfmt ");
|
||
bytes.extend_from_slice(&16_u32.to_le_bytes());
|
||
bytes.extend_from_slice(&1_u16.to_le_bytes());
|
||
bytes.extend_from_slice(&2_u16.to_le_bytes());
|
||
bytes.extend_from_slice(&capture.rate.to_le_bytes());
|
||
bytes.extend_from_slice(&(capture.rate * 4).to_le_bytes());
|
||
bytes.extend_from_slice(&4_u16.to_le_bytes());
|
||
bytes.extend_from_slice(&16_u16.to_le_bytes());
|
||
bytes.extend_from_slice(b"data");
|
||
bytes.extend_from_slice(&size.to_le_bytes());
|
||
for sample in &capture.samples {
|
||
bytes.extend_from_slice(&sample.to_le_bytes());
|
||
}
|
||
Ok(bytes)
|
||
}
|
||
|
||
pub fn decode(bytes: &[u8]) -> Result<DigitalSample> {
|
||
ensure!(bytes.len() <= 8 * 1024 * 1024, "sound exceeds 8 MiB");
|
||
if !bytes.starts_with(b"RIFF") {
|
||
return DigitalSample::decode(bytes);
|
||
}
|
||
let u32_at = |offset| -> Result<u32> {
|
||
Ok(u32::from_le_bytes(
|
||
bytes
|
||
.get(offset..offset + 4)
|
||
.context("truncated WAV field")?
|
||
.try_into()?,
|
||
))
|
||
};
|
||
ensure!(
|
||
bytes.get(8..12) == Some(b"WAVE") && u32_at(4)? as usize == bytes.len().saturating_sub(8),
|
||
"invalid RIFF/WAVE length"
|
||
);
|
||
let mut format = None;
|
||
let mut audio = None;
|
||
let mut offset = 12;
|
||
while offset < bytes.len() {
|
||
let tag = bytes
|
||
.get(offset..offset + 4)
|
||
.context("truncated WAV chunk")?;
|
||
let length = u32_at(offset + 4)? as usize;
|
||
let start = offset + 8;
|
||
let end = start
|
||
.checked_add(length)
|
||
.context("WAV chunk size overflow")?;
|
||
let chunk = bytes
|
||
.get(start..end)
|
||
.context("truncated WAV chunk payload")?;
|
||
match tag {
|
||
b"fmt " => {
|
||
ensure!(
|
||
format.is_none() && chunk.len() >= 16,
|
||
"duplicate/truncated WAV format"
|
||
);
|
||
let u16_at = |n| u16::from_le_bytes([chunk[n], chunk[n + 1]]);
|
||
let rate = u32::from_le_bytes(chunk[4..8].try_into()?);
|
||
let channels = u16_at(2) as usize;
|
||
let bits = u16_at(14) as usize;
|
||
ensure!(
|
||
u16_at(0) == 1
|
||
&& matches!(channels, 1 | 2)
|
||
&& matches!(bits, 8 | 16)
|
||
&& (8000..=48000).contains(&rate),
|
||
"sound must be 8/16-bit PCM WAV, mono/stereo, 8–48 kHz"
|
||
);
|
||
let align = channels * bits / 8;
|
||
ensure!(
|
||
u16_at(12) as usize == align
|
||
&& u32::from_le_bytes(chunk[8..12].try_into()?) == rate * align as u32,
|
||
"invalid WAV block/byte rate"
|
||
);
|
||
format = Some((rate, channels, bits, align));
|
||
}
|
||
b"data" => {
|
||
ensure!(audio.is_none(), "duplicate WAV data");
|
||
audio = Some(chunk);
|
||
}
|
||
_ => {}
|
||
}
|
||
offset = end
|
||
.checked_add(length & 1)
|
||
.context("WAV padding overflow")?;
|
||
ensure!(offset <= bytes.len(), "truncated WAV padding");
|
||
}
|
||
let (rate, channels, bits, align) = format.context("WAV has no format")?;
|
||
let audio = audio.context("WAV has no audio data")?;
|
||
ensure!(
|
||
!audio.is_empty() && audio.len() % align == 0 && audio.len() / align <= rate as usize * 30,
|
||
"invalid WAV sample count (maximum 30 seconds)"
|
||
);
|
||
let data = audio
|
||
.chunks_exact(align)
|
||
.map(|frame| {
|
||
let total: i32 = (0..channels)
|
||
.map(|channel| {
|
||
if bits == 8 {
|
||
(frame[channel] as i32 - 128) * 256
|
||
} else {
|
||
i16::from_le_bytes([frame[channel * 2], frame[channel * 2 + 1]]) as i32
|
||
}
|
||
})
|
||
.sum();
|
||
((total / channels as i32) / 256 + 128).clamp(0, 255) as u8
|
||
})
|
||
.collect();
|
||
Ok(DigitalSample { rate, data })
|
||
}
|