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)]
19pub struct RtxAudioHeader {
21 pub audio_type: AudioType,
22 pub sample_rate: u32,
23 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)] 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#[derive(Debug, Clone)]
265pub struct RtxIndex {
266 pub entries: Vec<RtxIndexEntry>,
267}
268impl RtxIndex {
269 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 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
319pub 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 #[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); buf.push(0); 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); buf.push(1); 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 buf.extend_from_slice(&type_id.to_le_bytes()); buf.extend_from_slice(&type_id.to_le_bytes()); buf.extend_from_slice(&sample_rate.to_le_bytes());
530 buf.push(100); buf.push(0); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&0xFFFF_FFFFu32.to_le_bytes()); buf.extend_from_slice(&pcm_len.to_le_bytes()); buf.push(0); 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}