use super::common::*;
use super::mimetype::*;
use super::progress::*;
use anyhow::{Context, Result, bail};
use indicatif::ProgressBar;
use std::fs::File;
use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use tokio::task::spawn_blocking;
const SECTOR_SIZE: u64 = 2352;
const USER_DATA_SIZE: usize = 2048;
const SECTORS_PER_SECOND: u64 = 75;
#[derive(Debug, PartialEq)]
struct DataTrack {
bin: Option<String>,
start: u64,
end: Option<u64>,
offset: usize,
}
fn first_track(cue: &str) -> Result<DataTrack> {
let mut bin = None;
let mut mode = None;
let mut start = None;
let mut end = None;
let mut tracks = 0;
for line in cue.lines() {
match line.split_whitespace().collect::<Vec<_>>().as_slice() {
["FILE", ..] if tracks == 0 => bin = line.split('"').nth(1).map(str::to_string),
["FILE", ..] => break,
["TRACK", _, track_mode] => {
tracks += 1;
if tracks == 1 {
mode = Some(track_mode.to_uppercase());
}
}
["INDEX", _, time] if tracks == 1 => start = Some(sectors(time)?),
["INDEX", _, time] if tracks == 2 => {
end = Some(sectors(time)?);
break;
}
_ => {}
}
}
let offset = match mode.as_deref() {
Some("MODE1/2352") => 16,
Some("MODE2/2352") => 24,
Some(mode) => bail!(
"The first track is {mode}, but only MODE1/2352 and MODE2/2352 data tracks convert to ISO"
),
None => bail!("No track in the CUE"),
};
let start = start.context("The first track has no INDEX")?;
Ok(DataTrack {
bin,
start,
end,
offset,
})
}
fn sectors(time: &str) -> Result<u64> {
let parts = time
.split(':')
.map(str::parse::<u64>)
.collect::<Result<Vec<_>, _>>()
.ok()
.filter(|parts| parts.len() == 3)
.with_context(|| format!("Invalid CUE timestamp \"{time}\""))?;
Ok((parts[0] * 60 + parts[1]) * SECTORS_PER_SECOND + parts[2])
}
fn extract(bin: &Path, iso: &Path, track: &DataTrack, progress_bar: &ProgressBar) -> Result<()> {
let mut input =
File::open(bin).with_context(|| format!("Failed to open \"{}\"", bin.display()))?;
let sectors = input.metadata()?.len() / SECTOR_SIZE;
let end = track.end.unwrap_or(sectors);
if end > sectors || end <= track.start {
bail!(
"\"{}\" is too short for the first track the CUE describes",
bin.display()
);
}
progress_bar.reset();
progress_bar.set_style(get_bytes_progress_style());
progress_bar.set_length((end - track.start) * SECTOR_SIZE);
input.seek(SeekFrom::Start(track.start * SECTOR_SIZE))?;
let mut reader = BufReader::with_capacity(1 << 20, input);
let mut writer = BufWriter::with_capacity(
1 << 20,
File::create(iso).with_context(|| format!("Failed to create \"{}\"", iso.display()))?,
);
let mut sector = [0u8; SECTOR_SIZE as usize];
for _ in track.start..end {
reader.read_exact(&mut sector)?;
writer.write_all(§or[track.offset..track.offset + USER_DATA_SIZE])?;
progress_bar.inc(SECTOR_SIZE);
}
writer.flush()?;
Ok(())
}
impl ToIso for CueBinRomfile {
async fn to_iso<P: AsRef<Path>>(
&self,
progress_bar: &ProgressBar,
destination_directory: &P,
) -> Result<IsoRomfile> {
start_action(progress_bar, Some("Creating iso"));
let path = destination_directory
.as_ref()
.join(self.cue_romfile.path.file_name().unwrap())
.with_extension(ISO_EXTENSION);
print_action(
progress_bar,
&format!(
"Creating \"{}\"",
path.file_name().unwrap().to_str().unwrap()
),
);
let track = first_track(&tokio::fs::read_to_string(&self.cue_romfile.path).await?)?;
let bin: PathBuf = match (track.bin.as_deref(), self.bin_romfiles.as_slice()) {
(_, [bin]) => bin,
(Some(name), bins) => bins
.iter()
.find(|romfile| romfile.path.file_name().and_then(|f| f.to_str()) == Some(name))
.with_context(|| {
format!("The CUE names \"{name}\", which is not one of its BINs")
})?,
(None, _) => bail!("The CUE names no BIN for its first track"),
}
.path
.clone();
let iso = path.clone();
let bar = progress_bar.clone();
spawn_blocking(move || extract(&bin, &iso, &track, &bar))
.await
.context("CUE/BIN conversion task failed")??;
stop_action(progress_bar);
CommonRomfile::from_path(&path)?.as_iso()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_single_track_starts_past_its_pregap() {
let cue = "FILE \"game.bin\" BINARY\n TRACK 01 MODE1/2352\n INDEX 00 00:00:00\n INDEX 01 00:03:06\n";
assert_eq!(
first_track(cue).unwrap(),
DataTrack {
bin: Some("game.bin".to_string()),
start: 231,
end: None,
offset: 16
}
);
}
#[test]
fn the_next_track_ends_the_first_at_its_first_index() {
let cue = "FILE \"game.bin\" BINARY\n TRACK 01 MODE2/2352\n INDEX 01 00:00:00\n TRACK 02 AUDIO\n INDEX 00 00:30:00\n INDEX 01 00:32:00\n";
assert_eq!(
first_track(cue).unwrap(),
DataTrack {
bin: Some("game.bin".to_string()),
start: 0,
end: Some(2250),
offset: 24
}
);
}
#[test]
fn a_next_track_in_another_bin_leaves_the_first_whole() {
let cue = "FILE \"1.bin\" BINARY\n TRACK 01 MODE1/2352\n INDEX 01 00:00:00\nFILE \"2.bin\" BINARY\n TRACK 02 AUDIO\n INDEX 01 00:00:00\n";
let track = first_track(cue).unwrap();
assert_eq!(track.end, None);
assert_eq!(track.bin.as_deref(), Some("1.bin"));
}
#[test]
fn a_bin_too_short_for_its_track_is_an_error() {
let directory = tempfile::tempdir().unwrap();
let (bin, iso) = (
directory.path().join("game.bin"),
directory.path().join("game.iso"),
);
std::fs::write(&bin, vec![0; 10 * SECTOR_SIZE as usize]).unwrap();
let progress_bar = ProgressBar::hidden();
for (start, end) in [(231, None), (0, Some(11)), (5, Some(5))] {
let track = DataTrack {
bin: None,
start,
end,
offset: 16,
};
assert!(
extract(&bin, &iso, &track, &progress_bar).is_err(),
"{start}..{end:?}"
);
}
}
#[test]
fn only_2352_byte_data_tracks_convert() {
for mode in ["AUDIO", "MODE1/2048", "MODE2/2336"] {
let cue =
format!("FILE \"game.bin\" BINARY\n TRACK 01 {mode}\n INDEX 01 00:00:00\n");
assert!(first_track(&cue).is_err(), "{mode}");
}
}
}