diff --git a/.gitignore b/.gitignore index ea8c4bf..3a447bf 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ /target +*.wav diff --git a/examples/play_audio_track.rs b/examples/play_audio_track.rs new file mode 100644 index 0000000..3043451 --- /dev/null +++ b/examples/play_audio_track.rs @@ -0,0 +1,104 @@ +//! Reads a short preview of the first audio track from the default drive and +//! plays it out loud, so you can confirm by ear that audio extraction works — +//! including on a mixed-mode / enhanced CD where data tracks sit alongside the +//! audio ones. +//! +//! It reads only the first N seconds (30 by default) rather than the whole +//! track, so it returns quickly. CD-DA is 75 sectors/second and already raw PCM +//! (44100 Hz, 16-bit signed little-endian, stereo), which is exactly WAV's +//! native format, so `create_wav` just prepends a 44-byte RIFF header and the +//! result is directly playable — no codecs, no extra crates. +//! +//! Usage: +//! cargo run --example play_audio_track # first 30 seconds +//! cargo run --example play_audio_track -- 60 # first 60 seconds +//! +//! The WAV is written to the current directory and then handed to the platform's +//! built-in player (`afplay` on macOS, `Media.SoundPlayer` on Windows). On other +//! platforms it is saved and you are told how to play it yourself. +use cd_da_reader::{CdReader, RetryConfig, SectorReadMode}; + +/// CD-DA plays 75 sectors (each 2352 bytes) per second. +const SECTORS_PER_SECOND: u32 = 75; + +fn main() -> Result<(), Box> { + let seconds: u32 = match std::env::args().nth(1) { + Some(a) => a.parse()?, + None => 30, + }; + + let reader = CdReader::open_default()?; + let toc = reader.read_toc()?; + + let track = toc + .tracks + .iter() + .find(|t| t.is_audio) + .ok_or("no audio tracks found on this disc")?; + + // Clamp the preview to what the track actually holds: the track ends where + // the next track starts, or at the lead-out if it's the last one. + let track_end = toc + .tracks + .iter() + .map(|t| t.start_lba) + .filter(|&lba| lba > track.start_lba) + .min() + .unwrap_or(toc.leadout_lba); + let track_sectors = track_end - track.start_lba; + let sectors = (seconds * SECTORS_PER_SECOND).min(track_sectors); + let actual_seconds = sectors / SECTORS_PER_SECOND; + + println!( + "Reading first {actual_seconds}s ({sectors} sectors) of audio track #{}...", + track.number + ); + let pcm = reader.read_data_sectors( + track.start_lba, + sectors, + SectorReadMode::Audio, + &RetryConfig::default(), + )?; + println!( + "Read {} bytes of PCM ({:.1} MiB)", + pcm.len(), + pcm.len() as f64 / (1024.0 * 1024.0) + ); + + let filename = format!("track{:02}_preview.wav", track.number); + std::fs::write(&filename, CdReader::create_wav(pcm))?; + println!("Saved {filename}"); + + play(&filename) +} + +/// Hand the WAV to the OS's built-in player and block until it finishes. +#[cfg(target_os = "macos")] +fn play(path: &str) -> Result<(), Box> { + println!("Playing (Ctrl+C to stop)..."); + let status = std::process::Command::new("afplay").arg(path).status()?; + if !status.success() { + return Err("afplay exited with an error".into()); + } + Ok(()) +} + +#[cfg(target_os = "windows")] +fn play(path: &str) -> Result<(), Box> { + println!("Playing (this blocks until the preview ends)..."); + // SoundPlayer.PlaySync plays a WAV synchronously using the built-in player. + let script = format!("(New-Object Media.SoundPlayer '{path}').PlaySync()"); + let status = std::process::Command::new("powershell") + .args(["-NoProfile", "-Command", &script]) + .status()?; + if !status.success() { + return Err("powershell SoundPlayer exited with an error".into()); + } + Ok(()) +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn play(path: &str) -> Result<(), Box> { + println!("Saved the WAV — play it with your audio player, e.g. `aplay {path}`."); + Ok(()) +} diff --git a/examples/read_data_track.rs b/examples/read_data_track.rs new file mode 100644 index 0000000..ab4c91d --- /dev/null +++ b/examples/read_data_track.rs @@ -0,0 +1,90 @@ +/// Reads the first data track from a mixed-mode / enhanced CD and verifies the +/// result against the on-disc structure, so it doubles as a correctness check +/// for the cooked (2048 B) and raw (2352 B) data read paths. +/// +/// What it checks, using only the data the disc itself carries: +/// 1. Raw sector framing: a 2352 B sector starts with the 12-byte sync +/// pattern `00 FF*10 00`, and byte 15 reports the sector mode. +/// 2. ISO 9660 signature: logical sector 16 of the volume is the Primary +/// Volume Descriptor — type byte `0x01` followed by `"CD001"`. +/// 3. Cooked vs raw: the cooked 2048 B must equal the user-data region of +/// the raw sector (offset 16 for Mode 1). +use cd_da_reader::{CdReader, RetryConfig, SectorReadMode}; + +// ISO 9660 places the Primary Volume Descriptor at logical sector 16. +const PVD_SECTOR_OFFSET: u32 = 16; + +fn main() -> Result<(), Box> { + let reader = CdReader::open_default()?; + let toc = reader.read_toc()?; + + let data_track = toc + .tracks + .iter() + .find(|t| !t.is_audio) + .ok_or("no data track on this disc (need a mixed-mode / enhanced CD)")?; + + let pvd_lba = data_track.start_lba + PVD_SECTOR_OFFSET; + println!( + "Data track #{} starts at LBA {}; reading PVD at LBA {}\n", + data_track.number, data_track.start_lba, pvd_lba + ); + + let cfg = RetryConfig::default(); + + // --- raw read (2352 B) ------------------------------------------------- + let raw = reader.read_data_sectors(pvd_lba, 1, SectorReadMode::DataRaw, &cfg)?; + if raw.len() != 2352 { + return Err(format!("raw read returned {} bytes, expected 2352", raw.len()).into()); + } + + let sync_ok = raw[0] == 0x00 && raw[1..11].iter().all(|&b| b == 0xFF) && raw[11] == 0x00; + let mode = raw[15]; + println!("raw sync pattern : {}", pass(sync_ok)); + println!("raw sector mode : Mode {mode}"); + + // User data sits after sync(12) + header(4) for Mode 1, and additionally + // after an 8-byte subheader for Mode 2 Form 1. + let user_offset = match mode { + 1 => 16, + 2 => 24, + other => return Err(format!("unexpected sector mode {other}").into()), + }; + let raw_user = &raw[user_offset..user_offset + 2048]; + + let iso_ok = raw_user[0] == 0x01 && &raw_user[1..6] == b"CD001"; + println!("ISO 9660 'CD001' : {}", pass(iso_ok)); + + // --- cooked read (2048 B) --------------------------------------------- + // Our cooked mode maps to Mode 1; only meaningful to cross-check there. + if mode == 1 { + let cooked = reader.read_data_sectors(pvd_lba, 1, SectorReadMode::DataCooked, &cfg)?; + if cooked.len() != 2048 { + return Err( + format!("cooked read returned {} bytes, expected 2048", cooked.len()).into(), + ); + } + let matches_raw = cooked == raw_user; + println!("cooked == raw[16..]: {}", pass(matches_raw)); + + if sync_ok && iso_ok && matches_raw { + println!("\nALL CHECKS PASSED — cooked and raw data reads are correct."); + return Ok(()); + } + } else { + println!( + "\nData track is Mode {mode} (e.g. CD-Extra / Mode 2 Form 1). The cooked path \ + targets Mode 1, so only the raw checks apply here." + ); + if sync_ok && iso_ok { + println!("Raw read verified against on-disc ISO structure."); + return Ok(()); + } + } + + Err("one or more verification checks FAILED — see output above".into()) +} + +fn pass(ok: bool) -> &'static str { + if ok { "PASS" } else { "FAIL" } +} diff --git a/src/data_reader.rs b/src/data_reader.rs new file mode 100644 index 0000000..6eb5dec --- /dev/null +++ b/src/data_reader.rs @@ -0,0 +1,95 @@ +/// Sector read mode for the READ CD (0xBE) command. +/// +/// Controls CDB byte 1 (Expected Sector Type) and byte 9 (Main Channel Selection) +/// to read different sector formats from the same READ CD command. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SectorReadMode { + /// Audio: 2352 bytes/sector, raw PCM. + /// CDB byte 1 = 0x00 (any type), byte 9 = 0x10 (user data). + Audio, + /// Data cooked: 2048 bytes/sector, user data only (no sync/header/EDC/ECC). + /// CDB byte 1 = 0x08 (Mode 1), byte 9 = 0x10 (user data). + DataCooked, + /// Data raw: 2352 bytes/sector with sync + header + user data + EDC/ECC. + /// CDB byte 1 = 0x08 (Mode 1), byte 9 = 0xF8 (sync + header + user data + EDC/ECC). + DataRaw, +} + +impl SectorReadMode { + /// Bytes per sector for this read mode. + pub fn sector_size(&self) -> usize { + match self { + SectorReadMode::Audio => 2352, + SectorReadMode::DataCooked => 2048, + SectorReadMode::DataRaw => 2352, + } + } + + /// CDB byte 1: Expected Sector Type field (bits 4-2). + /// + /// Per SCSI MMC, the type value is shifted left by 2: Mode 1 is + /// `010b << 2 = 0x08`. (`0x04` would be `001b << 2`, i.e. CD-DA.) + pub fn cdb_byte1(&self) -> u8 { + match self { + SectorReadMode::Audio => 0x00, + SectorReadMode::DataCooked => 0x08, + SectorReadMode::DataRaw => 0x08, + } + } + + /// CDB byte 9: Main Channel Selection bits. + pub fn cdb_byte9(&self) -> u8 { + match self { + SectorReadMode::Audio => 0x10, + SectorReadMode::DataCooked => 0x10, + SectorReadMode::DataRaw => 0xF8, + } + } + + /// Maximum sectors per single `READ CD` command. + /// + /// This is the sole chunker for the blocking `read_track` / + /// `read_data_sectors` paths, which hand a whole track (tens of thousands + /// of sectors) straight to the read loop; the streaming API already limits + /// itself via `TrackStreamConfig::sectors_per_chunk`. The cap is not about + /// OS pass-through limits (modern SG_IO/SPTI handle far larger transfers) + /// but about optical-drive firmware and USB-bridge reliability: large + /// multi-sector `READ CD` requests are flaky across the zoo of drives. The + /// values keep each transfer around 64 KiB, matching the conventional + /// ~27-sector chunk used by cdparanoia/libcdio. + pub(crate) fn max_sectors_per_xfer(&self) -> u32 { + match self.sector_size() { + 2048 => 32, // 32 * 2048 = 64 KiB + _ => 27, // 27 * 2352 ≈ 62 KiB + } + } +} + +#[cfg(test)] +mod tests { + use super::SectorReadMode; + + #[test] + fn cdb_byte1_encodes_expected_sector_type() { + // Expected Sector Type lives in CDB byte 1, bits 4-2 (value << 2). + // Audio leaves it 0 (any type) and relies on byte 9; data reads must + // select Mode 1 = 010b << 2 = 0x08 (0x04 would wrongly mean CD-DA). + assert_eq!(SectorReadMode::Audio.cdb_byte1(), 0x00); + assert_eq!(SectorReadMode::DataCooked.cdb_byte1(), 0x08); + assert_eq!(SectorReadMode::DataRaw.cdb_byte1(), 0x08); + } + + #[test] + fn cdb_byte9_selects_main_channel() { + assert_eq!(SectorReadMode::Audio.cdb_byte9(), 0x10); + assert_eq!(SectorReadMode::DataCooked.cdb_byte9(), 0x10); + assert_eq!(SectorReadMode::DataRaw.cdb_byte9(), 0xF8); + } + + #[test] + fn sector_size_matches_mode() { + assert_eq!(SectorReadMode::Audio.sector_size(), 2352); + assert_eq!(SectorReadMode::DataCooked.sector_size(), 2048); + assert_eq!(SectorReadMode::DataRaw.sector_size(), 2352); + } +} diff --git a/src/lib.rs b/src/lib.rs index 3f9ddd3..549b299 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -150,11 +150,14 @@ mod macos; #[cfg(target_os = "windows")] mod windows; +pub mod data_reader; mod discovery; mod errors; +mod read_loop; mod retry; mod stream; mod utils; +pub use data_reader::SectorReadMode; pub use discovery::DriveInfo; pub use errors::{CdReaderError, ScsiError, ScsiOp}; pub use retry::RetryConfig; @@ -321,6 +324,49 @@ impl CdReader { } } + /// Read sectors in a specific mode (audio, data cooked, or data raw). + /// + /// This uses the READ CD (0xBE) SCSI command with configurable sector type + /// and main channel flags, supporting audio, cooked data (2048 B/sector), + /// and raw data (2352 B/sector) reads. + pub fn read_data_sectors( + &self, + lba: u32, + count: u32, + mode: SectorReadMode, + cfg: &RetryConfig, + ) -> Result, CdReaderError> { + self.read_sectors_with_mode(lba, count, &mode, cfg) + } + + pub(crate) fn read_sectors_with_mode( + &self, + start_lba: u32, + sectors: u32, + mode: &SectorReadMode, + cfg: &RetryConfig, + ) -> Result, CdReaderError> { + #[cfg(target_os = "windows")] + { + windows::read_sectors_with_mode(start_lba, sectors, mode, cfg) + } + + #[cfg(target_os = "macos")] + { + macos::read_sectors_with_mode(start_lba, sectors, mode, cfg) + } + + #[cfg(target_os = "linux")] + { + linux::read_sectors_with_mode(start_lba, sectors, mode, cfg) + } + + #[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))] + { + compile_error!("Unsupported platform") + } + } + pub(crate) fn read_sectors_with_retry( &self, start_lba: u32, diff --git a/src/linux.rs b/src/linux.rs index 0ced759..38c094a 100644 --- a/src/linux.rs +++ b/src/linux.rs @@ -1,14 +1,12 @@ use libc::{O_NONBLOCK, O_RDWR, c_uchar, c_void}; -use std::cmp::min; use std::ffi::CString; use std::fs::File; use std::io::Error; use std::os::fd::{AsRawFd, FromRawFd}; use std::path::Path; -use std::thread::sleep; -use std::time::Duration; use crate::Toc; +use crate::data_reader::SectorReadMode; use crate::parse_toc::parse_toc; use crate::utils::get_track_bounds; use crate::{CdReaderError, RetryConfig, ScsiError, ScsiOp}; @@ -43,8 +41,11 @@ struct SgIoHeader { info: u32, } -// _IOWR('S', 0x85, struct sg_io_hdr) -const SG_IO: u64 = 0x2285; +// _IOWR('S', 0x85, struct sg_io_hdr). Typed as `c_ulong` to match the +// `request` parameter of `libc::ioctl` on every target: it is 64-bit on +// x86_64/aarch64 but 32-bit on armv7 (MiSTer / Cortex-A9), where a `u64` +// constant fails to compile (E0308). The value fits in 32 bits. +const SG_IO: libc::c_ulong = 0x2285; static mut DRIVE_HANDLE: Option = None; @@ -205,78 +206,32 @@ pub fn read_sectors_with_retry( sectors: u32, cfg: &RetryConfig, ) -> std::result::Result, CdReaderError> { - read_cd_audio_range(start_lba, sectors, cfg) + read_sectors_with_mode(start_lba, sectors, &SectorReadMode::Audio, cfg) } -// --- READ CD (0xBE): read an arbitrary LBA range as CD-DA (2352 bytes/sector) --- -fn read_cd_audio_range( +pub fn read_sectors_with_mode( start_lba: u32, sectors: u32, + mode: &SectorReadMode, cfg: &RetryConfig, ) -> std::result::Result, CdReaderError> { - // SCSI-2 defines reading data in 2352 bytes chunks - const SECTOR_BYTES: usize = 2352; - - // read ~64 KBs per request - const MAX_SECTORS_PER_XFER: u32 = 27; // 27 * 2352 = 63,504 bytes - - let total_bytes = (sectors as usize) * SECTOR_BYTES; - // allocate the entire necessary size from the beginning to avoid memory realloc - let mut out = Vec::::with_capacity(total_bytes); - - let mut remaining = sectors; - let mut lba = start_lba; - let attempts_total = cfg.max_attempts.max(1); - - while remaining > 0 { - let mut chunk_sectors = min(remaining, MAX_SECTORS_PER_XFER); - let min_chunk = cfg.min_sectors_per_read.max(1); - let mut backoff_ms = cfg.initial_backoff_ms; - let mut last_err: Option = None; - - for attempt in 1..=attempts_total { - match read_cd_audio_chunk(lba, chunk_sectors) { - Ok(chunk) => { - out.extend_from_slice(&chunk); - lba += chunk_sectors; - remaining -= chunk_sectors; - last_err = None; - break; - } - Err(err) => { - last_err = Some(err); - if attempt == attempts_total { - break; - } - if cfg.reduce_chunk_on_retry && chunk_sectors > min_chunk { - chunk_sectors = next_chunk_size(chunk_sectors, min_chunk); - } - if backoff_ms > 0 { - sleep(Duration::from_millis(backoff_ms)); - } - if cfg.max_backoff_ms > 0 { - backoff_ms = (backoff_ms.saturating_mul(2)).min(cfg.max_backoff_ms); - } - } - } - } - if let Some(err) = last_err { - return Err(err); - } - } - - Ok(out) + crate::read_loop::read_sectors_chunked(start_lba, sectors, mode, cfg, |lba, chunk_sectors| { + read_cd_chunk(lba, chunk_sectors, mode) + }) } -fn read_cd_audio_chunk(lba: u32, this_sectors: u32) -> std::result::Result, CdReaderError> { - const SECTOR_BYTES: usize = 2352; - let mut chunk = vec![0u8; (this_sectors as usize) * SECTOR_BYTES]; +fn read_cd_chunk( + lba: u32, + this_sectors: u32, + mode: &SectorReadMode, +) -> std::result::Result, CdReaderError> { + let sector_size = mode.sector_size(); + let mut chunk = vec![0u8; (this_sectors as usize) * sector_size]; let mut sense = vec![0u8; 64]; - // CDB: READ CD (0xBE), LBA addressing let mut cdb = [0u8; 12]; - cdb.fill(0); cdb[0] = 0xBE; // READ CD + cdb[1] = mode.cdb_byte1(); cdb[2] = ((lba >> 24) & 0xFF) as u8; cdb[3] = ((lba >> 16) & 0xFF) as u8; cdb[4] = ((lba >> 8) & 0xFF) as u8; @@ -284,9 +239,7 @@ fn read_cd_audio_chunk(lba: u32, this_sectors: u32) -> std::result::Result> 16) & 0xFF) as u8; cdb[7] = ((this_sectors >> 8) & 0xFF) as u8; cdb[8] = (this_sectors & 0xFF) as u8; - cdb[9] = 0x10; - cdb[10] = 0x00; - cdb[11] = 0x00; + cdb[9] = mode.cdb_byte9(); let mut hdr = SgIoHeader { interface_id: 'S' as i32, @@ -346,11 +299,3 @@ fn parse_sense(sense: &[u8], sb_len_wr: u8) -> (Option, Option, Option u32 { - if current > 8 { - 8.max(min_chunk) - } else { - min_chunk - } -} diff --git a/src/mac/audio_reader.c b/src/mac/audio_reader.c index 92ab9f3..35bbaef 100644 --- a/src/mac/audio_reader.c +++ b/src/mac/audio_reader.c @@ -1,19 +1,60 @@ #include "shim_common.h" -bool read_cd_audio(uint32_t lba, uint32_t sectors, uint8_t **outBuf, uint32_t *outLen, CdScsiError *outErr) { +// Map our SectorReadMode discriminant to the macOS CD sector area/type and the +// resulting bytes-per-sector. Keeping the mapping here (rather than in Rust) +// means the IOKit constants only ever appear where their header is imported. +// +// 0 = Audio -> user area, CDDA, 2352 B/sector +// 1 = DataCooked -> user area, Mode 1, 2048 B/sector +// 2 = DataRaw -> sync+header+user+aux, Mode 1, 2352 B/sector +static bool sector_layout_for_mode(uint32_t mode_id, + CDSectorArea *outArea, + CDSectorType *outType, + uint32_t *outSectorSize) { + switch (mode_id) { + case 0: + *outArea = kCDSectorAreaUser; + *outType = kCDSectorTypeCDDA; + *outSectorSize = 2352; + return true; + case 1: + *outArea = kCDSectorAreaUser; + *outType = kCDSectorTypeMode1; + *outSectorSize = 2048; + return true; + case 2: + *outArea = (CDSectorArea)(kCDSectorAreaSync | kCDSectorAreaHeader | + kCDSectorAreaUser | kCDSectorAreaAuxiliary); + *outType = kCDSectorTypeMode1; + *outSectorSize = 2352; + return true; + default: + return false; + } +} + +bool read_cd_sectors(uint32_t lba, uint32_t sectors, uint32_t mode_id, + uint8_t **outBuf, uint32_t *outLen, CdScsiError *outErr) { *outBuf = NULL; *outLen = 0; if (outErr) { memset(outErr, 0, sizeof(CdScsiError)); } - const uint32_t SECTOR_SZ = 2352; + CDSectorArea sectorArea; + CDSectorType sectorType; + uint32_t sectorSize; + if (!sector_layout_for_mode(mode_id, §orArea, §orType, §orSize)) { + fprintf(stderr, "[READ] unknown sector mode %u\n", mode_id); + goto fail; + } + if (sectors == 0) { fprintf(stderr, "[READ] sectors == 0\n"); goto fail; } - uint64_t totalBytes64 = (uint64_t)SECTOR_SZ * (uint64_t)sectors; + uint64_t totalBytes64 = (uint64_t)sectorSize * (uint64_t)sectors; if (totalBytes64 > UINT32_MAX) { fprintf(stderr, "[READ] requested size too large\n"); goto fail; @@ -34,9 +75,9 @@ bool read_cd_audio(uint32_t lba, uint32_t sectors, uint8_t **outBuf, uint32_t *o } dk_cd_read_t read = {0}; - read.offset = (uint64_t)lba * (uint64_t)SECTOR_SZ; - read.sectorArea = kCDSectorAreaUser; - read.sectorType = kCDSectorTypeCDDA; + read.offset = (uint64_t)lba * (uint64_t)sectorSize; + read.sectorArea = sectorArea; + read.sectorType = sectorType; read.bufferLength = totalBytes; read.buffer = dst; diff --git a/src/mac/device_service.c b/src/mac/device_service.c index 700f9f9..a5e7fa3 100644 --- a/src/mac/device_service.c +++ b/src/mac/device_service.c @@ -22,16 +22,25 @@ int open_cd_raw_device(void) { return open(path, O_RDONLY | O_NONBLOCK); } +// Reading the TOC and sector data both go through the read-only +// DKIOCCDREAD/DKIOCCDREADTOC ioctls on the raw BSD device, so opening a +// "session" is just remembering which device to open. This avoids the +// SCSITaskDeviceInterface path, which requires exclusive access and forces +// the volume to unmount. We validate the name by opening the raw device once. Boolean open_dev_session(const char *bsdName) { if (!bsdName || bsdName[0] == '\0') { return false; } - if (globalBsdName[0] != '\0') { - return strcmp(globalBsdName, bsdName) == 0; + snprintf(globalBsdName, sizeof(globalBsdName), "%s", bsdName); + + int fd = open_cd_raw_device(); + if (fd < 0) { + globalBsdName[0] = '\0'; + return false; } + close(fd); - snprintf(globalBsdName, sizeof(globalBsdName), "%s", bsdName); return true; } diff --git a/src/mac/shim_common.h b/src/mac/shim_common.h index 81bd712..f6c1cd0 100644 --- a/src/mac/shim_common.h +++ b/src/mac/shim_common.h @@ -37,7 +37,7 @@ typedef struct { extern char globalBsdName[64]; bool cd_read_toc(uint8_t **outBuf, uint32_t *outLen, CdScsiError *outErr); -bool read_cd_audio(uint32_t lba, uint32_t sectors, uint8_t **outBuf, uint32_t *outLen, CdScsiError *outErr); +bool read_cd_sectors(uint32_t lba, uint32_t sectors, uint32_t mode_id, uint8_t **outBuf, uint32_t *outLen, CdScsiError *outErr); void cd_free(void *p); bool list_cd_drives(CdDriveInfo **outDrives, uint32_t *outCount); diff --git a/src/macos.rs b/src/macos.rs index d66e6d9..6c4ec31 100644 --- a/src/macos.rs +++ b/src/macos.rs @@ -1,8 +1,8 @@ use std::ffi::{CStr, CString}; use std::io; use std::{ptr, slice}; -use std::{thread::sleep, time::Duration}; +use crate::data_reader::SectorReadMode; use crate::parse_toc::parse_toc; use crate::utils::get_track_bounds; use crate::{CdReaderError, DriveInfo, RetryConfig, ScsiError, ScsiOp, Toc}; @@ -31,9 +31,10 @@ struct MacDriveInfo { #[link(name = "macos_cd_shim", kind = "static")] unsafe extern "C" { fn cd_read_toc(out_buf: *mut *mut u8, out_len: *mut u32, out_err: *mut MacScsiError) -> bool; - fn read_cd_audio( + fn read_cd_sectors( lba: u32, sectors: u32, + mode_id: u32, out_buf: *mut *mut u8, out_len: *mut u32, out_err: *mut MacScsiError, @@ -133,70 +134,41 @@ pub fn read_sectors_with_retry( sectors: u32, cfg: &RetryConfig, ) -> Result, CdReaderError> { - const SECTOR_BYTES: usize = 2352; - const MAX_SECTORS_PER_XFER: u32 = 27; - - let mut out = Vec::::with_capacity((sectors as usize) * SECTOR_BYTES); - let mut remaining = sectors; - let mut lba = start_lba; - let attempts_total = cfg.max_attempts.max(1); - let min_chunk = cfg.min_sectors_per_read.max(1); - - while remaining > 0 { - let mut chunk_sectors = remaining.min(MAX_SECTORS_PER_XFER); - let mut backoff_ms = cfg.initial_backoff_ms; - let mut last_err: Option = None; - - for attempt in 1..=attempts_total { - match read_cd_audio_chunk(lba, chunk_sectors) { - Ok(chunk) => { - out.extend_from_slice(&chunk); - lba += chunk_sectors; - remaining -= chunk_sectors; - last_err = None; - break; - } - Err(err) => { - last_err = Some(err); - if attempt == attempts_total { - break; - } - if cfg.reduce_chunk_on_retry && chunk_sectors > min_chunk { - chunk_sectors = next_chunk_size(chunk_sectors, min_chunk); - } - if backoff_ms > 0 { - sleep(Duration::from_millis(backoff_ms)); - } - if cfg.max_backoff_ms > 0 { - backoff_ms = (backoff_ms.saturating_mul(2)).min(cfg.max_backoff_ms); - } - } - } - } - - if let Some(err) = last_err { - return Err(err); - } - } + read_sectors_with_mode(start_lba, sectors, &SectorReadMode::Audio, cfg) +} - Ok(out) +pub fn read_sectors_with_mode( + start_lba: u32, + sectors: u32, + mode: &SectorReadMode, + cfg: &RetryConfig, +) -> Result, CdReaderError> { + crate::read_loop::read_sectors_chunked(start_lba, sectors, mode, cfg, |lba, chunk_sectors| { + read_cd_chunk(lba, chunk_sectors, mode) + }) } -fn read_cd_audio_chunk(lba: u32, sectors: u32) -> Result, CdReaderError> { +fn read_cd_chunk(lba: u32, sectors: u32, mode: &SectorReadMode) -> Result, CdReaderError> { let mut buf: *mut u8 = ptr::null_mut(); let mut len: u32 = 0; let mut err: MacScsiError = Default::default(); - let ok = unsafe { read_cd_audio(lba, sectors, &mut buf, &mut len, &mut err) }; + + // Discriminant understood by `read_cd_sectors`, which maps it to the + // matching macOS CD sector area/type for DKIOCCDREAD. + let mode_id: u32 = match mode { + SectorReadMode::Audio => 0, + SectorReadMode::DataCooked => 1, + SectorReadMode::DataRaw => 2, + }; + + let ok = unsafe { read_cd_sectors(lba, sectors, mode_id, &mut buf, &mut len, &mut err) }; if !ok { return Err(map_mac_error(err, ScsiOp::ReadCd, Some(lba), Some(sectors))); } let data = unsafe { slice::from_raw_parts(buf, len as usize) }; - - // `.to_vec()` will copy the data, so we can free it safely after let result = data.to_vec(); - unsafe { cd_free(buf as *mut _) }; Ok(result) @@ -222,11 +194,3 @@ fn map_mac_error( CdReaderError::Io(std::io::Error::other("macOS CD command failed")) } - -fn next_chunk_size(current: u32, min_chunk: u32) -> u32 { - if current > 8 { - 8.max(min_chunk) - } else { - min_chunk - } -} diff --git a/src/read_loop.rs b/src/read_loop.rs new file mode 100644 index 0000000..5df91ac --- /dev/null +++ b/src/read_loop.rs @@ -0,0 +1,88 @@ +//! Shared retry + chunking loop for `READ CD` reads. +//! +//! Every platform issues the same logical read: split a sector range into +//! drive-safe chunks, read each chunk with capped exponential backoff and +//! adaptive chunk-size reduction, and concatenate the results. Only the +//! single-command read itself (SG_IO on Linux, SPTI on Windows, IOKit on +//! macOS) is platform-specific, so it is injected as a closure. + +use std::thread::sleep; +use std::time::Duration; + +use crate::data_reader::SectorReadMode; +use crate::{CdReaderError, RetryConfig}; + +/// Read `sectors` sectors starting at `start_lba` in the given `mode`. +/// +/// `read_chunk(lba, sectors)` performs one platform-specific `READ CD` +/// command and returns the raw bytes for that chunk. The loop owns chunk +/// sizing, retries, and backoff so platform code only implements the +/// single-command read. +pub(crate) fn read_sectors_chunked( + start_lba: u32, + sectors: u32, + mode: &SectorReadMode, + cfg: &RetryConfig, + mut read_chunk: F, +) -> Result, CdReaderError> +where + F: FnMut(u32, u32) -> Result, CdReaderError>, +{ + let total_bytes = (sectors as usize) * mode.sector_size(); + let max_sectors_per_xfer = mode.max_sectors_per_xfer(); + let mut out = Vec::::with_capacity(total_bytes); + + let mut remaining = sectors; + let mut lba = start_lba; + let attempts_total = cfg.max_attempts.max(1); + let min_chunk = cfg.min_sectors_per_read.max(1); + + while remaining > 0 { + let mut chunk_sectors = remaining.min(max_sectors_per_xfer); + let mut backoff_ms = cfg.initial_backoff_ms; + let mut last_err: Option = None; + + for attempt in 1..=attempts_total { + match read_chunk(lba, chunk_sectors) { + Ok(chunk) => { + out.extend_from_slice(&chunk); + lba += chunk_sectors; + remaining -= chunk_sectors; + last_err = None; + break; + } + Err(err) => { + last_err = Some(err); + if attempt == attempts_total { + break; + } + if cfg.reduce_chunk_on_retry && chunk_sectors > min_chunk { + chunk_sectors = next_chunk_size(chunk_sectors, min_chunk); + } + if backoff_ms > 0 { + sleep(Duration::from_millis(backoff_ms)); + } + if cfg.max_backoff_ms > 0 { + backoff_ms = (backoff_ms.saturating_mul(2)).min(cfg.max_backoff_ms); + } + } + } + } + + if let Some(err) = last_err { + return Err(err); + } + } + + Ok(out) +} + +/// Shrink the chunk size after a failed read to improve the odds of success, +/// stepping large reads down toward `min_chunk` (for example `27 -> 8 -> 1`). +fn next_chunk_size(current: u32, min_chunk: u32) -> u32 { + if current > 8 { + 8.max(min_chunk) + } else { + min_chunk + } +} diff --git a/src/windows.rs b/src/windows.rs index 4f14699..c952459 100644 --- a/src/windows.rs +++ b/src/windows.rs @@ -10,6 +10,7 @@ use windows_sys::Win32::Storage::IscsiDisc::{ }; use windows_sys::Win32::System::IO::DeviceIoControl; +use crate::data_reader::SectorReadMode; use crate::{CdReaderError, RetryConfig, ScsiError, ScsiOp, Toc, parse_toc, windows_read_track}; use std::mem; @@ -174,13 +175,22 @@ pub fn read_sectors_with_retry( start_lba: u32, sectors: u32, cfg: &RetryConfig, +) -> Result, CdReaderError> { + read_sectors_with_mode(start_lba, sectors, &SectorReadMode::Audio, cfg) +} + +pub fn read_sectors_with_mode( + start_lba: u32, + sectors: u32, + mode: &SectorReadMode, + cfg: &RetryConfig, ) -> Result, CdReaderError> { let handle = unsafe { DRIVE_HANDLE .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "Drive not opened")) .map_err(CdReaderError::Io)? }; - windows_read_track::read_audio_range_with_retry(handle, start_lba, sectors, cfg) + windows_read_track::read_range_with_retry(handle, start_lba, sectors, mode, cfg) } fn parse_sense(sense: &[u8], sense_len: u8) -> (Option, Option, Option) { diff --git a/src/windows_read_track.rs b/src/windows_read_track.rs index 6c9102d..b517fa8 100644 --- a/src/windows_read_track.rs +++ b/src/windows_read_track.rs @@ -1,8 +1,5 @@ -use std::cmp::min; use std::mem; use std::ptr; -use std::thread::sleep; -use std::time::Duration; use windows_sys::Win32::Foundation::HANDLE; use windows_sys::Win32::Storage::IscsiDisc::{ @@ -10,77 +7,30 @@ use windows_sys::Win32::Storage::IscsiDisc::{ }; use windows_sys::Win32::System::IO::DeviceIoControl; +use crate::data_reader::SectorReadMode; use crate::windows::SptdWithSense; use crate::{CdReaderError, RetryConfig, ScsiError, ScsiOp}; -// --- READ CD (0xBE): read an arbitrary LBA range as CD-DA (2352 bytes/sector) --- -pub fn read_audio_range_with_retry( +pub fn read_range_with_retry( handle: HANDLE, start_lba: u32, sectors: u32, + mode: &SectorReadMode, cfg: &RetryConfig, ) -> Result, CdReaderError> { - // SCSI-2 defines reading data in 2352 bytes chunks - const SECTOR_BYTES: usize = 2352; - - // read ~64 KBs per request - const MAX_SECTORS_PER_XFER: u32 = 27; // 27 * 2352 = 63,504 bytes - - let total_bytes = (sectors as usize) * SECTOR_BYTES; - // allocate the entire necessary size from the beginning to avoid memory realloc - let mut out = Vec::::with_capacity(total_bytes); - - let mut remaining = sectors; - let mut lba = start_lba; - let attempts_total = cfg.max_attempts.max(1); - - while remaining > 0 { - let mut chunk_sectors = min(remaining, MAX_SECTORS_PER_XFER); - let min_chunk = cfg.min_sectors_per_read.max(1); - let mut backoff_ms = cfg.initial_backoff_ms; - let mut last_err: Option = None; - - for attempt in 1..=attempts_total { - match read_cd_audio_chunk(handle, lba, chunk_sectors) { - Ok(chunk) => { - out.extend_from_slice(&chunk); - lba += chunk_sectors; - remaining -= chunk_sectors; - last_err = None; - break; - } - Err(err) => { - last_err = Some(err); - if attempt == attempts_total { - break; - } - if cfg.reduce_chunk_on_retry && chunk_sectors > min_chunk { - chunk_sectors = next_chunk_size(chunk_sectors, min_chunk); - } - if backoff_ms > 0 { - sleep(Duration::from_millis(backoff_ms)); - } - if cfg.max_backoff_ms > 0 { - backoff_ms = (backoff_ms.saturating_mul(2)).min(cfg.max_backoff_ms); - } - } - } - } - if let Some(err) = last_err { - return Err(err); - } - } - - Ok(out) + crate::read_loop::read_sectors_chunked(start_lba, sectors, mode, cfg, |lba, chunk_sectors| { + read_cd_chunk(handle, lba, chunk_sectors, mode) + }) } -fn read_cd_audio_chunk( +fn read_cd_chunk( handle: HANDLE, lba: u32, this_sectors: u32, + mode: &SectorReadMode, ) -> Result, CdReaderError> { - const SECTOR_BYTES: usize = 2352; - let mut chunk = vec![0u8; (this_sectors as usize) * SECTOR_BYTES]; + let sector_size = mode.sector_size(); + let mut chunk = vec![0u8; (this_sectors as usize) * sector_size]; let mut wrapper: SptdWithSense = unsafe { mem::zeroed() }; let sptd = &mut wrapper.sptd; @@ -97,6 +47,7 @@ fn read_cd_audio_chunk( let cdb = &mut sptd.Cdb; cdb.fill(0); cdb[0] = 0xBE; + cdb[1] = mode.cdb_byte1(); cdb[2] = ((lba >> 24) & 0xFF) as u8; cdb[3] = ((lba >> 16) & 0xFF) as u8; cdb[4] = ((lba >> 8) & 0xFF) as u8; @@ -104,7 +55,7 @@ fn read_cd_audio_chunk( cdb[6] = ((this_sectors >> 16) & 0xFF) as u8; cdb[7] = ((this_sectors >> 8) & 0xFF) as u8; cdb[8] = (this_sectors & 0xFF) as u8; - cdb[9] = 0x10; + cdb[9] = mode.cdb_byte9(); let mut bytes = 0u32; let ok = unsafe { @@ -146,11 +97,3 @@ fn parse_sense(sense: &[u8], sense_len: u8) -> (Option, Option, Option u32 { - if current > 8 { - 8.max(min_chunk) - } else { - min_chunk - } -}