Skip to main content

rgpre/import/
rtx.rs

1use super::sfx::AudioType;
2use crate::{Result, error::Error};
3use std::io::{Read, Seek, SeekFrom};
4
5const FOOTER_TAG: &[u8; 4] = b"RNAV";
6const FOOTER_SIZE: usize = 12;
7const INDEX_ENTRY_SIZE: usize = 12;
8const AUDIO_HEADER_SIZE: usize = 27;
9const PAYLOAD_PREFIX_SIZE: usize = 6;
10
11#[derive(Debug, Clone)]
12pub struct RtxIndexEntry {
13    pub tag: [u8; 4],
14    pub payload_offset: u32,
15    pub payload_size: u32,
16}
17
18#[derive(Debug, Clone)]
19/// Audio metadata embedded in voice entries (same 27-byte layout as SFX).
20pub struct RtxAudioHeader {
21    pub audio_type: AudioType,
22    pub sample_rate: u32,
23    /// Header byte 0x0C: the volume (0..=127) a voice clip starts at.
24    pub base_volume: u8,
25    pub loop_flag: i8,
26    pub loop_offset: u32,
27    pub loop_end: u32,
28    pub audio_length: u32,
29}
30
31#[derive(Debug, Clone)]
32pub enum RtxEntry {
33    Text {
34        tag: [u8; 4],
35        text: String,
36    },
37    Audio {
38        tag: [u8; 4],
39        label: String,
40        header: RtxAudioHeader,
41        pcm_data: Vec<u8>,
42    },
43}
44
45impl RtxEntry {
46    #[must_use]
47    pub fn tag_str(&self) -> String {
48        let tag = match self {
49            Self::Text { tag, .. } | Self::Audio { tag, .. } => tag,
50        };
51        String::from_utf8_lossy(tag).to_string()
52    }
53
54    #[must_use]
55    pub const fn is_audio(&self) -> bool {
56        matches!(self, Self::Audio { .. })
57    }
58}
59
60#[derive(Debug, Clone)]
61pub struct RtxFile {
62    pub index_count: u32,
63    pub entries: Vec<RtxEntry>,
64}
65
66fn read_u32_le(data: &[u8], offset: usize) -> Option<u32> {
67    let bytes: [u8; 4] = data.get(offset..offset + 4)?.try_into().ok()?;
68    Some(u32::from_le_bytes(bytes))
69}
70
71fn read_u16_le(data: &[u8], offset: usize) -> Option<u16> {
72    let bytes: [u8; 2] = data.get(offset..offset + 2)?.try_into().ok()?;
73    Some(u16::from_le_bytes(bytes))
74}
75
76fn parse_footer(input: &[u8]) -> Result<(u32, u32)> {
77    if input.len() < FOOTER_SIZE {
78        return Err(Error::Parse("RTX file too small for footer".into()));
79    }
80
81    let footer_start = input.len() - FOOTER_SIZE;
82
83    let tag = &input[footer_start..footer_start + 4];
84    if tag != FOOTER_TAG {
85        return Err(Error::Parse(format!(
86            "RTX footer tag mismatch: expected RNAV, got {:?}",
87            String::from_utf8_lossy(tag)
88        )));
89    }
90
91    let index_offset = read_u32_le(input, footer_start + 4)
92        .ok_or_else(|| Error::Parse("failed to read footer index_offset".into()))?;
93    let index_count = read_u32_le(input, footer_start + 8)
94        .ok_or_else(|| Error::Parse("failed to read footer index_count".into()))?;
95
96    Ok((index_offset, index_count))
97}
98
99fn parse_index_table(
100    input: &[u8],
101    index_offset: u32,
102    index_count: u32,
103    file_size: usize,
104) -> Result<Vec<RtxIndexEntry>> {
105    let offset = usize::try_from(index_offset)
106        .map_err(|_| Error::Parse("RTX index_offset does not fit usize".to_string()))?;
107    let count = usize::try_from(index_count)
108        .map_err(|_| Error::Parse("RTX index_count does not fit usize".to_string()))?;
109    let required = count
110        .checked_mul(INDEX_ENTRY_SIZE)
111        .and_then(|size| offset.checked_add(size))
112        .ok_or_else(|| Error::Parse("RTX index size overflow".into()))?;
113
114    if required > input.len() {
115        return Err(Error::Parse(format!(
116            "RTX index table extends beyond file (need {required} bytes, have {})",
117            input.len()
118        )));
119    }
120
121    let mut entries = Vec::with_capacity(count);
122    for i in 0..count {
123        let base = offset + i * INDEX_ENTRY_SIZE;
124        let mut tag = [0u8; 4];
125        tag.copy_from_slice(&input[base..base + 4]);
126
127        let payload_offset = read_u32_le(input, base + 4)
128            .ok_or_else(|| Error::Parse(format!("index {i}: failed to read payload_offset")))?;
129        let payload_size = read_u32_le(input, base + 8)
130            .ok_or_else(|| Error::Parse(format!("index {i}: failed to read payload_size")))?;
131
132        let payload_offset_usize = usize::try_from(payload_offset)
133            .map_err(|_| Error::Parse(format!("index {i}: payload_offset does not fit usize")))?;
134        let payload_size_usize = usize::try_from(payload_size)
135            .map_err(|_| Error::Parse(format!("index {i}: payload_size does not fit usize")))?;
136        let end = payload_offset_usize
137            .checked_add(payload_size_usize)
138            .ok_or_else(|| Error::Parse(format!("index {i}: payload range overflow")))?;
139        if end > file_size {
140            return Err(Error::Parse(format!(
141                "index {i} (tag '{}'): payload extends beyond file ({end} > {})",
142                String::from_utf8_lossy(&tag),
143                file_size
144            )));
145        }
146
147        entries.push(RtxIndexEntry {
148            tag,
149            payload_offset,
150            payload_size,
151        });
152    }
153
154    Ok(entries)
155}
156
157fn parse_audio_header(data: &[u8]) -> Result<RtxAudioHeader> {
158    if data.len() < AUDIO_HEADER_SIZE {
159        return Err(Error::Parse(format!(
160            "audio header too small: need {AUDIO_HEADER_SIZE} bytes, have {}",
161            data.len()
162        )));
163    }
164
165    let type_id = read_u32_le(data, 0)
166        .ok_or_else(|| Error::Parse("audio header: failed to read type_id".into()))?;
167    let _bit_depth = read_u32_le(data, 4);
168    let sample_rate = read_u32_le(data, 8)
169        .ok_or_else(|| Error::Parse("audio header: failed to read sample_rate".into()))?;
170    #[allow(clippy::cast_possible_wrap)] // Engine stores this as a signed flag byte in binary data.
171    let loop_flag = data[13] as i8;
172    let base_volume = data[12];
173    let loop_offset = read_u32_le(data, 14)
174        .ok_or_else(|| Error::Parse("audio header: failed to read loop_offset".into()))?;
175    let loop_end = read_u32_le(data, 18)
176        .ok_or_else(|| Error::Parse("audio header: failed to read loop_end".into()))?;
177    let audio_length = read_u32_le(data, 22)
178        .ok_or_else(|| Error::Parse("audio header: failed to read audio_length".into()))?;
179
180    let audio_type = AudioType::from_type_id(type_id)
181        .ok_or_else(|| Error::Parse(format!("audio header: unknown type_id {type_id}")))?;
182
183    Ok(RtxAudioHeader {
184        audio_type,
185        sample_rate,
186        base_volume,
187        loop_flag,
188        loop_offset,
189        loop_end,
190        audio_length,
191    })
192}
193
194fn parse_payload(tag: [u8; 4], payload: &[u8]) -> Result<RtxEntry> {
195    let tag_str = String::from_utf8_lossy(&tag);
196
197    if payload.len() < PAYLOAD_PREFIX_SIZE {
198        return Err(Error::Parse(format!(
199            "entry '{}': payload too small ({} < {PAYLOAD_PREFIX_SIZE})",
200            tag_str,
201            payload.len()
202        )));
203    }
204
205    let subtype = payload[1];
206    let string_len: usize = read_u16_le(payload, 2)
207        .ok_or_else(|| Error::Parse(format!("entry '{tag_str}': failed to read string_len")))?
208        .into();
209
210    let string_start = PAYLOAD_PREFIX_SIZE;
211    let string_end = string_start + string_len;
212
213    if string_end > payload.len() {
214        return Err(Error::Parse(format!(
215            "entry '{tag_str}': string extends beyond payload ({string_end} > {})",
216            payload.len()
217        )));
218    }
219
220    let text = String::from_utf8_lossy(&payload[string_start..string_end]).to_string();
221
222    match subtype {
223        0 => Ok(RtxEntry::Text { tag, text }),
224        1 => {
225            let audio_start = string_end;
226            if audio_start + AUDIO_HEADER_SIZE > payload.len() {
227                return Err(Error::Parse(format!(
228                    "entry '{tag_str}': not enough bytes for audio header at offset {audio_start}"
229                )));
230            }
231
232            let header = parse_audio_header(&payload[audio_start..])?;
233            let pcm_start = audio_start + AUDIO_HEADER_SIZE;
234            let pcm_len = usize::try_from(header.audio_length).map_err(|_| {
235                Error::Parse(format!(
236                    "entry '{tag_str}': audio_length does not fit usize"
237                ))
238            })?;
239            let pcm_end = pcm_start + pcm_len;
240
241            if pcm_end > payload.len() {
242                return Err(Error::Parse(format!(
243                    "entry '{tag_str}': PCM data extends beyond payload ({pcm_end} > {})",
244                    payload.len()
245                )));
246            }
247
248            let pcm_data = payload[pcm_start..pcm_end].to_vec();
249
250            Ok(RtxEntry::Audio {
251                tag,
252                label: text,
253                header,
254                pcm_data,
255            })
256        }
257        other => Err(Error::Parse(format!(
258            "entry '{tag_str}': unknown subtype {other}"
259        ))),
260    }
261}
262
263/// Archive directory in authored index order. Reading it does not load entry payloads.
264#[derive(Debug, Clone)]
265pub struct RtxIndex {
266    pub entries: Vec<RtxIndexEntry>,
267}
268impl RtxIndex {
269    /// Reads and validates the footer and index from a seekable original file.
270    pub fn read(reader: &mut (impl Read + Seek)) -> Result<Self> {
271        let size = reader.seek(SeekFrom::End(0))?;
272        let file_size = usize::try_from(size)
273            .map_err(|_| Error::Parse("RTX file size does not fit usize".into()))?;
274        let footer_start = size
275            .checked_sub(FOOTER_SIZE as u64)
276            .ok_or_else(|| Error::Parse("RTX file too small for footer".into()))?;
277        reader.seek(SeekFrom::Start(footer_start))?;
278        let mut footer = [0; FOOTER_SIZE];
279        reader.read_exact(&mut footer)?;
280        let (offset, count) = parse_footer(&footer)?;
281        let table_size = u64::from(count) * INDEX_ENTRY_SIZE as u64;
282        if u64::from(offset) + table_size > footer_start {
283            return Err(Error::Parse(
284                "RTX index table extends beyond data region".into(),
285            ));
286        }
287        reader.seek(SeekFrom::Start(u64::from(offset)))?;
288        let mut table = vec![
289            0;
290            usize::try_from(table_size).map_err(|_| Error::Parse(
291                "RTX index size does not fit usize".into()
292            ))?
293        ];
294        reader.read_exact(&mut table)?;
295        Ok(Self {
296            entries: parse_index_table(&table, 0, count, file_size)?,
297        })
298    }
299
300    /// Loads only the selected payload and parses its text or PCM audio.
301    pub fn read_entry(&self, reader: &mut (impl Read + Seek), index: usize) -> Result<RtxEntry> {
302        let entry = self
303            .entries
304            .get(index)
305            .ok_or_else(|| Error::Parse(format!("RTX index {index}: member missing")))?;
306        reader.seek(SeekFrom::Start(u64::from(entry.payload_offset)))?;
307        let mut payload = vec![
308            0;
309            usize::try_from(entry.payload_size).map_err(|_| Error::Parse(
310                format!("RTX index {index}: payload size does not fit usize")
311            ))?
312        ];
313        reader.read_exact(&mut payload)?;
314        parse_payload(entry.tag, &payload)
315            .map_err(|error| Error::Parse(format!("RTX index {index}: {error}")))
316    }
317}
318
319/// Parses a Redguard RTX dialogue/audio container from raw bytes.
320pub fn parse_rtx_file(input: &[u8]) -> Result<RtxFile> {
321    let (index_offset, index_count) = parse_footer(input)?;
322    let index = parse_index_table(input, index_offset, index_count, input.len())?;
323
324    let mut entries = Vec::with_capacity(index.len());
325    for (i, idx) in index.iter().enumerate() {
326        let start = usize::try_from(idx.payload_offset)
327            .map_err(|_| Error::Parse(format!("index {i}: payload_offset does not fit usize")))?;
328        let size = usize::try_from(idx.payload_size)
329            .map_err(|_| Error::Parse(format!("index {i}: payload_size does not fit usize")))?;
330        let end = start + size;
331        let payload = &input[start..end];
332
333        let entry = parse_payload(idx.tag, payload).map_err(|e| {
334            Error::Parse(format!(
335                "index {i} (tag '{}'): {e}",
336                String::from_utf8_lossy(&idx.tag)
337            ))
338        })?;
339        entries.push(entry);
340    }
341
342    Ok(RtxFile {
343        index_count,
344        entries,
345    })
346}
347
348impl RtxFile {
349    #[must_use]
350    pub fn audio_count(&self) -> usize {
351        self.entries.iter().filter(|e| e.is_audio()).count()
352    }
353
354    #[must_use]
355    pub fn text_count(&self) -> usize {
356        self.entries.iter().filter(|e| !e.is_audio()).count()
357    }
358
359    /// Builds a text map containing only non-empty labels.
360    /// For presence-aware lookup, use `entries`: an empty label still has an RTX entry.
361    #[must_use]
362    pub fn build_label_map(&self) -> std::collections::HashMap<String, String> {
363        let mut map = std::collections::HashMap::new();
364        for entry in &self.entries {
365            match entry {
366                RtxEntry::Audio { tag, label, .. } => {
367                    let key = String::from_utf8_lossy(tag).to_string();
368                    if !label.is_empty() {
369                        map.insert(key, label.clone());
370                    }
371                }
372                RtxEntry::Text { tag, text } => {
373                    let key = String::from_utf8_lossy(tag).to_string();
374                    if !text.is_empty() {
375                        map.insert(key, text.clone());
376                    }
377                }
378            }
379        }
380        map
381    }
382}
383
384pub fn try_load_rtx_labels(
385    asset_root: &std::path::Path,
386) -> Option<std::collections::HashMap<String, String>> {
387    let entries = std::fs::read_dir(asset_root).ok()?;
388    for entry in entries.flatten() {
389        let path = entry.path();
390        if path.is_file()
391            && path
392                .file_name()
393                .and_then(|n| n.to_str())
394                .is_some_and(|n| n.eq_ignore_ascii_case("ENGLISH.RTX"))
395        {
396            let data = std::fs::read(&path).ok()?;
397            let rtx = parse_rtx_file(&data).ok()?;
398            return Some(rtx.build_label_map());
399        }
400    }
401    None
402}
403
404impl RtxAudioHeader {
405    #[must_use]
406    pub fn duration_secs(&self) -> f64 {
407        let bytes_per_sample = (u32::from(self.audio_type.bits_per_sample()) / 8)
408            * u32::from(self.audio_type.channels());
409        if self.sample_rate == 0 || bytes_per_sample == 0 {
410            return 0.0;
411        }
412        f64::from(self.audio_length) / (f64::from(self.sample_rate) * f64::from(bytes_per_sample))
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419
420    #[test]
421    fn seekable_index_reads_no_payload_until_selected() {
422        struct Counted {
423            inner: std::io::Cursor<Vec<u8>>,
424            ranges: Vec<(u64, usize)>,
425        }
426        impl Read for Counted {
427            fn read(&mut self, bytes: &mut [u8]) -> std::io::Result<usize> {
428                let start = self.inner.position();
429                let count = self.inner.read(bytes)?;
430                self.ranges.push((start, count));
431                Ok(count)
432            }
433        }
434        impl Seek for Counted {
435            fn seek(&mut self, position: SeekFrom) -> std::io::Result<u64> {
436                self.inner.seek(position)
437            }
438        }
439        let payload = make_text_payload("Selected dialogue");
440        let bytes = make_rtx(&[(b"TEXT", &payload), (b"BAD!", &[0])]);
441        let table_start = bytes.len() - FOOTER_SIZE - 2 * INDEX_ENTRY_SIZE;
442        let mut reader = Counted {
443            inner: std::io::Cursor::new(bytes),
444            ranges: Vec::new(),
445        };
446        let index = RtxIndex::read(&mut reader).expect("directory");
447        assert_eq!(index.entries.len(), 2);
448        assert!(
449            reader
450                .ranges
451                .iter()
452                .all(|(start, _)| *start >= table_start as u64)
453        );
454        assert_eq!(
455            reader.ranges.iter().map(|(_, count)| count).sum::<usize>(),
456            36
457        );
458        reader.ranges.clear();
459        assert!(
460            matches!(index.read_entry(&mut reader, 0).unwrap(), RtxEntry::Text { text, .. } if text == "Selected dialogue")
461        );
462        assert_eq!(reader.ranges, [(8, payload.len())]);
463        assert!(index.read_entry(&mut reader, 1).is_err());
464        assert!(index.read_entry(&mut reader, 2).is_err());
465        let bytes = reader.inner.get_mut();
466        bytes[table_start + 4..table_start + 8].copy_from_slice(&u32::MAX.to_le_bytes());
467        assert!(RtxIndex::read(&mut reader).is_err());
468    }
469
470    fn make_rtx(payloads: &[(&[u8; 4], &[u8])]) -> Vec<u8> {
471        let mut buf = Vec::new();
472        let mut index_entries: Vec<([u8; 4], u32, u32)> = Vec::new();
473
474        for (tag, payload) in payloads {
475            buf.extend_from_slice(*tag);
476            let payload_len = u32::try_from(payload.len()).expect("payload length must fit u32");
477            buf.extend_from_slice(&payload_len.to_be_bytes());
478            let payload_offset = u32::try_from(buf.len()).expect("offset must fit u32");
479            buf.extend_from_slice(payload);
480            index_entries.push((**tag, payload_offset, payload_len));
481        }
482
483        let index_offset = u32::try_from(buf.len()).expect("index offset must fit u32");
484        for (tag, off, size) in &index_entries {
485            buf.extend_from_slice(tag);
486            buf.extend_from_slice(&off.to_le_bytes());
487            buf.extend_from_slice(&size.to_le_bytes());
488        }
489
490        buf.extend_from_slice(FOOTER_TAG);
491        buf.extend_from_slice(&index_offset.to_le_bytes());
492        buf.extend_from_slice(
493            &u32::try_from(payloads.len())
494                .expect("payload count must fit u32")
495                .to_le_bytes(),
496        );
497
498        buf
499    }
500
501    fn make_text_payload(text: &str) -> Vec<u8> {
502        let mut buf = Vec::new();
503        buf.push(0); // kind
504        buf.push(0); // subtype
505        buf.extend_from_slice(
506            &u16::try_from(text.len())
507                .expect("text length must fit u16")
508                .to_le_bytes(),
509        );
510        buf.extend_from_slice(&0u16.to_le_bytes());
511        buf.extend_from_slice(text.as_bytes());
512        buf
513    }
514
515    fn make_audio_payload(label: &str, type_id: u32, sample_rate: u32, pcm_len: u32) -> Vec<u8> {
516        let mut buf = Vec::new();
517        buf.push(0); // kind
518        buf.push(1); // subtype
519        buf.extend_from_slice(
520            &u16::try_from(label.len())
521                .expect("label length must fit u16")
522                .to_le_bytes(),
523        );
524        buf.extend_from_slice(&0u16.to_le_bytes());
525        buf.extend_from_slice(label.as_bytes());
526        // 27-byte audio header
527        buf.extend_from_slice(&type_id.to_le_bytes()); // type_id
528        buf.extend_from_slice(&type_id.to_le_bytes()); // bit_depth
529        buf.extend_from_slice(&sample_rate.to_le_bytes());
530        buf.push(100); // level_0c
531        buf.push(0); // loop_flag
532        buf.extend_from_slice(&0u32.to_le_bytes()); // loop_offset
533        buf.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); // loop_end
534        buf.extend_from_slice(&pcm_len.to_le_bytes()); // audio_length
535        buf.push(0); // reserved_1a
536        let pcm_size = usize::try_from(pcm_len).expect("pcm length must fit usize");
537        buf.extend_from_slice(&vec![0x80u8; pcm_size]);
538        buf
539    }
540
541    #[test]
542    fn parse_single_text_entry() {
543        let payload = make_text_payload("Hello world");
544        let data = make_rtx(&[(b"txt1", &payload)]);
545        let file = parse_rtx_file(&data).unwrap();
546        assert_eq!(file.entries.len(), 1);
547        assert_eq!(file.text_count(), 1);
548        assert_eq!(file.audio_count(), 0);
549        match &file.entries[0] {
550            RtxEntry::Text { tag, text } => {
551                assert_eq!(tag, b"txt1");
552                assert_eq!(text, "Hello world");
553            }
554            RtxEntry::Audio { .. } => panic!("expected text entry"),
555        }
556    }
557
558    #[test]
559    fn parse_single_audio_entry() {
560        let payload = make_audio_payload("Cyrus speaks", 1, 22050, 100);
561        let data = make_rtx(&[(b"aud1", &payload)]);
562        let file = parse_rtx_file(&data).unwrap();
563        assert_eq!(file.entries.len(), 1);
564        assert_eq!(file.audio_count(), 1);
565        assert_eq!(file.text_count(), 0);
566        match &file.entries[0] {
567            RtxEntry::Audio {
568                tag,
569                label,
570                header,
571                pcm_data,
572            } => {
573                assert_eq!(tag, b"aud1");
574                assert_eq!(label, "Cyrus speaks");
575                assert_eq!(header.audio_type, AudioType::Mono16);
576                assert_eq!(header.sample_rate, 22050);
577                assert_eq!(header.base_volume, 100);
578                assert_eq!(pcm_data.len(), 100);
579            }
580            RtxEntry::Text { .. } => panic!("expected audio entry"),
581        }
582    }
583
584    #[test]
585    fn parse_mixed_entries() {
586        let text = make_text_payload("line one");
587        let audio = make_audio_payload("voice", 0, 11025, 50);
588        let data = make_rtx(&[(b"tst1", &text), (b"vox1", &audio)]);
589        let file = parse_rtx_file(&data).unwrap();
590        assert_eq!(file.entries.len(), 2);
591        assert_eq!(file.text_count(), 1);
592        assert_eq!(file.audio_count(), 1);
593    }
594
595    #[test]
596    fn rejects_bad_footer_tag() {
597        let mut data = make_rtx(&[]);
598        let len = data.len();
599        data[len - 12..len - 8].copy_from_slice(b"XXXX");
600        assert!(parse_rtx_file(&data).is_err());
601    }
602
603    #[test]
604    fn rejects_truncated_file() {
605        let data = vec![0u8; 8];
606        assert!(parse_rtx_file(&data).is_err());
607    }
608}