cuebin.rsannotatedcuebin.rssource540 lines · 17.1 KB · raw

Code for loading and reading CD-ROM images in CUE/BIN format

3#[cfg(test)]
4mod tests;
6use crate::cdtime::CdTime;
7use crate::cue::{CueSheet, Track, TrackMode};
8use crate::reader::{SECTOR_HEADER_LEN, synthesize_data_header};
9use crate::{CdRomError, CdRomResult, cue};
10use bincode::{Decode, Encode};
11use regex::Regex;
12use std::collections::{HashMap, HashSet};
13use std::fs::File;
14use std::io::{BufReader, Read, Seek, SeekFrom};
15use std::path::Path;
16use std::str::FromStr;
17use std::sync::LazyLock;
18use std::{env, fs, io, mem};
19
20#[derive(Debug, Clone, Encode, Decode)]
21pub struct TrackMetadata {
22    pub file_name: String,
23    pub mode: TrackMode,
24    pub address_in_file: u64,
25}
26
27#[derive(Debug)]
28struct CdRomFile<F: Read + Seek> {
29    file: BufReader<F>,
30    position: u64,
31}
32
33impl<F: Read + Seek> CdRomFile<F> {
34    fn new(file: F) -> Self {
35        Self { file: BufReader::new(file), position: 0 }
36    }
37}
38
39#[derive(Debug)]
40pub struct CdBinFiles<F: Read + Seek> {
41    files: HashMap<String, CdRomFile<F>>,
42    track_metadata: Vec<TrackMetadata>,
43}
44
45impl<F: Read + Seek> CdBinFiles<F> {
46    pub fn empty() -> Self {
47        Self { files: HashMap::new(), track_metadata: Vec::new() }
48    }
49
50    pub fn create<OpenFn, P: AsRef<Path>>(
51        cue_path: P,
52        bin_open_fn: OpenFn,
53    ) -> CdRomResult<(Self, CueSheet)>
54    where
55        OpenFn: for<'a> Fn(&'a Path) -> io::Result<F>,
56    {
57        let cue_path = cue_path.as_ref();
58
59        let (cue_sheet, track_metadata) = parse_cue(cue_path)?;
60
61        let file_names: HashSet<_> =
62            track_metadata.iter().map(|metadata| metadata.file_name.clone()).collect();
63
64        let parent_dir = cue_path
65            .parent()
66            .ok_or_else(|| CdRomError::CueParentDir(cue_path.display().to_string()))?;
67
68        let mut files = HashMap::with_capacity(file_names.len());
69        for file_name in file_names {
70            let file_path = parent_dir.join(Path::new(&file_name));
71            let file = bin_open_fn(&file_path).map_err(|source| CdRomError::BinOpen {
72                path: file_path.display().to_string(),
73                source,
74            })?;
75            files.insert(file_name, CdRomFile::new(file));
76        }
77
78        let bin_files = Self { files, track_metadata };
79        Ok((bin_files, cue_sheet))
80    }
81
82    pub fn read_sector(
83        &mut self,
84        track_number: u8,
85        absolute_time: CdTime,
86        relative_sector_number: u32,
87        out: &mut [u8],
88    ) -> CdRomResult<()> {
89        let metadata = &self.track_metadata[(track_number - 1) as usize];
90        let CdRomFile { file: track_file, position } = self
91            .files
92            .get_mut(&metadata.file_name)
93            .expect("Track file was not opened on load; this is a bug");
94
95        let sector_addr = metadata.address_in_file
96            + u64::from(relative_sector_number) * metadata.mode.bytes_per_sector();
97
98        // Only seek if the file descriptor is not already at the desired position
99        if *position != sector_addr {
100            track_file.seek(SeekFrom::Start(sector_addr)).map_err(CdRomError::DiscReadIo)?;
101        }
102
103        match metadata.mode {
104            TrackMode::Mode1DataOnly => {
105                // 2048-byte sectors
106                out[..SECTOR_HEADER_LEN as usize]
107                    .copy_from_slice(&synthesize_data_header(metadata.mode, absolute_time));
108                track_file
109                    .read_exact(
110                        &mut out[SECTOR_HEADER_LEN as usize..(SECTOR_HEADER_LEN + 2048) as usize],
111                    )
112                    .map_err(CdRomError::DiscReadIo)?;
113                out[(SECTOR_HEADER_LEN + 2048) as usize..crate::BYTES_PER_SECTOR as usize].fill(0);
114
115                *position = sector_addr + 2048;
116            }
117            TrackMode::Mode1 | TrackMode::Mode2 | TrackMode::Audio => {
118                track_file
119                    .read_exact(&mut out[..crate::BYTES_PER_SECTOR as usize])
120                    .map_err(CdRomError::DiscReadIo)?;
121                *position = sector_addr + crate::BYTES_PER_SECTOR;
122            }
123        }
124
125        Ok(())
126    }
127}
128
129#[derive(Debug, Clone)]
130#[cfg_attr(test, derive(PartialEq, Eq))]
131struct ParsedTrack {
132    number: u8,
133    mode: TrackMode,
134    pregap_len: Option<CdTime>,
135    pause_start: Option<CdTime>,
136    postgap_len: Option<CdTime>,
137    track_start: CdTime,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq)]
141enum FileType {
142    Binary,
143    Wave,
144}
145
146impl FromStr for FileType {
147    type Err = String;
148
149    fn from_str(s: &str) -> Result<Self, Self::Err> {
150        match s {
151            "BINARY" => Ok(Self::Binary),
152            "WAVE" => Ok(Self::Wave),
153            _ => Err(format!("unrecognized FILE type: {s}")),
154        }
155    }
156}
157
158#[derive(Debug, Clone)]
159#[cfg_attr(test, derive(PartialEq, Eq))]
160struct ParsedFile {
161    file_name: String,
162    file_type: FileType,
163    tracks: Vec<ParsedTrack>,
164}
165
166#[derive(Debug, Clone)]
167struct CueParser {
168    files: Vec<ParsedFile>,
169    tracks: Vec<ParsedTrack>,
170    current_file: Option<(String, FileType)>,
171    current_track: Option<(u8, TrackMode)>,
172    last_track_number: Option<u8>,
173    pregap_len: Option<CdTime>,
174    pause_start: Option<CdTime>,
175    postgap_len: Option<CdTime>,
176    track_start: Option<CdTime>,
177}
178
179impl CueParser {
180    fn new() -> Self {
181        Self {
182            files: vec![],
183            tracks: vec![],
184            current_file: None,
185            current_track: None,
186            last_track_number: None,
187            pregap_len: None,
188            pause_start: None,
189            postgap_len: None,
190            track_start: None,
191        }
192    }
193
194    fn parse(mut self, file: &str) -> CdRomResult<Vec<ParsedFile>> {
195        for line in file.lines() {
196            let trimmed = line.trim_start();
197            if trimmed.starts_with("FILE ") {
198                self.parse_file_line(line)?;
199            } else if trimmed.starts_with("TRACK ") {
200                self.parse_track_line(line)?;
201            } else if trimmed.starts_with("INDEX ") {
202                self.parse_index_line(line)?;
203            } else if trimmed.starts_with("PREGAP ") {
204                self.parse_pregap_line(line)?;
205            } else if trimmed.starts_with("POSTGAP ") {
206                self.parse_postgap_line(line)?;
207            }
208        }
209
210        self.push_file()?;
211
212        if self.files.is_empty() {
213            return Err(CdRomError::CueParse("CUE file has no tracks".into()));
214        }
215
216        Ok(self.files)
217    }
218
219    #[allow(clippy::items_after_statements)]
220    fn parse_file_line(&mut self, line: &str) -> CdRomResult<()> {
221        self.push_file()?;
222
223        static RE: LazyLock<Regex> =
224            LazyLock::new(|| Regex::new(r#"FILE "(.*)" ([^ ]*)"#).unwrap());
225
226        let captures =
227            RE.captures(line).ok_or_else(|| CdRomError::CueInvalidFileLine(line.into()))?;
228        let file_name = captures.get(1).unwrap();
229        let file_type = captures
230            .get(2)
231            .unwrap()
232            .as_str()
233            .parse::<FileType>()
234            .map_err(|_| CdRomError::CueInvalidFileLine(line.into()))?;
235
236        self.current_file = Some((file_name.as_str().into(), file_type));
237
238        Ok(())
239    }
240
241    #[allow(clippy::items_after_statements)]
242    fn parse_track_line(&mut self, line: &str) -> CdRomResult<()> {
243        self.push_track()?;
244
245        static RE: LazyLock<Regex> =
246            LazyLock::new(|| Regex::new(r"TRACK ([^ ]*) ([^ ]*)").unwrap());
247
248        let captures =
249            RE.captures(line).ok_or_else(|| CdRomError::CueInvalidTrackLine(line.into()))?;
250        let track_number = captures
251            .get(1)
252            .unwrap()
253            .as_str()
254            .parse::<u8>()
255            .map_err(|_| CdRomError::CueInvalidTrackLine(line.into()))?;
256        let mode = captures
257            .get(2)
258            .unwrap()
259            .as_str()
260            .parse::<TrackMode>()
261            .map_err(|_| CdRomError::CueInvalidTrackLine(line.into()))?;
262
263        self.current_track = Some((track_number, mode));
264
265        Ok(())
266    }
267
268    fn parse_index_line(&mut self, line: &str) -> CdRomResult<()> {
269        static RE: LazyLock<Regex> =
270            LazyLock::new(|| Regex::new(r"INDEX ([^ ]*) ([^ ]*)").unwrap());
271
272        let captures =
273            RE.captures(line).ok_or_else(|| CdRomError::CueInvalidIndexLine(line.into()))?;
274        let index_number = captures.get(1).unwrap();
275        let start_time = captures
276            .get(2)
277            .unwrap()
278            .as_str()
279            .parse::<CdTime>()
280            .map_err(|_| CdRomError::CueInvalidIndexLine(line.into()))?;
281
282        match index_number.as_str() {
283            "00" => {
284                self.pause_start = Some(start_time);
285            }
286            "01" => {
287                self.track_start = Some(start_time);
288            }
289            _ => {
290                return Err(CdRomError::CueInvalidIndexLine(line.into()));
291            }
292        }
293
294        Ok(())
295    }
296
297    fn parse_pregap_line(&mut self, line: &str) -> CdRomResult<()> {
298        static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"PREGAP ([^ ]*)").unwrap());
299
300        let captures =
301            RE.captures(line).ok_or_else(|| CdRomError::CueInvalidPregapLine(line.into()))?;
302        let pregap_len = captures
303            .get(1)
304            .unwrap()
305            .as_str()
306            .parse::<CdTime>()
307            .map_err(|_| CdRomError::CueInvalidPregapLine(line.into()))?;
308
309        self.pregap_len = Some(pregap_len);
310
311        Ok(())
312    }
313
314    fn parse_postgap_line(&mut self, line: &str) -> CdRomResult<()> {
315        static RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"POSTGAP ([^ ]*)").unwrap());
316
317        let captures =
318            RE.captures(line).ok_or_else(|| CdRomError::CueInvalidPregapLine(line.into()))?;
319        let postgap_len = captures
320            .get(1)
321            .unwrap()
322            .as_str()
323            .parse::<CdTime>()
324            .map_err(|_| CdRomError::CueInvalidPostgapLine(line.into()))?;
325
326        self.postgap_len = Some(postgap_len);
327
328        Ok(())
329    }
330
331    fn push_file(&mut self) -> CdRomResult<()> {
332        self.push_track()?;
333
334        let Some((file_name, file_type)) = self.current_file.take() else { return Ok(()) };
335
336        if self.tracks.is_empty() {
337            return Err(CdRomError::CueParse(format!("No tracks listed for file '{file_name}'")));
338        }
339
340        self.files.push(ParsedFile { file_name, file_type, tracks: mem::take(&mut self.tracks) });
341
342        Ok(())
343    }
344
345    fn push_track(&mut self) -> CdRomResult<()> {
346        let Some((track_number, track_mode)) = self.current_track.take() else {
347            return Ok(());
348        };
349
350        match self.last_track_number {
351            None => {
352                if track_number != 1 {
353                    return Err(CdRomError::CueParse(format!(
354                        "Expected first track to be 01, was {track_number}"
355                    )));
356                }
357            }
358            Some(last_track_number) => {
359                if track_number != last_track_number + 1 {
360                    return Err(CdRomError::CueParse(format!(
361                        "Tracks out of order; track {track_number} after {last_track_number}"
362                    )));
363                }
364            }
365        }
366        self.last_track_number = Some(track_number);
367
368        let Some(track_start) = self.track_start.take() else {
369            return Err(CdRomError::CueParse(format!(
370                "No start time found for track {track_number}"
371            )));
372        };
373
374        self.tracks.push(ParsedTrack {
375            number: track_number,
376            mode: track_mode,
377            pregap_len: self.pregap_len.take(),
378            pause_start: self.pause_start.take(),
379            postgap_len: self.postgap_len.take(),
380            track_start,
381        });
382
383        Ok(())
384    }
385}
386
387fn parse_cue<P: AsRef<Path>>(cue_path: P) -> CdRomResult<(CueSheet, Vec<TrackMetadata>)> {
388    let cue_path = cue_path.as_ref();
389
390    let cue_file = fs::read_to_string(cue_path)
391        .map_err(|source| CdRomError::CueOpen { path: cue_path.display().to_string(), source })?;
392    let parsed_files = CueParser::new().parse(&cue_file)?;
393
394    to_cue_sheet(parsed_files, cue_path)
395}
396
397const WAVE_HEADER_LEN: u64 = 44;
398
399fn to_cue_sheet(
400    parsed_files: Vec<ParsedFile>,
401    cue_path: &Path,
402) -> CdRomResult<(CueSheet, Vec<TrackMetadata>)> {
403    let cue_parent_dir = cue_path
404        .parent()
405        .ok_or_else(|| CdRomError::CueParentDir(cue_path.display().to_string()))?;
406
407    let mut absolute_start_time = CdTime::ZERO;
408    let mut tracks = Vec::new();
409    let mut track_metadata = Vec::new();
410
411    for ParsedFile { file_name, file_type, tracks: parsed_tracks } in parsed_files {
412        let bin_path = cue_parent_dir.join(&file_name);
413
414        let file_metadata = fs::metadata(&bin_path).map_err(|source| CdRomError::FsMetadata {
415            path: bin_path.display().to_string(),
416            source,
417        })?;
418
419        if file_type == FileType::Wave {
420            validate_wav_header(&bin_path, &file_metadata)?;
421        }
422
423        let mut address_in_file = match file_type {
424            FileType::Binary => 0,
425            FileType::Wave => WAVE_HEADER_LEN,
426        };
427
428        for i in 0..parsed_tracks.len() {
429            let track = &parsed_tracks[i];
430
431            let track_type = track.mode.to_type();
432            let pregap_len = match track.number {
433                1 => {
434                    // Track 1 always has a 2-second pregap; 00:02:00 should be first user data sector of track 1
435                    CdTime::new(0, 2, 0)
436                }
437                _ => track.pregap_len.unwrap_or(CdTime::ZERO),
438            };
439            let pause_len = track
440                .pause_start
441                .map_or(CdTime::ZERO, |pause_start| track.track_start - pause_start);
442
443            let start_time_in_file = track.pause_start.unwrap_or(track.track_start);
444            let is_first_track_in_file = i == 0;
445            if is_first_track_in_file {
446                address_in_file += u64::from(start_time_in_file.to_sector_number())
447                    * track.mode.bytes_per_sector();
448            }
449
450            let is_last_track_in_file = i == parsed_tracks.len() - 1;
451            let data_end_time = if is_last_track_in_file {
452                let file_len_bytes = file_metadata.len();
453                let track_len_bytes = file_len_bytes - address_in_file;
454                let track_len_sectors = track_len_bytes / track.mode.bytes_per_sector();
455
456                start_time_in_file + CdTime::from_sector_number(track_len_sectors as u32)
457            } else {
458                let next_track = &parsed_tracks[i + 1];
459                next_track.pause_start.unwrap_or(next_track.track_start)
460            };
461
462            let postgap_len = track.postgap_len.unwrap_or(CdTime::ZERO);
463
464            let padded_track_len =
465                pregap_len + pause_len + (data_end_time - track.track_start) + postgap_len;
466            tracks.push(Track {
467                number: track.number,
468                mode: track.mode,
469                track_type,
470                start_time: absolute_start_time,
471                end_time: absolute_start_time + padded_track_len,
472                pregap_len,
473                pause_len,
474                postgap_len,
475            });
476            track_metadata.push(TrackMetadata {
477                file_name: file_name.clone(),
478                mode: track.mode,
479                address_in_file,
480            });
481
482            absolute_start_time += padded_track_len;
483
484            let track_len_sectors = (data_end_time - start_time_in_file).to_sector_number();
485            address_in_file += u64::from(track_len_sectors) * track.mode.bytes_per_sector();
486        }
487    }
488
489    cue::finalize_track_list(&mut tracks);
490
491    // This is gross but I don't want cdrom to depend on env_logger just for the validator tool
492    if env::var("JGENESIS_PRINT_CUE").is_ok_and(|var| !var.is_empty()) {
493        println!("Parsed cue sheet:\n{tracks:#?}");
494    } else {
495        log::debug!("Parsed cue sheet:\n{tracks:#?}");
496    }
497
498    assert!(
499        cue::tracks_are_continuous(&tracks),
500        "Tracks in parsed CUE sheet are not continuous; this is a bug"
501    );
502
503    Ok((CueSheet::new(tracks), track_metadata))
504}
505
506fn validate_wav_header(bin_path: &Path, metadata: &fs::Metadata) -> CdRomResult<()> {
507    if metadata.len() < WAVE_HEADER_LEN {
508        return Err(CdRomError::WaveUnsupported);
509    }
510
511    let mut header = [0; WAVE_HEADER_LEN as usize];
512    let mut file = File::open(bin_path).map_err(CdRomError::DiscReadIo)?;
513    file.read_exact(&mut header).map_err(CdRomError::DiscReadIo)?;
514
515    // Format - must be 1 (PCM integer)
516    let format = u16::from_le_bytes(header[20..22].try_into().unwrap());
517    if format != 1 {
518        return Err(CdRomError::WaveUnsupported);
519    }
520
521    // Channels - must be 2 (stereo)
522    let channels = u16::from_le_bytes(header[22..24].try_into().unwrap());
523    if channels != 2 {
524        return Err(CdRomError::WaveUnsupported);
525    }
526
527    // Sample rate - must be 44100 Hz
528    let sample_rate = u32::from_le_bytes(header[24..28].try_into().unwrap());
529    if sample_rate != 44100 {
530        return Err(CdRomError::WaveUnsupported);
531    }
532
533    // Bits per sample - must be 16
534    let bits_per_sample = u16::from_le_bytes(header[34..36].try_into().unwrap());
535    if bits_per_sample != 16 {
536        return Err(CdRomError::WaveUnsupported);
537    }
538
539    Ok(())
540}