use crate::error::DiscError;
use crate::types::{CdSubcodeType, CdToc, CdTrack, CdTrackType, DiscType, Msf};
use std::collections::BTreeMap;
use std::fmt::Write as FmtWrite;
use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CcdEntry {
pub session: u32,
pub point: u8,
pub adr: u8,
pub control: u8,
pub track_no: u8,
pub a_min: u8,
pub a_sec: u8,
pub a_frame: u8,
pub a_lba: i32,
pub zero: u8,
pub p_min: u8,
pub p_sec: u8,
pub p_frame: u8,
pub p_lba: i32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CcdTrackInfo {
pub track_num: u32,
pub mode: u8,
pub index0: Option<i32>,
pub index1: i32,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CcdSession {
pub session_num: u32,
pub pregap_mode: u32,
pub pregap_subc: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct CcdSheet {
pub version: u32,
pub toc_entries: u32,
pub track_count: u32,
pub cd_text_length: u32,
pub sessions: Vec<CcdSession>,
pub entries: Vec<CcdEntry>,
pub tracks: Vec<CcdTrackInfo>,
}
fn clean_val(val: &str) -> &str {
let no_comment = if let Some(idx) = val.find([';', '#']) {
&val[..idx]
} else {
val
};
no_comment
.trim()
.trim_matches('"')
.trim_matches('\'')
.trim()
}
fn parse_i32(val: &str) -> Result<i32, DiscError> {
let s = clean_val(val);
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
i32::from_str_radix(hex, 16)
.map_err(|e| DiscError::InvalidCcd(format!("invalid hex integer '{}': {}", val, e)))
} else {
s.parse::<i32>()
.map_err(|e| DiscError::InvalidCcd(format!("invalid integer '{}': {}", val, e)))
}
}
fn parse_u32(val: &str) -> Result<u32, DiscError> {
let s = clean_val(val);
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
u32::from_str_radix(hex, 16)
.map_err(|e| DiscError::InvalidCcd(format!("invalid hex integer '{}': {}", val, e)))
} else {
s.parse::<u32>()
.map_err(|e| DiscError::InvalidCcd(format!("invalid integer '{}': {}", val, e)))
}
}
fn parse_u8(val: &str) -> Result<u8, DiscError> {
let s = clean_val(val);
if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
u8::from_str_radix(hex, 16)
.map_err(|e| DiscError::InvalidCcd(format!("invalid hex byte '{}': {}", val, e)))
} else {
s.parse::<u8>()
.map_err(|e| DiscError::InvalidCcd(format!("invalid byte '{}': {}", val, e)))
}
}
enum SectionType {
None,
CloneCd,
Disc,
Session(u32),
Entry(u32),
Track(u32),
}
impl CcdSheet {
pub fn parse(content: &str) -> Result<Self, DiscError> {
let content = content.strip_prefix('\u{feff}').unwrap_or(content);
let mut version: Option<u32> = None;
let mut toc_entries: Option<u32> = None;
let mut track_count: Option<u32> = None;
let mut cd_text_length: u32 = 0;
let mut session_map: BTreeMap<u32, CcdSession> = BTreeMap::new();
let mut entry_map: BTreeMap<u32, CcdEntry> = BTreeMap::new();
let mut track_map: BTreeMap<u32, CcdTrackInfo> = BTreeMap::new();
let mut current_section = SectionType::None;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with(';') || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with('[') {
if let Some(close_bracket) = trimmed.find(']') {
let sec_name = trimmed[1..close_bracket].trim();
let lower = sec_name.to_ascii_lowercase();
if lower == "clonecd" {
current_section = SectionType::CloneCd;
} else if lower == "disc" {
current_section = SectionType::Disc;
} else if let Some(rest) = lower.strip_prefix("session ") {
let num = parse_u32(rest)?;
session_map.entry(num).or_insert(CcdSession {
session_num: num,
pregap_mode: 0,
pregap_subc: 0,
});
current_section = SectionType::Session(num);
} else if let Some(rest) = lower.strip_prefix("entry ") {
let num = parse_u32(rest)?;
entry_map.entry(num).or_default();
current_section = SectionType::Entry(num);
} else if let Some(rest) = lower.strip_prefix("track ") {
let num = parse_u32(rest)?;
track_map.entry(num).or_insert(CcdTrackInfo {
track_num: num,
mode: 0,
index0: None,
index1: 0,
});
current_section = SectionType::Track(num);
} else {
current_section = SectionType::None;
}
continue;
}
}
if let Some((key_raw, val_raw)) = trimmed.split_once('=') {
let key = key_raw.trim().to_ascii_uppercase();
let val = clean_val(val_raw);
match current_section {
SectionType::CloneCd => {
if key == "VERSION" {
version = Some(parse_u32(val)?);
}
}
SectionType::Disc => match key.as_str() {
"TOCENTRIES" => toc_entries = Some(parse_u32(val)?),
"TRACKS" => track_count = Some(parse_u32(val)?),
"CDTEXTLENGTH" => cd_text_length = parse_u32(val)?,
_ => {}
},
SectionType::Session(num) => {
if let Some(session) = session_map.get_mut(&num) {
match key.as_str() {
"PREGAPMODE" => session.pregap_mode = parse_u32(val)?,
"PREGAPSUBC" => session.pregap_subc = parse_u32(val)?,
_ => {}
}
}
}
SectionType::Entry(num) => {
if let Some(entry) = entry_map.get_mut(&num) {
match key.as_str() {
"SESSION" => entry.session = parse_u32(val)?,
"POINT" => entry.point = parse_u8(val)?,
"ADR" => entry.adr = parse_u8(val)?,
"CONTROL" => entry.control = parse_u8(val)?,
"TRACKNO" => entry.track_no = parse_u8(val)?,
"AMIN" => entry.a_min = parse_u8(val)?,
"ASEC" => entry.a_sec = parse_u8(val)?,
"AFRAME" => entry.a_frame = parse_u8(val)?,
"ALBA" => entry.a_lba = parse_i32(val)?,
"ZERO" => entry.zero = parse_u8(val)?,
"PMIN" => entry.p_min = parse_u8(val)?,
"PSEC" => entry.p_sec = parse_u8(val)?,
"PFRAME" => entry.p_frame = parse_u8(val)?,
"PLBA" => entry.p_lba = parse_i32(val)?,
_ => {}
}
}
}
SectionType::Track(num) => {
if let Some(tr) = track_map.get_mut(&num) {
match key.as_str() {
"MODE" => tr.mode = parse_u8(val)?,
"INDEX 0" => tr.index0 = Some(parse_i32(val)?),
"INDEX 1" => tr.index1 = parse_i32(val)?,
_ => {}
}
}
}
SectionType::None => {}
}
}
}
let version = version.unwrap_or(3);
let track_count = match track_count {
Some(tc) => tc,
None => {
if track_map.is_empty() {
return Err(DiscError::InvalidCcd(
"missing [Disc] Tracks count".to_string(),
));
}
track_map.len() as u32
}
};
let toc_entries = toc_entries.unwrap_or(entry_map.len() as u32);
let sessions = session_map.into_values().collect();
let entries = entry_map.into_values().collect();
let tracks = track_map.into_values().collect();
Ok(CcdSheet {
version,
toc_entries,
track_count,
cd_text_length,
sessions,
entries,
tracks,
})
}
pub fn to_toc(
&self,
parent_dir: Option<&Path>,
img_filename: Option<&str>,
) -> Result<CdToc, DiscError> {
if self.tracks.is_empty() {
return Err(DiscError::InvalidCcd(
"no track sections [TRACK X] defined in CCD".to_string(),
));
}
let mut tracks = Vec::with_capacity(self.tracks.len());
let leadout_lba = self
.entries
.iter()
.find(|e| e.point == 0xa2)
.map(|e| e.p_lba);
let (has_sub, sub_filename_str) = match (parent_dir, img_filename) {
(Some(dir), Some(img_name)) => {
let sub_name = Path::new(img_name).with_extension("sub");
let mut sub_path = dir.join(&sub_name);
if !sub_path.is_file() {
let sub_upper = dir.join(Path::new(img_name).with_extension("SUB"));
if sub_upper.is_file() {
sub_path = sub_upper;
}
}
if sub_path.is_file() {
let name_str = sub_path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string());
(true, name_str)
} else {
(false, None)
}
}
(None, _) => (true, None),
_ => (false, None),
};
for (i, tr) in self.tracks.iter().enumerate() {
let start_lba = tr.index0.unwrap_or(tr.index1);
let next_start_lba = if i + 1 < self.tracks.len() {
let next = &self.tracks[i + 1];
next.index0.unwrap_or(next.index1)
} else if let Some(leadout) = leadout_lba {
leadout
} else if let (Some(dir), Some(fname)) = (parent_dir, img_filename) {
let mut img_path = dir.join(fname);
if !img_path.is_file() {
let upper_path = dir.join(Path::new(fname).with_extension("IMG"));
if upper_path.is_file() {
img_path = upper_path;
}
}
let meta = std::fs::metadata(&img_path)?;
(meta.len() / 2352) as i32
} else {
return Err(DiscError::InvalidCcd(
"cannot determine last track frame length: missing Entry Point 0xa2 and image file"
.to_string(),
));
};
if next_start_lba < start_lba {
return Err(DiscError::InvalidCcd(format!(
"track {} end LBA {} before start LBA {}",
tr.track_num, next_start_lba, start_lba
)));
}
let frames = (next_start_lba - start_lba) as u32;
let pregap_frames = match tr.index0 {
Some(idx0) => {
if tr.index1 < idx0 {
return Err(DiscError::InvalidCcd(format!(
"track {} INDEX 1 ({}) before INDEX 0 ({})",
tr.track_num, tr.index1, idx0
)));
}
(tr.index1 - idx0) as u32
}
None => 0,
};
let track_type = match tr.mode {
0 => CdTrackType::Audio,
1 => CdTrackType::Mode1Raw,
2 => CdTrackType::Mode2Raw,
other => {
return Err(DiscError::UnsupportedTrackType(format!(
"CCD mode {}",
other
)))
}
};
let file_offset = (start_lba.max(0) as u64) * 2352;
let sub_file_offset = (start_lba.max(0) as u64) * 96;
let mut cd_track = CdTrack::new(tr.track_num, track_type, frames);
cd_track.subcode_type = if has_sub {
CdSubcodeType::RwRaw
} else {
CdSubcodeType::None
};
cd_track.pregap_frames = pregap_frames;
cd_track.file_name = img_filename.map(|s| s.to_string());
cd_track.file_offset = file_offset;
cd_track.sub_file_name = if has_sub {
sub_filename_str.clone()
} else {
None
};
cd_track.sub_file_offset = if has_sub { sub_file_offset } else { 0 };
tracks.push(cd_track);
}
Ok(CdToc {
disc_type: DiscType::Cd,
tracks,
})
}
pub fn format(toc: &CdToc) -> String {
let mut out = String::new();
let track_count = toc.tracks.len() as u32;
let toc_entries = track_count + 3;
let _ = writeln!(out, "[CloneCD]");
let _ = writeln!(out, "Version=3\n");
let _ = writeln!(out, "[Disc]");
let _ = writeln!(out, "TocEntries={}", toc_entries);
let _ = writeln!(out, "Tracks={}", track_count);
let _ = writeln!(out, "CDTextLength=0\n");
let _ = writeln!(out, "[Session 1]");
let _ = writeln!(out, "PreGapMode=1");
let _ = writeln!(out, "PreGapSubC=0\n");
let first_track = toc.tracks.first().map(|t| t.track_num).unwrap_or(1);
let last_track = toc.tracks.last().map(|t| t.track_num).unwrap_or(1);
let has_data = toc.tracks.iter().any(|t| !t.track_type.is_audio());
let disc_control = if has_data { 0x04 } else { 0x00 };
let disc_psec = if has_data { 0x20 } else { 0x00 };
let _ = writeln!(out, "[Entry 0]");
let _ = writeln!(out, "Session=1");
let _ = writeln!(out, "Point=0xa0");
let _ = writeln!(out, "ADR=0x01");
let _ = writeln!(out, "Control=0x{:02x}", disc_control);
let _ = writeln!(out, "TrackNo=0");
let _ = writeln!(out, "AMin=0\nASec=0\nAFrame=0\nALBA=-150\nZero=0");
let _ = writeln!(out, "PMin={}", first_track);
let _ = writeln!(out, "PSec=0x{:02x}", disc_psec);
let _ = writeln!(out, "PFrame=0\nPLBA=0\n");
let _ = writeln!(out, "[Entry 1]");
let _ = writeln!(out, "Session=1");
let _ = writeln!(out, "Point=0xa1");
let _ = writeln!(out, "ADR=0x01");
let _ = writeln!(out, "Control=0x{:02x}", disc_control);
let _ = writeln!(out, "TrackNo=0");
let _ = writeln!(out, "AMin=0\nASec=0\nAFrame=0\nALBA=-150\nZero=0");
let _ = writeln!(out, "PMin={}", last_track);
let _ = writeln!(out, "PSec=0\nPFrame=0\nPLBA=0\n");
let total_frames = toc.total_frames();
let leadout_msf = Msf::from_lba(total_frames + 150);
let _ = writeln!(out, "[Entry 2]");
let _ = writeln!(out, "Session=1");
let _ = writeln!(out, "Point=0xa2");
let _ = writeln!(out, "ADR=0x01");
let _ = writeln!(out, "Control=0x{:02x}", disc_control);
let _ = writeln!(out, "TrackNo=0");
let _ = writeln!(out, "AMin=0\nASec=0\nAFrame=0\nALBA=-150\nZero=0");
let _ = writeln!(out, "PMin={}", leadout_msf.m);
let _ = writeln!(out, "PSec={}", leadout_msf.s);
let _ = writeln!(out, "PFrame={}", leadout_msf.f);
let _ = writeln!(out, "PLBA={}\n", total_frames);
let mut current_lba = 0u32;
for (entry_idx, tr) in (3..).zip(&toc.tracks) {
let ctrl = if tr.track_type.is_audio() { 0x00 } else { 0x04 };
let track_start_lba = current_lba + tr.pregap_frames;
let msf = Msf::from_lba(track_start_lba + 150);
let _ = writeln!(out, "[Entry {}]", entry_idx);
let _ = writeln!(out, "Session=1");
let _ = writeln!(out, "Point={}", tr.track_num);
let _ = writeln!(out, "ADR=0x01");
let _ = writeln!(out, "Control=0x{:02x}", ctrl);
let _ = writeln!(out, "TrackNo=0");
let _ = writeln!(out, "AMin=0\nASec=0\nAFrame=0\nALBA=-150\nZero=0");
let _ = writeln!(out, "PMin={}", msf.m);
let _ = writeln!(out, "PSec={}", msf.s);
let _ = writeln!(out, "PFrame={}", msf.f);
let _ = writeln!(out, "PLBA={}\n", track_start_lba);
current_lba += tr.frames;
}
current_lba = 0;
for tr in &toc.tracks {
let mode = match tr.track_type {
CdTrackType::Audio => 0,
CdTrackType::Mode1 | CdTrackType::Mode1Raw => 1,
_ => 2,
};
let _ = writeln!(out, "[TRACK {}]", tr.track_num);
let _ = writeln!(out, "MODE={}", mode);
if tr.pregap_frames > 0 {
let _ = writeln!(out, "INDEX 0={}", current_lba);
let _ = writeln!(out, "INDEX 1={}", current_lba + tr.pregap_frames);
} else {
let _ = writeln!(out, "INDEX 1={}", current_lba);
}
let _ = writeln!(out);
current_lba += tr.frames;
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_CCD_DATA_AND_AUDIO: &str = r#"
[CloneCD]
Version=3
[Disc]
TocEntries=5
Tracks=2
CDTextLength=0
[Session 1]
PreGapMode=1
PreGapSubC=0
[Entry 0]
Session=1
Point=0xa0
ADR=0x01
Control=0x04
TrackNo=0
AMin=0
ASec=0
AFrame=0
ALBA=-150
Zero=0
PMin=1
PSec=0x20
PFrame=0
PLBA=4350
[Entry 1]
Session=1
Point=0xa1
ADR=0x01
Control=0x04
TrackNo=0
AMin=0
ASec=0
AFrame=0
ALBA=-150
Zero=0
PMin=2
PSec=0
PFrame=0
PLBA=4350
[Entry 2]
Session=1
Point=0xa2
ADR=0x01
Control=0x04
TrackNo=0
AMin=0
ASec=0
AFrame=0
ALBA=-150
Zero=0
PMin=10
PSec=2
PFrame=0
PLBA=45150
[Entry 3]
Session=1
Point=1
ADR=0x01
Control=0x04
TrackNo=0
AMin=0
ASec=0
AFrame=0
ALBA=-150
Zero=0
PMin=0
PSec=2
PFrame=0
PLBA=0
[Entry 4]
Session=1
Point=2
ADR=0x01
Control=0x00
TrackNo=0
AMin=0
ASec=0
AFrame=0
ALBA=-150
Zero=0
PMin=10
PSec=0
PFrame=0
PLBA=45000
[TRACK 1]
MODE=1
INDEX 1=0
[TRACK 2]
MODE=0
INDEX 0=45000
INDEX 1=45150
"#;
#[test]
fn test_parse_ccd_basic() {
let sheet = CcdSheet::parse(SAMPLE_CCD_DATA_AND_AUDIO).expect("parse failed");
assert_eq!(sheet.version, 3);
assert_eq!(sheet.toc_entries, 5);
assert_eq!(sheet.track_count, 2);
assert_eq!(sheet.sessions.len(), 1);
assert_eq!(sheet.sessions[0].pregap_mode, 1);
assert_eq!(sheet.entries.len(), 5);
assert_eq!(sheet.tracks.len(), 2);
let e0 = &sheet.entries[0];
assert_eq!(e0.point, 0xa0);
assert_eq!(e0.control, 0x04);
assert_eq!(e0.p_sec, 0x20);
assert_eq!(sheet.tracks[0].track_num, 1);
assert_eq!(sheet.tracks[0].mode, 1);
assert_eq!(sheet.tracks[0].index0, None);
assert_eq!(sheet.tracks[0].index1, 0);
assert_eq!(sheet.tracks[1].track_num, 2);
assert_eq!(sheet.tracks[1].mode, 0);
assert_eq!(sheet.tracks[1].index0, Some(45000));
assert_eq!(sheet.tracks[1].index1, 45150);
}
#[test]
fn test_ccd_to_toc_conversion() {
let sheet = CcdSheet::parse(SAMPLE_CCD_DATA_AND_AUDIO).expect("parse failed");
let toc = sheet
.to_toc(None, Some("game.img"))
.expect("conversion to CdToc failed");
assert_eq!(toc.tracks.len(), 2);
let t1 = &toc.tracks[0];
assert_eq!(t1.track_num, 1);
assert_eq!(t1.track_type, CdTrackType::Mode1Raw);
assert_eq!(t1.subcode_type, CdSubcodeType::RwRaw);
assert_eq!(t1.pregap_frames, 0);
assert_eq!(t1.frames, 45000);
assert_eq!(t1.file_offset, 0);
assert_eq!(t1.file_name.as_deref(), Some("game.img"));
let t2 = &toc.tracks[1];
assert_eq!(t2.track_num, 2);
assert_eq!(t2.track_type, CdTrackType::Audio);
assert_eq!(t2.subcode_type, CdSubcodeType::RwRaw);
assert_eq!(t2.pregap_frames, 150); assert_eq!(t2.frames, 150); assert_eq!(t2.file_offset, 45000 * 2352);
assert_eq!(t2.file_name.as_deref(), Some("game.img"));
}
#[test]
fn test_ccd_format_roundtrip() {
let sheet = CcdSheet::parse(SAMPLE_CCD_DATA_AND_AUDIO).expect("parse failed");
let toc = sheet.to_toc(None, Some("disc.img")).expect("to_toc failed");
let formatted = CcdSheet::format(&toc);
let reparsed = CcdSheet::parse(&formatted).expect("reparse formatted CCD failed");
assert_eq!(reparsed.track_count, 2);
assert_eq!(reparsed.tracks.len(), 2);
assert_eq!(reparsed.tracks[0].mode, 1);
assert_eq!(reparsed.tracks[1].mode, 0);
}
#[test]
fn test_parse_ccd_invalid() {
let invalid = "not an ini file";
assert!(CcdSheet::parse(invalid).is_err());
}
#[test]
fn test_parse_ccd_comments_and_case_insensitivity() {
let ccd_text = r#"
; CloneCD descriptor comment
# Another comment line
[clonecd]
version=3
[DISC]
tocentries=4
tracks=1
cdtextlength=0
[entry 0]
session=1
point=0xa0
adr=0x01
control=0x04
trackno=0
amin=0
asec=0
aframe=0
alba=-150
zero=0
pmin=1
psec=0x20
pframe=0
plba=0
[entry 1]
session=1
point=0xa1
adr=0x01
control=0x04
trackno=0
amin=0
asec=0
aframe=0
alba=-150
zero=0
pmin=1
psec=0
pframe=0
plba=0
[entry 2]
session=1
point=0xa2
adr=0x01
control=0x04
trackno=0
amin=0
asec=0
aframe=0
alba=-150
zero=0
pmin=1
psec=0
pframe=0
plba=4500
[entry 3]
session=1
point=1
adr=0x01
control=0x04
trackno=0
amin=0
asec=0
aframe=0
alba=-150
zero=0
pmin=0
psec=2
pframe=0
plba=0
[track 1]
mode=2
index 1=0
"#;
let sheet = CcdSheet::parse(ccd_text).expect("case insensitive parse failed");
assert_eq!(sheet.version, 3);
assert_eq!(sheet.track_count, 1);
assert_eq!(sheet.tracks[0].mode, 2);
let toc = sheet
.to_toc(None, Some("mode2.img"))
.expect("to_toc failed");
assert_eq!(toc.tracks[0].track_type, CdTrackType::Mode2Raw);
assert_eq!(toc.tracks[0].frames, 4500);
}
#[test]
fn test_ccd_to_toc_img_size_fallback() {
let temp_dir = std::env::temp_dir().join(format!("ccd_test_{}", std::process::id()));
let _ = std::fs::create_dir_all(&temp_dir);
let img_path = temp_dir.join("test.img");
let dummy_data = vec![0u8; 100 * 2352];
std::fs::write(&img_path, &dummy_data).unwrap();
let ccd_no_leadout = r#"
[CloneCD]
Version=3
[Disc]
TocEntries=2
Tracks=1
CDTextLength=0
[TRACK 1]
MODE=1
INDEX 1=0
"#;
let sheet = CcdSheet::parse(ccd_no_leadout).expect("parse failed");
let toc = sheet
.to_toc(Some(&temp_dir), Some("test.img"))
.expect("fallback to file size failed");
assert_eq!(toc.tracks[0].frames, 100);
let _ = std::fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_ccd_invalid_index_ordering() {
let ccd_bad_index = r#"
[CloneCD]
Version=3
[Disc]
TocEntries=1
Tracks=1
[TRACK 1]
MODE=1
INDEX 0=500
INDEX 1=300
"#;
let sheet = CcdSheet::parse(ccd_bad_index).expect("parse succeeded");
let err = sheet.to_toc(None, Some("dummy.img"));
assert!(err.is_err());
}
#[test]
fn test_parse_ccd_invalid_numbers() {
let bad_num = "[CloneCD]\nVersion=not_a_number\n";
assert!(CcdSheet::parse(bad_num).is_err());
let bad_hex = "[Entry 0]\nPoint=0xGG\n";
assert!(CcdSheet::parse(bad_hex).is_err());
}
#[test]
fn test_parse_ccd_unicode_bom_and_special_characters() {
let unicode_ccd = "\u{FEFF}\
; ゲームのイメージ: クロノ・トリガー (Chrono Trigger)
# Русская локализация и специальные символы: ¡¿äöüß!
[CloneCD] ; メインセクション
Version=\"3\" # quoted version string
[Disc]
TocEntries=4 ; entries
Tracks=1
CDTextLength=0
[Session 1] # Сессия 1
PreGapMode=1
PreGapSubC=0
[Entry 0]
Session=1
Point=\"0xa0\"
ADR=0x01
Control=0x04
TrackNo=0
AMin=0\nASec=0\nAFrame=0\nALBA=-150\nZero=0
PMin=1
PSec=0x20
PFrame=0
PLBA=0
[Entry 1]
Session=1
Point=0xa1
ADR=0x01
Control=0x04
TrackNo=0
AMin=0\nASec=0\nAFrame=0\nALBA=-150\nZero=0
PMin=1
PSec=0\nPFrame=0\nPLBA=0
[Entry 2]
Session=1
Point=0xa2
ADR=0x01
Control=0x04
TrackNo=0
AMin=0\nASec=0\nAFrame=0\nALBA=-150\nZero=0
PMin=10\nPSec=0\nPFrame=0\nPLBA=45000
[Entry 3]
Session=1
Point=1
ADR=0x01
Control=0x04
TrackNo=0
AMin=0\nASec=0\nAFrame=0\nALBA=-150\nZero=0
PMin=0\nPSec=2\nPFrame=0\nPLBA=0
[TRACK 1] ; Primeira Faixa
MODE = 1 # Mode 1 Raw
INDEX 1 = 0
";
let sheet = CcdSheet::parse(unicode_ccd).expect("parse unicode CCD failed");
assert_eq!(sheet.version, 3);
assert_eq!(sheet.track_count, 1);
assert_eq!(sheet.entries.len(), 4);
assert_eq!(sheet.tracks.len(), 1);
let unicode_filename =
"ゲーム [Special Edition] (1995) 🎮 - Русская Версия (São Paulo).img";
let toc = sheet
.to_toc(None, Some(unicode_filename))
.expect("to_toc with unicode filename failed");
assert_eq!(toc.tracks[0].file_name.as_deref(), Some(unicode_filename));
assert_eq!(toc.tracks[0].frames, 45000);
}
}