1//! Code for loading and reading CD-ROM images in CUE/BIN format 2 3#[cfg(test)] 4mod tests; 5 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}