diff --git a/Cargo.lock b/Cargo.lock index 7edfa4d..77595d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,6 +15,7 @@ dependencies = [ "cdrustanoia_capi_types", "cdrustanoia_core", "libc", + "tempfile", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0226ed9..b98bbeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "26.0.1" +version = "26.0.2" edition = "2021" license = "LGPL-2.1-or-later" diff --git a/Makefile b/Makefile index 28dfb19..b615d05 100644 --- a/Makefile +++ b/Makefile @@ -23,7 +23,7 @@ check: $(CARGO) fmt --all -- --check $(CARGO) clippy --workspace --all-targets -- -D warnings $(CARGO) test --workspace - $(CC) -std=gnu11 -Wall -Wextra -Werror -Wno-unused-variable -Wno-unused-function -Iinclude -c tests/capi_compile.c -o target/capi_compile.o + $(CC) -std=gnu11 -Wall -Wextra -Werror -Wno-unused-variable -Wno-unused-function -DCDDA_TEST -Iinclude -c tests/capi_compile.c -o target/capi_compile.o install: build $(INSTALL) -d -m 0755 $(BINDIR) diff --git a/README.md b/README.md index 8408329..09b0105 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ cc app.c -lcdda_paranoia -lcdda_interface The public `cdrom_drive` layout, `TOC` layout, function-pointer fields, error codes and `paranoia_*` entry points match cdparanoia 10.2 on 64-bit Linux. +The test interface is exported as `cdda_identify_test()` and declared when a +caller defines `CDDA_TEST`, matching the original source tree. ## Testing without a CD drive @@ -144,8 +146,10 @@ CD-ROM drive. When hardware is available, verify: generic device where the kernel supports it. - `-i/--output-info` is accepted but is a no-op, matching the 10.2 source. - 64-bit Linux ABI is the primary target. -- The `-A` cache analysis is a compact functional port; its timing - recommendations should be treated as advisory. +- `-A` is a full port of `cachetest.c`, including the fast/slow cache-size + search, readahead, rollbehind, cache-tail granularity and backseek-flush + tests. Its findings still depend on drive firmware and system load, as with + the original. ## Licence diff --git a/crates/cdda-interface/Cargo.toml b/crates/cdda-interface/Cargo.toml index 1e063c7..66fb5bd 100644 --- a/crates/cdda-interface/Cargo.toml +++ b/crates/cdda-interface/Cargo.toml @@ -14,3 +14,6 @@ path = "src/lib.rs" cdrustanoia_core.workspace = true cdrustanoia_capi_types.workspace = true libc.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/cdda-interface/src/lib.rs b/crates/cdda-interface/src/lib.rs index 316f24a..c76254f 100644 --- a/crates/cdda-interface/src/lib.rs +++ b/crates/cdda-interface/src/lib.rs @@ -8,9 +8,11 @@ use cdrustanoia_capi_types::*; use cdrustanoia_core::device::discover; +use cdrustanoia_core::device::image::ImageDrive; use cdrustanoia_core::device::{DiscToc, Drive, InterfaceKind, ReadError}; use libc::{c_char, c_int, c_long, c_void}; use std::ffi::CString; +use std::path::Path; use std::ptr; pub struct BackendHolder { @@ -42,6 +44,12 @@ unsafe fn set_error(d: *mut CdromDrive, message: &str) { (*d).errorbuf = cstr(message); } +unsafe fn set_message(messages: *mut *mut c_char, message: &str) { + if !messages.is_null() && (*messages).is_null() { + *messages = cstr(message); + } +} + pub unsafe fn holder<'a>(d: *mut CdromDrive) -> Option<&'a mut BackendHolder> { if d.is_null() { return None; @@ -65,6 +73,13 @@ pub unsafe fn make_drive( ) -> *mut CdromDrive { let mut d = Box::new(CdromDrive::empty()); d.drive_type = 0; + d.is_atapi = backend.is_atapi() as c_int; + d.is_mmc = backend.is_mmc() as c_int; + d.density = backend.density(); + d.fua = backend.fua() as c_int; + d.lun = backend.lun(); + d.adjust_ssize = backend.adjust_ssize() as c_int; + d.bigbuff = backend.nsectors().max(0).saturating_mul(2352) as c_long; d.interface = match backend.interface() { InterfaceKind::SgIo => INTERFACE_SGIO_SCSI, InterfaceKind::Cooked => INTERFACE_COOKED_IOCTL, @@ -141,10 +156,23 @@ unsafe fn read_into( sectors: c_long, ) -> Result { let h = holder(d).ok_or_else(ReadError::closed)?; + if !d.is_null() { + h.drive.set_error_retry((*d).error_retry != 0); + if (*d).nsectors > 0 { + h.drive.set_nsectors((*d).nsectors as i64); + } + if (*d).bigendianp >= 0 { + h.drive.set_bigendianp((*d).bigendianp); + } + } if p.is_null() { h.drive.read_audio_raw(None, begin as i64, sectors as i64) } else { - let len = (sectors.max(0) as usize) * cdrustanoia_core::device::CD_FRAMESIZE_RAW; + let Some(len) = + (sectors.max(0) as usize).checked_mul(cdrustanoia_core::device::CD_FRAMESIZE_RAW) + else { + return Err(ReadError::new(libc::EINVAL, "Invalid read size")); + }; let buf = std::slice::from_raw_parts_mut(p as *mut u8, len); h.drive .read_audio_raw(Some(buf), begin as i64, sectors as i64) @@ -212,6 +240,7 @@ unsafe fn fill_toc(d: *mut CdromDrive) { (*d).audio_last_sector = toc.disc_lastsector() as c_long; (*d).bigendianp = h.drive.bigendianp(); (*d).nsectors = h.drive.nsectors() as c_int; + (*d).bigbuff = (*d).nsectors.max(0) as c_long * 2352; } #[no_mangle] @@ -242,7 +271,13 @@ unsafe fn cdda_identify_impl( }; match discover::identify_device(device, messagedest == CDDA_MESSAGE_PRINTIT) { Ok(drive) => make_drive(device, Some(device), drive), - Err(_) => ptr::null_mut(), + Err(e) => { + set_message( + messages, + &format!("\tCould not identify {}: {}\n", device, e), + ); + ptr::null_mut() + } } } @@ -274,7 +309,13 @@ unsafe fn cdda_identify_cooked_impl( }; match discover::identify_cooked(device, messagedest == CDDA_MESSAGE_PRINTIT) { Ok(drive) => make_drive(device, Some(device), drive), - Err(_) => ptr::null_mut(), + Err(e) => { + set_message( + messages, + &format!("\tCould not identify cooked device {}: {}\n", device, e), + ); + ptr::null_mut() + } } } @@ -320,9 +361,64 @@ unsafe fn cdda_identify_scsi_impl( return make_drive(dev, Some(dev), drive); } } + set_message( + messages, + "\tCould not identify a usable SCSI/cooked device\n", + ); ptr::null_mut() } +#[no_mangle] +pub unsafe extern "C" fn cdda_identify_test( + filename: *const c_char, + messagedest: c_int, + messages: *mut *mut c_char, +) -> *mut CdromDrive { + ffi_guard(ptr::null_mut(), || { + cdda_identify_test_impl(filename, messagedest, messages) + }) +} + +unsafe fn cdda_identify_test_impl( + filename: *const c_char, + messagedest: c_int, + messages: *mut *mut c_char, +) -> *mut CdromDrive { + if !messages.is_null() { + *messages = ptr::null_mut(); + } + if filename.is_null() { + set_message(messages, "\tNo filename supplied for test interface\n"); + return ptr::null_mut(); + } + let filename = match std::ffi::CStr::from_ptr(filename).to_str() { + Ok(value) => value, + Err(_) => return ptr::null_mut(), + }; + let verbose = messagedest == CDDA_MESSAGE_PRINTIT; + if verbose { + eprintln!("\tTesting {} for file/test interface", filename); + } + match ImageDrive::open_path(Path::new(filename)) { + Ok(drive) => { + if verbose { + eprintln!("\t\tCDROM sensed: {}", drive.drive_model()); + } + make_drive(filename, None, Box::new(drive)) + } + Err(e) => { + if verbose { + eprintln!("\t\tCould not open file {}: {}", filename, e); + } + set_message( + messages, + &format!("\tCould not open file {}: {}\n", filename, e), + ); + ptr::null_mut() + } + } +} + #[no_mangle] pub unsafe extern "C" fn cdda_find_a_cdrom( messagedest: c_int, @@ -342,7 +438,10 @@ unsafe fn cdda_find_a_cdrom_impl( } match discover::find_a_cdrom(messagedest == CDDA_MESSAGE_PRINTIT) { Some(drive) => make_drive("auto", None, drive), - None => ptr::null_mut(), + None => { + set_message(messages, "\tNo CD-ROM drive found\n"); + ptr::null_mut() + } } } @@ -443,6 +542,7 @@ unsafe fn cdda_read_timed_impl( return -400; } }; + h.drive.set_error_retry((*d).error_retry != 0); if (*d).bigendianp >= 0 { h.drive.set_bigendianp((*d).bigendianp); } @@ -452,7 +552,12 @@ unsafe fn cdda_read_timed_impl( let timed = if buffer.is_null() { h.drive.read_timed(None, beginsector as i64, sectors as i64) } else { - let len = (sectors as usize) * cdrustanoia_core::device::CD_FRAMESIZE_RAW; + let Some(len) = (sectors as usize).checked_mul(cdrustanoia_core::device::CD_FRAMESIZE_RAW) + else { + set_errno(libc::EINVAL); + set_error(d, "Invalid read size\n"); + return -7; + }; let buf = std::slice::from_raw_parts_mut(buffer as *mut u8, len); h.drive .read_timed(Some(buf), beginsector as i64, sectors as i64) @@ -460,6 +565,9 @@ unsafe fn cdda_read_timed_impl( if !ms.is_null() { *ms = timed.milliseconds; } + if !(*d).private.is_null() { + (*(*d).private).last_milliseconds = timed.milliseconds; + } if (*d).bigendianp < 0 { (*d).bigendianp = h.drive.bigendianp(); } @@ -680,6 +788,7 @@ mod tests { TimedRead { result: Err(ReadError::closed()), milliseconds: -1, + microseconds: -1, } } fn speed_set(&mut self, _speed: i32) -> i32 { @@ -702,6 +811,29 @@ mod tests { } } + #[test] + fn c_abi_version_stays_cdparanoia_compatible() { + let version = unsafe { std::ffi::CStr::from_ptr(cdda_version()) }; + assert_eq!(version.to_str().unwrap(), "10.2"); + } + + #[test] + fn test_interface_exposes_image_backend() { + let dir = tempfile::tempdir().unwrap(); + let image = dir.path().join("disc.raw"); + std::fs::write(&image, vec![0u8; 2352 * 10]).unwrap(); + let name = CString::new(image.to_string_lossy().as_bytes()).unwrap(); + unsafe { + let d = cdda_identify_test(name.as_ptr(), CDDA_MESSAGE_FORGETIT, ptr::null_mut()); + assert!(!d.is_null()); + assert_eq!((*d).interface, INTERFACE_TEST); + assert_eq!(cdda_open(d), 0); + assert_eq!(cdda_tracks(d), 1); + assert_eq!(cdda_disc_lastsector(d), 9); + assert_eq!(cdda_close(d), 0); + } + } + #[test] fn c_api_catches_panics() { unsafe { diff --git a/crates/cdda-paranoia/src/lib.rs b/crates/cdda-paranoia/src/lib.rs index 720e8a8..57a908e 100644 --- a/crates/cdda-paranoia/src/lib.rs +++ b/crates/cdda-paranoia/src/lib.rs @@ -133,9 +133,11 @@ impl Drive for CAbiDrive { fn read_timed(&mut self, buffer: Option<&mut [u8]>, begin: i64, sectors: i64) -> TimedRead { let started = Instant::now(); let result = self.read_audio(buffer, begin, sectors); + let elapsed = started.elapsed(); TimedRead { result, - milliseconds: started.elapsed().as_millis() as i32, + milliseconds: ((elapsed.as_micros() + 500) / 1000) as i32, + microseconds: elapsed.as_micros() as i64, } } fn speed_set(&mut self, speed: i32) -> i32 { @@ -177,6 +179,13 @@ impl Drive for CAbiDrive { } } } + fn set_error_retry(&mut self, enabled: bool) { + unsafe { + if !self.d.is_null() { + (*self.d).error_retry = enabled as c_int; + } + } + } fn interface(&self) -> InterfaceKind { self.interface } @@ -302,6 +311,12 @@ mod tests { use cdrustanoia_core::device::samples_to_bytes; use cdrustanoia_core::device::CD_FRAMEWORDS; + #[test] + fn c_abi_version_stays_cdparanoia_compatible() { + let version = unsafe { std::ffi::CStr::from_ptr(paranoia_version()) }; + assert_eq!(version.to_str().unwrap(), "10.2"); + } + #[test] fn c_api_query_and_read() { unsafe { diff --git a/crates/cli/src/analyze.rs b/crates/cli/src/analyze.rs index 193d376..1113c60 100644 --- a/crates/cli/src/analyze.rs +++ b/crates/cli/src/analyze.rs @@ -1,39 +1,54 @@ -//! Drive cache/timing analysis (`-A`), based on cachetest.c. +//! Drive cache/timing analysis (`-A`), ported from cachetest.c. //! -//! This is a compact but functional port: it measures seek/read timing, -//! estimates the random-access cache size, checks cache transfer speed, and -//! verifies that a backward seek flushes the cache. Output wording follows -//! the original where it matters to users. +//! The tests are deliberately timing-based: they look for seek-sized delays +//! rather than trying to distinguish media transfers from cached transfers by +//! throughput alone. The control flow and output follow the original +//! cdparanoia 10.2 implementation closely, including the fast/slow cache-size +//! search, readahead, rollbehind, cache-tail granularity and backseek flush +//! checks. use crate::log::Log; use crate::{log_c, print_c, report_c}; -use cdrustanoia_core::device::{Drive, CD_FRAMESIZE_RAW}; +use cdrustanoia_core::device::Drive; +use cdrustanoia_core::paranoia::CACHEMODEL_SECTORS; +use cdrustanoia_core::{paranoia_version, VERSIONNUM}; use std::thread::sleep; use std::time::Duration; -const MIN_SEEK_MS: i32 = 6; +const MIN_SEEK_MS: f64 = 6.0; +const CACHE_SEARCH_LIMIT: i64 = 15_000; +const MAX_ERROR_RETRIES: i32 = 10; -fn read_timed(d: &mut dyn Drive, lba: i64, len: i64) -> (i64, i32) { - let t = d.read_timed(None, lba, len); - match t.result { - Ok(n) => (n as i64, t.milliseconds), - Err(e) => { - if e.errno == libc::ENOMEDIUM { - (-404, -1) - } else { - (-1, -1) - } - } +fn read_timed(d: &mut dyn Drive, lba: i64, len: i64) -> (i64, f64) { + if len <= 0 { + return (0, 0.0); } + let timed = d.read_timed(None, lba, len); + match timed.result { + Ok(n) => { + let ms = if timed.microseconds >= 0 { + timed.microseconds as f64 / 1000.0 + } else { + timed.milliseconds as f64 + }; + (n as i64, ms) + } + Err(e) if e.errno == libc::ENOMEDIUM => (-404, -1.0), + Err(_) => (-1, -1.0), + } +} + +fn read_one(d: &mut dyn Drive, lba: i64) -> i64 { + read_timed(d, lba, 1).0 } fn time_drive(d: &mut dyn Drive, log: &mut Log, lba: i64, len: i64, initial_seek: bool) -> i64 { log_c!(log, "\n"); + let mut sum = 0.0f64; + let mut sumsq = 0.0f64; + let mut latency = 0.0f64; let mut sofar = 0i64; - let mut sum = 0f64; - let mut sumsq = 0f64; - let mut latency = 0i32; - let mut i = 0; + let mut i = 0i64; while sofar < len { let toread = if i == 0 && initial_seek { 1 @@ -44,17 +59,20 @@ fn time_drive(d: &mut dyn Drive, log: &mut Log, lba: i64, len: i64, initial_seek if ret <= 0 { return if ret == -404 { -404 } else { -1 }; } - let x = x.clamp(0, 9999); - log_c!(log, "{}:{}:{} ", lba + sofar, ret, x); + let x = if x < 0.0 { 0.0 } else { x.min(9999.0) }; + log_c!(log, "{}:{}:{} ", lba + sofar, ret, x.round() as i64); sofar += ret; if i != 0 || !initial_seek { - sum += x as f64; - sumsq += (x * x) as f64 / ret as f64; + sum += x; + sumsq += x * x / ret as f64; } else { latency = x; } i += 1; } + + // The original counts even upper outliers because readahead normally + // catches up while the linear read continues. let denom = (len - 1).max(1) as f64; let mean = sum / denom; let stddev = ((sumsq / denom - mean * mean).max(0.0)).sqrt(); @@ -62,7 +80,7 @@ fn time_drive(d: &mut dyn Drive, log: &mut Log, lba: i64, len: i64, initial_seek print_c!( log, "{:4}ms seek, {:.2}ms/sec read [{:.1}x]", - latency, + latency.round() as i64, mean, 1000.0 / 75.0 / mean.max(0.001) ); @@ -70,7 +88,7 @@ fn time_drive(d: &mut dyn Drive, log: &mut Log, lba: i64, len: i64, initial_seek log, "\n\tInitial seek latency ({} sectors): {}ms", len, - latency + latency.round() as i64 ); } log_c!( @@ -92,14 +110,15 @@ fn retime_drive(d: &mut dyn Drive, log: &mut Log, lba: i64, readahead: i64, oldm if sectors as f64 * oldmean > 5000.0 { sectors = (5000.0 / oldmean.max(0.001)) as i64; } + sectors = sectors.max(1); let readahead = readahead * 10 / 9; if readahead > sectors { sectors = readahead; } + + print_c!(log, "\u{8}o"); + log_c!(log, "\n\tRetiming drive... "); let total = time_drive(d, log, lba, sectors, true); - if total <= 0 { - return oldmean; - } let newmean = total as f64 / sectors as f64; log_c!( log, @@ -107,34 +126,68 @@ fn retime_drive(d: &mut dyn Drive, log: &mut Log, lba: i64, readahead: i64, oldm oldmean, newmean ); + print_c!(log, "\u{8}"); newmean.max(oldmean) } -#[allow(unused_assignments)] +fn pause_usec(usec: i64) { + if usec > 0 { + sleep(Duration::from_micros(usec as u64)); + } +} + +fn spinner(elapsed: i64) -> char { + match (elapsed * 5 / 1000) % 4 { + 0 | 2 => 'o', + 1 => 'O', + _ => '.', + } +} + +/// Analyse the drive's cache and timing behaviour. +/// +/// Returns 0 when the drive behaves exactly as the paranoia model expects, +/// 1 when a warning was emitted, and -1 on media/read errors. pub fn analyze_cache(d: &mut dyn Drive, log: &mut Log, speed: i32) -> i32 { + // The analysis wants the first access, not a retry loop that can hide + // media errors and distort timing. + d.set_error_retry(false); + let result = analyze_cache_inner(d, log, speed); + d.set_error_retry(true); + result +} + +fn analyze_cache_inner(d: &mut dyn Drive, log: &mut Log, speed: i32) -> i32 { let speed = if speed <= 0 { -1 } else { speed }; report_c!( log, "\n=================== Checking drive cache/timing behavior ===================\n" ); - // Longest contiguous audio stretch. + if VERSIONNUM != paranoia_version() { + report_c!( + log, + "\nWARNING: cdparanoia application (and thus the cache tests) does not match the\ + \ninstalled (or in use) libcdda_paranoia.so library. The final verdict of this\ + \ntesting may or may not be accurate for the actual version of the paranoia\ + library. Continuing anyway...\n\n" + ); + } + + // Find the longest contiguous stretch of audio data. let mut firstsector = -1i64; let mut lastsector = -1i64; - let mut firsttest = -1i64; - let mut lasttest = -1i64; + let mut firsttest: Option = None; for i in 1..=d.tracks() { if d.track_audiop(i) { - if firsttest == -1 { - firsttest = d.track_firstsector(i); - } - lasttest = d.track_lastsector(i); - if lasttest - firsttest > lastsector - firstsector { - firstsector = firsttest; - lastsector = lasttest; + let first = *firsttest.get_or_insert_with(|| d.track_firstsector(i)); + let last = d.track_lastsector(i); + if last - first > lastsector - firstsector { + firstsector = first; + lastsector = last; } } else { - firsttest = -1; + firsttest = None; } } if firstsector == -1 { @@ -145,79 +198,250 @@ pub fn analyze_cache(d: &mut dyn Drive, log: &mut Log, speed: i32) -> i32 { return -1; } - let avail = lastsector - firstsector + 1; - let current = (1000i64).min((avail / 2).max(1)); - let mut offset = firstsector + 10; - if offset + current > lastsector { - offset = firstsector; - } + // Dump some initial timing data: readings ten minutes apart and at the + // end of the disc. Once the drive has obviously spun up, skip most of + // the remaining probes so the analysis does not take forever. + let current = 1000i64; + let mut mspersector = 0.0f64; + { + let mut best = 0i64; + let mut bestcount = 0i64; + let mut iterating = false; - report_c!(log, "\nSeek/read timing:\n"); - let total = time_drive(d, log, offset, current, true); - if total == -404 { - return -1; - } - if total > 0 { - let mspersector = total as f64 / current as f64; - report_c!( - log, - "\n\tAverage access speed: {:.2}ms/sector [{:.1}x]", - mspersector, - 1000.0 / 75.0 / mspersector.max(0.001) - ); + let mut offset = lastsector - firstsector - current - 1; + report_c!(log, "\nSeek/read timing:\n"); + + while offset >= firstsector { + let m = offset / 4500; + let s = (offset - m * 4500) / 75; + let f = offset - m * 4500 - s * 75; + + if iterating { + report_c!(log, "\n"); + } else { + print_c!(log, "\r"); + log_c!(log, "\n"); + } + report_c!(log, "\t[{:02}:{:02}.{:02}]: ", m, s, f); + + // Initial seek to put the read position past the upcoming reads. + let ret = read_one(d, offset + current + 1); + if ret < 0 { + if ret == -404 { + return -1; + } + report_c!( + log, + "\n\tWARNING: media error during read; continuing at next offset..." + ); + offset = (offset - firstsector + 44999) / 45000 * 45000 + firstsector; + offset -= 45000; + continue; + } + + let sofar = time_drive(d, log, offset, current, true); + if offset == firstsector { + mspersector = sofar as f64 / current as f64; + } + if sofar == -404 { + return -1; + } else if sofar < 0 { + report_c!( + log, + "\n\tWARNING: media error during read; continuing at next offset..." + ); + offset = (offset - firstsector + 44999) / 45000 * 45000 + firstsector; + offset -= 45000; + continue; + } else if !iterating { + if best == 0 || (sofar as f64) * 1.01 < best as f64 { + best = sofar; + bestcount = 0; + } else { + bestcount += sofar; + if bestcount > sofar && bestcount > 4000 { + iterating = true; + } + } + } + + if iterating { + offset = (offset - firstsector + 44999) / 45000 * 45000 + firstsector; + offset -= 45000; + print_c!(log, " "); + } else { + offset -= 1; + print_c!(log, " spinning up... "); + } + } } report_c!(log, "\n\nAnalyzing cache behavior...\n"); - // Find the end of the drive's random-access cache by preloading a - // linear run and timing a read of its first sector. - let mut cache_size = 0i64; - let mut c = 1i64; - while c <= 1200 { - let (r1, _) = read_timed(d, offset + c, 1); - if r1 <= 0 { - offset += c + 100; - if offset + c > lastsector { + // Linear search through cache hits up to the first miss. The first 15 + // attempts read the last sector of the proposed cache; the remaining + // attempts verify at 1x and preload the whole range. + let mut offset = firstsector + 10; + let mut current = 0i64; + let mut under = true; + let mut onex = false; + + while current <= CACHE_SEARCH_LIMIT && under { + under = false; + current += 1; + + if onex { + if speed == -1 { + log_c!(log, "\tAttempting to reset read speed to full... "); + } else { + log_c!(log, "\tAttempting to reset read speed to {}x... ", speed); + } + if d.speed_set(speed) != 0 { + log_c!(log, "failed.\n"); + } else { + log_c!(log, "drive said OK\n"); + } + onex = false; + } + + print_c!(log, "\r"); + report_c!( + log, + "\tFast search for approximate cache size... {} sectors ", + current - 1 + ); + log_c!(log, "\n"); + + let mut i = 0i64; + while i < 25 && !under { + let mut j = 0i64; + loop { + let mut ret1 = 0i64; + + if i >= 15 { + let mut sofar = 0i64; + if i == 15 { + print_c!(log, "\r"); + report_c!( + log, + "\tSlow verify for approximate cache size... {} sectors", + current - 1 + ); + log_c!(log, "\n"); + log_c!(log, "\tAttempting to reduce read speed to 1x... "); + if d.speed_set(1) != 0 { + log_c!(log, "failed.\n"); + } else { + log_c!(log, "drive said OK\n"); + } + onex = true; + } + print_c!(log, "."); + log_c!(log, "\t\t>>> "); + + while sofar < current { + let (r, x) = read_timed(d, offset + sofar, current - sofar); + ret1 = r; + log_c!( + log, + "slow_read={}:{}:{} ", + offset + sofar, + r, + x.round() as i64 + ); + if ret1 <= 0 { + break; + } + sofar += ret1; + } + } else { + let (r, x) = read_timed(d, offset + current - 1, 1); + ret1 = r; + log_c!( + log, + "\t\t>>> fast_read={}:{}:{} ", + offset + current - 1, + r, + x.round() as i64 + ); + + // A drive whose early sectors still come back at cache + // speed after reading the last sector has not dumped its + // readahead yet. + if ret1 == 1 && i > 0 && x < MIN_SEEK_MS { + under = true; + log_c!(log, "\n"); + break; + } + } + + let (ret2, x2) = read_timed(d, offset, 1); + log_c!(log, "seek_read={}:{}:{}\n", offset, ret2, x2.round() as i64); + + if ret1 <= 0 || ret2 <= 0 { + offset += current + 100; + if j == 10 || offset + current > lastsector { + report_c!( + log, + "\n\tToo many read errors while performing drive cache checks;\ + \n\t aborting test.\n\n" + ); + return -1; + } + report_c!( + log, + "\n\tRead error while performing drive cache checks;\ + \n\t choosing new offset and trying again.\n" + ); + j += 1; + continue; + } + if x2 < 0.0 { + report_c!( + log, + "\n\tTiming error while performing drive cache checks; aborting test.\n" + ); + return -1; + } + if x2 < MIN_SEEK_MS { + under = true; + } break; } - continue; + i += 1; } - let (r2, ms) = read_timed(d, offset, 1); - if r2 <= 0 { - offset += c + 100; - if offset + c > lastsector { - break; - } - continue; - } - if ms >= MIN_SEEK_MS { - break; - } - cache_size = c; - c += 64; } - if cache_size == 0 { + let cachesize = current - 1; + print_c!(log, "\r"); + if cachesize == CACHE_SEARCH_LIMIT { + report_c!( + log, + "\tWARNING: Cannot determine drive cache size or behavior! \n" + ); + return 1; + } else if cachesize != 0 { + report_c!( + log, + "\tApproximate random access cache size: {} sector(s) \n", + cachesize + ); + } else { report_c!( log, "\tDrive does not cache nonlinear access \n" ); return 0; } - report_c!( - log, - "\tApproximate random access cache size: {} sector(s) \n", - cache_size - ); - let mut warn = 0; - if cache_size > 1200 { + + if cachesize > CACHEMODEL_SECTORS { report_c!( log, "\nWARNING: This drive appears to be caching more sectors of\n\ - readahead than Paranoia can currently handle!\n" + \x20 readahead than Paranoia can currently handle!\n" ); - warn = 1; } + let mut warn = i32::from(cachesize > CACHEMODEL_SECTORS); if speed == -1 { log_c!(log, "\tAttempting to reset read speed to full... "); @@ -230,22 +454,453 @@ pub fn analyze_cache(d: &mut dyn Drive, log: &mut Log, speed: i32) -> i32 { log_c!(log, "drive said OK\n"); } - // Cache transfer speed and backseek flushing. - let readsize = (cache_size - 8).max(1).min(cache_size); - if readsize >= 7 { - report_c!(log, "\tTesting cache transfer speed..."); - let (_, _) = read_timed(d, offset, readsize); - let mut elapsed = 0i64; - let mut sectors = 0i64; - let start = std::time::Instant::now(); - while start.elapsed().as_millis() < 1000 { - let (n, ms) = read_timed(d, offset, readsize); - if n <= 0 { + // Cache hits may be tracked as several non-contiguous regions on some + // drives. Verify that a read three cache lengths away still flushes it. + { + let seekoff = cachesize * 3; + let mut under = false; + report_c!(log, "\tVerifying that cache is contiguous..."); + + let mut i = 0i64; + while i < 20 && !under { + print_c!(log, "."); + let mut j = 0i64; + loop { + if offset + seekoff > lastsector { + report_c!( + log, + "\n\tOut of readable space on CDROM while performing drive checks;\ + \n\t aborting test.\n\n" + ); + return -1; + } + + let (ret1, x1) = read_timed(d, offset + seekoff, 1); + log_c!( + log, + "\t\t>>> {}:{}:{} ", + offset + seekoff, + ret1, + x1.round() as i64 + ); + let (ret2, x2) = read_timed(d, offset, 1); + log_c!(log, "seek_read:{}:{}:{}\n", offset, ret2, x2.round() as i64); + + if ret1 <= 0 || ret2 <= 0 { + offset += cachesize + 100; + if j == 10 { + report_c!( + log, + "\n\tToo many read errors while performing drive cache checks;\ + \n\t aborting test.\n\n" + ); + return -1; + } + report_c!( + log, + "\n\tRead error while performing drive cache checks;\ + \n\t choosing new offset and trying again.\n" + ); + j += 1; + continue; + } + if x2 < 0.0 { + report_c!( + log, + "\n\tTiming error while performing drive cache checks; aborting test.\n" + ); + return -1; + } + if x2 < MIN_SEEK_MS { + under = true; + } break; } - elapsed += ms as i64; - sectors += n; + i += 1; } + print_c!(log, "\r"); + if under { + report_c!( + log, + "\nWARNING: Drive cache does not appear to be contiguous!\n" + ); + warn = 1; + } else { + report_c!( + log, + "\tDrive cache tests as contiguous \n" + ); + } + } + + // Determine the background readahead beyond the read cursor. The + // granularity is refined from 64 sectors down to 1; failures at a given + // size set the lower bound. + let readahead = { + let mut lower = 0i64; + let mut gran = 64i64; + let mut it = 3i64; + let mut tests = 0i64; + let mut under = true; + + while gran > 1 || under { + tests += 1; + if tests > 8 && gran < 64 { + gran <<= 3; + tests = 0; + it = 3; + } + if gran != 0 && !under { + gran >>= 3; + tests = 0; + if gran == 1 { + it = 10; + } + } + + under = false; + let probe = lower + gran; + + print_c!(log, "\r"); + log_c!(log, "\n"); + report_c!( + log, + "\tTesting background readahead past read cursor... {}", + probe + ); + print_c!( + log, + " \u{8}\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}\u{8}" + ); + + let mut i = 0i64; + while i < it { + let mut sofar = 0i64; + let mut ret = 0i64; + log_c!(log, "\n\t\t{} >>> ", i); + loop { + if sofar >= cachesize { + break; + } + let (r, x) = read_timed(d, offset + sofar, cachesize - sofar); + ret = r; + if ret <= 0 { + break; + } + log_c!(log, "{}:{}:{} ", offset + sofar, ret, x.round() as i64); + sofar += ret; + } + if ret <= 0 { + report_c!( + log, + "\n\tRead error while performing drive cache checks;\ + \n\t aborting test.\n\n" + ); + return -1; + } + + print_c!(log, "."); + let usec = { + let predicted = mspersector * probe as f64 * (6 + i) as f64 * 200.0; + let max = 13000.0 * 2.0 * probe as f64; + predicted.min(max).max(0.0) as i64 + }; + log_c!(log, "sleep={}us ", usec); + pause_usec(usec); + + let (ret, x) = read_timed(d, offset + cachesize + probe - 1, 1); + if ret <= 0 { + break; + } + log_c!( + log, + "seek={}:{}:{}", + offset + cachesize + probe - 1, + ret, + x.round() as i64 + ); + if x < MIN_SEEK_MS { + under = true; + break; + } else if i % 3 == 1 { + mspersector = retime_drive(d, log, offset, probe, mspersector); + } + i += 1; + } + + if under { + lower = probe; + } + } + lower + }; + log_c!(log, "\n"); + print_c!(log, "\r"); + if readahead == 0 { + report_c!( + log, + "\tDrive does not read ahead past read cursor (very strange) \n" + ); + } else { + report_c!( + log, + "\tDrive readahead past read cursor: {} sector(s) \n", + readahead + ); + } + + report_c!(log, "\tTesting cache tail cursor..."); + + // Determine how far behind the read cursor the cached tail extends. + let mut rollbehind; + loop { + rollbehind = cachesize; + let mut i = 0i64; + while i < 10 && rollbehind != 0 { + log_c!(log, "\n\t\t>>> "); + print_c!(log, "."); + let mut sofar = 0i64; + loop { + if sofar >= cachesize { + break; + } + let (ret, x) = read_timed(d, offset + sofar, cachesize - sofar); + if ret <= 0 { + report_c!( + log, + "\n\tRead error while performing drive cache checks;\ + \n\t aborting test.\n\n" + ); + return -1; + } + log_c!(log, "{}:{}:{} ", offset + sofar, ret, x.round() as i64); + sofar += ret; + } + + let usec = (mspersector * readahead as f64 * 1500.0).max(0.0) as i64; + log_c!(log, "\n\t\tsleeping {} microseconds", usec); + pause_usec(usec); + + log_c!(log, "\n\t\t<<< "); + let mut ret = 0i64; + sofar = rollbehind; + while sofar > 0 { + sofar -= 1; + let (r, x) = read_timed(d, offset + sofar, 1); + ret = r; + if ret <= 0 { + break; + } + log_c!(log, "{}:{}:{} ", sofar, ret, x.round() as i64); + if x >= MIN_SEEK_MS { + if rollbehind != sofar + 1 { + rollbehind = sofar + 1; + i = 0; + } else { + i += 1; + } + break; + } + if sofar == 0 { + rollbehind = 0; + } + } + + if ret <= 0 { + offset += cachesize; + if offset + cachesize > lastsector { + report_c!( + log, + "\n\tToo many read errors while performing drive cache checks;\ + \n\t aborting test.\n\n" + ); + return -1; + } + report_c!( + log, + "\n\tRead error while performing drive cache checks;\ + \n\t choosing new offset and trying again.\n" + ); + i += 1; + } + } + + let newms = retime_drive(d, log, offset, readahead, mspersector); + if newms > mspersector * 1.2 { + mspersector = newms; + print_c!(log, "\r"); + report_c!(log, "\tDrive timing changed during test; retrying..."); + continue; + } + break; + } + + log_c!(log, "\n"); + print_c!(log, "\r"); + if rollbehind == 0 { + report_c!( + log, + "\tCache tail cursor tied to read cursor \n" + ); + } else { + report_c!( + log, + "\tCache tail rollbehind: {} sector(s) \n", + rollbehind + ); + } + + report_c!(log, "\tTesting granularity of cache tail"); + let mut cachegran; + loop { + cachegran = cachesize + 1; + let mut i = 0i64; + while i < 10 && cachegran != 0 { + log_c!(log, "\n\t\t>>> "); + print_c!(log, "."); + let mut sofar = 0i64; + loop { + if sofar > cachesize { + break; + } + let (ret, x) = read_timed(d, offset + sofar, cachesize - sofar + 1); + if ret <= 0 { + report_c!( + log, + "\n\tRead error while performing drive cache checks;\ + \n\t aborting test.\n\n" + ); + return -1; + } + log_c!(log, "{}:{}:{} ", offset + sofar, ret, x.round() as i64); + sofar += ret; + } + + let usec = (mspersector * readahead as f64 * 1500.0).max(0.0) as i64; + log_c!(log, "\n\t\tsleeping {} microseconds", usec); + pause_usec(usec); + + log_c!(log, "\n\t\t<<< "); + let mut ret = 0i64; + sofar = cachegran; + while sofar > 0 { + sofar -= 1; + let (r, x) = read_timed(d, offset + sofar, 1); + ret = r; + if ret <= 0 { + break; + } + log_c!(log, "{}:{}:{} ", sofar, ret, x.round() as i64); + if x >= MIN_SEEK_MS { + if cachegran == sofar + 1 { + i += 1; + } else { + cachegran = sofar + 1; + i = 0; + } + break; + } + if sofar == 0 { + cachegran = 0; + } + } + + if ret <= 0 { + offset += cachesize; + if offset + cachesize > lastsector { + report_c!( + log, + "\n\tToo many read errors while performing drive cache checks;\ + \n\t aborting test.\n\n" + ); + return -1; + } + report_c!( + log, + "\n\tRead error while performing drive cache checks;\ + \n\t choosing new offset and trying again.\n" + ); + i += 1; + } + } + + let newms = retime_drive(d, log, offset, readahead, mspersector); + if newms > mspersector * 1.2 { + mspersector = newms; + print_c!(log, "\r"); + report_c!(log, "\tDrive timing changed during test; retrying..."); + continue; + } + break; + } + + cachegran -= rollbehind; + log_c!(log, "\n"); + print_c!(log, "\r"); + report_c!( + log, + "\tCache tail granularity: {} sector(s) \n", + cachegran + ); + + // Verify that a read beginning before the cached readahead dumps the + // whole cache. Cache timing is independent of rotational speed, so a + // good read followed by repeated cached reads is enough. + let readsize = { + let mut readsize = cachesize - rollbehind - 8; + if readsize > cachesize - 1 { + readsize = cachesize - 1; + } + readsize + }; + + if readsize < 7 { + report_c!( + log, + "\tCache size (considering rollbehind) too small to test cache speed.\n" + ); + } else { + report_c!(log, "\tTesting cache transfer speed..."); + + let mut retry = 0i32; + loop { + let ret = time_drive(d, log, offset, readsize, false); + if ret == -404 { + return -1; + } + if ret > 0 { + break; + } + retry += 1; + if retry == MAX_ERROR_RETRIES { + report_c!( + log, + "\n\tToo many read errors while performing drive cache checks;\ + \n\t aborting test.\n\n" + ); + return -1; + } + report_c!( + log, + "\n\tRead error while performing drive cache checks;\ + \n\t choosing new offset and trying again.\n" + ); + } + + let mut elapsed = 0i64; + let mut sectors = 0i64; + while elapsed < 5000 { + sectors += readsize; + let ret = time_drive(d, log, offset, readsize, false); + if ret <= 0 { + break; + } + elapsed += ret; + let spinner = spinner(elapsed); + print_c!(log, "\u{8}{}", spinner); + } + print_c!(log, "\r"); + log_c!(log, "\n"); let cachems = if sectors > 0 { elapsed as f64 / sectors as f64 } else { @@ -258,38 +913,315 @@ pub fn analyze_cache(d: &mut dyn Drive, log: &mut Log, speed: i32) -> i32 { (1000.0 / 75.0 / cachems.max(0.001)) as i64 ); - report_c!(log, "\tTesting that backseek flushes cache..."); - let seekpos = (offset + cache_size + 20000).min((lastsector - 150).max(offset + 1)); - let (r, _) = read_timed(d, seekpos, 1); - let mut readms = 0.0; - if r > 0 { - let total = time_drive(d, log, offset + 1, readsize, true); - if total > 0 { - readms = total as f64 / readsize as f64; - } - } - report_c!( - log, - "\t Access speed after backseek: {:.2}ms/sector [{}x]\n", - readms, - (1000.0 / 75.0 / readms.max(0.001)) as i64 - ); - if readms > 0.0 && readms * 2.0 < cachems.max(0.001) { + if cachems * 3.0 > mspersector { report_c!( log, - "\tWARNING: Read timing after backseek faster than expected!\n\ - \t It's possible/likely that this drive is not\n\ - \t flushing the readahead cache on backward seeks!\n\n" + "\tCache access insufficiently faster than media access to\n\ + \t\tperform cache backseek tests\n\n" ); - warn = 1; - } else if readms > 0.0 { - report_c!(log, "\tBackseek flushes the cache as expected\n"); + } else { + report_c!(log, "\tTesting that backseek flushes cache..."); + let mut total = 0i32; + let mut elapsed = 0i64; + let mut sectors = 0i64; + let mut retry = 0i32; + + while elapsed < 5000 && total < 25 { + let mut ret; + loop { + let seekpos = (offset + cachesize + 20000).min(lastsector - 150); + ret = read_one(d, seekpos); + if ret > 0 { + ret = time_drive(d, log, offset + 1, readsize, true); + } + if ret >= 0 { + ret = time_drive(d, log, offset, readsize, true); + } + if ret <= 0 { + retry += 1; + if retry == MAX_ERROR_RETRIES { + report_c!( + log, + "\n\tToo many read errors while performing drive cache checks;\ + \n\t aborting test.\n\n" + ); + return -1; + } + report_c!( + log, + "\n\tRead error while performing drive cache checks; retrying..." + ); + } else { + break; + } + } + + sectors += readsize - 1; + elapsed += ret; + total += 1; + let spinner = spinner(elapsed); + print_c!(log, "\u{8}{}", spinner); + } + + print_c!(log, "\r"); + log_c!(log, "\n"); + let readms = if sectors > 0 { + elapsed as f64 / sectors as f64 + } else { + 0.0 + }; + report_c!( + log, + "\t Access speed after backseek: {:.2}ms/sector [{}x]\n", + readms, + (1000.0 / 75.0 / readms.max(0.001)) as i64 + ); + if readms * 2.0 < mspersector || cachems * 2.0 > readms { + report_c!( + log, + "\tWARNING: Read timing after backseek faster than expected!\n\ + \t It's possible/likely that this drive is not\n\ + \t flushing the readahead cache on backward seeks!\n\n" + ); + warn = 1; + } else { + report_c!(log, "\tBackseek flushes the cache as expected\n"); + } } - let _ = retime_drive(d, log, offset, readsize, cachems.max(1.0)); } - // A short pause mirrors the original's readahead settling delay. - sleep(Duration::from_millis(50)); - let _ = CD_FRAMESIZE_RAW; warn } + +#[cfg(test)] +mod tests { + use super::*; + use cdrustanoia_core::device::{DiscToc, InterfaceKind, ReadError, TimedRead}; + + struct MockDrive { + toc: DiscToc, + fail_no_medium: bool, + error_retry: bool, + } + + impl MockDrive { + fn new() -> Self { + Self { + toc: DiscToc::default(), + fail_no_medium: false, + error_retry: true, + } + } + } + + impl Drive for MockDrive { + fn toc(&self) -> &DiscToc { + &self.toc + } + fn toc_mut(&mut self) -> &mut DiscToc { + &mut self.toc + } + fn opened(&self) -> bool { + true + } + fn read_audio( + &mut self, + _buffer: Option<&mut [u8]>, + _begin: i64, + sectors: i64, + ) -> Result { + if self.fail_no_medium { + Err(ReadError::no_medium()) + } else { + Ok(sectors.min(2) as usize) + } + } + fn read_timed( + &mut self, + _buffer: Option<&mut [u8]>, + _begin: i64, + sectors: i64, + ) -> TimedRead { + let result = if self.fail_no_medium { + Err(ReadError::no_medium()) + } else { + Ok(sectors.min(2) as usize) + }; + TimedRead { + result, + milliseconds: 2, + microseconds: 2000, + } + } + fn speed_set(&mut self, _speed: i32) -> i32 { + 0 + } + fn close(&mut self) {} + fn drive_model(&self) -> &str { + "mock" + } + fn bigendianp(&self) -> i32 { + 0 + } + fn set_bigendianp(&mut self, _value: i32) {} + fn nsectors(&self) -> i64 { + 2 + } + fn set_nsectors(&mut self, _value: i64) {} + fn set_error_retry(&mut self, enabled: bool) { + self.error_retry = enabled; + } + fn interface(&self) -> InterfaceKind { + InterfaceKind::Image + } + } + + #[test] + fn time_drive_accumulates_timings() { + let mut drive = MockDrive::new(); + let mut log = Log::new(); + assert_eq!(time_drive(&mut drive, &mut log, 0, 3, false), 4); + } + + #[test] + fn media_errors_are_reported() { + let mut drive = MockDrive { + fail_no_medium: true, + ..MockDrive::new() + }; + assert_eq!(read_timed(&mut drive, 0, 1), (-404, -1.0)); + } + + #[test] + fn analysis_without_audio_returns_error() { + let mut drive = MockDrive::new(); + let mut log = Log::new(); + log.quiet = true; + assert_eq!(analyze_cache(&mut drive, &mut log, -1), -1); + assert!(drive.error_retry); + } +} + +#[cfg(test)] +mod cache_tests { + use super::*; + use cdrustanoia_core::device::{DiscToc, InterfaceKind, ReadError, TimedRead, TocEntry}; + + const SECTORS: i64 = 4_000; + + struct CachedDrive { + toc: DiscToc, + cache_begin: i64, + cache_end: i64, + } + + impl CachedDrive { + fn new() -> Self { + let mut toc = DiscToc::default(); + toc.entries[0] = TocEntry { + flags: 0, + track: 1, + start_sector: 0, + }; + toc.entries[1] = TocEntry { + flags: 0x04, + track: 0xAA, + start_sector: SECTORS, + }; + toc.tracks = 1; + Self { + toc, + cache_begin: 0, + cache_end: 0, + } + } + + fn timing(&mut self, begin: i64, sectors: i64) -> (usize, i32) { + let end = begin + sectors; + let hit = begin >= self.cache_begin && end <= self.cache_end; + let ms = if hit { 1 } else { 20 }; + if hit { + self.cache_end = self.cache_end.max(end + 200); + } else { + self.cache_begin = begin; + self.cache_end = end + 200; + } + if self.cache_end - self.cache_begin > 1_200 { + self.cache_begin = self.cache_end - 1_200; + } + (sectors as usize, ms) + } + } + + impl Drive for CachedDrive { + fn toc(&self) -> &DiscToc { + &self.toc + } + fn toc_mut(&mut self) -> &mut DiscToc { + &mut self.toc + } + fn opened(&self) -> bool { + true + } + fn read_audio( + &mut self, + _buffer: Option<&mut [u8]>, + begin: i64, + sectors: i64, + ) -> Result { + Ok(self.timing(begin, sectors).0) + } + fn read_timed( + &mut self, + _buffer: Option<&mut [u8]>, + begin: i64, + sectors: i64, + ) -> TimedRead { + let (n, ms) = self.timing(begin, sectors); + TimedRead { + result: Ok(n), + milliseconds: ms, + microseconds: ms as i64 * 1000, + } + } + fn speed_set(&mut self, _speed: i32) -> i32 { + 0 + } + fn close(&mut self) {} + fn drive_model(&self) -> &str { + "cached mock" + } + fn bigendianp(&self) -> i32 { + 0 + } + fn set_bigendianp(&mut self, _value: i32) {} + fn nsectors(&self) -> i64 { + 13 + } + fn set_nsectors(&mut self, _value: i64) {} + fn interface(&self) -> InterfaceKind { + InterfaceKind::Image + } + } + + #[test] + fn full_analysis_with_simulated_cache() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("analysis.log"); + let file = std::fs::File::create(&path).unwrap(); + let mut drive = CachedDrive::new(); + let mut log = Log::new(); + log.quiet = true; + log.report_file = Some(Box::new(file)); + let result = analyze_cache(&mut drive, &mut log, -1); + drop(log); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(result == 0 || result == 1); + assert!( + text.contains("Approximate random access cache size: 1000"), + "analysis did not find the simulated cache:\n{text}" + ); + assert!(text.contains("Testing cache tail cursor")); + assert!(text.contains("Cache tail granularity")); + } +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 67ba5a9..18bac5e 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -22,8 +22,7 @@ use std::os::unix::fs::OpenOptionsExt; use std::os::unix::io::FromRawFd; use std::path::Path; -const MANUAL: &str = r#"cdrustanoia 26.0.1 (2026-11-03) -based on the great cdparanoia by Monty and Xiph.Org +const MANUAL_BODY: &str = r#"based on the great cdparanoia by Monty and Xiph.Org (C) 2026 Martin Wohlert @@ -167,7 +166,8 @@ A few examples, protected from the shell: "#; fn usage(out: &mut dyn Write) { - let _ = out.write_all(MANUAL.as_bytes()); + let _ = writeln!(out, "{}", VERSION); + let _ = out.write_all(MANUAL_BODY.as_bytes()); } fn open_log_name(name: &str) -> Result, String> { @@ -358,7 +358,7 @@ pub fn run(argv: &[String]) -> i32 { return 0; } if args.version { - eprintln!("{}\n", VERSION); + eprintln!("{}", VERSION); return 0; } diff --git a/crates/cli/tests/e2e.rs b/crates/cli/tests/e2e.rs index 6a078a7..4d88e43 100644 --- a/crates/cli/tests/e2e.rs +++ b/crates/cli/tests/e2e.rs @@ -23,6 +23,79 @@ fn end_to_end_image_rip() { assert_eq!(cdrustanoia_cli::run(&argv), 0); let got = std::fs::read(&out).unwrap(); assert_eq!(got, samples_to_bytes(&samples)); + // Output formats and sample offsets use the same source data. + let raw_bytes = samples_to_bytes(&samples); + let wav = dir.path().join("format.wav"); + let argv = vec![ + "cdparanoia".to_string(), + "-q".to_string(), + "-w".to_string(), + "1".to_string(), + wav.to_string_lossy().into_owned(), + ]; + assert_eq!(cdrustanoia_cli::run(&argv), 0); + let wav_bytes = std::fs::read(&wav).unwrap(); + assert_eq!(&wav_bytes[..4], b"RIFF"); + assert_eq!(&wav_bytes[44..], raw_bytes.as_slice()); + + let aiff = dir.path().join("format.aiff"); + let argv = vec![ + "cdparanoia".to_string(), + "-q".to_string(), + "-f".to_string(), + "1".to_string(), + aiff.to_string_lossy().into_owned(), + ]; + assert_eq!(cdrustanoia_cli::run(&argv), 0); + let aiff_bytes = std::fs::read(&aiff).unwrap(); + assert_eq!(&aiff_bytes[..4], b"FORM"); + let mut big_endian = Vec::with_capacity(samples.len() * 2); + for sample in &samples { + big_endian.extend_from_slice(&sample.to_be_bytes()); + } + assert_eq!(&aiff_bytes[54..], big_endian.as_slice()); + + let offset_out = dir.path().join("offset.raw"); + let argv = vec![ + "cdparanoia".to_string(), + "-q".to_string(), + "-r".to_string(), + "-O".to_string(), + "10".to_string(), + "1".to_string(), + offset_out.to_string_lossy().into_owned(), + ]; + assert_eq!(cdrustanoia_cli::run(&argv), 0); + let offset_bytes = std::fs::read(&offset_out).unwrap(); + assert_eq!(&offset_bytes[..raw_bytes.len() - 40], &raw_bytes[40..]); + + for mode in ["-Z", "-Y"] { + let out = dir + .path() + .join(format!("mode{}.raw", mode.trim_start_matches('-'))); + let argv = vec![ + "cdparanoia".to_string(), + "-q".to_string(), + "-r".to_string(), + mode.to_string(), + "1".to_string(), + out.to_string_lossy().into_owned(), + ]; + assert_eq!(cdrustanoia_cli::run(&argv), 0); + assert_eq!(std::fs::read(&out).unwrap(), raw_bytes); + } + + // The compact image has no simulated cache; -A must complete cleanly. + let analyze_log = dir.path().join("analyze.log"); + let analyze_argv = vec![ + "cdparanoia".to_string(), + "-A".to_string(), + format!("-L{}", analyze_log.to_string_lossy()), + ]; + std::env::set_var("CDRUSTANOIA_TEST_IMAGE", &image); + assert_eq!(cdrustanoia_cli::run(&analyze_argv), 0); + let analyze_text = std::fs::read_to_string(&analyze_log).unwrap(); + assert!(analyze_text.contains("Checking drive cache/timing behavior")); std::env::remove_var("CDRUSTANOIA_TEST_IMAGE"); // Multi-track batch mode. @@ -48,3 +121,29 @@ fn end_to_end_image_rip() { assert_eq!(&t2[44..], &samples_to_bytes(&multi_samples[24 * 1176..])); std::env::remove_var("CDRUSTANOIA_TEST_IMAGE"); } + +#[test] +fn version_output_matches_cdparanoia_shape() { + let exe = env!("CARGO_BIN_EXE_cdparanoia"); + let out = std::process::Command::new(exe) + .arg("-V") + .output() + .expect("run cdparanoia -V"); + assert!(out.status.success()); + let expected = format!("{}\n", cdrustanoia_core::VERSION); + assert_eq!(String::from_utf8_lossy(&out.stderr), expected); +} + +#[test] +fn help_starts_with_cargo_version() { + let exe = env!("CARGO_BIN_EXE_cdparanoia"); + let out = std::process::Command::new(exe) + .arg("--help") + .output() + .expect("run cdparanoia --help"); + assert!(out.status.success()); + let text = String::from_utf8_lossy(&out.stdout); + let first = text.lines().next().unwrap_or_default(); + assert_eq!(first, cdrustanoia_core::VERSION); + assert!(!text.contains("2026-11-03")); +} diff --git a/crates/core/src/device/image.rs b/crates/core/src/device/image.rs index 8426770..d8d5941 100644 --- a/crates/core/src/device/image.rs +++ b/crates/core/src/device/image.rs @@ -325,6 +325,7 @@ impl Drive for ImageDrive { TimedRead { result, milliseconds: ms, + microseconds: ms as i64 * 1000, } } fn speed_set(&mut self, _speed: i32) -> i32 { diff --git a/crates/core/src/device/linux.rs b/crates/core/src/device/linux.rs index 0bc6db7..58934c4 100644 --- a/crates/core/src/device/linux.rs +++ b/crates/core/src/device/linux.rs @@ -179,6 +179,12 @@ pub struct LinuxDrive { read_cmd: ReadCommand, enable_mode: EnableMode, last_milliseconds: i32, + last_microseconds: i64, + error_retry: bool, + /// Scratch buffer used by timing/analysis reads that do not request a + /// destination buffer. Reusing it avoids a multi-megabyte allocation on + /// every `read_timed(None, ...)` call, which would distort cache timing. + scratch: Vec, } impl LinuxDrive { @@ -201,6 +207,9 @@ impl LinuxDrive { read_cmd: ReadCommand::D8, enable_mode: EnableMode::Dummy, last_milliseconds: 0, + last_microseconds: -1, + error_retry: true, + scratch: Vec::new(), }; d.inquiry()?; d.check_atapi(); @@ -241,6 +250,9 @@ impl LinuxDrive { read_cmd: ReadCommand::Read28, enable_mode: EnableMode::Dummy, last_milliseconds: 0, + last_microseconds: -1, + error_retry: true, + scratch: Vec::new(), }; Ok(d) } @@ -566,7 +578,9 @@ impl LinuxDrive { }; let started = Instant::now(); let rc = unsafe { libc::ioctl(self.fd, SG_IO, &mut hdr as *mut SgIoHdr) }; - self.last_milliseconds = started.elapsed().as_millis() as i32; + let elapsed = started.elapsed(); + self.last_microseconds = elapsed.as_micros() as i64; + self.last_milliseconds = ((elapsed.as_micros() + 500) / 1000) as i32; if rc < 0 { return Err(ScsiError::io(io::Error::last_os_error())); } @@ -703,10 +717,14 @@ impl LinuxDrive { let _ = use_msf; cdb[1] |= ((self.lun as u8) & 0x07) << 5; let actual = self.sg_command(&cdb[..len], Some(&mut buf[..need]), SG_DXFER_FROM_DEV)?; - let count = count_2352_bytes(&buf[..need]); - let by_count = count / CD_FRAMESIZE_RAW; - let by_actual = actual / CD_FRAMESIZE_RAW; - Ok(by_count.min(by_actual)) + // A successful ioctl with a completely untouched buffer means the + // transfer failed silently. Do not inspect individual trailing + // bytes: valid audio may legitimately end in any byte sequence, + // including the 0x7f fill pattern. + if actual >= need && buf[..need].iter().all(|b| *b == 0x7F) { + return Ok(0); + } + Ok((actual / CD_FRAMESIZE_RAW).min(sectors as usize)) } fn scsi_read_map( @@ -715,15 +733,28 @@ impl LinuxDrive { p: Option<&mut [u8]>, begin: i64, sectors: i64, + ) -> Result { + let mut scratch = std::mem::take(&mut self.scratch); + let result = self.scsi_read_map_inner(command, p, begin, sectors, &mut scratch); + self.scratch = scratch; + result + } + + fn scsi_read_map_inner( + &mut self, + command: ReadCommand, + p: Option<&mut [u8]>, + begin: i64, + sectors: i64, + scratch: &mut Vec, ) -> Result { let mut requested = sectors.min(self.nsectors).max(1); let mut retry_count = 0; - let mut scratch; let buffer: &mut [u8] = match p { Some(b) => b, None => { - scratch = vec![0u8; (requested * CD_FRAMESIZE_RAW as i64) as usize]; - &mut scratch + scratch.resize((requested * CD_FRAMESIZE_RAW as i64) as usize, 0); + scratch.as_mut_slice() } }; if buffer.len() < (requested * CD_FRAMESIZE_RAW as i64) as usize { @@ -748,6 +779,12 @@ impl LinuxDrive { if errno == libc::ENOMEDIUM { return Err(ReadError::no_medium()); } + if !self.error_retry { + return Err(ReadError::new( + errno, + format!("Read error at sector {}", begin), + )); + } if requested == 1 && retry_count > MAX_RETRIES - 1 { return Err(ReadError::new( errno, @@ -801,15 +838,27 @@ impl LinuxDrive { p: Option<&mut [u8]>, begin: i64, sectors: i64, + ) -> Result { + let mut scratch = std::mem::take(&mut self.scratch); + let result = self.cooked_read_inner(p, begin, sectors, &mut scratch); + self.scratch = scratch; + result + } + + fn cooked_read_inner( + &mut self, + p: Option<&mut [u8]>, + begin: i64, + sectors: i64, + scratch: &mut Vec, ) -> Result { let mut requested = sectors.min(self.nsectors).max(1); let mut retry = 0; - let mut scratch; let buf: &mut [u8] = match p { Some(b) => b, None => { - scratch = vec![0u8; (requested * CD_FRAMESIZE_RAW as i64) as usize]; - &mut scratch + scratch.resize((requested * CD_FRAMESIZE_RAW as i64) as usize, 0); + scratch.as_mut_slice() } }; if buf.len() < (requested * CD_FRAMESIZE_RAW as i64) as usize { @@ -826,12 +875,20 @@ impl LinuxDrive { }; let started = Instant::now(); let rc = unsafe { libc::ioctl(self.fd, CDROMREADAUDIO, &mut arg as *mut _) }; - self.last_milliseconds = started.elapsed().as_millis() as i32; + let elapsed = started.elapsed(); + self.last_microseconds = elapsed.as_micros() as i64; + self.last_milliseconds = ((elapsed.as_micros() + 500) / 1000) as i32; if rc == 0 { return Ok(requested as usize); } let err = io::Error::last_os_error(); let errno = err.raw_os_error().unwrap_or(libc::EIO); + if !self.error_retry { + return Err(ReadError::new( + errno, + format!("Read error at sector {}", begin), + )); + } if errno == libc::ENXIO || errno == libc::EBADF || errno == libc::ENOMEDIUM { return Err(ReadError::no_medium()); } @@ -859,40 +916,33 @@ impl LinuxDrive { fn verify_read_command(&mut self) -> Result<(), String> { let mut buf = vec![0u8; CD_FRAMESIZE_RAW]; - let mut tried_any = false; - let tried = [self.read_cmd]; - for command in tried { - if self.try_read_command(command, &mut buf) { + if self.try_read_command(self.read_cmd, &mut buf, false) { + return Ok(()); + } + + let all = [ + ReadCommand::Read28, + ReadCommand::ReadA8, + ReadCommand::MmcB10, + ReadCommand::MmcB2_10, + ReadCommand::MmcB6_F8, + ReadCommand::MmcB2_F8, + ReadCommand::MmcB4_F8, + ReadCommand::Mmc2_F8, + ReadCommand::Mmc3_F8, + ReadCommand::Msf10, + ReadCommand::MsfF8, + ReadCommand::Msf3F8, + ReadCommand::D4_10, + ReadCommand::D4_12, + ReadCommand::D5, + ReadCommand::D8, + ]; + for command in all { + if self.try_read_command(command, &mut buf, true) { self.read_cmd = command; return Ok(()); } - tried_any = true; - } - if tried_any { - let all = [ - ReadCommand::Read28, - ReadCommand::ReadA8, - ReadCommand::MmcB10, - ReadCommand::MmcB2_10, - ReadCommand::MmcB6_F8, - ReadCommand::MmcB2_F8, - ReadCommand::MmcB4_F8, - ReadCommand::Mmc2_F8, - ReadCommand::Mmc3_F8, - ReadCommand::Msf10, - ReadCommand::MsfF8, - ReadCommand::Msf3F8, - ReadCommand::D4_10, - ReadCommand::D4_12, - ReadCommand::D5, - ReadCommand::D8, - ]; - for command in all { - if self.try_read_command(command, &mut buf) { - self.read_cmd = command; - return Ok(()); - } - } } Err( "Unable to find any suitable command set from probe; drive probably not CDDA capable" @@ -900,7 +950,12 @@ impl LinuxDrive { ) } - fn try_read_command(&mut self, command: ReadCommand, buf: &mut [u8]) -> bool { + fn try_read_command( + &mut self, + command: ReadCommand, + buf: &mut [u8], + require_nonzero: bool, + ) -> bool { if self.enable_cdda().is_err() { return false; } @@ -910,10 +965,7 @@ impl LinuxDrive { let last = self.toc.track_lastsector(t); let sector = (first + last) >> 1; if let Ok(n) = self.read_once(command, sector, 1, buf) { - if n > 0 - && count_2352_bytes(buf) == CD_FRAMESIZE_RAW - && buf.iter().any(|b| *b != 0) - { + if n > 0 && (!require_nonzero || buf.iter().any(|b| *b != 0)) { return true; } } @@ -1073,15 +1125,6 @@ fn fft(re: &mut [f64], im: &mut [f64]) { } } -fn count_2352_bytes(buf: &[u8]) -> usize { - for i in (0..buf.len()).rev() { - if buf[i] != 0x7F { - return ((i + 3) >> 2) << 2; - } - } - 0 -} - fn check_sbp(status: u8, sense: &[u8]) -> ScsiError { let key = sense.get(2).copied().unwrap_or(0) & 0x0F; let asc = sense.get(12).copied().unwrap_or(0); @@ -1219,10 +1262,12 @@ impl Drive for LinuxDrive { } fn read_timed(&mut self, buffer: Option<&mut [u8]>, begin: i64, sectors: i64) -> TimedRead { self.last_milliseconds = -1; + self.last_microseconds = -1; let result = self.read_audio(buffer, begin, sectors); TimedRead { result, milliseconds: self.last_milliseconds, + microseconds: self.last_microseconds, } } fn speed_set(&mut self, speed: i32) -> i32 { @@ -1268,9 +1313,30 @@ impl Drive for LinuxDrive { fn set_nsectors(&mut self, value: i64) { self.nsectors = value; } + fn set_error_retry(&mut self, enabled: bool) { + self.error_retry = enabled; + } fn interface(&self) -> InterfaceKind { self.interface } + fn is_atapi(&self) -> bool { + self.is_atapi + } + fn is_mmc(&self) -> bool { + self.is_mmc + } + fn density(&self) -> u8 { + self.density + } + fn fua(&self) -> bool { + self.fua + } + fn lun(&self) -> i32 { + self.lun + } + fn adjust_ssize(&self) -> i64 { + self.adjust_ssize + } } impl Drop for LinuxDrive { @@ -1295,15 +1361,13 @@ mod tests { } #[test] - fn fill_byte_counting() { + fn fill_pattern_detection() { let mut buf = vec![0x7Fu8; 2352 * 2]; - assert_eq!(count_2352_bytes(&buf), 0); + assert!(buf.iter().all(|b| *b == 0x7F)); buf[10] = 0; - assert_eq!(count_2352_bytes(&buf), 12); - for b in buf.iter_mut() { - *b = 0; - } - assert_eq!(count_2352_bytes(&buf), buf.len()); + assert!(buf.iter().any(|b| *b != 0x7F)); + let tail = [0x7Fu8; 16]; + assert!(tail.iter().all(|b| *b == 0x7F)); } #[test] diff --git a/crates/core/src/device/mod.rs b/crates/core/src/device/mod.rs index d6ae905..03f50af 100644 --- a/crates/core/src/device/mod.rs +++ b/crates/core/src/device/mod.rs @@ -252,7 +252,11 @@ impl fmt::Display for ReadError { #[derive(Debug, Clone)] pub struct TimedRead { pub result: Result, + /// Whole milliseconds, matching the C ABI. pub milliseconds: i32, + /// Higher precision duration for internal timing analysis. Backends + /// that only know millisecond figures fill this with `ms * 1000`. + pub microseconds: i64, } /// Backend interface used by both the command line program and the C ABI. @@ -318,7 +322,29 @@ pub trait Drive { fn set_bigendianp(&mut self, value: i32); fn nsectors(&self) -> i64; fn set_nsectors(&mut self, value: i64); + /// Enable or disable the low-level read retry loop. Cache analysis + /// disables retries so that timing reflects the first access. + fn set_error_retry(&mut self, _enabled: bool) {} fn interface(&self) -> InterfaceKind; + /// Optional drive attributes exposed through the C ABI. + fn is_atapi(&self) -> bool { + false + } + fn is_mmc(&self) -> bool { + false + } + fn density(&self) -> u8 { + 0 + } + fn fua(&self) -> bool { + false + } + fn lun(&self) -> i32 { + 0 + } + fn adjust_ssize(&self) -> i64 { + 1 + } fn cdda_version(&self) -> &'static str { crate::VERSIONNUM } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 615ab7d..e91cb92 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -15,8 +15,8 @@ pub mod callback; pub mod device; pub mod paranoia; -pub const VERSIONNUM: &str = "26.0.1"; -pub const VERSION: &str = "cdrustanoia 26.0.1 (2026-11-03)"; +pub const VERSIONNUM: &str = env!("CARGO_PKG_VERSION"); +pub const VERSION: &str = concat!("cdrustanoia ", env!("CARGO_PKG_VERSION")); pub fn paranoia_version() -> &'static str { VERSIONNUM @@ -25,3 +25,15 @@ pub fn paranoia_version() -> &'static str { pub fn cdda_version() -> &'static str { VERSIONNUM } + +#[cfg(test)] +mod tests { + #[test] + fn versions_come_from_cargo() { + assert_eq!(super::VERSIONNUM, env!("CARGO_PKG_VERSION")); + assert_eq!( + super::VERSION, + concat!("cdrustanoia ", env!("CARGO_PKG_VERSION")) + ); + } +} diff --git a/crates/core/tests/paranoia_image.rs b/crates/core/tests/paranoia_image.rs index 8db1787..82fe7cf 100644 --- a/crates/core/tests/paranoia_image.rs +++ b/crates/core/tests/paranoia_image.rs @@ -128,3 +128,38 @@ fn jittered_image_roundtrip() { }; run_config(sectors, config); } + +#[test] +fn fragmented_image_roundtrip() { + use cdrustanoia_core::device::image::{ImageConfig, ImageFaults}; + let sectors = 24usize; + let config = ImageConfig { + tracks: vec![(0, sectors as i64 - 1, 0)], + faults: ImageFaults { + max_read_sectors: 3, + ..Default::default() + }, + ..Default::default() + }; + run_config(sectors, config); +} + +#[test] +fn silent_image_roundtrip() { + use cdrustanoia_core::device::image::{ImageConfig, ImageFaults}; + let sectors = 24usize; + let config = ImageConfig { + tracks: vec![(0, sectors as i64 - 1, 0)], + faults: ImageFaults::default(), + ..Default::default() + }; + let bytes = vec![0u8; sectors * CD_FRAMESIZE_RAW]; + let drive = ImageDrive::with_config(bytes, config); + let mut p = Paranoia::new(Box::new(drive)); + p.modeset(PARANOIA_MODE_FULL); + let mut cb = |_: i64, _: i32| {}; + for _ in 0..sectors { + let sec = p.read(&mut cb).expect("read silent sector"); + assert!(sec.iter().all(|s| *s == 0)); + } +} diff --git a/tests/capi_compile.c b/tests/capi_compile.c index 090f0ad..ec88db0 100644 --- a/tests/capi_compile.c +++ b/tests/capi_compile.c @@ -17,6 +17,9 @@ static void use_api(cdrom_drive *d, cdrom_paranoia *p) { (void)cdda_version(); (void)paranoia_version(); +#ifdef CDDA_TEST + (void)cdda_identify_test("", 0, 0); +#endif (void)cdda_open(d); (void)cdda_read(d, 0, 0, 1); (void)paranoia_init(d);