WO-2 (delete SectorReader trait): - The 0.18 trait split into SectorSource (read-only) and SectorSink (write-only) is final; the legacy SectorReader alias was a bridge. - Renames every internal &mut dyn SectorReader (~25 sites) to &mut dyn SectorSource. The trait method capacity() becomes capacity_sectors() with a default of 0 (preserves SectorReader's default-0 behavior). - Deletes the SectorReader trait, its blanket-to-Source bridge, and the FileSectorReader type alias. Adds explicit forwarding impls for Box<dyn SectorSource> and &mut dyn SectorSource so generic decorators like DecryptingSectorSource<S: SectorSource> compose. WO-3a (extract Disc::patch): - Moves Disc::patch (1230 lines) and bytes_bad_in_title from disc/mod.rs into disc/patch.rs as a split inherent impl. Zero behavior change — pure mechanical relocation. disc/mod.rs drops from 3,945 to 2,714 LOC. WO-6 (partial): - Deletes src/labels/png_filenames.rs — was a 72-LOC stub with detect() returning false, never wired into the PARSERS registry. project docs doc drift fixes (audited 2026-05-13): - JUMP_BASE_SECTORS: 256→1024 (64 MB base for UHD, not 8 MB) - PASSN_DAMAGE_THRESHOLD_PCT: 12→6 - PASSN_SKIP_SECTORS_BASE: 64→32 - MAX_RANGE_SECS=180: replaced by proportional range_sectors × 25, capped at RANGE_BUDGET_CAP_SECS=1800.
261 lines
9.4 KiB
Rust
261 lines
9.4 KiB
Rust
//! Disc sector verification — read every sector and classify health.
|
|
|
|
use crate::disc::{Chapter, DiscTitle};
|
|
use crate::progress::Progress;
|
|
use crate::sector::SectorSource;
|
|
use std::time::Instant;
|
|
|
|
/// Health status of a single sector read.
|
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
|
pub enum SectorStatus {
|
|
/// Read succeeded on first attempt, under 500ms
|
|
Good,
|
|
/// Read succeeded but took longer than expected (>500ms per sector)
|
|
Slow,
|
|
/// Read failed initially but succeeded on retry
|
|
Recovered,
|
|
/// Read failed all attempts — unrecoverable
|
|
Bad,
|
|
}
|
|
|
|
/// A contiguous range of bad or slow sectors.
|
|
#[derive(Debug, Clone)]
|
|
pub struct SectorRange {
|
|
pub start_lba: u32,
|
|
pub count: u32,
|
|
pub status: SectorStatus,
|
|
/// Byte offset from start of title
|
|
pub byte_offset: u64,
|
|
}
|
|
|
|
/// Result of a disc verification scan.
|
|
#[derive(Debug, Clone)]
|
|
pub struct VerifyResult {
|
|
pub total_sectors: u64,
|
|
pub good: u64,
|
|
pub slow: u64,
|
|
pub recovered: u64,
|
|
pub bad: u64,
|
|
/// Contiguous ranges of non-good sectors
|
|
pub ranges: Vec<SectorRange>,
|
|
pub elapsed_secs: f64,
|
|
}
|
|
|
|
impl VerifyResult {
|
|
/// Percentage of sectors that are fully readable (good + slow + recovered).
|
|
pub fn readable_pct(&self) -> f64 {
|
|
if self.total_sectors == 0 {
|
|
return 100.0;
|
|
}
|
|
(self.total_sectors - self.bad) as f64 / self.total_sectors as f64 * 100.0
|
|
}
|
|
|
|
/// True if every sector read successfully.
|
|
pub fn is_perfect(&self) -> bool {
|
|
self.bad == 0 && self.recovered == 0 && self.slow == 0
|
|
}
|
|
|
|
/// Map a bad sector range to a chapter timestamp.
|
|
pub fn chapter_at_offset(
|
|
chapters: &[Chapter],
|
|
byte_offset: u64,
|
|
duration_secs: f64,
|
|
total_bytes: u64,
|
|
) -> Option<(usize, f64)> {
|
|
if total_bytes == 0 || chapters.is_empty() {
|
|
return None;
|
|
}
|
|
let time_secs = byte_offset as f64 / total_bytes as f64 * duration_secs;
|
|
let mut chapter_idx = 0;
|
|
for (i, ch) in chapters.iter().enumerate() {
|
|
if ch.time_secs <= time_secs {
|
|
chapter_idx = i;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
Some((chapter_idx + 1, time_secs))
|
|
}
|
|
}
|
|
|
|
/// Verify all sectors in a title's extents.
|
|
/// Reads in batches for speed, falls back to single-sector on failure.
|
|
/// The progress callback returns false to request early stop.
|
|
pub fn verify_title(
|
|
reader: &mut dyn SectorSource,
|
|
title: &DiscTitle,
|
|
batch_sectors: u16,
|
|
on_progress: Option<&dyn Progress>,
|
|
) -> VerifyResult {
|
|
let start = Instant::now();
|
|
let mut good: u64 = 0;
|
|
let mut slow: u64 = 0;
|
|
let mut recovered: u64 = 0;
|
|
let mut bad: u64 = 0;
|
|
let mut ranges: Vec<SectorRange> = Vec::new();
|
|
let mut sectors_done: u64 = 0;
|
|
let mut byte_offset: u64 = 0;
|
|
|
|
let total_sectors: u64 = title.extents.iter().map(|e| e.sector_count as u64).sum();
|
|
let mut buf = vec![0u8; batch_sectors as usize * 2048];
|
|
|
|
'outer: for ext in &title.extents {
|
|
let mut offset: u32 = 0;
|
|
while offset < ext.sector_count {
|
|
let remaining = ext.sector_count - offset;
|
|
let count = remaining.min(batch_sectors as u32) as u16;
|
|
let lba = ext.start_lba + offset;
|
|
let bytes = count as usize * 2048;
|
|
|
|
let batch_start = Instant::now();
|
|
let batch_ok = reader
|
|
.read_sectors(lba, count, &mut buf[..bytes], false)
|
|
.is_ok();
|
|
let batch_ms = batch_start.elapsed().as_millis();
|
|
|
|
if batch_ok {
|
|
// Batch succeeded — classify by speed
|
|
let status = if batch_ms > count as u128 * 500 {
|
|
SectorStatus::Slow
|
|
} else {
|
|
SectorStatus::Good
|
|
};
|
|
|
|
match status {
|
|
SectorStatus::Good => good += count as u64,
|
|
SectorStatus::Slow => {
|
|
slow += count as u64;
|
|
ranges.push(SectorRange {
|
|
start_lba: lba,
|
|
count: count as u32,
|
|
status: SectorStatus::Slow,
|
|
byte_offset,
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
sectors_done += count as u64;
|
|
if let Some(cb) = on_progress {
|
|
let pp = crate::progress::PassProgress {
|
|
kind: crate::progress::PassKind::Verify,
|
|
work_done: sectors_done,
|
|
work_total: total_sectors,
|
|
bytes_good_total: (good + slow + recovered) * 2048,
|
|
bytes_unreadable_total: bad * 2048,
|
|
bytes_pending_total: 0,
|
|
bytes_total_disc: total_sectors * 2048,
|
|
disc_duration_secs: Some(title.duration_secs),
|
|
bytes_bad_in_main_title: 0,
|
|
main_title_duration_secs: Some(title.duration_secs),
|
|
main_title_size_bytes: Some(total_sectors * 2048),
|
|
};
|
|
if !cb.report(&pp) {
|
|
break 'outer;
|
|
}
|
|
}
|
|
} else {
|
|
// Batch failed — test each sector individually
|
|
for i in 0..count {
|
|
let sector_lba = lba + i as u32;
|
|
let sector_offset = i as usize * 2048;
|
|
let sector_byte_offset = byte_offset + i as u64 * 2048;
|
|
|
|
let s1 = Instant::now();
|
|
let first_ok = reader
|
|
.read_sectors(
|
|
sector_lba,
|
|
1,
|
|
&mut buf[sector_offset..sector_offset + 2048],
|
|
false,
|
|
)
|
|
.is_ok();
|
|
let s1_ms = s1.elapsed().as_millis();
|
|
|
|
let status = if first_ok && s1_ms <= 500 {
|
|
good += 1;
|
|
SectorStatus::Good
|
|
} else if first_ok {
|
|
slow += 1;
|
|
SectorStatus::Slow
|
|
} else {
|
|
// Retry once more after brief pause
|
|
std::thread::sleep(std::time::Duration::from_secs(2));
|
|
if reader
|
|
.read_sectors(
|
|
sector_lba,
|
|
1,
|
|
&mut buf[sector_offset..sector_offset + 2048],
|
|
false,
|
|
)
|
|
.is_ok()
|
|
{
|
|
recovered += 1;
|
|
SectorStatus::Recovered
|
|
} else {
|
|
bad += 1;
|
|
SectorStatus::Bad
|
|
}
|
|
};
|
|
|
|
if status != SectorStatus::Good {
|
|
// Merge with previous range if contiguous and same status
|
|
if let Some(last) = ranges.last_mut() {
|
|
if last.status == status && last.start_lba + last.count == sector_lba {
|
|
last.count += 1;
|
|
} else {
|
|
ranges.push(SectorRange {
|
|
start_lba: sector_lba,
|
|
count: 1,
|
|
status,
|
|
byte_offset: sector_byte_offset,
|
|
});
|
|
}
|
|
} else {
|
|
ranges.push(SectorRange {
|
|
start_lba: sector_lba,
|
|
count: 1,
|
|
status,
|
|
byte_offset: sector_byte_offset,
|
|
});
|
|
}
|
|
}
|
|
|
|
sectors_done += 1;
|
|
if let Some(cb) = on_progress {
|
|
let pp = crate::progress::PassProgress {
|
|
kind: crate::progress::PassKind::Verify,
|
|
work_done: sectors_done,
|
|
work_total: total_sectors,
|
|
bytes_good_total: (good + slow + recovered) * 2048,
|
|
bytes_unreadable_total: bad * 2048,
|
|
bytes_pending_total: 0,
|
|
bytes_total_disc: total_sectors * 2048,
|
|
disc_duration_secs: Some(title.duration_secs),
|
|
bytes_bad_in_main_title: 0,
|
|
main_title_duration_secs: Some(title.duration_secs),
|
|
main_title_size_bytes: Some(total_sectors * 2048),
|
|
};
|
|
if !cb.report(&pp) {
|
|
break 'outer;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
offset += count as u32;
|
|
byte_offset += bytes as u64;
|
|
}
|
|
}
|
|
|
|
VerifyResult {
|
|
total_sectors,
|
|
good,
|
|
slow,
|
|
recovered,
|
|
bad,
|
|
ranges,
|
|
elapsed_secs: start.elapsed().as_secs_f64(),
|
|
}
|
|
}
|