use std::fs::{self, File};
use std::io::{BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use cue_sheet::parser::{parse_cue, Command, TrackType as CueTrackType};
use crate::error::{OpticaldiscsError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrackType {
Audio,
Mode1Raw,
Mode1Cooked,
Mode2Form1,
Mode2Form2,
}
impl TrackType {
pub fn sector_size(self) -> u64 {
match self {
Self::Audio | Self::Mode1Raw | Self::Mode2Form1 => 2352,
Self::Mode1Cooked => 2048,
Self::Mode2Form2 => 2336,
}
}
pub fn data_offset(self) -> u64 {
match self {
Self::Audio | Self::Mode1Cooked => 0,
Self::Mode1Raw => 16,
Self::Mode2Form1 => 24,
Self::Mode2Form2 => 8,
}
}
pub fn is_data(self) -> bool {
!matches!(self, Self::Audio)
}
pub fn cue_label(self) -> &'static str {
match self {
Self::Audio => "AUDIO",
Self::Mode1Raw => "MODE1/2352",
Self::Mode1Cooked => "MODE1/2048",
Self::Mode2Form1 => "MODE2/2352",
Self::Mode2Form2 => "MODE2/2336",
}
}
fn from_cue(ct: &CueTrackType) -> Self {
match ct {
CueTrackType::Audio | CueTrackType::Cdg => Self::Audio,
CueTrackType::Mode(1, 2352) => Self::Mode1Raw,
CueTrackType::Mode(1, _) => Self::Mode1Cooked,
CueTrackType::Mode(2, 2336) => Self::Mode2Form2,
CueTrackType::Mode(2, _) => Self::Mode2Form1,
CueTrackType::Mode(_, _) => Self::Mode1Cooked, CueTrackType::Cdi(_) => Self::Mode2Form1,
}
}
}
#[derive(Debug, Clone)]
pub struct BinTrack {
pub track_no: u32,
pub track_type: TrackType,
pub bin_path: PathBuf,
pub file_byte_offset: u64,
pub frame_count: u64,
}
impl BinTrack {
pub fn sector_size(&self) -> u64 {
self.track_type.sector_size()
}
pub fn data_offset(&self) -> u64 {
self.track_type.data_offset()
}
pub fn is_data(&self) -> bool {
self.track_type.is_data()
}
}
pub fn parse_cue_tracks(cue_path: &Path) -> Result<Vec<BinTrack>> {
let content = fs::read_to_string(cue_path).map_err(OpticaldiscsError::Io)?;
let normalized = normalize_cue_keywords(&content);
let commands = parse_cue(&normalized).map_err(|e| OpticaldiscsError::Cue(format!("{e:?}")))?;
let cue_dir = cue_path.parent().unwrap_or(Path::new("."));
struct RawTrack {
track_no: u32,
track_type: TrackType,
bin_filename: String,
index_01_frames: u64, }
let mut current_bin: Option<String> = None;
let mut raw: Vec<RawTrack> = Vec::new();
for cmd in &commands {
match cmd {
Command::File(name, _fmt) => {
current_bin = Some(name.clone());
}
Command::Track(no, ct) => {
raw.push(RawTrack {
track_no: *no,
track_type: TrackType::from_cue(ct),
bin_filename: current_bin.clone().unwrap_or_else(|| "unknown.bin".into()),
index_01_frames: 0,
});
}
Command::Index(idx_no, msf) if *idx_no == 1 => {
if let Some(t) = raw.last_mut() {
t.index_01_frames =
msf_to_frames(msf.minutes() as u8, msf.seconds() as u8, msf.frames() as u8);
}
}
_ => {}
}
}
if raw.is_empty() {
return Err(OpticaldiscsError::Cue(
"no TRACK entries found in CUE sheet".into(),
));
}
let mut tracks: Vec<BinTrack> = Vec::with_capacity(raw.len());
for (i, rt) in raw.iter().enumerate() {
let bin_path = resolve_bin_path(cue_dir, &rt.bin_filename, cue_path)?;
let file_byte_offset = rt.index_01_frames * rt.track_type.sector_size();
let frame_count = raw.get(i + 1).map_or(0, |next| {
if next.bin_filename == rt.bin_filename && next.index_01_frames > rt.index_01_frames {
next.index_01_frames - rt.index_01_frames
} else {
0
}
});
tracks.push(BinTrack {
track_no: rt.track_no,
track_type: rt.track_type,
bin_path,
file_byte_offset,
frame_count,
});
}
Ok(tracks)
}
pub fn write_single_bin_cue(
tracks: &[BinTrack],
out_bin: &Path,
out_cue: &Path,
out_bin_name: &str,
) -> Result<()> {
if tracks.is_empty() {
return Err(OpticaldiscsError::Cue("no tracks to write".into()));
}
let mut writer = BufWriter::new(File::create(out_bin).map_err(OpticaldiscsError::Io)?);
let mut running_frames: Vec<u64> = Vec::with_capacity(tracks.len());
let mut current_frame: u64 = 0;
for track in tracks {
running_frames.push(current_frame);
let bin_len = track
.bin_path
.metadata()
.map_err(OpticaldiscsError::Io)?
.len();
let copy_start = track.file_byte_offset;
let copy_len = bin_len.saturating_sub(copy_start);
if copy_len == 0 {
running_frames.push(current_frame); continue;
}
let mut src = File::open(&track.bin_path).map_err(OpticaldiscsError::Io)?;
if copy_start > 0 {
use std::io::Seek;
src.seek(std::io::SeekFrom::Start(copy_start))
.map_err(OpticaldiscsError::Io)?;
}
let mut buf = [0u8; 65536];
let mut remaining = copy_len;
while remaining > 0 {
let to_read = (remaining as usize).min(buf.len());
let n = src
.read(&mut buf[..to_read])
.map_err(OpticaldiscsError::Io)?;
if n == 0 {
break;
}
writer.write_all(&buf[..n]).map_err(OpticaldiscsError::Io)?;
remaining -= n as u64;
}
let frames_written = copy_len / track.sector_size();
current_frame += frames_written;
}
writer.flush().map_err(OpticaldiscsError::Io)?;
drop(writer);
let mut cue = BufWriter::new(File::create(out_cue).map_err(OpticaldiscsError::Io)?);
writeln!(cue, "FILE \"{}\" BINARY", out_bin_name).map_err(OpticaldiscsError::Io)?;
for (track, &frame_offset) in tracks.iter().zip(running_frames.iter()) {
writeln!(
cue,
" TRACK {:02} {}",
track.track_no,
track.track_type.cue_label()
)
.map_err(OpticaldiscsError::Io)?;
let (mm, ss, ff) = frames_to_msf(frame_offset);
writeln!(cue, " INDEX 01 {:02}:{:02}:{:02}", mm, ss, ff)
.map_err(OpticaldiscsError::Io)?;
}
cue.flush().map_err(OpticaldiscsError::Io)?;
Ok(())
}
pub fn msf_to_frames(mm: u8, ss: u8, ff: u8) -> u64 {
mm as u64 * 60 * 75 + ss as u64 * 75 + ff as u64
}
pub fn frames_to_msf(frames: u64) -> (u8, u8, u8) {
let ff = (frames % 75) as u8;
let total_secs = frames / 75;
let ss = (total_secs % 60) as u8;
let mm = (total_secs / 60) as u8;
(mm, ss, ff)
}
pub(crate) fn normalize_cue_keywords(content: &str) -> String {
let mut out = String::with_capacity(content.len());
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("CATALOG") {
continue;
}
out.push_str(line);
out.push('\n');
}
out.replace("BINARY", "Binary")
.replace("MOTOROLA", "Motorola")
.replace(" WAVE", " Wave")
.replace(" MP3", " Mp3")
.replace(" AIFF", " Aiff")
}
fn resolve_bin_path(cue_dir: &Path, bin_filename: &str, cue_path: &Path) -> Result<PathBuf> {
let candidates: Vec<PathBuf> = {
let mut v = vec![
cue_dir.join(bin_filename),
cue_dir.join(Path::new(bin_filename).file_name().unwrap_or_default()),
];
let stem = Path::new(bin_filename)
.file_stem()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
for ext in ["bin", "BIN", "img", "IMG"] {
v.push(cue_dir.join(format!("{stem}.{ext}")));
}
if let Some(cue_stem) = cue_path.file_stem() {
let s = cue_stem.to_string_lossy();
for ext in ["bin", "BIN", "img", "IMG"] {
v.push(cue_dir.join(format!("{s}.{ext}")));
}
}
v
};
candidates.into_iter().find(|p| p.exists()).ok_or_else(|| {
OpticaldiscsError::Cue(format!(
"BIN file not found: '{}' (relative to {})",
bin_filename,
cue_dir.display()
))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn track_type_params() {
assert_eq!(TrackType::Audio.sector_size(), 2352);
assert_eq!(TrackType::Audio.data_offset(), 0);
assert!(!TrackType::Audio.is_data());
assert_eq!(TrackType::Mode1Raw.sector_size(), 2352);
assert_eq!(TrackType::Mode1Raw.data_offset(), 16);
assert!(TrackType::Mode1Raw.is_data());
assert_eq!(TrackType::Mode1Cooked.sector_size(), 2048);
assert_eq!(TrackType::Mode1Cooked.data_offset(), 0);
assert!(TrackType::Mode1Cooked.is_data());
assert_eq!(TrackType::Mode2Form1.sector_size(), 2352);
assert_eq!(TrackType::Mode2Form1.data_offset(), 24);
assert_eq!(TrackType::Mode2Form2.sector_size(), 2336);
assert_eq!(TrackType::Mode2Form2.data_offset(), 8);
}
#[test]
fn track_type_from_cue() {
assert_eq!(TrackType::from_cue(&CueTrackType::Audio), TrackType::Audio);
assert_eq!(
TrackType::from_cue(&CueTrackType::Mode(1, 2352)),
TrackType::Mode1Raw
);
assert_eq!(
TrackType::from_cue(&CueTrackType::Mode(1, 2048)),
TrackType::Mode1Cooked
);
assert_eq!(
TrackType::from_cue(&CueTrackType::Mode(2, 2352)),
TrackType::Mode2Form1
);
assert_eq!(
TrackType::from_cue(&CueTrackType::Mode(2, 2336)),
TrackType::Mode2Form2
);
}
#[test]
fn msf_roundtrip() {
let frames = msf_to_frames(1, 23, 45);
assert_eq!(frames, 1 * 60 * 75 + 23 * 75 + 45);
let (mm, ss, ff) = frames_to_msf(frames);
assert_eq!((mm, ss, ff), (1, 23, 45));
}
#[test]
fn msf_zero() {
assert_eq!(msf_to_frames(0, 0, 0), 0);
assert_eq!(frames_to_msf(0), (0, 0, 0));
}
#[test]
fn normalize_removes_catalog() {
let cue = "CATALOG 0000000000000\nFILE \"a.bin\" BINARY\n";
let n = normalize_cue_keywords(cue);
assert!(!n.contains("CATALOG"));
assert!(n.contains("FILE"));
}
#[test]
fn normalize_fixes_binary_case() {
let n = normalize_cue_keywords("FILE \"a.bin\" BINARY\n");
assert!(n.contains("Binary"));
assert!(!n.contains("BINARY"));
}
#[test]
fn parse_single_bin_cue() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let bin_path = dir.path().join("disc.bin");
let cue_path = dir.path().join("disc.cue");
std::fs::write(&bin_path, vec![0u8; 2352 * 20]).unwrap();
let cue_content = format!(
"FILE \"disc.bin\" BINARY\n\
TRACK 01 MODE1/2352\n\
INDEX 01 00:00:00\n"
);
let mut f = File::create(&cue_path).unwrap();
f.write_all(cue_content.as_bytes()).unwrap();
let tracks = parse_cue_tracks(&cue_path).unwrap();
assert_eq!(tracks.len(), 1);
assert_eq!(tracks[0].track_no, 1);
assert_eq!(tracks[0].track_type, TrackType::Mode1Raw);
assert_eq!(tracks[0].file_byte_offset, 0);
assert!(tracks[0].is_data());
}
#[test]
fn parse_mixed_mode_cue() {
use std::io::Write;
let dir = tempfile::tempdir().unwrap();
let bin_size = 2352 * 100;
std::fs::write(dir.path().join("disc.bin"), vec![0u8; bin_size]).unwrap();
let cue_path = dir.path().join("disc.cue");
let cue_content = "FILE \"disc.bin\" BINARY\n\
TRACK 01 MODE1/2352\n\
INDEX 01 00:00:00\n\
TRACK 02 AUDIO\n\
INDEX 01 01:00:00\n";
let mut f = File::create(&cue_path).unwrap();
f.write_all(cue_content.as_bytes()).unwrap();
let tracks = parse_cue_tracks(&cue_path).unwrap();
assert_eq!(tracks.len(), 2);
assert!(tracks[0].is_data());
assert!(!tracks[1].is_data());
assert_eq!(tracks[1].file_byte_offset, 4500 * 2352);
}
#[test]
fn write_single_bin_cue_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let bin1 = dir.path().join("track01.bin");
let bin2 = dir.path().join("track02.bin");
std::fs::write(&bin1, vec![0xAAu8; 2352 * 10]).unwrap();
std::fs::write(&bin2, vec![0xBBu8; 2352 * 5]).unwrap();
let tracks = vec![
BinTrack {
track_no: 1,
track_type: TrackType::Mode1Raw,
bin_path: bin1,
file_byte_offset: 0,
frame_count: 10,
},
BinTrack {
track_no: 2,
track_type: TrackType::Audio,
bin_path: bin2,
file_byte_offset: 0,
frame_count: 5,
},
];
let out_bin = dir.path().join("merged.bin");
let out_cue = dir.path().join("merged.cue");
write_single_bin_cue(&tracks, &out_bin, &out_cue, "merged.bin").unwrap();
assert_eq!(out_bin.metadata().unwrap().len(), 15 * 2352);
let cue_text = std::fs::read_to_string(&out_cue).unwrap();
assert!(cue_text.contains("FILE \"merged.bin\" BINARY"));
assert!(cue_text.contains("TRACK 01 MODE1/2352"));
assert!(cue_text.contains("INDEX 01 00:00:00"));
assert!(cue_text.contains("TRACK 02 AUDIO"));
assert!(cue_text.contains("INDEX 01 00:00:10"));
}
}