use crate::error::DiscError;
use crate::types::{CdSubcodeType, CdToc, CdTrack, CdTrackType, DiscType};
use std::fs::File;
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
pub const CHUNK_DAOX: [u8; 4] = *b"DAOX";
pub const CHUNK_DAOI: [u8; 4] = *b"DAOI";
pub const CHUNK_CUEX: [u8; 4] = *b"CUEX";
pub const CHUNK_CUES: [u8; 4] = *b"CUES";
pub const CHUNK_ETN2: [u8; 4] = *b"ETN2";
pub const CHUNK_ETNF: [u8; 4] = *b"ETNF";
pub const CHUNK_SINF: [u8; 4] = *b"SINF";
pub const CHUNK_MTYP: [u8; 4] = *b"MTYP";
pub const CHUNK_CDTX: [u8; 4] = *b"CDTX";
pub const CHUNK_END: [u8; 4] = *b"END!";
pub const MAGIC_NER5: [u8; 4] = *b"NER5";
pub const MAGIC_NERO: [u8; 4] = *b"NERO";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NrgTrack {
pub track_num: u8,
pub sector_size: u16,
pub mode_code: u16,
pub pregap_offset: u64,
pub start_offset: u64,
pub end_offset: u64,
pub isrc: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NrgDisc {
pub is_v2: bool,
pub chunk_offset: u64,
pub mcn: Option<String>,
pub num_sessions: u32,
pub media_type: u32,
pub tracks: Vec<NrgTrack>,
}
impl NrgDisc {
pub fn parse_file<P: AsRef<Path>>(path: P) -> Result<CdToc, DiscError> {
let path_ref = path.as_ref();
let mut file = File::open(path_ref)?;
let file_len = file.metadata()?.len();
if file_len < 12 {
return Err(DiscError::InvalidNrg(
"file is too small to be a valid NRG image".to_string(),
));
}
file.seek(SeekFrom::End(-12))?;
let mut tail12 = [0u8; 12];
file.read_exact(&mut tail12)?;
let (is_v2, chunk_offset) = if tail12[0..4] == MAGIC_NER5 {
let offset = u64::from_be_bytes([
tail12[4], tail12[5], tail12[6], tail12[7], tail12[8], tail12[9], tail12[10],
tail12[11],
]);
(true, offset)
} else if tail12[4..8] == MAGIC_NERO {
let offset =
u32::from_be_bytes([tail12[8], tail12[9], tail12[10], tail12[11]]) as u64;
(false, offset)
} else {
return Err(DiscError::InvalidNrg(
"missing NER5 or NERO signature in file trailer".to_string(),
));
};
let trailer_len = if is_v2 { 12u64 } else { 8u64 };
if chunk_offset > file_len.saturating_sub(trailer_len) {
return Err(DiscError::InvalidNrg(format!(
"invalid chunk offset {} exceeds file size {}",
chunk_offset, file_len
)));
}
let chunk_data_len = (file_len - trailer_len) - chunk_offset;
file.seek(SeekFrom::Start(chunk_offset))?;
let mut chunk_bytes = vec![0u8; chunk_data_len as usize];
file.read_exact(&mut chunk_bytes)?;
let disc = Self::parse_chunks(&chunk_bytes, is_v2, chunk_offset)?;
disc.to_toc(path_ref)
}
pub fn parse(data: &[u8]) -> Result<Self, DiscError> {
let len = data.len();
if len < 12 {
return Err(DiscError::InvalidNrg(
"data too short for NRG image".to_string(),
));
}
let (is_v2, chunk_offset) = if data[len - 12..len - 8] == MAGIC_NER5 {
let offset = u64::from_be_bytes([
data[len - 8],
data[len - 7],
data[len - 6],
data[len - 5],
data[len - 4],
data[len - 3],
data[len - 2],
data[len - 1],
]);
(true, offset)
} else if data[len - 8..len - 4] == MAGIC_NERO {
let offset = u32::from_be_bytes([
data[len - 4],
data[len - 3],
data[len - 2],
data[len - 1],
]) as u64;
(false, offset)
} else {
return Err(DiscError::InvalidNrg(
"missing NER5 or NERO signature in file trailer".to_string(),
));
};
let trailer_len = if is_v2 { 12usize } else { 8usize };
if chunk_offset as usize > len.saturating_sub(trailer_len) {
return Err(DiscError::InvalidNrg(format!(
"chunk offset {} exceeds file length {}",
chunk_offset, len
)));
}
let chunk_slice = &data[chunk_offset as usize..len - trailer_len];
Self::parse_chunks(chunk_slice, is_v2, chunk_offset)
}
fn parse_chunks(data: &[u8], is_v2: bool, chunk_offset: u64) -> Result<Self, DiscError> {
let mut pos = 0usize;
let mut tracks = Vec::new();
let mut mcn = None;
let mut num_sessions = 1u32;
let mut media_type = 1u32;
while pos + 8 <= data.len() {
let chunk_id = &data[pos..pos + 4];
let chunk_size = u32::from_be_bytes([
data[pos + 4],
data[pos + 5],
data[pos + 6],
data[pos + 7],
]) as usize;
let payload_start = pos + 8;
let payload_end = payload_start + chunk_size;
if payload_end > data.len() {
return Err(DiscError::InvalidNrg(format!(
"chunk '{}' size {} exceeds available metadata",
String::from_utf8_lossy(chunk_id),
chunk_size
)));
}
let payload = &data[payload_start..payload_end];
match chunk_id {
b"DAOX" => {
if payload.len() < 22 {
return Err(DiscError::InvalidNrg(
"DAOX chunk payload smaller than 22-byte header".to_string(),
));
}
let mcn_slice = &payload[4..17];
if let Ok(mcn_str) = std::str::from_utf8(mcn_slice) {
let trimmed = mcn_str.trim_matches('\0').trim();
if !trimmed.is_empty() {
mcn = Some(trimmed.to_string());
}
}
num_sessions = payload[19] as u32;
let mut entry_pos = 22usize;
while entry_pos + 42 <= payload.len() {
let isrc_slice = &payload[entry_pos..entry_pos + 12];
let isrc_str = std::str::from_utf8(isrc_slice)
.ok()
.map(|s| s.trim_matches('\0').trim().to_string())
.filter(|s| !s.is_empty());
let sec_size = u16::from_be_bytes([
payload[entry_pos + 12],
payload[entry_pos + 13],
]);
let mode_code = u16::from_be_bytes([
payload[entry_pos + 14],
payload[entry_pos + 15],
]);
let pregap_offset = u64::from_be_bytes([
payload[entry_pos + 18],
payload[entry_pos + 19],
payload[entry_pos + 20],
payload[entry_pos + 21],
payload[entry_pos + 22],
payload[entry_pos + 23],
payload[entry_pos + 24],
payload[entry_pos + 25],
]);
let start_offset = u64::from_be_bytes([
payload[entry_pos + 26],
payload[entry_pos + 27],
payload[entry_pos + 28],
payload[entry_pos + 29],
payload[entry_pos + 30],
payload[entry_pos + 31],
payload[entry_pos + 32],
payload[entry_pos + 33],
]);
let end_offset = u64::from_be_bytes([
payload[entry_pos + 34],
payload[entry_pos + 35],
payload[entry_pos + 36],
payload[entry_pos + 37],
payload[entry_pos + 38],
payload[entry_pos + 39],
payload[entry_pos + 40],
payload[entry_pos + 41],
]);
let track_num = (tracks.len() + 1) as u8;
tracks.push(NrgTrack {
track_num,
sector_size: sec_size,
mode_code,
pregap_offset,
start_offset,
end_offset,
isrc: isrc_str,
});
entry_pos += 42;
}
}
b"DAOI" => {
if payload.len() < 22 {
return Err(DiscError::InvalidNrg(
"DAOI chunk payload smaller than 22-byte header".to_string(),
));
}
let mcn_slice = &payload[4..17];
if let Ok(mcn_str) = std::str::from_utf8(mcn_slice) {
let trimmed = mcn_str.trim_matches('\0').trim();
if !trimmed.is_empty() {
mcn = Some(trimmed.to_string());
}
}
num_sessions = payload[19] as u32;
let mut entry_pos = 22usize;
while entry_pos + 30 <= payload.len() {
let isrc_slice = &payload[entry_pos..entry_pos + 12];
let isrc_str = std::str::from_utf8(isrc_slice)
.ok()
.map(|s| s.trim_matches('\0').trim().to_string())
.filter(|s| !s.is_empty());
let sec_size = u16::from_be_bytes([
payload[entry_pos + 12],
payload[entry_pos + 13],
]);
let mode_code = u16::from_be_bytes([
payload[entry_pos + 14],
payload[entry_pos + 15],
]);
let pregap_offset = u32::from_be_bytes([
payload[entry_pos + 18],
payload[entry_pos + 19],
payload[entry_pos + 20],
payload[entry_pos + 21],
]) as u64;
let start_offset = u32::from_be_bytes([
payload[entry_pos + 22],
payload[entry_pos + 23],
payload[entry_pos + 24],
payload[entry_pos + 25],
]) as u64;
let end_offset = u32::from_be_bytes([
payload[entry_pos + 26],
payload[entry_pos + 27],
payload[entry_pos + 28],
payload[entry_pos + 29],
]) as u64;
let track_num = (tracks.len() + 1) as u8;
tracks.push(NrgTrack {
track_num,
sector_size: sec_size,
mode_code,
pregap_offset,
start_offset,
end_offset,
isrc: isrc_str,
});
entry_pos += 30;
}
}
b"SINF" => {
if payload.len() >= 4 {
num_sessions = u32::from_be_bytes([
payload[0], payload[1], payload[2], payload[3],
])
.max(1);
}
}
b"MTYP" => {
if payload.len() >= 4 {
media_type = u32::from_be_bytes([
payload[0], payload[1], payload[2], payload[3],
]);
}
}
b"END!" => {
break;
}
_ => {}
}
pos = payload_end;
}
if tracks.is_empty() {
return Err(DiscError::InvalidNrg(
"no DAO tracks found in NRG image".to_string(),
));
}
Ok(Self {
is_v2,
chunk_offset,
mcn,
num_sessions,
media_type,
tracks,
})
}
pub fn to_toc(&self, nrg_path: &Path) -> Result<CdToc, DiscError> {
let file_name = nrg_path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string());
let mut cd_tracks = Vec::new();
for track in &self.tracks {
if track.track_num == 0xAA {
continue;
}
let sec_size = track.sector_size as u64;
if sec_size == 0 {
return Err(DiscError::InvalidNrg(format!(
"track {} has invalid sector size 0",
track.track_num
)));
}
if track.end_offset < track.start_offset {
return Err(DiscError::InvalidNrg(format!(
"track {} end_offset ({}) < start_offset ({})",
track.track_num, track.end_offset, track.start_offset
)));
}
let data_bytes = track.end_offset - track.start_offset;
let frames = (data_bytes / sec_size) as u32;
let pregap_frames = if track.pregap_offset < track.start_offset {
((track.start_offset - track.pregap_offset) / sec_size) as u32
} else {
0
};
let mode_byte = if (track.mode_code & 0xFF00) != 0 {
(track.mode_code >> 8) as u8
} else {
(track.mode_code & 0xFF) as u8
};
let (track_type, subcode_type) = match mode_byte {
0x00 => {
if track.sector_size == 2048 {
(CdTrackType::Mode1, CdSubcodeType::None)
} else if track.sector_size == 2352 {
(CdTrackType::Mode1Raw, CdSubcodeType::None)
} else if track.sector_size == 2448 {
(CdTrackType::Mode1Raw, CdSubcodeType::RwRaw)
} else {
(CdTrackType::Mode1, CdSubcodeType::None)
}
}
0x02 => (CdTrackType::Mode2Form1, CdSubcodeType::None),
0x03 => (CdTrackType::Mode2Form2, CdSubcodeType::None),
0x05 => (CdTrackType::Mode1Raw, CdSubcodeType::None),
0x06 => (CdTrackType::Mode2Raw, CdSubcodeType::None),
0x07 => (CdTrackType::Audio, CdSubcodeType::None),
0x0F => (CdTrackType::Mode1Raw, CdSubcodeType::RwRaw),
0x10 => (CdTrackType::Audio, CdSubcodeType::RwRaw),
0x11 => (CdTrackType::Mode2Raw, CdSubcodeType::RwRaw),
_ => {
if track.sector_size == 2048 {
(CdTrackType::Mode1, CdSubcodeType::None)
} else if track.sector_size == 2448 {
(CdTrackType::Audio, CdSubcodeType::RwRaw)
} else {
(CdTrackType::Audio, CdSubcodeType::None)
}
}
};
let mut cd_track = CdTrack::new(track.track_num as u32, track_type, frames);
cd_track.subcode_type = subcode_type;
cd_track.pregap_frames = pregap_frames;
cd_track.file_name = file_name.clone();
cd_track.file_offset = track.start_offset;
cd_tracks.push(cd_track);
}
if cd_tracks.is_empty() {
return Err(DiscError::InvalidNrg(
"no valid tracks found in NRG image".to_string(),
));
}
cd_tracks.sort_by_key(|t| t.track_num);
Ok(CdToc {
disc_type: DiscType::Cd,
tracks: cd_tracks,
})
}
pub fn format_footer(toc: &CdToc, chunk_offset: u64) -> Vec<u8> {
let mut out = Vec::new();
let mut cuex_payload = vec![0x41, 0x00, 0x00, 0x00];
cuex_payload.extend_from_slice(&(-150i32).to_be_bytes());
let mut current_sector = 0i32;
for track in &toc.tracks {
let adr_ctl = if track.track_type.is_audio() { 0x01 } else { 0x41 };
if track.pregap_frames > 0 {
let pregap_start_sec = current_sector - track.pregap_frames as i32;
cuex_payload.push(adr_ctl);
cuex_payload.push(track.track_num as u8);
cuex_payload.push(0x00); cuex_payload.push(0x00);
cuex_payload.extend_from_slice(&pregap_start_sec.to_be_bytes());
}
cuex_payload.push(adr_ctl);
cuex_payload.push(track.track_num as u8);
cuex_payload.push(0x01); cuex_payload.push(0x00);
cuex_payload.extend_from_slice(¤t_sector.to_be_bytes());
current_sector += track.frames as i32;
}
let last_adr_ctl = toc
.tracks
.last()
.map(|t| if t.track_type.is_audio() { 0x01 } else { 0x41 })
.unwrap_or(0x41);
cuex_payload.push(last_adr_ctl);
cuex_payload.push(0xAA); cuex_payload.push(0x01);
cuex_payload.push(0x00);
cuex_payload.extend_from_slice(¤t_sector.to_be_bytes());
out.extend_from_slice(&CHUNK_CUEX);
out.extend_from_slice(&(cuex_payload.len() as u32).to_be_bytes());
out.extend_from_slice(&cuex_payload);
let mut daox_payload = Vec::with_capacity(22 + 42 * toc.tracks.len());
daox_payload.extend_from_slice(&[0x00, 0x00, 0x00, 0x40]); daox_payload.extend_from_slice(&[0u8; 13]); daox_payload.push(0x00); daox_payload.push(0x00); daox_payload.push(0x01); let first_trk = toc.tracks.first().map(|t| t.track_num as u8).unwrap_or(1);
let last_trk = toc.tracks.last().map(|t| t.track_num as u8).unwrap_or(1);
daox_payload.push(first_trk);
daox_payload.push(last_trk);
let mut current_offset = 0u64;
for track in &toc.tracks {
let sec_size = if track.subcode_type == CdSubcodeType::RwRaw {
2448u16
} else {
track.track_type.sector_size() as u16
};
let mode_code = if track.subcode_type == CdSubcodeType::RwRaw {
if track.track_type.is_audio() {
0x1000u16
} else if matches!(track.track_type, CdTrackType::Mode2Raw | CdTrackType::Mode2) {
0x1100u16
} else {
0x0F00u16
}
} else if track.track_type.is_audio() {
0x0700u16
} else if matches!(track.track_type, CdTrackType::Mode2Raw | CdTrackType::Mode2) {
0x0600u16
} else if sec_size == 2048 {
0x0000u16
} else {
0x0500u16
};
let pregap_bytes = track.pregap_frames as u64 * sec_size as u64;
let track_bytes = track.frames as u64 * sec_size as u64;
let pregap_offset = current_offset;
let start_offset = current_offset + pregap_bytes;
let end_offset = start_offset + track_bytes;
daox_payload.extend_from_slice(&[0u8; 12]); daox_payload.extend_from_slice(&sec_size.to_be_bytes());
daox_payload.extend_from_slice(&mode_code.to_be_bytes());
daox_payload.extend_from_slice(&[0u8; 2]); daox_payload.extend_from_slice(&pregap_offset.to_be_bytes());
daox_payload.extend_from_slice(&start_offset.to_be_bytes());
daox_payload.extend_from_slice(&end_offset.to_be_bytes());
current_offset = end_offset;
}
out.extend_from_slice(&CHUNK_DAOX);
out.extend_from_slice(&(daox_payload.len() as u32).to_be_bytes());
out.extend_from_slice(&daox_payload);
out.extend_from_slice(&CHUNK_SINF);
out.extend_from_slice(&4u32.to_be_bytes());
out.extend_from_slice(&1u32.to_be_bytes());
out.extend_from_slice(&CHUNK_MTYP);
out.extend_from_slice(&4u32.to_be_bytes());
out.extend_from_slice(&1u32.to_be_bytes());
out.extend_from_slice(&CHUNK_END);
out.extend_from_slice(&0u32.to_be_bytes());
out.extend_from_slice(&MAGIC_NER5);
out.extend_from_slice(&chunk_offset.to_be_bytes());
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_nrg_v2_format_and_parse_roundtrip() {
let mut toc = CdToc::new(DiscType::Cd);
let mut t1 = CdTrack::new(1, CdTrackType::Mode1Raw, 100);
t1.subcode_type = CdSubcodeType::None;
t1.pregap_frames = 150;
toc.tracks.push(t1);
let mut t2 = CdTrack::new(2, CdTrackType::Audio, 200);
t2.subcode_type = CdSubcodeType::None;
toc.tracks.push(t2);
let payload_size = 450u64 * 2352;
let mut full_nrg = vec![0u8; payload_size as usize];
let footer_bytes = NrgDisc::format_footer(&toc, payload_size);
full_nrg.extend_from_slice(&footer_bytes);
let parsed = NrgDisc::parse(&full_nrg).expect("parse synthetic NRG v2");
assert!(parsed.is_v2);
assert_eq!(parsed.chunk_offset, payload_size);
assert_eq!(parsed.tracks.len(), 2);
assert_eq!(parsed.tracks[0].track_num, 1);
assert_eq!(parsed.tracks[0].sector_size, 2352);
assert_eq!(parsed.tracks[0].pregap_offset, 0);
assert_eq!(parsed.tracks[0].start_offset, 150 * 2352);
assert_eq!(parsed.tracks[0].end_offset, 250 * 2352);
assert_eq!(parsed.tracks[1].track_num, 2);
assert_eq!(parsed.tracks[1].sector_size, 2352);
assert_eq!(parsed.tracks[1].pregap_offset, 250 * 2352);
assert_eq!(parsed.tracks[1].start_offset, 250 * 2352);
assert_eq!(parsed.tracks[1].end_offset, 450 * 2352);
let converted_toc = parsed
.to_toc(Path::new("disc.nrg"))
.expect("convert to TOC");
assert_eq!(converted_toc.tracks.len(), 2);
assert_eq!(converted_toc.tracks[0].track_num, 1);
assert_eq!(converted_toc.tracks[0].frames, 100);
assert_eq!(converted_toc.tracks[0].pregap_frames, 150);
assert_eq!(converted_toc.tracks[0].track_type, CdTrackType::Mode1Raw);
assert_eq!(converted_toc.tracks[0].subcode_type, CdSubcodeType::None);
assert_eq!(converted_toc.tracks[1].track_num, 2);
assert_eq!(converted_toc.tracks[1].frames, 200);
assert_eq!(converted_toc.tracks[1].pregap_frames, 0);
assert_eq!(converted_toc.tracks[1].track_type, CdTrackType::Audio);
}
#[test]
fn test_nrg_v2_with_subcode_roundtrip() {
let mut toc = CdToc::new(DiscType::Cd);
let mut t1 = CdTrack::new(1, CdTrackType::Mode1Raw, 10);
t1.subcode_type = CdSubcodeType::RwRaw;
toc.tracks.push(t1);
let payload_size = 10u64 * 2448;
let mut full_nrg = vec![0u8; payload_size as usize];
let footer_bytes = NrgDisc::format_footer(&toc, payload_size);
full_nrg.extend_from_slice(&footer_bytes);
let parsed = NrgDisc::parse(&full_nrg).expect("parse NRG with subcode");
assert_eq!(parsed.tracks[0].sector_size, 2448);
assert_eq!(parsed.tracks[0].mode_code, 0x0F00);
let converted_toc = parsed
.to_toc(Path::new("disc.nrg"))
.expect("convert to TOC");
assert_eq!(converted_toc.tracks[0].subcode_type, CdSubcodeType::RwRaw);
assert_eq!(converted_toc.tracks[0].frames, 10);
}
#[test]
fn test_nrg_v1_daoi_parsing() {
let mut nrg = vec![0u8; 100 * 2352]; let chunk_offset = nrg.len() as u64;
let mut daoi_payload = Vec::new();
daoi_payload.extend_from_slice(&[0u8; 4]); daoi_payload.extend_from_slice(&[0u8; 13]); daoi_payload.push(0); daoi_payload.push(0); daoi_payload.push(1); daoi_payload.push(1); daoi_payload.push(1);
daoi_payload.extend_from_slice(&[0u8; 12]);
daoi_payload.extend_from_slice(&2352u16.to_be_bytes());
daoi_payload.extend_from_slice(&0x0700u16.to_be_bytes()); daoi_payload.extend_from_slice(&[0u8; 2]);
daoi_payload.extend_from_slice(&0u32.to_be_bytes()); daoi_payload.extend_from_slice(&0u32.to_be_bytes()); daoi_payload.extend_from_slice(&(100 * 2352u32).to_be_bytes());
nrg.extend_from_slice(&CHUNK_DAOI);
nrg.extend_from_slice(&(daoi_payload.len() as u32).to_be_bytes());
nrg.extend_from_slice(&daoi_payload);
nrg.extend_from_slice(&CHUNK_END);
nrg.extend_from_slice(&0u32.to_be_bytes());
nrg.extend_from_slice(&MAGIC_NERO);
nrg.extend_from_slice(&(chunk_offset as u32).to_be_bytes());
let parsed = NrgDisc::parse(&nrg).expect("parse NRG v1");
assert!(!parsed.is_v2);
assert_eq!(parsed.chunk_offset, chunk_offset);
assert_eq!(parsed.tracks.len(), 1);
assert_eq!(parsed.tracks[0].sector_size, 2352);
assert_eq!(parsed.tracks[0].mode_code, 0x0700);
let converted = parsed.to_toc(Path::new("test.nrg")).unwrap();
assert_eq!(converted.tracks[0].track_type, CdTrackType::Audio);
assert_eq!(converted.tracks[0].frames, 100);
}
#[test]
fn test_nrg_invalid_magic_error() {
let data = vec![0u8; 20];
let err = NrgDisc::parse(&data);
assert!(matches!(err, Err(DiscError::InvalidNrg(_))));
}
#[test]
fn test_nrg_too_short_error() {
let data = vec![0u8; 5];
let err = NrgDisc::parse(&data);
assert!(matches!(err, Err(DiscError::InvalidNrg(_))));
}
}