Skip to main content

rgpre/import/
sfx.rs

1use crate::{Result, error::Error};
2use std::io::{Read, Seek, SeekFrom};
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5/// Encoded audio channel/bit-depth layout for an SFX effect.
6pub enum AudioType {
7    Mono8,
8    Mono16,
9    Stereo8,
10    Stereo16,
11}
12
13impl AudioType {
14    #[must_use]
15    pub(crate) const fn from_type_id(id: u32) -> Option<Self> {
16        match id {
17            0 => Some(Self::Mono8),
18            1 => Some(Self::Mono16),
19            2 => Some(Self::Stereo8),
20            3 => Some(Self::Stereo16),
21            _ => None,
22        }
23    }
24
25    /// Returns the number of PCM channels for this audio type.
26    #[must_use]
27    pub const fn channels(&self) -> u16 {
28        match self {
29            Self::Mono8 | Self::Mono16 => 1,
30            Self::Stereo8 | Self::Stereo16 => 2,
31        }
32    }
33
34    /// Returns bits per sample for this audio type.
35    #[must_use]
36    pub const fn bits_per_sample(&self) -> u16 {
37        match self {
38            Self::Mono8 | Self::Stereo8 => 8,
39            Self::Mono16 | Self::Stereo16 => 16,
40        }
41    }
42}
43
44#[derive(Debug, Clone)]
45/// Parsed sound effect entry from an SFX file.
46pub struct SfxEffect {
47    pub audio_type: AudioType,
48    pub sample_rate: u32,
49    /// Header byte 0x0C: the volume (0..=127) of non-positional starts; 64 in every shipped effect.
50    pub base_volume: u8,
51    pub loop_flag: i8,
52    pub loop_offset: u32,
53    pub loop_end: u32,
54    pub pcm_data: Vec<u8>,
55}
56
57impl SfxEffect {
58    /// Estimates effect duration in seconds from PCM byte length and format.
59    #[must_use]
60    pub fn duration_secs(&self) -> f64 {
61        let bytes_per_sample = (u32::from(self.audio_type.bits_per_sample()) / 8)
62            * u32::from(self.audio_type.channels());
63        if self.sample_rate == 0 || bytes_per_sample == 0 {
64            return 0.0;
65        }
66        let pcm_len = u32::try_from(self.pcm_data.len()).unwrap_or(u32::MAX);
67        f64::from(pcm_len) / (f64::from(self.sample_rate) * f64::from(bytes_per_sample))
68    }
69}
70
71#[derive(Debug, Clone)]
72/// Parsed SFX file description and effect list.
73pub struct SfxFile {
74    pub description: String,
75    pub effects: Vec<SfxEffect>,
76}
77
78fn read_u32_le(data: &[u8], offset: usize) -> Option<u32> {
79    let bytes: [u8; 4] = data.get(offset..offset + 4)?.try_into().ok()?;
80    Some(u32::from_le_bytes(bytes))
81}
82
83fn read_u32_be(data: &[u8], offset: usize) -> Option<u32> {
84    let bytes: [u8; 4] = data.get(offset..offset + 4)?.try_into().ok()?;
85    Some(u32::from_be_bytes(bytes))
86}
87
88/// SFX effect metadata and payload boundaries, without loading PCM data.
89pub struct SfxIndex {
90    pub description: String,
91    pub effects: Vec<SfxEffect>,
92    ranges: Vec<(u64, usize)>,
93}
94impl SfxIndex {
95    /// Reads fixed bank/effect headers and seeks over PCM payloads.
96    pub fn read(reader: &mut (impl Read + Seek)) -> Result<Self> {
97        let length = reader.seek(SeekFrom::End(0))?;
98        reader.seek(SeekFrom::Start(0))?;
99        let mut input = [0; 52];
100        reader.read_exact(&mut input)?;
101        let input = &input[..];
102        if input.len() < 52 {
103            return Err(Error::Parse("SFX file too small for FXHD section".into()));
104        }
105
106        // FXHD: 4-byte tag + 4 bytes BE section_size + 32 bytes description + 4 bytes LE effect_count
107        if &input[0..4] != b"FXHD" {
108            return Err(Error::Parse(format!(
109                "expected FXHD tag, got {:?}",
110                String::from_utf8_lossy(&input[0..4])
111            )));
112        }
113
114        let _section_size = read_u32_be(input, 4)
115            .ok_or_else(|| Error::Parse("failed to read FXHD section_size".into()))?;
116
117        let description = String::from_utf8_lossy(&input[8..40])
118            .trim_matches('\0')
119            .to_string();
120
121        let effect_count = usize::try_from(
122            read_u32_le(input, 40)
123                .ok_or_else(|| Error::Parse("failed to read FXHD effect_count".into()))?,
124        )
125        .map_err(|_| Error::Parse("FXHD effect_count does not fit usize".to_string()))?;
126
127        // FXDT: 4-byte tag + 4 bytes BE section_size, then sequential effects
128        let fxdt_offset = 44;
129        if input.len() < fxdt_offset + 8 {
130            return Err(Error::Parse("SFX file too small for FXDT header".into()));
131        }
132        if &input[fxdt_offset..fxdt_offset + 4] != b"FXDT" {
133            return Err(Error::Parse(format!(
134                "expected FXDT tag, got {:?}",
135                String::from_utf8_lossy(&input[fxdt_offset..fxdt_offset + 4])
136            )));
137        }
138        let _fxdt_size = read_u32_be(input, fxdt_offset + 4)
139            .ok_or_else(|| Error::Parse("failed to read FXDT section_size".into()))?;
140
141        if effect_count as u64 > length.saturating_sub(52) / 27 {
142            return Err(Error::Parse("SFX effect count exceeds file length".into()));
143        }
144        let mut position = 52;
145        let mut effects = Vec::new();
146        let mut ranges = Vec::new();
147        for i in 0..effect_count {
148            reader.seek(SeekFrom::Start(position))?;
149            let mut bytes = [0; 27];
150            reader.read_exact(&mut bytes)?;
151            let input = &bytes[..];
152            let cursor = 0;
153            let type_id = read_u32_le(input, cursor)
154                .ok_or_else(|| Error::Parse(format!("effect {i}: failed to read type_id")))?;
155            let _bit_depth = read_u32_le(input, cursor + 4);
156            let sample_rate = read_u32_le(input, cursor + 8)
157                .ok_or_else(|| Error::Parse(format!("effect {i}: failed to read sample_rate")))?;
158            #[allow(clippy::cast_possible_wrap)]
159            // Engine stores this as a signed flag byte in binary data.
160            let loop_flag = input[cursor + 13] as i8;
161            let base_volume = input[cursor + 12];
162            let loop_offset = read_u32_le(input, cursor + 14)
163                .ok_or_else(|| Error::Parse(format!("effect {i}: failed to read loop_offset")))?;
164            let loop_end = read_u32_le(input, cursor + 18)
165                .ok_or_else(|| Error::Parse(format!("effect {i}: failed to read loop_end")))?;
166            let data_length =
167                usize::try_from(read_u32_le(input, cursor + 22).ok_or_else(|| {
168                    Error::Parse(format!("effect {i}: failed to read data_length"))
169                })?)
170                .map_err(|_| Error::Parse(format!("effect {i}: data_length does not fit usize")))?;
171
172            let audio_type = AudioType::from_type_id(type_id).ok_or_else(|| {
173                Error::Parse(format!("effect {i}: unknown audio type_id {type_id}"))
174            })?;
175
176            position += 27;
177            if data_length as u64 > length.saturating_sub(position) {
178                return Err(Error::Parse(format!(
179                    "SFX effect {i}: PCM payload exceeds file length"
180                )));
181            }
182            ranges.push((position, data_length));
183            position += data_length as u64;
184            effects.push(SfxEffect {
185                audio_type,
186                sample_rate,
187                base_volume,
188                loop_flag,
189                loop_offset,
190                loop_end,
191                pcm_data: Vec::new(),
192            });
193        }
194        Ok(Self {
195            description,
196            effects,
197            ranges,
198        })
199    }
200    /// Reads only the selected effect's PCM bytes.
201    pub fn read_effect(&self, reader: &mut (impl Read + Seek), index: usize) -> Result<SfxEffect> {
202        let mut effect = self
203            .effects
204            .get(index)
205            .ok_or_else(|| Error::Parse(format!("SFX effect {index} missing")))?
206            .clone();
207        let (offset, length) = self.ranges[index];
208        reader.seek(SeekFrom::Start(offset))?;
209        effect.pcm_data.resize(length, 0);
210        reader.read_exact(&mut effect.pcm_data)?;
211        Ok(effect)
212    }
213}
214
215/// Parses a Redguard SFX file from bytes.
216pub fn parse_sfx_file(input: &[u8]) -> Result<SfxFile> {
217    let mut reader = std::io::Cursor::new(input);
218    let index = SfxIndex::read(&mut reader)?;
219    let effects = (0..index.effects.len())
220        .map(|i| index.read_effect(&mut reader, i))
221        .collect::<Result<Vec<_>>>()?;
222    Ok(SfxFile {
223        description: index.description,
224        effects,
225    })
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn make_minimal_sfx(effect_count: u32, effects_data: &[u8]) -> Vec<u8> {
233        let mut buf = Vec::new();
234        buf.extend_from_slice(b"FXHD");
235        let fxhd_payload_size: u32 = 36;
236        buf.extend_from_slice(&fxhd_payload_size.to_be_bytes());
237        buf.extend_from_slice(&[0u8; 32]);
238        buf.extend_from_slice(&effect_count.to_le_bytes());
239        buf.extend_from_slice(b"FXDT");
240        let fxdt_size = u32::try_from(effects_data.len()).expect("effects data must fit u32");
241        buf.extend_from_slice(&fxdt_size.to_be_bytes());
242        buf.extend_from_slice(effects_data);
243        buf.extend_from_slice(b"END ");
244        buf
245    }
246
247    fn make_effect_bytes(type_id: u32, sample_rate: u32, pcm_len: u32) -> Vec<u8> {
248        let mut buf = Vec::new();
249        buf.extend_from_slice(&type_id.to_le_bytes());
250        buf.extend_from_slice(&0u32.to_le_bytes()); // bit_depth
251        buf.extend_from_slice(&sample_rate.to_le_bytes());
252        buf.push(64); // unknown_0c
253        buf.push(0); // loop_flag
254        buf.extend_from_slice(&0u32.to_le_bytes()); // loop_offset
255        buf.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); // loop_end
256        buf.extend_from_slice(&pcm_len.to_le_bytes());
257        buf.push(0); // unknown_1a
258        let pcm_size = usize::try_from(pcm_len).expect("pcm length must fit usize");
259        buf.extend_from_slice(&vec![0x80u8; pcm_size]);
260        buf
261    }
262
263    #[test]
264    fn parse_single_effect() {
265        let effect = make_effect_bytes(1, 22050, 100);
266        let sfx = make_minimal_sfx(1, &effect);
267        let file = parse_sfx_file(&sfx).unwrap();
268        assert_eq!(file.effects.len(), 1);
269        assert_eq!(file.effects[0].audio_type, AudioType::Mono16);
270        assert_eq!(file.effects[0].sample_rate, 22050);
271        assert_eq!(file.effects[0].base_volume, 64);
272        assert_eq!(file.effects[0].pcm_data.len(), 100);
273    }
274
275    #[test]
276    fn parse_multiple_effects() {
277        let mut data = Vec::new();
278        data.extend_from_slice(&make_effect_bytes(0, 11025, 50));
279        data.extend_from_slice(&make_effect_bytes(3, 22050, 200));
280        let sfx = make_minimal_sfx(2, &data);
281        let file = parse_sfx_file(&sfx).unwrap();
282        assert_eq!(file.effects.len(), 2);
283        assert_eq!(file.effects[0].audio_type, AudioType::Mono8);
284        assert_eq!(file.effects[1].audio_type, AudioType::Stereo16);
285    }
286
287    #[test]
288    fn rejects_truncated_file() {
289        let sfx = vec![0u8; 10];
290        assert!(parse_sfx_file(&sfx).is_err());
291    }
292}