From f1926c38dcfaebcf6e7014ececf7f60bf190ddcb Mon Sep 17 00:00:00 2001 From: MattJackson <1085847+MattJackson@users.noreply.github.com> Date: Wed, 13 May 2026 11:36:55 -0700 Subject: [PATCH] v0.20.1: delete SectorReader, extract Disc::patch, doc/stub cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 and &mut dyn SectorSource so generic decorators like DecryptingSectorSource 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. --- Cargo.toml | 2 +- src/css/mod.rs | 4 +- src/disc/bluray.rs | 8 +- src/disc/dvd.rs | 4 +- src/disc/encrypt.rs | 6 +- src/disc/mod.rs | 1254 +----------------------- src/disc/patch.rs | 1246 ++++++++++++++++++++++- src/disc/sweep.rs | 2 +- src/drive/mod.rs | 4 +- src/ifo.rs | 6 +- src/labels/bdmt.rs | 4 +- src/labels/clpi_audit.rs | 4 +- src/labels/criterion.rs | 4 +- src/labels/ctrm.rs | 10 +- src/labels/dbp.rs | 6 +- src/labels/deluxe.rs | 6 +- src/labels/jar.rs | 6 +- src/labels/mod.rs | 23 +- src/labels/mpls_universal.rs | 4 +- src/labels/paramount.rs | 4 +- src/labels/pixelogic.rs | 4 +- src/labels/png_filenames.rs | 72 -- src/lib.rs | 12 +- src/mux/disc.rs | 22 +- src/mux/iso.rs | 12 +- src/mux/resolve.rs | 2 +- src/sector/file.rs | 14 +- src/sector/mod.rs | 209 ++-- src/udf.rs | 34 +- src/verify.rs | 4 +- tests/disc_tests.rs | 4 +- tests/integration_progress_and_halt.rs | 30 +- tests/pass_n_size_aware_skip.rs | 6 +- tests/udf_tests.rs | 8 +- 34 files changed, 1443 insertions(+), 1597 deletions(-) delete mode 100644 src/labels/png_filenames.rs diff --git a/Cargo.toml b/Cargo.toml index cdeca01..fe4496f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libfreemkv" -version = "0.20.0" +version = "0.20.1" edition = "2024" rust-version = "1.86" license = "AGPL-3.0-only" diff --git a/src/css/mod.rs b/src/css/mod.rs index 409f435..bcbc68a 100644 --- a/src/css/mod.rs +++ b/src/css/mod.rs @@ -16,7 +16,7 @@ pub mod lfsr; pub(crate) mod tables; use crate::disc::Extent; -use crate::sector::SectorReader; +use crate::sector::SectorSource; /// CSS decryption state for a DVD title. #[derive(Debug, Clone)] @@ -34,7 +34,7 @@ pub struct CssState { /// 0x80 (start of the encrypted region). This only happens when a new PES /// packet begins at exactly sector offset 128. We scan up to 50000 /// scrambled sectors sequentially across all extents. -pub fn crack_key(reader: &mut dyn SectorReader, extents: &[Extent]) -> Option { +pub fn crack_key(reader: &mut dyn SectorSource, extents: &[Extent]) -> Option { let mut tried = 0u32; let max_tries = 50_000; diff --git a/src/disc/bluray.rs b/src/disc/bluray.rs index c3c7e98..6a05681 100644 --- a/src/disc/bluray.rs +++ b/src/disc/bluray.rs @@ -3,13 +3,13 @@ use super::*; use crate::clpi; use crate::mpls; -use crate::sector::SectorReader; +use crate::sector::SectorSource; use crate::udf; impl Disc { /// Scan Blu-ray titles from MPLS playlists. pub(super) fn scan_bluray_titles( - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs, ) -> Vec { let mut titles = Vec::new(); @@ -31,7 +31,7 @@ impl Disc { } pub(super) fn parse_playlist( - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs, filename: &str, data: &[u8], @@ -199,7 +199,7 @@ impl Disc { /// Prefers English, falls back to first available language. /// Returns None if META directory is empty or XML has no usable title. pub(super) fn read_meta_title( - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs, ) -> Option { let meta_dir = udf_fs.find_dir("/BDMV/META")?; diff --git a/src/disc/dvd.rs b/src/disc/dvd.rs index 6f0206c..c340878 100644 --- a/src/disc/dvd.rs +++ b/src/disc/dvd.rs @@ -2,13 +2,13 @@ use super::*; use crate::ifo; -use crate::sector::SectorReader; +use crate::sector::SectorSource; use crate::udf; impl Disc { /// Scan DVD titles from IFO files (VIDEO_TS.IFO + VTS_XX_0.IFO). pub(super) fn scan_dvd_titles( - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs, ) -> Vec { let dvd_info = match ifo::parse_vmg(reader, udf_fs) { diff --git a/src/disc/encrypt.rs b/src/disc/encrypt.rs index f2b8a45..70d9f04 100644 --- a/src/disc/encrypt.rs +++ b/src/disc/encrypt.rs @@ -2,7 +2,7 @@ use super::*; use crate::error::{Error, Result}; -use crate::sector::SectorReader; +use crate::sector::SectorSource; use crate::udf; /// Result of SCSI AACS handshake (ECDH authentication). @@ -113,12 +113,12 @@ impl Disc { /// Resolve disc encryption — AACS 1.0, AACS 2.0, CSS, or none. /// - /// Reads AACS files from UDF (via SectorReader), resolves keys through + /// Reads AACS files from UDF (via SectorSource), resolves keys through /// whatever path works: KEYDB VUK lookup, media key derivation, processing /// keys, device keys. Uses handshake result (volume ID, bus key) if available. pub(super) fn resolve_encryption( udf_fs: &udf::UdfFs, - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, keydb_path: &std::path::Path, handshake: Option<&HandshakeResult>, ) -> Result { diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 65e1d85..8f38c30 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -18,7 +18,7 @@ mod sweep; use crate::drive::{Drive, extract_scsi_context}; use crate::error::{Error, Result}; -use crate::sector::SectorReader; +use crate::sector::SectorSource; use crate::udf; use encrypt::HandshakeResult; @@ -1092,10 +1092,10 @@ impl Disc { Ok(disc) } - /// Scan a disc image (ISO or any SectorReader). No SCSI, no handshake. + /// Scan a disc image (ISO or any SectorSource). No SCSI, no handshake. /// AACS resolution uses KEYDB VUK lookup only. pub fn scan_image( - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, capacity: u32, opts: &ScanOptions, ) -> Result { @@ -1103,9 +1103,9 @@ impl Disc { Self::scan_with(reader, capacity, None, opts, udf_fs) } - /// Core scan pipeline — works with any SectorReader. + /// Core scan pipeline — works with any SectorSource. fn scan_with( - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, capacity: u32, handshake: Option, opts: &ScanOptions, @@ -1349,7 +1349,7 @@ impl Disc { /// Without `multipass`: aborts on the first read error (legacy single-pass). pub fn copy( &self, - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, path: &std::path::Path, opts: &CopyOptions, ) -> Result { @@ -1403,7 +1403,7 @@ impl Disc { fn sweep_internal( &self, - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, path: &std::path::Path, opts: &CopyOptions, resume: bool, @@ -1421,7 +1421,7 @@ impl Disc { fn patch_internal( &self, - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, path: &std::path::Path, opts: &CopyOptions, ) -> Result { @@ -1477,7 +1477,7 @@ impl Disc { /// caller's job — see [`PatchOptions`] for the retry primitive. pub fn sweep( &self, - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, path: &std::path::Path, opts: &SweepOptions, ) -> Result { @@ -2094,1238 +2094,6 @@ impl Disc { } } -impl Disc { - /// Bytes of bad/unreadable data in a title's extents, from a mapfile. - /// - /// Consumers (CLI, autorip) call this after a rip pass to determine - /// how much damage affects a particular title — useful for showing - /// "42s lost (12s in main movie)" in the UI. - pub fn bytes_bad_in_title(&self, mapfile_path: &std::path::Path, title: &DiscTitle) -> u64 { - let map = match mapfile::Mapfile::load(mapfile_path) { - Ok(m) => m, - Err(_) => return 0, - }; - let bad_ranges = map.ranges_with(&[ - mapfile::SectorStatus::NonTrimmed, - mapfile::SectorStatus::Unreadable, - mapfile::SectorStatus::NonScraped, - mapfile::SectorStatus::NonTried, - ]); - bytes_bad_in_title(title, &bad_ranges) - } - - /// Pass 2..N of a multipass rip: re-read the bad ranges - /// recorded in the sidecar mapfile and try to recover them. - /// With `reverse: true` (the default for the recovery walker), - /// the bad-range walk runs end-to-start so escalating skips - /// converge on the actual bad sub-zones inside any - /// `NonTrimmed` block. Returns a [`PatchOutcome`] with - /// recovered byte counts and wedge-detection signals. - /// - /// 0.18: paired with [`Disc::sweep`] as the library's other flat - /// rip-phase verb. Caller drives the retry loop and the - /// sweep-vs-patch dispatch. - pub fn patch( - &self, - reader: &mut dyn SectorReader, - path: &std::path::Path, - opts: &PatchOptions, - ) -> Result { - use crate::io::pipeline::{Pipeline, WRITE_THROUGH_DEPTH}; - use crate::sector::{DecryptingSectorSource, SectorSource}; - use patch::{PatchItem, PatchSink}; - - const BRIDGE_DEGRADATION_PAUSE_SECS: u64 = 10; - const POST_FAILURE_PAUSE_SECS: u64 = 1; - const CONSECUTIVE_FAIL_LONG_PAUSE: u64 = 5; - const CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD: u64 = 10; - - fn skip_sectors_for_probe(idx: usize) -> u64 { - let base = PASSN_SKIP_SECTORS_BASE as i64; - let escalation = (idx * 3) as i64; - let shifted = if escalation < 64 { - base << escalation - } else { - base - }; - shifted.min(PASSN_SKIP_SECTORS_CAP as i64) as u64 - } - - let mapfile_path = self.mapfile_for(path); - let map = - mapfile::Mapfile::load(&mapfile_path).map_err(|e| Error::IoError { source: e })?; - let total_bytes = map.total_size(); - let keys = if opts.decrypt { - self.decrypt_keys() - } else { - crate::decrypt::DecryptKeys::None - }; - - // Wrap the producer-side reader once so every read_sectors - // call (the main recovery read, the backtrack read, and the - // non-NOT_READY retry read) yields plaintext. Replaces three - // inline decrypt_sectors call sites that all keyed off the - // same `keys`. `DecryptKeys::None` keeps the unencrypted / - // --raw path a pass-through. - let mut reader = DecryptingSectorSource::new(reader, keys); - let reader = &mut reader; - - let is_regular = std::fs::metadata(path) - .map(|m| m.file_type().is_file()) - .unwrap_or(false); - - // Snapshot fields we need from the mapfile *before* it moves into - // the consumer thread: bytes_good baseline, total entries, the - // initial `bad_ranges` work list, and the start-of-patch - // diagnostic dump. The shared state (`shared`) republishes these - // throughout the pass; the consumer owns the live `Mapfile`. - let bytes_good_before = map.stats().bytes_good; - let bytes_good_start = bytes_good_before; - let initial_stats = map.stats(); - let initial_entries: Vec<_> = map.entries().to_vec(); - // Every retry pass acts on every non-Finished range. Including - // Unreadable means a sector that failed in pass N gets a fresh - // shot in pass N+1 — drive state evolves, the same read can - // succeed later. Each pass owns its own jumps/skips; if pass 5 - // jumps over the same zone as pass 2, fine. - let mut bad_ranges = map.ranges_with(&[ - mapfile::SectorStatus::NonTrimmed, - mapfile::SectorStatus::NonScraped, - mapfile::SectorStatus::Unreadable, - ]); - if opts.reverse { - bad_ranges.reverse(); - } - let work_total: u64 = bad_ranges.iter().map(|(_, sz)| *sz).sum(); - - // Spawn the consumer. The `WritebackFile` (same bounded-cache - // wrapper sweep uses, so patch's recovery writes — sparse but - // can be many across a damaged region — get the burst-flush - // protection on slow / NFS-backed staging) and the `Mapfile` - // both move into the sink. We hold an `Arc>` snapshot - // the sink republishes after every record so producer-side - // stall guards / progress callbacks can read consumer side- - // effects. - let (sink, shared) = PatchSink::new(path, map, is_regular)?; - // Why: WRITE_THROUGH_DEPTH (=1) — patch reads ONE sector per - // recovery decision and the producer's stall / damage-window - // logic checks consumer-published stats inline. Sweep's - // DEFAULT_PIPELINE_DEPTH (=4) would let several sectors of - // recovered bytes queue up between producer decisions and - // writes, which conflicts with the per-sector lockstep this - // loop was written against. - let pipe = Pipeline::::spawn(WRITE_THROUGH_DEPTH, sink)?; - - // Send a `PatchItem` and translate a `SendError` (consumer - // thread died / panicked) into a useful library error so the - // caller can propagate cleanly. Mirrors `sweep_pipeline.rs`'s - // `send_or_abort`. - let send_or_abort = |pipe: &Pipeline, item: PatchItem| -> Result<()> { - pipe.send(item).map_err(|_| Error::IoError { - source: std::io::Error::other("patch consumer terminated unexpectedly"), - }) - }; - - // Snapshot helper for producer-side stats reads. Holds the - // mutex briefly; we never read across operations so a fresh - // snapshot per call is fine. - let read_shared = |shared: &std::sync::Mutex| -> ( - mapfile::MapStats, - Vec<(u64, u64)>, - ) { - let g = shared.lock().expect("PatchSink shared state mutex poisoned"); - (g.stats, g.bad_ranges.clone()) - }; - - // Log ISO file size at patch start for write monitoring - if let Ok(metadata) = std::fs::metadata(path) { - tracing::info!( - target: "freemkv::disc", - phase = "patch_iso_size_start", - iso_bytes = metadata.len(), - "ISO file size at patch start" - ); - } - - // Adaptive batching: read at `current_batch`, drop to 1 on - // batch-read failure, climb back to `initial_batch` after - // ADAPTIVE_UPSCALE_THRESHOLD consecutive single-sector successes. - // Rationale: dense damage scattered through a NonTrimmed range - // is rare — most "bad ranges" in pass N have lots of good - // sectors that swept-by-default landed inside. Batch reads - // walk those at ~32x the speed of singles, dropping to 1 - // only when the drive actually returns an error. Guarantees: - // - no good sector is ever marked NonTrimmed because it - // was bundled in a failed batch — failed batches are - // "split decisions", not recorded failures - // - drop-to-1 retries the SAME starting position, so every - // sector in the failed batch is individually probed - let initial_batch = opts.block_sectors.unwrap_or(1); - let mut current_batch: u16 = initial_batch; - let mut consecutive_singles_ok: u32 = 0; - const ADAPTIVE_UPSCALE_THRESHOLD: u32 = 16; - let recovery = opts.full_recovery; - - let mut halted = false; - let mut wedged_exit = false; - let mut blocks_attempted: u64 = 0; - let mut blocks_read_ok: u64 = 0; - let mut blocks_read_failed: u64 = 0; - // Reset to 0 at the start of every range; declared without init - // because the per-range reset (below) always runs before any read. - let mut consecutive_failures: u64; - let mut unreadable_count: u64 = 0; - let mut bytes_good_last = bytes_good_before; - let mut stall_start = std::time::Instant::now(); - let mut range_start; - let mut range_bytes_good; - const STALL_SECS: u64 = 3600; - // Per-range budget = sectors_in_range × SECONDS_PER_SECTOR, capped - // at RANGE_BUDGET_CAP. Replaces the old flat 180 s/range — that - // was unfair to medium ranges (a 51-sector range got the same - // 180 s as a 1-sector range, so multi-sector ranges couldn't - // even attempt every sector inside their budget) and pointlessly - // generous to single-sector ranges (180 s when ~5 s would do). - // The cap keeps catastrophic ranges (10s of MB) bounded so they - // can't consume the entire patch run; multi-pass orchestration - // raises the cap on later passes for the genuinely-stuck ones. - // Empirical per-failed-sector cost on direct-SATA BU40N (2026-05-08): - // ~3 s SCSI READ failure + ~15 s sr0 pread fallback (kernel sr_mod - // does ~5 internal retries) ≈ 18-25 s total. SECONDS_PER_SECTOR=25 - // lets a small range fully sample within budget instead of bailing - // after one slow read. Previous value of 5 was too tight: a - // 3-sector range got 15 s budget but the first failed read alone - // took ~20 s, so the watchdog fired before sector 2 could be tried. - const SECONDS_PER_SECTOR: u64 = 25; - const RANGE_BUDGET_CAP_SECS: u64 = 1800; - const MAX_SKIPS_PER_RANGE: u32 = 10; - let mut skip_count: u32; - let mut buf = vec![0u8; initial_batch as usize * 2048]; - - // Pass 2 uses smaller sectors (1 vs 32) but same damage detection logic - const PASSN_DAMAGE_WINDOW: usize = 16; - // Reduced from 12% to 6% for BU40N encrypted UHD discs. - // Lower threshold means patch tries harder before skipping ahead, - // giving more sectors a chance to be recovered on marginal media. - const PASSN_DAMAGE_THRESHOLD_PCT: usize = 6; - // Reduced base from 64 to 32 sectors (64 KB) for BU40N encrypted UHD. - // Smaller initial skips give patch more chances to recover marginal data - // before jumping far ahead in the range. Escalation still works up to cap. - const PASSN_SKIP_SECTORS_BASE: u64 = 32; - const PASSN_SKIP_SECTORS_CAP: u64 = 4096; - const PASSN_ESCALATION_RESET_GOOD: u32 = 4; - let mut damage_window: Vec = Vec::with_capacity(PASSN_DAMAGE_WINDOW); - let mut consecutive_skips_without_recovery: u32; - let mut consecutive_good_since_skip: u32; - let mut last_skip_from: Option = None; - - reader.set_speed(0x0000); - - // Log ALL mapfile entries for diagnostic purposes - tracing::info!( - target: "freemkv::disc", - phase = "patch_mapfile_snapshot", - total_entries = initial_entries.len(), - bytes_good_before, - bytes_retryable = initial_stats.bytes_retryable, - bytes_unreadable = initial_stats.bytes_unreadable, - bytes_nontried = initial_stats.bytes_nontried, - "Mapfile state snapshot at patch start" - ); - - // Log first 10 and last 10 entries for inspection - if !initial_entries.is_empty() { - tracing::info!( - target: "freemkv::disc", - phase = "patch_mapfile_entries_start", - num_to_log = (initial_entries.len().min(10)) as u32, - "First 10 entries" - ); - for entry in initial_entries.iter().take(10) { - tracing::debug!( - target: "freemkv::disc", - phase = "patch_mapfile_entry_start", - pos_hex = format!("0x{:09x}", entry.pos), - size_mb = entry.size as f64 / 1_048_576.0, - status_char = entry.status.to_char() as u8 as i32, - "Mapfile entry" - ); - } - } - if initial_entries.len() > 10 { - tracing::info!( - target: "freemkv::disc", - phase = "patch_mapfile_entries_end", - num_to_log = (initial_entries.len().min(10)) as u32, - "Last 10 entries" - ); - for entry in initial_entries.iter().skip(initial_entries.len() - 10) { - tracing::debug!( - target: "freemkv::disc", - phase = "patch_mapfile_entry_end", - pos_hex = format!("0x{:09x}", entry.pos), - size_mb = entry.size as f64 / 1_048_576.0, - status_char = format!("{}", entry.status.to_char()), - "Mapfile entry" - ); - } - } - - tracing::info!( - target: "freemkv::disc", - phase = "patch_bad_ranges", - num_ranges = bad_ranges.len(), - work_total, - reverse_mode = opts.reverse, - "Bad ranges for patch" - ); - let mut work_done: u64 = 0; - tracing::info!( - target: "freemkv::disc", - phase = "patch_start", - block_sectors = initial_batch, - recovery, - reverse = opts.reverse, - wedged_threshold = opts.wedged_threshold, - num_ranges = bad_ranges.len(), - work_total, - bytes_good_start, - "Disc::patch entered" - ); - - 'outer: for (range_idx, (range_pos, range_size)) in bad_ranges.iter().enumerate() { - tracing::info!( - target: "freemkv::disc", - phase = "patch_range_start", - range_index = range_idx, - num_total_ranges = bad_ranges.len(), - range_lba = *range_pos / 2048, - range_size_mb = *range_size as f64 / 1_048_576.0, - "Starting patch range" - ); - let end = *range_pos + *range_size; - let mut block_end = if opts.reverse { end } else { *range_pos }; - damage_window.clear(); - consecutive_skips_without_recovery = 0; - consecutive_good_since_skip = 0; - range_start = std::time::Instant::now(); - range_bytes_good = bytes_good_before; - skip_count = 0; - // Reset consecutive_failures at each range boundary. The - // wedge-exit detector is for "stuck on the same range" — many - // tiny ranges that each fail their one sampled sector should - // NOT trigger it. Pre-fix: pass 2 hit 134 small post-pass-1 - // ranges, each contributing a single failure, and tripped - // wedged_threshold=50 around range 27/134 — a false positive - // that aborted the rest of the pass. - consecutive_failures = 0; - let range_sectors = *range_size / 2048; - let range_budget_secs = (range_sectors * SECONDS_PER_SECTOR).min(RANGE_BUDGET_CAP_SECS); - tracing::debug!( - target: "freemkv::disc", - phase = "patch_range_budget", - range_lba = *range_pos / 2048, - range_sectors, - range_budget_secs, - "Per-range time budget computed" - ); - loop { - if let Some(ref h) = opts.halt { - if h.load(std::sync::atomic::Ordering::Relaxed) { - halted = true; - break 'outer; - } - } - - // Per-range watchdog: budget = range_sectors × 5 s, capped - // at RANGE_BUDGET_CAP_SECS. Tiny ranges exit fast (1-sector - // range = 5 s budget); medium ranges get proportional time - // (51-sector range = 255 s); huge ranges still bounded by - // the cap so they can't monopolise pass 1. - // - // Both the absolute-elapsed and no-progress checks share - // the same per-range budget. The progress check resets - // range_start on every byte gained, so a steadily-recovering - // range can run as long as it makes progress. - if range_start.elapsed().as_secs() > range_budget_secs { - tracing::warn!( - target: "freemkv::disc", - phase = "patch_range_timeout", - range_lba = range_pos / 2048, - range_sectors, - elapsed_secs = range_start.elapsed().as_secs(), - budget_secs = range_budget_secs, - bytes_recovered = range_bytes_good.saturating_sub(bytes_good_before), - "Range timeout - moving to next range" - ); - break; - } - - let bytes_good_now = read_shared(&shared).0.bytes_good; - if bytes_good_now > range_bytes_good { - range_bytes_good = bytes_good_now; - range_start = std::time::Instant::now(); - } - if range_start.elapsed().as_secs() > range_budget_secs { - tracing::warn!( - target: "freemkv::disc", - phase = "patch_range_stall", - range_lba = range_pos / 2048, - range_sectors, - elapsed_secs = range_start.elapsed().as_secs(), - budget_secs = range_budget_secs, - bytes_recovered = range_bytes_good.saturating_sub(bytes_good_before), - "Range stalled - moving to next range" - ); - break; - } - - // Test 3: Skip count - max 10 skips per range - if skip_count >= MAX_SKIPS_PER_RANGE { - tracing::warn!( - target: "freemkv::disc", - phase = "patch_skip_limit", - range_lba = range_pos / 2048, - skip_count, - "Skip limit reached - leaving remaining bytes NonTrimmed for next pass", - ); - // CRITICAL: don't mark sectors we NEVER ATTEMPTED as - // Unreadable. Only sectors we actually read+failed get - // the terminal `-` status. Sectors we jumped over are - // hopeful — the drive may read them on a later pass - // when state has evolved (cache, mechanical settle). - // 2026-05-07 dd-as-oracle test confirmed ~36% of - // patch-marked Unreadable sectors are actually readable. - let unmarked_bytes = block_end.saturating_sub(*range_pos); - if opts.reverse { - send_or_abort( - &pipe, - PatchItem::NonTrimmed { - pos: *range_pos, - len: unmarked_bytes, - }, - )?; - } else { - let remaining_start = *range_pos + (end - block_end); - if remaining_start < end { - send_or_abort( - &pipe, - PatchItem::NonTrimmed { - pos: remaining_start, - len: end - remaining_start, - }, - )?; - } - } - // Continue to next range (break inner loop only) - break; - } - let (pos, block_bytes) = if opts.reverse { - if block_end <= *range_pos { - break; - } - let span = (block_end - *range_pos).min(current_batch as u64 * 2048); - (block_end - span, span) - } else { - if block_end >= end { - break; - } - let span = (end - block_end).min(current_batch as u64 * 2048); - (block_end, span) - }; - let lba = (pos / 2048) as u32; - let count = (block_bytes / 2048) as u16; - let bytes = count as usize * 2048; - blocks_attempted += 1; - - tracing::debug!( - target: "freemkv::disc", - phase = "patch_read_start", - lba, - count, - bytes, - attempt_num = blocks_attempted, - range_index = range_idx, - pos_byte = pos, - "Starting sector read" - ); - - // Cache priming: before reading the target sector, do - // a few single-sector reads at LBAs immediately preceding - // it. The drive's read-ahead cache prefetches forward on - // sequential reads — so by the time we ask for `lba` it - // may already be cached, even if a cold read fails. Proven - // 2026-05-07 with dd-as-oracle: 8/8 sectors recoverable - // when primed vs 6/8 cold. Throwaway reads — we already - // have those bytes Finished from a prior pass; failures - // here don't update mapfile state. - const CACHE_PRIME_SECTORS: u32 = 3; - if lba >= CACHE_PRIME_SECTORS && count == 1 { - let mut prime_buf = [0u8; 2048]; - for i in 0..CACHE_PRIME_SECTORS { - let prime_lba = lba - CACHE_PRIME_SECTORS + i; - // Best-effort; ignore errors. Recovery=false is - // intentional: a fast 1.5s timeout is fine because - // we don't need the data. - let _ = reader.read_sectors(prime_lba, 1, &mut prime_buf[..], false); - } - } - - // Single-shot read. Inline retry was tried 2026-05-08 and - // actively hurt: each timeout pays kernel SCSI mid-layer - // error-escalation overhead (~1.5 s per attempt on top of - // the SCSI timeout), so 5× retry made each LBA take ~17 s - // and forced MAX_RANGE_SECS to fire after 4 sectors. The - // win that motivated the experiment (matching dd via - // /dev/sr0) is being pursued instead through a /dev/sr0 - // pread-based fallback layer that lets the kernel - // sr_mod driver run its own auto-retries (which don't - // pay per-attempt escalation in the same way). - let read_start = std::time::Instant::now(); - let read_result = reader.read_sectors(lba, count, &mut buf[..bytes], recovery); - let read_duration_ms = read_start.elapsed().as_millis(); - - match read_result { - Ok(_) => { - blocks_read_ok += 1; - consecutive_failures = 0; - consecutive_good_since_skip += 1; - if consecutive_good_since_skip >= PASSN_ESCALATION_RESET_GOOD { - consecutive_skips_without_recovery = 0; - } - // Adaptive batching: track clean single-sector reads to - // decide when to climb back to `initial_batch`. A batch - // read succeeding (count > 1) tells us the drive is healthy - // but doesn't accumulate toward upscale — we got back to - // batch=1 because of a failure here, we need consistent - // health at the slow tempo before scaling up again. - if count == 1 && current_batch < initial_batch { - consecutive_singles_ok += 1; - if consecutive_singles_ok >= ADAPTIVE_UPSCALE_THRESHOLD { - tracing::info!( - target: "freemkv::disc", - phase = "patch_adaptive_upscale", - from = current_batch, - to = initial_batch, - consecutive_singles_ok, - lba, - "adaptive batching: drive stable, climbing back to initial_batch" - ); - current_batch = initial_batch; - consecutive_singles_ok = 0; - } - } - damage_window.push(true); - if damage_window.len() > PASSN_DAMAGE_WINDOW { - damage_window.remove(0); - - tracing::info!( - target: "freemkv::disc", - phase = "patch_read_ok", - lba, - count, - bytes, - blocks_read_ok, - consecutive_failures, - read_duration_ms, - range_idx, - pos, - "Read succeeded" - ); - } - // Plaintext: DecryptingSectorSource applied AACS / CSS - // in-place during the read_sectors call above. The - // pre-0.18 inline decrypt_sectors call lived here. - let write_start = std::time::Instant::now(); - tracing::debug!( - target: "freemkv::disc", - phase = "patch_write_start", - pos, - bytes, - "Starting ISO write" - ); - // Hand the recovered bytes off to the consumer: - // seek + write + mapfile.record(Finished) all - // happen on the consumer thread, so the producer - // can immediately move on to the next read while - // these bytes are being committed. - send_or_abort( - &pipe, - PatchItem::Recovered { - pos, - buf: buf[..bytes].to_vec(), - }, - )?; - let write_duration_ms = write_start.elapsed().as_millis(); - tracing::info!( - target: "freemkv::disc", - phase = "patch_write_ok", - pos, - bytes, - write_duration_ms, - "ISO write succeeded" - ); - tracing::info!( - target: "freemkv::disc", - phase = "patch_mapfile_record_ok", - pos, - block_bytes, - "Mapfile record dispatched" - ); - - // Stall guard: watch bytes_good (real progress), - // not pos (advances on skips). With the consumer - // running in its own thread, this read can lag - // by up to one item; the watchdog operates at - // STALL_SECS=3600 granularity so single-item lag - // is irrelevant. - let bytes_good_now = read_shared(&shared).0.bytes_good; - if bytes_good_now > bytes_good_last { - stall_start = std::time::Instant::now(); - bytes_good_last = bytes_good_now; - } - if stall_start.elapsed() > std::time::Duration::from_secs(STALL_SECS) { - tracing::warn!( - target: "freemkv::disc", - phase = "patch_stall", - elapsed_secs = stall_start.elapsed().as_secs(), - bytes_good = bytes_good_now, - bytes_good_start, - "Patch stalled - no recovery for {}s, exiting pass", - STALL_SECS - ); - wedged_exit = true; - break 'outer; - } - - if let Some(skip_from) = last_skip_from.take() { - let backtrack_start = block_end; - let backtrack_end = skip_from; - if opts.reverse && backtrack_start < backtrack_end { - tracing::info!( - target: "freemkv::disc", - phase = "patch_backtrack_start", - from_lba = pos, - to_lba = backtrack_end / 2048, - "recovered after skip; backtracking into gap" - ); - let mut bt_pos = backtrack_start; - while bt_pos < backtrack_end { - let span = - // Backtrack always at count=1: this path - // fills a gap that the main loop's damage- - // window skip jumped over. Using batched - // reads here would lump good sectors into - // NonTrimmed marks when the gap contains - // even one bad sector. Backtrack is rare - // enough that the per-sector cost is fine. - (backtrack_end - bt_pos).min(2048); - let bt_lba = (bt_pos / 2048) as u32; - let bt_count = (span / 2048) as u16; - let bt_bytes = bt_count as usize * 2048; - match reader.read_sectors( - bt_lba, - bt_count, - &mut buf[..bt_bytes], - recovery, - ) { - Ok(_) => { - blocks_read_ok += 1; - // Plaintext via DecryptingSectorSource - // wrapping; same path the main read - // takes above. - send_or_abort( - &pipe, - PatchItem::Recovered { - pos: bt_pos, - buf: buf[..bt_bytes].to_vec(), - }, - )?; - } - Err(_err) => { - blocks_read_failed += 1; - // Leave NonTrimmed (not Unreadable) so a later - // pass gets another shot. Per the project goal - // — "recover 100% of readable data" — and the - // multi-pass design's promise: bytes stay - // Good-or-Maybe across passes; promotion to - // Unreadable is the orchestrator's job at - // end-of-recovery (final retry pass complete). - // Reference: 2026-05-11 design call. - send_or_abort( - &pipe, - PatchItem::NonTrimmed { - pos: bt_pos, - len: span, - }, - )?; - tracing::info!( - target: "freemkv::disc", - phase = "patch_backtrack_stop", - lba = bt_lba, - "backtrack hit damage; stopping" - ); - break; - } - } - work_done = work_done.saturating_add(span); - bt_pos += span; - } - } - } - } - Err(err) => { - // Adaptive batching split decision: a batch-read - // failure (count > 1) is NOT a recorded failure. - // We don't yet know which sector in the batch was - // actually bad — could be one, could be many. - // Drop to count=1 and retry the SAME starting - // position so every sector gets individually - // probed. Cursor stays put; loop continues. - // Invariants: no good sector ever gets lumped - // into a NonTrimmed mark, no spurious - // consecutive_failures (which drives wedge - // detection), no damage_window pollution from - // batch-level signals. - if count > 1 { - tracing::info!( - target: "freemkv::disc", - phase = "patch_adaptive_split", - lba, - count, - from_batch = current_batch, - err_code = err.code(), - "adaptive batching: batch read failed, dropping to count=1 to probe individually" - ); - current_batch = 1; - consecutive_singles_ok = 0; - continue; - } - - blocks_read_failed += 1; - consecutive_failures += 1; - consecutive_good_since_skip = 0; - consecutive_singles_ok = 0; - unreadable_count += 1; - - tracing::warn!( - target: "freemkv::disc", - phase = "patch_read_err", - lba, - count, - bytes, - blocks_read_failed, - consecutive_failures, - read_duration_ms, - error_code = err.code(), - range_idx, - pos, - "Read failed" - ); - - // Check if this is a NOT_READY error that should be retried - let sense = err.scsi_sense(); - - // ASC values indicating temporary drive unresponsiveness: - // 0x02 = medium not present, 0x03 = becoming ready, 0x04 = initialization required - let is_not_ready_retryable = sense - .map(|s| { - s.sense_key == 0x02 - && (s.asc == 0x02 || s.asc == 0x03 || s.asc == 0x04) - }) - .unwrap_or(false); - - // For retryable NOT_READY errors, pause longer and don't mark as Unreadable yet - if is_not_ready_retryable { - tracing::info!( - target: "freemkv::disc", - phase = "patch_not_ready_retry", - lba, - consecutive_failures, - err_asc = sense.map(|s| s.asc as u32).unwrap_or(0), - "NOT_READY with ASC=0x03/0x04; pausing for drive recovery before retry" - ); - - // Extended pause for NOT_READY - let drive complete internal mechanical recovery - let pause_secs = 15u64; - tracing::debug!( - target: "freemkv::disc", - phase = "patch_not_ready_pause", - lba, - consecutive_failures, - pause_secs, - "Waiting for drive to become ready" - ); - std::thread::sleep(std::time::Duration::from_secs(pause_secs)); - - // Don't mark as Unreadable yet - will retry on next iteration - damage_window.push(false); - if damage_window.len() > PASSN_DAMAGE_WINDOW { - damage_window.remove(0); - } - continue; - } - - // For non-NOT_READY errors (MEDIUM ERROR, ABORTED COMMAND, etc.), - // try additional retries before marking Unreadable. This is especially - // important for encrypted UHD discs where decryption failures can - // manifest as read errors that succeed on retry. - let mut retry_count = 0; - const MAX_NON_NOT_READY_RETRIES: u64 = 3; - let should_retry = opts.decrypt && retry_count < MAX_NON_NOT_READY_RETRIES; - - if should_retry { - tracing::info!( - target: "freemkv::disc", - phase = "patch_non_not_ready_retry", - lba, - err_code = err.code(), - retry = retry_count + 1, - max_retries = MAX_NON_NOT_READY_RETRIES, - "Non-NOT_READY error on encrypted disc; retrying" - ); - - // Exponential backoff: 2s, 4s, 8s before final Unreadable mark - let pause_secs = (1u64 << retry_count).min(8); - std::thread::sleep(std::time::Duration::from_secs(pause_secs)); - retry_count += 1; - - // Retry the read - match reader.read_sectors(lba, count, &mut buf[..bytes], recovery) { - Ok(_) => { - blocks_read_ok += 1; - consecutive_failures = 0; - consecutive_good_since_skip += 1; - if consecutive_good_since_skip >= PASSN_ESCALATION_RESET_GOOD { - consecutive_skips_without_recovery = 0; - } - damage_window.push(true); - if damage_window.len() > PASSN_DAMAGE_WINDOW { - damage_window.remove(0); - } - - tracing::info!( - target: "freemkv::disc", - phase = "patch_retry_success", - lba, - retry_count, - "Retry succeeded after non-NOT_READY error" - ); - - // Plaintext via DecryptingSectorSource; - // same path the original read takes. - let write_start = std::time::Instant::now(); - tracing::debug!( - target: "freemkv::disc", - phase = "patch_write_start", - pos, - bytes, - "Starting ISO write" - ); - send_or_abort( - &pipe, - PatchItem::Recovered { - pos, - buf: buf[..bytes].to_vec(), - }, - )?; - let write_duration_ms = write_start.elapsed().as_millis(); - tracing::info!( - target: "freemkv::disc", - phase = "patch_write_ok", - pos, - bytes, - write_duration_ms, - "ISO write succeeded" - ); - tracing::info!( - target: "freemkv::disc", - phase = "patch_mapfile_record_ok", - pos, - block_bytes, - "Mapfile record dispatched" - ); - - // Stall guard after successful retry - let bytes_good_now = read_shared(&shared).0.bytes_good; - if bytes_good_now > bytes_good_last { - stall_start = std::time::Instant::now(); - bytes_good_last = bytes_good_now; - } - continue; - } - Err(_) => { - tracing::warn!( - target: "freemkv::disc", - phase = "patch_retry_failed", - lba, - retry_count, - "Retry failed after non-NOT_READY error" - ); - } - } - } - - // All retries exhausted IN THIS PASS — leave NonTrimmed - // so a subsequent pass gets another shot. Bytes stay - // Good-or-Maybe across passes; only the orchestrator - // (autorip) promotes still-NonTrimmed → Unreadable - // after the FINAL retry pass completes. Reference: - // 2026-05-11 design call ("good or maybe until all - // passes are done, then it's gone"). Pre-fix the - // patch loop marked Unreadable here, which gave up - // on sectors that a later pass might have recovered - // (drive reads are stochastic — same sector that - // fails 10x in Pass 2 might succeed on attempt 1 in - // Pass 3 after the drive state has shifted). - send_or_abort( - &pipe, - PatchItem::NonTrimmed { - pos, - len: block_bytes, - }, - )?; - - damage_window.push(false); - if damage_window.len() > PASSN_DAMAGE_WINDOW { - damage_window.remove(0); - } - - // Stall guard: check on failures too, not just successes - let bytes_good_now = read_shared(&shared).0.bytes_good; - if bytes_good_now > bytes_good_last { - stall_start = std::time::Instant::now(); - bytes_good_last = bytes_good_now; - } - if stall_start.elapsed() > std::time::Duration::from_secs(STALL_SECS) { - tracing::warn!( - target: "freemkv::disc", - phase = "patch_stall", - elapsed_secs = stall_start.elapsed().as_secs(), - consecutive_failures, - bytes_good = bytes_good_now, - bytes_good_start, - "Patch stalled - no recovery for {}s, exiting pass", - STALL_SECS - ); - wedged_exit = true; - break 'outer; - } - - // Log every 10 failures or when approaching wedged threshold - if consecutive_failures % 10 == 0 - || consecutive_failures >= opts.wedged_threshold - { - tracing::warn!( - target: "freemkv::disc", - phase = "patch_failure_count", - lba, - consecutive_failures, - wedged_threshold = opts.wedged_threshold, - "Failure count" - ); - } - - // Probe good sectors to differentiate wedge vs bad sector - if consecutive_failures >= 3 && consecutive_failures % 5 == 0 { - let probe_offsets: [u64; 3] = - [0, skip_sectors_for_probe(1), skip_sectors_for_probe(2)]; - let mut probes_ok = 0; - - for (probe_idx, &offset) in probe_offsets.iter().enumerate() { - if offset >= block_bytes - || (offset == 0 && consecutive_failures < 5) - { - continue; - } - - let probe_pos = pos + offset; - let probe_lba = (probe_pos / 2048) as u32; - let probe_count = 1u16; - let mut probe_buf = [0u8; 2048]; - - match reader.read_sectors( - probe_lba, - probe_count, - &mut probe_buf[..], - recovery, - ) { - Ok(_) => { - probes_ok += 1; - tracing::debug!( - target: "freemkv::disc", - phase = "patch_probe_ok", - lba = probe_lba, - offset_from_current = offset, - probe_idx, - "Probe read succeeded — drive responsive" - ); - } - Err(_) => { - tracing::debug!( - target: "freemkv::disc", - phase = "patch_probe_err", - lba = probe_lba, - offset_from_current = offset, - probe_idx, - "Probe read failed" - ); - } - } - } - - if probes_ok > 0 { - tracing::info!( - target: "freemkv::disc", - phase = "patch_drive_responsive", - consecutive_failures, - probes_ok, - total_probes = 3, - lba, - range_idx, - "Drive responsive — bad sector cluster, not wedged" - ); - } else if probes_ok == 0 && consecutive_failures >= 10 { - // Heuristic suspicion of wedge — NOT the - // confirmed wedge_transition log that fires - // when the SCSI sense family flips into - // Hardware/IllegalRequest. This log just - // says "the local zone is fully bad" which - // could mean a real wedge OR a fully-bad - // cluster on a non-wedged drive. The - // wedge_skip handler in read_error.rs is - // what actually decides + acts. - tracing::warn!( - target: "freemkv::disc", - phase = "patch_zone_fully_bad", - consecutive_failures, - lba, - range_idx, - "patch zone fully bad (10+ failures, all probes failed); \ - not a wedge unless read_error.rs's wedge_transition also fires" - ); - } - } - - // Pair with the earlier NonTrimmed dispatch — same - // bytes, same state. Pre-2026-05-11 this was a - // second Unreadable mark; now it's NonTrimmed for - // the same reason: cross-pass retry survival. - send_or_abort( - &pipe, - PatchItem::NonTrimmed { - pos, - len: block_bytes, - }, - )?; - tracing::info!( - target: "freemkv::disc", - phase = "patch_mapfile_record_nontrimmed", - pos, - block_bytes, - consecutive_failures, - "Mapfile record dispatched as NonTrimmed (retry next pass)" - ); - - let pause_secs = if err.is_bridge_degradation() { - tracing::debug!( - target: "freemkv::disc", - phase = "patch_bridge_degradation", - lba, - consecutive_failures, - error = %err, - "bridge degradation; cooling down" - ); - BRIDGE_DEGRADATION_PAUSE_SECS - } else if consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD { - CONSECUTIVE_FAIL_LONG_PAUSE - } else { - POST_FAILURE_PAUSE_SECS - }; - - tracing::debug!( - target: "freemkv::disc", - phase = "patch_post_failure_pause", - lba, - consecutive_failures, - pause_secs, - "breathing room after failure" - ); - std::thread::sleep(std::time::Duration::from_secs(pause_secs)); - } - } - - let bad_count = damage_window.iter().filter(|&&b| !b).count(); - let mut did_skip = false; - if damage_window.len() >= PASSN_DAMAGE_WINDOW - && bad_count * 100 / damage_window.len() >= PASSN_DAMAGE_THRESHOLD_PCT - { - // Size-aware cap: never skip more than 1/4 of the - // remaining bad range. A 100-sector bad range is - // really 25-bad + 50-good + 25-bad in disguise; a - // hardcoded MB-scale skip would leap over the - // entire thing and miss the good middle. Capping - // at range_remaining/4 forces convergence on the - // actual bad sub-zones. - let range_remaining_bytes = if opts.reverse { - block_end.saturating_sub(*range_pos) - } else { - end.saturating_sub(block_end) - }; - let range_remaining_sectors = range_remaining_bytes / 2048; - let range_quarter = (range_remaining_sectors / 4).max(1); - let escalated = (PASSN_SKIP_SECTORS_BASE << consecutive_skips_without_recovery) - .min(PASSN_SKIP_SECTORS_CAP); - let skip_sectors = escalated.min(range_quarter); - let skip_bytes = skip_sectors * 2048; - let new_block_end = if opts.reverse { - block_end.saturating_sub(skip_bytes).max(*range_pos) - } else { - (block_end + skip_bytes).min(end) - }; - if new_block_end != block_end { - tracing::info!( - target: "freemkv::disc", - phase = "patch_damage_skip", - from_lba = lba, - skip_sectors, - escalation = consecutive_skips_without_recovery, - bad_pct = bad_count * 100 / damage_window.len(), - "damage cluster detected; skipping within range" - ); - let gap_bytes = if opts.reverse { - block_end.saturating_sub(new_block_end) - } else { - new_block_end.saturating_sub(block_end) - }; - work_done = work_done.saturating_add(gap_bytes); - last_skip_from = Some(block_end); - block_end = new_block_end; - consecutive_skips_without_recovery += 1; - skip_count += 1; - did_skip = true; - } - } - - if !did_skip { - if opts.reverse { - block_end = block_end.saturating_sub(block_bytes); - } else { - block_end += block_bytes; - } - } - - if opts.wedged_threshold > 0 && consecutive_failures >= opts.wedged_threshold { - // Only exit wedged after attempting multiple ranges with zero recovery. - // Single-range terminal failures should not abort the entire pass. - let multi_range_attempted = range_idx > 0; - if multi_range_attempted { - tracing::info!( - target: "freemkv::disc", - phase = "patch_wedged_exit", - consecutive_failures, - blocks_read_failed, - blocks_read_ok, - range_index = range_idx, - total_ranges = bad_ranges.len(), - "Disc::patch giving up — drive appears wedged after multiple ranges" - ); - wedged_exit = true; - break 'outer; - } - } - - work_done = work_done.saturating_add(block_bytes); - - if let Some(reporter) = opts.progress { - let (s, bad_ranges_now) = read_shared(&shared); - let kind = if initial_batch == 1 { - crate::progress::PassKind::Scrape { - reverse: opts.reverse, - } - } else { - crate::progress::PassKind::Trim { - reverse: opts.reverse, - } - }; - let main_title_bad = self - .titles - .first() - .map(|t| bytes_bad_in_title(t, &bad_ranges_now)) - .unwrap_or(0); - let main_title = self.titles.first(); - let pp = crate::progress::PassProgress { - kind, - work_done, - work_total, - bytes_good_total: s.bytes_good, - bytes_unreadable_total: s.bytes_unreadable, - bytes_pending_total: s.bytes_pending, - bytes_total_disc: total_bytes, - disc_duration_secs: main_title.map(|t| t.duration_secs), - bytes_bad_in_main_title: main_title_bad, - main_title_duration_secs: main_title.map(|t| t.duration_secs), - main_title_size_bytes: main_title.map(|t| t.size_bytes), - }; - if !reporter.report(&pp) { - halted = true; - break 'outer; - } - } - } - } - - // Drain the consumer thread: drop tx, wait for `close` to run - // sync_all + mapfile.flush, then take the final stats from the - // sink's summary. `close` failing on a regular-file sync_all is - // surfaced here as `Error::IoError`, matching pre-split - // behaviour. - let summary = pipe.finish()?; - let stats = summary.stats; - - // Log final ISO file size for write verification - if let Ok(metadata) = std::fs::metadata(path) { - tracing::info!( - target: "freemkv::disc", - phase = "patch_iso_size_end", - iso_bytes = metadata.len(), - bytes_recovered = stats.bytes_good.saturating_sub(bytes_good_before), - "ISO file size at patch end" - ); - } - - tracing::info!( - target: "freemkv::disc", - phase = "patch_done", - blocks_attempted, - blocks_read_ok, - blocks_read_failed, - unreadable_count, - wedged_exit, - halted, - bytes_recovered = stats.bytes_good.saturating_sub(bytes_good_before), - final_bytes_good = stats.bytes_good, - final_bytes_unreadable = stats.bytes_unreadable, - final_bytes_pending = stats.bytes_pending, - total_ranges_processed = bad_ranges.len(), - "Disc::patch returning" - ); - Ok(PatchOutcome { - bytes_total: total_bytes, - bytes_good: stats.bytes_good, - bytes_unreadable: stats.bytes_unreadable, - bytes_pending: stats.bytes_pending, - bytes_recovered_this_pass: stats.bytes_good.saturating_sub(bytes_good_before), - halted, - blocks_attempted, - blocks_read_ok, - blocks_read_failed, - wedged_exit, - wedged_threshold: opts.wedged_threshold, - }) - } -} - const MAX_BATCH_SECTORS: u16 = 510; const DEFAULT_BATCH_SECTORS_OPTICAL: u16 = 60; const DEFAULT_BATCH_SECTORS_BLOCK: u16 = 8192; @@ -3682,7 +2450,7 @@ mod tests { bad_sectors: std::collections::HashSet, } - impl crate::sector::SectorReader for MockReader { + impl crate::sector::SectorSource for MockReader { fn read_sectors( &mut self, lba: u32, @@ -3708,7 +2476,7 @@ mod tests { Ok(n) } - fn capacity(&self) -> u32 { + fn capacity_sectors(&self) -> u32 { self.total_sectors } } diff --git a/src/disc/patch.rs b/src/disc/patch.rs index 00126da..c1e21cb 100644 --- a/src/disc/patch.rs +++ b/src/disc/patch.rs @@ -11,7 +11,7 @@ //! This module decouples them. A consumer thread owns the //! [`crate::io::WritebackFile`] (the ISO file) and the //! [`super::mapfile::Mapfile`]. The producer thread (`Disc::patch`) -//! keeps the [`crate::sector::SectorReader`], the wedge / damage-window +//! keeps the [`crate::sector::SectorSource`], the wedge / damage-window //! state, the per-range watchdog, decrypt — so what enters the channel //! is already-clean cleartext bytes (or an "Unreadable" terminal mark). //! @@ -31,7 +31,7 @@ //! - All recovery state (damage window, consecutive_failures, skip //! escalation, range watchdog) stays on the producer thread. //! - `set_speed` calls happen on the producer thread (same thread that -//! owns the `SectorReader`). No new SCSI concurrency. +//! owns the `SectorSource`). No new SCSI concurrency. //! - Per-iteration ordering of file-write → mapfile-record is kept //! intact in the consumer (write before record), so the on-disk //! invariant "mapfile only marks Finished what the file has received" @@ -57,7 +57,7 @@ use std::sync::{Arc, Mutex}; use crate::error::{Error, Result}; use crate::io::pipeline::{Flow, Sink}; -use super::mapfile::{MapStats, Mapfile, SectorStatus}; +use super::mapfile::{self, MapStats, Mapfile, SectorStatus}; /// Item the producer hands to the patch consumer. One per per-sector /// recovery decision. @@ -260,3 +260,1243 @@ impl Sink for PatchSink { }) } } + +// ───────────────────────────────────────────────────────────────── +// Disc::patch + bytes_bad_in_title — extracted from disc/mod.rs in +// 0.20.1. Behavior unchanged; the move splits the 3,900-line mod.rs +// into a cleaner-to-read file. +// ───────────────────────────────────────────────────────────────── + +use super::{Disc, DiscTitle, PatchOptions, PatchOutcome, bytes_bad_in_title}; +use crate::sector::SectorSource; + +impl Disc { + /// Bytes of bad/unreadable data in a title's extents, from a mapfile. + /// + /// Consumers (CLI, autorip) call this after a rip pass to determine + /// how much damage affects a particular title — useful for showing + /// "42s lost (12s in main movie)" in the UI. + pub fn bytes_bad_in_title(&self, mapfile_path: &std::path::Path, title: &DiscTitle) -> u64 { + let map = match mapfile::Mapfile::load(mapfile_path) { + Ok(m) => m, + Err(_) => return 0, + }; + let bad_ranges = map.ranges_with(&[ + mapfile::SectorStatus::NonTrimmed, + mapfile::SectorStatus::Unreadable, + mapfile::SectorStatus::NonScraped, + mapfile::SectorStatus::NonTried, + ]); + bytes_bad_in_title(title, &bad_ranges) + } + + /// Pass 2..N of a multipass rip: re-read the bad ranges + /// recorded in the sidecar mapfile and try to recover them. + /// With `reverse: true` (the default for the recovery walker), + /// the bad-range walk runs end-to-start so escalating skips + /// converge on the actual bad sub-zones inside any + /// `NonTrimmed` block. Returns a [`PatchOutcome`] with + /// recovered byte counts and wedge-detection signals. + /// + /// 0.18: paired with [`Disc::sweep`] as the library's other flat + /// rip-phase verb. Caller drives the retry loop and the + /// sweep-vs-patch dispatch. + pub fn patch( + &self, + reader: &mut dyn SectorSource, + path: &std::path::Path, + opts: &PatchOptions, + ) -> Result { + use crate::io::pipeline::{Pipeline, WRITE_THROUGH_DEPTH}; + use crate::sector::{DecryptingSectorSource, SectorSource}; + + const BRIDGE_DEGRADATION_PAUSE_SECS: u64 = 10; + const POST_FAILURE_PAUSE_SECS: u64 = 1; + const CONSECUTIVE_FAIL_LONG_PAUSE: u64 = 5; + const CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD: u64 = 10; + + fn skip_sectors_for_probe(idx: usize) -> u64 { + let base = PASSN_SKIP_SECTORS_BASE as i64; + let escalation = (idx * 3) as i64; + let shifted = if escalation < 64 { + base << escalation + } else { + base + }; + shifted.min(PASSN_SKIP_SECTORS_CAP as i64) as u64 + } + + let mapfile_path = self.mapfile_for(path); + let map = + mapfile::Mapfile::load(&mapfile_path).map_err(|e| Error::IoError { source: e })?; + let total_bytes = map.total_size(); + let keys = if opts.decrypt { + self.decrypt_keys() + } else { + crate::decrypt::DecryptKeys::None + }; + + // Wrap the producer-side reader once so every read_sectors + // call (the main recovery read, the backtrack read, and the + // non-NOT_READY retry read) yields plaintext. Replaces three + // inline decrypt_sectors call sites that all keyed off the + // same `keys`. `DecryptKeys::None` keeps the unencrypted / + // --raw path a pass-through. + let mut reader = DecryptingSectorSource::new(reader, keys); + let reader = &mut reader; + + let is_regular = std::fs::metadata(path) + .map(|m| m.file_type().is_file()) + .unwrap_or(false); + + // Snapshot fields we need from the mapfile *before* it moves into + // the consumer thread: bytes_good baseline, total entries, the + // initial `bad_ranges` work list, and the start-of-patch + // diagnostic dump. The shared state (`shared`) republishes these + // throughout the pass; the consumer owns the live `Mapfile`. + let bytes_good_before = map.stats().bytes_good; + let bytes_good_start = bytes_good_before; + let initial_stats = map.stats(); + let initial_entries: Vec<_> = map.entries().to_vec(); + // Every retry pass acts on every non-Finished range. Including + // Unreadable means a sector that failed in pass N gets a fresh + // shot in pass N+1 — drive state evolves, the same read can + // succeed later. Each pass owns its own jumps/skips; if pass 5 + // jumps over the same zone as pass 2, fine. + let mut bad_ranges = map.ranges_with(&[ + mapfile::SectorStatus::NonTrimmed, + mapfile::SectorStatus::NonScraped, + mapfile::SectorStatus::Unreadable, + ]); + if opts.reverse { + bad_ranges.reverse(); + } + let work_total: u64 = bad_ranges.iter().map(|(_, sz)| *sz).sum(); + + // Spawn the consumer. The `WritebackFile` (same bounded-cache + // wrapper sweep uses, so patch's recovery writes — sparse but + // can be many across a damaged region — get the burst-flush + // protection on slow / NFS-backed staging) and the `Mapfile` + // both move into the sink. We hold an `Arc>` snapshot + // the sink republishes after every record so producer-side + // stall guards / progress callbacks can read consumer side- + // effects. + let (sink, shared) = PatchSink::new(path, map, is_regular)?; + // Why: WRITE_THROUGH_DEPTH (=1) — patch reads ONE sector per + // recovery decision and the producer's stall / damage-window + // logic checks consumer-published stats inline. Sweep's + // DEFAULT_PIPELINE_DEPTH (=4) would let several sectors of + // recovered bytes queue up between producer decisions and + // writes, which conflicts with the per-sector lockstep this + // loop was written against. + let pipe = Pipeline::::spawn(WRITE_THROUGH_DEPTH, sink)?; + + // Send a `PatchItem` and translate a `SendError` (consumer + // thread died / panicked) into a useful library error so the + // caller can propagate cleanly. Mirrors `sweep_pipeline.rs`'s + // `send_or_abort`. + let send_or_abort = |pipe: &Pipeline, item: PatchItem| -> Result<()> { + pipe.send(item).map_err(|_| Error::IoError { + source: std::io::Error::other("patch consumer terminated unexpectedly"), + }) + }; + + // Snapshot helper for producer-side stats reads. Holds the + // mutex briefly; we never read across operations so a fresh + // snapshot per call is fine. + let read_shared = + |shared: &std::sync::Mutex| -> (mapfile::MapStats, Vec<(u64, u64)>) { + let g = shared + .lock() + .expect("PatchSink shared state mutex poisoned"); + (g.stats, g.bad_ranges.clone()) + }; + + // Log ISO file size at patch start for write monitoring + if let Ok(metadata) = std::fs::metadata(path) { + tracing::info!( + target: "freemkv::disc", + phase = "patch_iso_size_start", + iso_bytes = metadata.len(), + "ISO file size at patch start" + ); + } + + // Adaptive batching: read at `current_batch`, drop to 1 on + // batch-read failure, climb back to `initial_batch` after + // ADAPTIVE_UPSCALE_THRESHOLD consecutive single-sector successes. + // Rationale: dense damage scattered through a NonTrimmed range + // is rare — most "bad ranges" in pass N have lots of good + // sectors that swept-by-default landed inside. Batch reads + // walk those at ~32x the speed of singles, dropping to 1 + // only when the drive actually returns an error. Guarantees: + // - no good sector is ever marked NonTrimmed because it + // was bundled in a failed batch — failed batches are + // "split decisions", not recorded failures + // - drop-to-1 retries the SAME starting position, so every + // sector in the failed batch is individually probed + let initial_batch = opts.block_sectors.unwrap_or(1); + let mut current_batch: u16 = initial_batch; + let mut consecutive_singles_ok: u32 = 0; + const ADAPTIVE_UPSCALE_THRESHOLD: u32 = 16; + let recovery = opts.full_recovery; + + let mut halted = false; + let mut wedged_exit = false; + let mut blocks_attempted: u64 = 0; + let mut blocks_read_ok: u64 = 0; + let mut blocks_read_failed: u64 = 0; + // Reset to 0 at the start of every range; declared without init + // because the per-range reset (below) always runs before any read. + let mut consecutive_failures: u64; + let mut unreadable_count: u64 = 0; + let mut bytes_good_last = bytes_good_before; + let mut stall_start = std::time::Instant::now(); + let mut range_start; + let mut range_bytes_good; + const STALL_SECS: u64 = 3600; + // Per-range budget = sectors_in_range × SECONDS_PER_SECTOR, capped + // at RANGE_BUDGET_CAP. Replaces the old flat 180 s/range — that + // was unfair to medium ranges (a 51-sector range got the same + // 180 s as a 1-sector range, so multi-sector ranges couldn't + // even attempt every sector inside their budget) and pointlessly + // generous to single-sector ranges (180 s when ~5 s would do). + // The cap keeps catastrophic ranges (10s of MB) bounded so they + // can't consume the entire patch run; multi-pass orchestration + // raises the cap on later passes for the genuinely-stuck ones. + // Empirical per-failed-sector cost on direct-SATA BU40N (2026-05-08): + // ~3 s SCSI READ failure + ~15 s sr0 pread fallback (kernel sr_mod + // does ~5 internal retries) ≈ 18-25 s total. SECONDS_PER_SECTOR=25 + // lets a small range fully sample within budget instead of bailing + // after one slow read. Previous value of 5 was too tight: a + // 3-sector range got 15 s budget but the first failed read alone + // took ~20 s, so the watchdog fired before sector 2 could be tried. + const SECONDS_PER_SECTOR: u64 = 25; + const RANGE_BUDGET_CAP_SECS: u64 = 1800; + const MAX_SKIPS_PER_RANGE: u32 = 10; + let mut skip_count: u32; + let mut buf = vec![0u8; initial_batch as usize * 2048]; + + // Pass 2 uses smaller sectors (1 vs 32) but same damage detection logic + const PASSN_DAMAGE_WINDOW: usize = 16; + // Reduced from 12% to 6% for BU40N encrypted UHD discs. + // Lower threshold means patch tries harder before skipping ahead, + // giving more sectors a chance to be recovered on marginal media. + const PASSN_DAMAGE_THRESHOLD_PCT: usize = 6; + // Reduced base from 64 to 32 sectors (64 KB) for BU40N encrypted UHD. + // Smaller initial skips give patch more chances to recover marginal data + // before jumping far ahead in the range. Escalation still works up to cap. + const PASSN_SKIP_SECTORS_BASE: u64 = 32; + const PASSN_SKIP_SECTORS_CAP: u64 = 4096; + const PASSN_ESCALATION_RESET_GOOD: u32 = 4; + let mut damage_window: Vec = Vec::with_capacity(PASSN_DAMAGE_WINDOW); + let mut consecutive_skips_without_recovery: u32; + let mut consecutive_good_since_skip: u32; + let mut last_skip_from: Option = None; + + reader.set_speed(0x0000); + + // Log ALL mapfile entries for diagnostic purposes + tracing::info!( + target: "freemkv::disc", + phase = "patch_mapfile_snapshot", + total_entries = initial_entries.len(), + bytes_good_before, + bytes_retryable = initial_stats.bytes_retryable, + bytes_unreadable = initial_stats.bytes_unreadable, + bytes_nontried = initial_stats.bytes_nontried, + "Mapfile state snapshot at patch start" + ); + + // Log first 10 and last 10 entries for inspection + if !initial_entries.is_empty() { + tracing::info!( + target: "freemkv::disc", + phase = "patch_mapfile_entries_start", + num_to_log = (initial_entries.len().min(10)) as u32, + "First 10 entries" + ); + for entry in initial_entries.iter().take(10) { + tracing::debug!( + target: "freemkv::disc", + phase = "patch_mapfile_entry_start", + pos_hex = format!("0x{:09x}", entry.pos), + size_mb = entry.size as f64 / 1_048_576.0, + status_char = entry.status.to_char() as u8 as i32, + "Mapfile entry" + ); + } + } + if initial_entries.len() > 10 { + tracing::info!( + target: "freemkv::disc", + phase = "patch_mapfile_entries_end", + num_to_log = (initial_entries.len().min(10)) as u32, + "Last 10 entries" + ); + for entry in initial_entries.iter().skip(initial_entries.len() - 10) { + tracing::debug!( + target: "freemkv::disc", + phase = "patch_mapfile_entry_end", + pos_hex = format!("0x{:09x}", entry.pos), + size_mb = entry.size as f64 / 1_048_576.0, + status_char = format!("{}", entry.status.to_char()), + "Mapfile entry" + ); + } + } + + tracing::info!( + target: "freemkv::disc", + phase = "patch_bad_ranges", + num_ranges = bad_ranges.len(), + work_total, + reverse_mode = opts.reverse, + "Bad ranges for patch" + ); + let mut work_done: u64 = 0; + tracing::info!( + target: "freemkv::disc", + phase = "patch_start", + block_sectors = initial_batch, + recovery, + reverse = opts.reverse, + wedged_threshold = opts.wedged_threshold, + num_ranges = bad_ranges.len(), + work_total, + bytes_good_start, + "Disc::patch entered" + ); + + 'outer: for (range_idx, (range_pos, range_size)) in bad_ranges.iter().enumerate() { + tracing::info!( + target: "freemkv::disc", + phase = "patch_range_start", + range_index = range_idx, + num_total_ranges = bad_ranges.len(), + range_lba = *range_pos / 2048, + range_size_mb = *range_size as f64 / 1_048_576.0, + "Starting patch range" + ); + let end = *range_pos + *range_size; + let mut block_end = if opts.reverse { end } else { *range_pos }; + damage_window.clear(); + consecutive_skips_without_recovery = 0; + consecutive_good_since_skip = 0; + range_start = std::time::Instant::now(); + range_bytes_good = bytes_good_before; + skip_count = 0; + // Reset consecutive_failures at each range boundary. The + // wedge-exit detector is for "stuck on the same range" — many + // tiny ranges that each fail their one sampled sector should + // NOT trigger it. Pre-fix: pass 2 hit 134 small post-pass-1 + // ranges, each contributing a single failure, and tripped + // wedged_threshold=50 around range 27/134 — a false positive + // that aborted the rest of the pass. + consecutive_failures = 0; + let range_sectors = *range_size / 2048; + let range_budget_secs = (range_sectors * SECONDS_PER_SECTOR).min(RANGE_BUDGET_CAP_SECS); + tracing::debug!( + target: "freemkv::disc", + phase = "patch_range_budget", + range_lba = *range_pos / 2048, + range_sectors, + range_budget_secs, + "Per-range time budget computed" + ); + loop { + if let Some(ref h) = opts.halt { + if h.load(std::sync::atomic::Ordering::Relaxed) { + halted = true; + break 'outer; + } + } + + // Per-range watchdog: budget = range_sectors × 5 s, capped + // at RANGE_BUDGET_CAP_SECS. Tiny ranges exit fast (1-sector + // range = 5 s budget); medium ranges get proportional time + // (51-sector range = 255 s); huge ranges still bounded by + // the cap so they can't monopolise pass 1. + // + // Both the absolute-elapsed and no-progress checks share + // the same per-range budget. The progress check resets + // range_start on every byte gained, so a steadily-recovering + // range can run as long as it makes progress. + if range_start.elapsed().as_secs() > range_budget_secs { + tracing::warn!( + target: "freemkv::disc", + phase = "patch_range_timeout", + range_lba = range_pos / 2048, + range_sectors, + elapsed_secs = range_start.elapsed().as_secs(), + budget_secs = range_budget_secs, + bytes_recovered = range_bytes_good.saturating_sub(bytes_good_before), + "Range timeout - moving to next range" + ); + break; + } + + let bytes_good_now = read_shared(&shared).0.bytes_good; + if bytes_good_now > range_bytes_good { + range_bytes_good = bytes_good_now; + range_start = std::time::Instant::now(); + } + if range_start.elapsed().as_secs() > range_budget_secs { + tracing::warn!( + target: "freemkv::disc", + phase = "patch_range_stall", + range_lba = range_pos / 2048, + range_sectors, + elapsed_secs = range_start.elapsed().as_secs(), + budget_secs = range_budget_secs, + bytes_recovered = range_bytes_good.saturating_sub(bytes_good_before), + "Range stalled - moving to next range" + ); + break; + } + + // Test 3: Skip count - max 10 skips per range + if skip_count >= MAX_SKIPS_PER_RANGE { + tracing::warn!( + target: "freemkv::disc", + phase = "patch_skip_limit", + range_lba = range_pos / 2048, + skip_count, + "Skip limit reached - leaving remaining bytes NonTrimmed for next pass", + ); + // CRITICAL: don't mark sectors we NEVER ATTEMPTED as + // Unreadable. Only sectors we actually read+failed get + // the terminal `-` status. Sectors we jumped over are + // hopeful — the drive may read them on a later pass + // when state has evolved (cache, mechanical settle). + // 2026-05-07 dd-as-oracle test confirmed ~36% of + // patch-marked Unreadable sectors are actually readable. + let unmarked_bytes = block_end.saturating_sub(*range_pos); + if opts.reverse { + send_or_abort( + &pipe, + PatchItem::NonTrimmed { + pos: *range_pos, + len: unmarked_bytes, + }, + )?; + } else { + let remaining_start = *range_pos + (end - block_end); + if remaining_start < end { + send_or_abort( + &pipe, + PatchItem::NonTrimmed { + pos: remaining_start, + len: end - remaining_start, + }, + )?; + } + } + // Continue to next range (break inner loop only) + break; + } + let (pos, block_bytes) = if opts.reverse { + if block_end <= *range_pos { + break; + } + let span = (block_end - *range_pos).min(current_batch as u64 * 2048); + (block_end - span, span) + } else { + if block_end >= end { + break; + } + let span = (end - block_end).min(current_batch as u64 * 2048); + (block_end, span) + }; + let lba = (pos / 2048) as u32; + let count = (block_bytes / 2048) as u16; + let bytes = count as usize * 2048; + blocks_attempted += 1; + + tracing::debug!( + target: "freemkv::disc", + phase = "patch_read_start", + lba, + count, + bytes, + attempt_num = blocks_attempted, + range_index = range_idx, + pos_byte = pos, + "Starting sector read" + ); + + // Cache priming: before reading the target sector, do + // a few single-sector reads at LBAs immediately preceding + // it. The drive's read-ahead cache prefetches forward on + // sequential reads — so by the time we ask for `lba` it + // may already be cached, even if a cold read fails. Proven + // 2026-05-07 with dd-as-oracle: 8/8 sectors recoverable + // when primed vs 6/8 cold. Throwaway reads — we already + // have those bytes Finished from a prior pass; failures + // here don't update mapfile state. + const CACHE_PRIME_SECTORS: u32 = 3; + if lba >= CACHE_PRIME_SECTORS && count == 1 { + let mut prime_buf = [0u8; 2048]; + for i in 0..CACHE_PRIME_SECTORS { + let prime_lba = lba - CACHE_PRIME_SECTORS + i; + // Best-effort; ignore errors. Recovery=false is + // intentional: a fast 1.5s timeout is fine because + // we don't need the data. + let _ = reader.read_sectors(prime_lba, 1, &mut prime_buf[..], false); + } + } + + // Single-shot read. Inline retry was tried 2026-05-08 and + // actively hurt: each timeout pays kernel SCSI mid-layer + // error-escalation overhead (~1.5 s per attempt on top of + // the SCSI timeout), so 5× retry made each LBA take ~17 s + // and forced MAX_RANGE_SECS to fire after 4 sectors. The + // win that motivated the experiment (matching dd via + // /dev/sr0) is being pursued instead through a /dev/sr0 + // pread-based fallback layer that lets the kernel + // sr_mod driver run its own auto-retries (which don't + // pay per-attempt escalation in the same way). + let read_start = std::time::Instant::now(); + let read_result = reader.read_sectors(lba, count, &mut buf[..bytes], recovery); + let read_duration_ms = read_start.elapsed().as_millis(); + + match read_result { + Ok(_) => { + blocks_read_ok += 1; + consecutive_failures = 0; + consecutive_good_since_skip += 1; + if consecutive_good_since_skip >= PASSN_ESCALATION_RESET_GOOD { + consecutive_skips_without_recovery = 0; + } + // Adaptive batching: track clean single-sector reads to + // decide when to climb back to `initial_batch`. A batch + // read succeeding (count > 1) tells us the drive is healthy + // but doesn't accumulate toward upscale — we got back to + // batch=1 because of a failure here, we need consistent + // health at the slow tempo before scaling up again. + if count == 1 && current_batch < initial_batch { + consecutive_singles_ok += 1; + if consecutive_singles_ok >= ADAPTIVE_UPSCALE_THRESHOLD { + tracing::info!( + target: "freemkv::disc", + phase = "patch_adaptive_upscale", + from = current_batch, + to = initial_batch, + consecutive_singles_ok, + lba, + "adaptive batching: drive stable, climbing back to initial_batch" + ); + current_batch = initial_batch; + consecutive_singles_ok = 0; + } + } + damage_window.push(true); + if damage_window.len() > PASSN_DAMAGE_WINDOW { + damage_window.remove(0); + + tracing::info!( + target: "freemkv::disc", + phase = "patch_read_ok", + lba, + count, + bytes, + blocks_read_ok, + consecutive_failures, + read_duration_ms, + range_idx, + pos, + "Read succeeded" + ); + } + // Plaintext: DecryptingSectorSource applied AACS / CSS + // in-place during the read_sectors call above. The + // pre-0.18 inline decrypt_sectors call lived here. + let write_start = std::time::Instant::now(); + tracing::debug!( + target: "freemkv::disc", + phase = "patch_write_start", + pos, + bytes, + "Starting ISO write" + ); + // Hand the recovered bytes off to the consumer: + // seek + write + mapfile.record(Finished) all + // happen on the consumer thread, so the producer + // can immediately move on to the next read while + // these bytes are being committed. + send_or_abort( + &pipe, + PatchItem::Recovered { + pos, + buf: buf[..bytes].to_vec(), + }, + )?; + let write_duration_ms = write_start.elapsed().as_millis(); + tracing::info!( + target: "freemkv::disc", + phase = "patch_write_ok", + pos, + bytes, + write_duration_ms, + "ISO write succeeded" + ); + tracing::info!( + target: "freemkv::disc", + phase = "patch_mapfile_record_ok", + pos, + block_bytes, + "Mapfile record dispatched" + ); + + // Stall guard: watch bytes_good (real progress), + // not pos (advances on skips). With the consumer + // running in its own thread, this read can lag + // by up to one item; the watchdog operates at + // STALL_SECS=3600 granularity so single-item lag + // is irrelevant. + let bytes_good_now = read_shared(&shared).0.bytes_good; + if bytes_good_now > bytes_good_last { + stall_start = std::time::Instant::now(); + bytes_good_last = bytes_good_now; + } + if stall_start.elapsed() > std::time::Duration::from_secs(STALL_SECS) { + tracing::warn!( + target: "freemkv::disc", + phase = "patch_stall", + elapsed_secs = stall_start.elapsed().as_secs(), + bytes_good = bytes_good_now, + bytes_good_start, + "Patch stalled - no recovery for {}s, exiting pass", + STALL_SECS + ); + wedged_exit = true; + break 'outer; + } + + if let Some(skip_from) = last_skip_from.take() { + let backtrack_start = block_end; + let backtrack_end = skip_from; + if opts.reverse && backtrack_start < backtrack_end { + tracing::info!( + target: "freemkv::disc", + phase = "patch_backtrack_start", + from_lba = pos, + to_lba = backtrack_end / 2048, + "recovered after skip; backtracking into gap" + ); + let mut bt_pos = backtrack_start; + while bt_pos < backtrack_end { + let span = + // Backtrack always at count=1: this path + // fills a gap that the main loop's damage- + // window skip jumped over. Using batched + // reads here would lump good sectors into + // NonTrimmed marks when the gap contains + // even one bad sector. Backtrack is rare + // enough that the per-sector cost is fine. + (backtrack_end - bt_pos).min(2048); + let bt_lba = (bt_pos / 2048) as u32; + let bt_count = (span / 2048) as u16; + let bt_bytes = bt_count as usize * 2048; + match reader.read_sectors( + bt_lba, + bt_count, + &mut buf[..bt_bytes], + recovery, + ) { + Ok(_) => { + blocks_read_ok += 1; + // Plaintext via DecryptingSectorSource + // wrapping; same path the main read + // takes above. + send_or_abort( + &pipe, + PatchItem::Recovered { + pos: bt_pos, + buf: buf[..bt_bytes].to_vec(), + }, + )?; + } + Err(_err) => { + blocks_read_failed += 1; + // Leave NonTrimmed (not Unreadable) so a later + // pass gets another shot. Per the project goal + // — "recover 100% of readable data" — and the + // multi-pass design's promise: bytes stay + // Good-or-Maybe across passes; promotion to + // Unreadable is the orchestrator's job at + // end-of-recovery (final retry pass complete). + // Reference: 2026-05-11 design call. + send_or_abort( + &pipe, + PatchItem::NonTrimmed { + pos: bt_pos, + len: span, + }, + )?; + tracing::info!( + target: "freemkv::disc", + phase = "patch_backtrack_stop", + lba = bt_lba, + "backtrack hit damage; stopping" + ); + break; + } + } + work_done = work_done.saturating_add(span); + bt_pos += span; + } + } + } + } + Err(err) => { + // Adaptive batching split decision: a batch-read + // failure (count > 1) is NOT a recorded failure. + // We don't yet know which sector in the batch was + // actually bad — could be one, could be many. + // Drop to count=1 and retry the SAME starting + // position so every sector gets individually + // probed. Cursor stays put; loop continues. + // Invariants: no good sector ever gets lumped + // into a NonTrimmed mark, no spurious + // consecutive_failures (which drives wedge + // detection), no damage_window pollution from + // batch-level signals. + if count > 1 { + tracing::info!( + target: "freemkv::disc", + phase = "patch_adaptive_split", + lba, + count, + from_batch = current_batch, + err_code = err.code(), + "adaptive batching: batch read failed, dropping to count=1 to probe individually" + ); + current_batch = 1; + consecutive_singles_ok = 0; + continue; + } + + blocks_read_failed += 1; + consecutive_failures += 1; + consecutive_good_since_skip = 0; + consecutive_singles_ok = 0; + unreadable_count += 1; + + tracing::warn!( + target: "freemkv::disc", + phase = "patch_read_err", + lba, + count, + bytes, + blocks_read_failed, + consecutive_failures, + read_duration_ms, + error_code = err.code(), + range_idx, + pos, + "Read failed" + ); + + // Check if this is a NOT_READY error that should be retried + let sense = err.scsi_sense(); + + // ASC values indicating temporary drive unresponsiveness: + // 0x02 = medium not present, 0x03 = becoming ready, 0x04 = initialization required + let is_not_ready_retryable = sense + .map(|s| { + s.sense_key == 0x02 + && (s.asc == 0x02 || s.asc == 0x03 || s.asc == 0x04) + }) + .unwrap_or(false); + + // For retryable NOT_READY errors, pause longer and don't mark as Unreadable yet + if is_not_ready_retryable { + tracing::info!( + target: "freemkv::disc", + phase = "patch_not_ready_retry", + lba, + consecutive_failures, + err_asc = sense.map(|s| s.asc as u32).unwrap_or(0), + "NOT_READY with ASC=0x03/0x04; pausing for drive recovery before retry" + ); + + // Extended pause for NOT_READY - let drive complete internal mechanical recovery + let pause_secs = 15u64; + tracing::debug!( + target: "freemkv::disc", + phase = "patch_not_ready_pause", + lba, + consecutive_failures, + pause_secs, + "Waiting for drive to become ready" + ); + std::thread::sleep(std::time::Duration::from_secs(pause_secs)); + + // Don't mark as Unreadable yet - will retry on next iteration + damage_window.push(false); + if damage_window.len() > PASSN_DAMAGE_WINDOW { + damage_window.remove(0); + } + continue; + } + + // For non-NOT_READY errors (MEDIUM ERROR, ABORTED COMMAND, etc.), + // try additional retries before marking Unreadable. This is especially + // important for encrypted UHD discs where decryption failures can + // manifest as read errors that succeed on retry. + let mut retry_count = 0; + const MAX_NON_NOT_READY_RETRIES: u64 = 3; + let should_retry = opts.decrypt && retry_count < MAX_NON_NOT_READY_RETRIES; + + if should_retry { + tracing::info!( + target: "freemkv::disc", + phase = "patch_non_not_ready_retry", + lba, + err_code = err.code(), + retry = retry_count + 1, + max_retries = MAX_NON_NOT_READY_RETRIES, + "Non-NOT_READY error on encrypted disc; retrying" + ); + + // Exponential backoff: 2s, 4s, 8s before final Unreadable mark + let pause_secs = (1u64 << retry_count).min(8); + std::thread::sleep(std::time::Duration::from_secs(pause_secs)); + retry_count += 1; + + // Retry the read + match reader.read_sectors(lba, count, &mut buf[..bytes], recovery) { + Ok(_) => { + blocks_read_ok += 1; + consecutive_failures = 0; + consecutive_good_since_skip += 1; + if consecutive_good_since_skip >= PASSN_ESCALATION_RESET_GOOD { + consecutive_skips_without_recovery = 0; + } + damage_window.push(true); + if damage_window.len() > PASSN_DAMAGE_WINDOW { + damage_window.remove(0); + } + + tracing::info!( + target: "freemkv::disc", + phase = "patch_retry_success", + lba, + retry_count, + "Retry succeeded after non-NOT_READY error" + ); + + // Plaintext via DecryptingSectorSource; + // same path the original read takes. + let write_start = std::time::Instant::now(); + tracing::debug!( + target: "freemkv::disc", + phase = "patch_write_start", + pos, + bytes, + "Starting ISO write" + ); + send_or_abort( + &pipe, + PatchItem::Recovered { + pos, + buf: buf[..bytes].to_vec(), + }, + )?; + let write_duration_ms = write_start.elapsed().as_millis(); + tracing::info!( + target: "freemkv::disc", + phase = "patch_write_ok", + pos, + bytes, + write_duration_ms, + "ISO write succeeded" + ); + tracing::info!( + target: "freemkv::disc", + phase = "patch_mapfile_record_ok", + pos, + block_bytes, + "Mapfile record dispatched" + ); + + // Stall guard after successful retry + let bytes_good_now = read_shared(&shared).0.bytes_good; + if bytes_good_now > bytes_good_last { + stall_start = std::time::Instant::now(); + bytes_good_last = bytes_good_now; + } + continue; + } + Err(_) => { + tracing::warn!( + target: "freemkv::disc", + phase = "patch_retry_failed", + lba, + retry_count, + "Retry failed after non-NOT_READY error" + ); + } + } + } + + // All retries exhausted IN THIS PASS — leave NonTrimmed + // so a subsequent pass gets another shot. Bytes stay + // Good-or-Maybe across passes; only the orchestrator + // (autorip) promotes still-NonTrimmed → Unreadable + // after the FINAL retry pass completes. Reference: + // 2026-05-11 design call ("good or maybe until all + // passes are done, then it's gone"). Pre-fix the + // patch loop marked Unreadable here, which gave up + // on sectors that a later pass might have recovered + // (drive reads are stochastic — same sector that + // fails 10x in Pass 2 might succeed on attempt 1 in + // Pass 3 after the drive state has shifted). + send_or_abort( + &pipe, + PatchItem::NonTrimmed { + pos, + len: block_bytes, + }, + )?; + + damage_window.push(false); + if damage_window.len() > PASSN_DAMAGE_WINDOW { + damage_window.remove(0); + } + + // Stall guard: check on failures too, not just successes + let bytes_good_now = read_shared(&shared).0.bytes_good; + if bytes_good_now > bytes_good_last { + stall_start = std::time::Instant::now(); + bytes_good_last = bytes_good_now; + } + if stall_start.elapsed() > std::time::Duration::from_secs(STALL_SECS) { + tracing::warn!( + target: "freemkv::disc", + phase = "patch_stall", + elapsed_secs = stall_start.elapsed().as_secs(), + consecutive_failures, + bytes_good = bytes_good_now, + bytes_good_start, + "Patch stalled - no recovery for {}s, exiting pass", + STALL_SECS + ); + wedged_exit = true; + break 'outer; + } + + // Log every 10 failures or when approaching wedged threshold + if consecutive_failures % 10 == 0 + || consecutive_failures >= opts.wedged_threshold + { + tracing::warn!( + target: "freemkv::disc", + phase = "patch_failure_count", + lba, + consecutive_failures, + wedged_threshold = opts.wedged_threshold, + "Failure count" + ); + } + + // Probe good sectors to differentiate wedge vs bad sector + if consecutive_failures >= 3 && consecutive_failures % 5 == 0 { + let probe_offsets: [u64; 3] = + [0, skip_sectors_for_probe(1), skip_sectors_for_probe(2)]; + let mut probes_ok = 0; + + for (probe_idx, &offset) in probe_offsets.iter().enumerate() { + if offset >= block_bytes + || (offset == 0 && consecutive_failures < 5) + { + continue; + } + + let probe_pos = pos + offset; + let probe_lba = (probe_pos / 2048) as u32; + let probe_count = 1u16; + let mut probe_buf = [0u8; 2048]; + + match reader.read_sectors( + probe_lba, + probe_count, + &mut probe_buf[..], + recovery, + ) { + Ok(_) => { + probes_ok += 1; + tracing::debug!( + target: "freemkv::disc", + phase = "patch_probe_ok", + lba = probe_lba, + offset_from_current = offset, + probe_idx, + "Probe read succeeded — drive responsive" + ); + } + Err(_) => { + tracing::debug!( + target: "freemkv::disc", + phase = "patch_probe_err", + lba = probe_lba, + offset_from_current = offset, + probe_idx, + "Probe read failed" + ); + } + } + } + + if probes_ok > 0 { + tracing::info!( + target: "freemkv::disc", + phase = "patch_drive_responsive", + consecutive_failures, + probes_ok, + total_probes = 3, + lba, + range_idx, + "Drive responsive — bad sector cluster, not wedged" + ); + } else if probes_ok == 0 && consecutive_failures >= 10 { + // Heuristic suspicion of wedge — NOT the + // confirmed wedge_transition log that fires + // when the SCSI sense family flips into + // Hardware/IllegalRequest. This log just + // says "the local zone is fully bad" which + // could mean a real wedge OR a fully-bad + // cluster on a non-wedged drive. The + // wedge_skip handler in read_error.rs is + // what actually decides + acts. + tracing::warn!( + target: "freemkv::disc", + phase = "patch_zone_fully_bad", + consecutive_failures, + lba, + range_idx, + "patch zone fully bad (10+ failures, all probes failed); \ + not a wedge unless read_error.rs's wedge_transition also fires" + ); + } + } + + // Pair with the earlier NonTrimmed dispatch — same + // bytes, same state. Pre-2026-05-11 this was a + // second Unreadable mark; now it's NonTrimmed for + // the same reason: cross-pass retry survival. + send_or_abort( + &pipe, + PatchItem::NonTrimmed { + pos, + len: block_bytes, + }, + )?; + tracing::info!( + target: "freemkv::disc", + phase = "patch_mapfile_record_nontrimmed", + pos, + block_bytes, + consecutive_failures, + "Mapfile record dispatched as NonTrimmed (retry next pass)" + ); + + let pause_secs = if err.is_bridge_degradation() { + tracing::debug!( + target: "freemkv::disc", + phase = "patch_bridge_degradation", + lba, + consecutive_failures, + error = %err, + "bridge degradation; cooling down" + ); + BRIDGE_DEGRADATION_PAUSE_SECS + } else if consecutive_failures >= CONSECUTIVE_FAIL_LONG_PAUSE_THRESHOLD { + CONSECUTIVE_FAIL_LONG_PAUSE + } else { + POST_FAILURE_PAUSE_SECS + }; + + tracing::debug!( + target: "freemkv::disc", + phase = "patch_post_failure_pause", + lba, + consecutive_failures, + pause_secs, + "breathing room after failure" + ); + std::thread::sleep(std::time::Duration::from_secs(pause_secs)); + } + } + + let bad_count = damage_window.iter().filter(|&&b| !b).count(); + let mut did_skip = false; + if damage_window.len() >= PASSN_DAMAGE_WINDOW + && bad_count * 100 / damage_window.len() >= PASSN_DAMAGE_THRESHOLD_PCT + { + // Size-aware cap: never skip more than 1/4 of the + // remaining bad range. A 100-sector bad range is + // really 25-bad + 50-good + 25-bad in disguise; a + // hardcoded MB-scale skip would leap over the + // entire thing and miss the good middle. Capping + // at range_remaining/4 forces convergence on the + // actual bad sub-zones. + let range_remaining_bytes = if opts.reverse { + block_end.saturating_sub(*range_pos) + } else { + end.saturating_sub(block_end) + }; + let range_remaining_sectors = range_remaining_bytes / 2048; + let range_quarter = (range_remaining_sectors / 4).max(1); + let escalated = (PASSN_SKIP_SECTORS_BASE << consecutive_skips_without_recovery) + .min(PASSN_SKIP_SECTORS_CAP); + let skip_sectors = escalated.min(range_quarter); + let skip_bytes = skip_sectors * 2048; + let new_block_end = if opts.reverse { + block_end.saturating_sub(skip_bytes).max(*range_pos) + } else { + (block_end + skip_bytes).min(end) + }; + if new_block_end != block_end { + tracing::info!( + target: "freemkv::disc", + phase = "patch_damage_skip", + from_lba = lba, + skip_sectors, + escalation = consecutive_skips_without_recovery, + bad_pct = bad_count * 100 / damage_window.len(), + "damage cluster detected; skipping within range" + ); + let gap_bytes = if opts.reverse { + block_end.saturating_sub(new_block_end) + } else { + new_block_end.saturating_sub(block_end) + }; + work_done = work_done.saturating_add(gap_bytes); + last_skip_from = Some(block_end); + block_end = new_block_end; + consecutive_skips_without_recovery += 1; + skip_count += 1; + did_skip = true; + } + } + + if !did_skip { + if opts.reverse { + block_end = block_end.saturating_sub(block_bytes); + } else { + block_end += block_bytes; + } + } + + if opts.wedged_threshold > 0 && consecutive_failures >= opts.wedged_threshold { + // Only exit wedged after attempting multiple ranges with zero recovery. + // Single-range terminal failures should not abort the entire pass. + let multi_range_attempted = range_idx > 0; + if multi_range_attempted { + tracing::info!( + target: "freemkv::disc", + phase = "patch_wedged_exit", + consecutive_failures, + blocks_read_failed, + blocks_read_ok, + range_index = range_idx, + total_ranges = bad_ranges.len(), + "Disc::patch giving up — drive appears wedged after multiple ranges" + ); + wedged_exit = true; + break 'outer; + } + } + + work_done = work_done.saturating_add(block_bytes); + + if let Some(reporter) = opts.progress { + let (s, bad_ranges_now) = read_shared(&shared); + let kind = if initial_batch == 1 { + crate::progress::PassKind::Scrape { + reverse: opts.reverse, + } + } else { + crate::progress::PassKind::Trim { + reverse: opts.reverse, + } + }; + let main_title_bad = self + .titles + .first() + .map(|t| bytes_bad_in_title(t, &bad_ranges_now)) + .unwrap_or(0); + let main_title = self.titles.first(); + let pp = crate::progress::PassProgress { + kind, + work_done, + work_total, + bytes_good_total: s.bytes_good, + bytes_unreadable_total: s.bytes_unreadable, + bytes_pending_total: s.bytes_pending, + bytes_total_disc: total_bytes, + disc_duration_secs: main_title.map(|t| t.duration_secs), + bytes_bad_in_main_title: main_title_bad, + main_title_duration_secs: main_title.map(|t| t.duration_secs), + main_title_size_bytes: main_title.map(|t| t.size_bytes), + }; + if !reporter.report(&pp) { + halted = true; + break 'outer; + } + } + } + } + + // Drain the consumer thread: drop tx, wait for `close` to run + // sync_all + mapfile.flush, then take the final stats from the + // sink's summary. `close` failing on a regular-file sync_all is + // surfaced here as `Error::IoError`, matching pre-split + // behaviour. + let summary = pipe.finish()?; + let stats = summary.stats; + + // Log final ISO file size for write verification + if let Ok(metadata) = std::fs::metadata(path) { + tracing::info!( + target: "freemkv::disc", + phase = "patch_iso_size_end", + iso_bytes = metadata.len(), + bytes_recovered = stats.bytes_good.saturating_sub(bytes_good_before), + "ISO file size at patch end" + ); + } + + tracing::info!( + target: "freemkv::disc", + phase = "patch_done", + blocks_attempted, + blocks_read_ok, + blocks_read_failed, + unreadable_count, + wedged_exit, + halted, + bytes_recovered = stats.bytes_good.saturating_sub(bytes_good_before), + final_bytes_good = stats.bytes_good, + final_bytes_unreadable = stats.bytes_unreadable, + final_bytes_pending = stats.bytes_pending, + total_ranges_processed = bad_ranges.len(), + "Disc::patch returning" + ); + Ok(PatchOutcome { + bytes_total: total_bytes, + bytes_good: stats.bytes_good, + bytes_unreadable: stats.bytes_unreadable, + bytes_pending: stats.bytes_pending, + bytes_recovered_this_pass: stats.bytes_good.saturating_sub(bytes_good_before), + halted, + blocks_attempted, + blocks_read_ok, + blocks_read_failed, + wedged_exit, + wedged_threshold: opts.wedged_threshold, + }) + } +} diff --git a/src/disc/sweep.rs b/src/disc/sweep.rs index f41c325..d4a8c91 100644 --- a/src/disc/sweep.rs +++ b/src/disc/sweep.rs @@ -20,7 +20,7 @@ //! - Mapfile is single-writer (consumer-only). No locking. //! - All `read_error::ReadCtx` state stays on the producer thread. //! - `set_speed` calls happen on the producer thread (same thread that -//! owns the `SectorReader`). No new SCSI concurrency. +//! owns the `SectorSource`). No new SCSI concurrency. //! - Per-iteration ordering of file-write → mapfile-record is kept //! intact in the consumer (write before record), so the on-disk //! invariant "mapfile only marks Finished what the file has diff --git a/src/drive/mod.rs b/src/drive/mod.rs index 6a92586..415c186 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -29,7 +29,7 @@ use crate::platform::PlatformDriver; use crate::platform::mt1959::Mt1959; use crate::profile::{self, DriveProfile}; use crate::scsi::ScsiTransport; -use crate::sector::SectorReader; +use crate::sector::SectorSource; use std::path::Path; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -707,7 +707,7 @@ fn open_block_device_for_sg(sg_path: &Path) -> Option } } -impl SectorReader for Drive { +impl SectorSource for Drive { fn read_sectors( &mut self, lba: u32, diff --git a/src/ifo.rs b/src/ifo.rs index 98dd796..4a07348 100644 --- a/src/ifo.rs +++ b/src/ifo.rs @@ -9,7 +9,7 @@ use crate::disc::{Codec, Resolution}; use crate::error::{Error, Result}; -use crate::sector::SectorReader; +use crate::sector::SectorSource; use crate::udf::UdfFs; // ── Public types ──────────────────────────────────────────────────────────── @@ -184,7 +184,7 @@ fn bcd_byte(b: u8) -> u32 { /// /// Reads the VMG (Video Manager) to discover title sets, then reads each /// VTS IFO to extract PGC chains, cell addresses, and stream attributes. -pub fn parse_vmg(reader: &mut dyn SectorReader, udf: &UdfFs) -> Result { +pub fn parse_vmg(reader: &mut dyn SectorSource, udf: &UdfFs) -> Result { let vmg_data = udf.read_file(reader, "/VIDEO_TS/VIDEO_TS.IFO")?; // Validate VMG magic @@ -263,7 +263,7 @@ pub fn parse_vmg(reader: &mut dyn SectorReader, udf: &UdfFs) -> Result /// /// `titles_info` is a list of (chapter_count, vts_title_number) from TT_SRPT. fn parse_vts( - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, udf: &UdfFs, vts_number: u8, titles_info: &[(u16, u8)], diff --git a/src/labels/bdmt.rs b/src/labels/bdmt.rs index b218e47..90d78b1 100644 --- a/src/labels/bdmt.rs +++ b/src/labels/bdmt.rs @@ -26,7 +26,7 @@ // The module wiring (registry hook + public re-export) is added // separately. Until then the parse/detect entry points have no use super::xml; -use crate::sector::SectorReader; +use crate::sector::SectorSource; use crate::udf::UdfFs; use std::collections::BTreeMap; @@ -60,7 +60,7 @@ pub fn detect(udf: &UdfFs) -> bool { /// Read every `bdmt_.xml` under `/BDMV/META/DL/` and return the /// aggregated [`DiscMetadata`]. Returns `None` if no titles could be /// extracted from any file. -pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option { +pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option { let dir = udf.find_dir("/BDMV/META/DL")?; let mut out = DiscMetadata::default(); diff --git a/src/labels/clpi_audit.rs b/src/labels/clpi_audit.rs index 97d6db5..af0b25b 100644 --- a/src/labels/clpi_audit.rs +++ b/src/labels/clpi_audit.rs @@ -29,7 +29,7 @@ //! report. Surfaced via the labels-analyze tool — not part of the //! `analyze()` pipeline (no impact on the label output). -use crate::sector::SectorReader; +use crate::sector::SectorSource; use crate::udf::UdfFs; use std::collections::BTreeMap; @@ -116,7 +116,7 @@ impl ClpiVsMplsAudit { /// dedup-by-PID table of (CLPI fields, MPLS fields), return the /// merged view. Missing files (read errors, parse failures) are /// silently skipped — this is diagnostic, not correctness-critical. -pub fn audit(reader: &mut dyn SectorReader, udf: &UdfFs) -> ClpiVsMplsAudit { +pub fn audit(reader: &mut dyn SectorSource, udf: &UdfFs) -> ClpiVsMplsAudit { // Aggregate by PID across all CLPI files. If a PID appears in // multiple clips (typical — main movie clip + trailers reference // the same audio stream PIDs), first encountered wins (they should diff --git a/src/labels/criterion.rs b/src/labels/criterion.rs index a654b28..780c4a6 100644 --- a/src/labels/criterion.rs +++ b/src/labels/criterion.rs @@ -4,7 +4,7 @@ //! stream number mapping via playbackconfig. use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, xml}; -use crate::sector::SectorReader; +use crate::sector::SectorSource; use crate::udf::UdfFs; use std::collections::HashMap; @@ -12,7 +12,7 @@ pub fn detect(udf: &UdfFs) -> bool { super::jar_file_exists(udf, "streamproperties.xml") } -pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option { +pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option { let sp_data = super::read_jar_file(reader, udf, "streamproperties.xml")?; let sp_text = std::str::from_utf8(&sp_data).ok()?; diff --git a/src/labels/ctrm.rs b/src/labels/ctrm.rs index a6d57e1..6d257fc 100644 --- a/src/labels/ctrm.rs +++ b/src/labels/ctrm.rs @@ -5,7 +5,7 @@ //! menu_base.prop provides stream number → button name mapping. use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, vocab}; -use crate::sector::SectorReader; +use crate::sector::SectorSource; use crate::udf::UdfFs; use std::collections::HashMap; @@ -14,7 +14,7 @@ pub fn detect(udf: &UdfFs) -> bool { || super::jar_file_exists(udf, "language_streams.txt") } -pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option { +pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option { // Try language_streams.txt first (richer structured data) let ls_labels = parse_language_streams(reader, udf); @@ -55,7 +55,7 @@ fn merge(ls: Vec, mb: Vec) -> Vec { // ── language_streams.txt parser ──────────────────────────────────────────── -fn parse_language_streams(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option> { +fn parse_language_streams(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option> { let data = super::read_jar_file(reader, udf, "language_streams.txt")?; let text = std::str::from_utf8(&data).ok()?; @@ -184,7 +184,7 @@ mod tests { /// Build a minimal menu_base.prop text and run `parse_menu_base`'s /// inner logic via a temporary closure. This isolates the prop - /// parsing without needing a SectorReader. + /// parsing without needing a SectorSource. fn parse_props(text: &str) -> Vec { // Mirror the inner loop of parse_menu_base exactly. Kept // separate so the test doesn't need disc fixtures. @@ -338,7 +338,7 @@ mod tests { // ── menu_base.prop parser ────────────────────────────────────────────────── -fn parse_menu_base(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option> { +fn parse_menu_base(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option> { let data = super::read_jar_file(reader, udf, "menu_base.prop")?; let text = std::str::from_utf8(&data).ok()?; diff --git a/src/labels/dbp.rs b/src/labels/dbp.rs index ce20e62..812aa26 100644 --- a/src/labels/dbp.rs +++ b/src/labels/dbp.rs @@ -35,11 +35,11 @@ use super::class_reader::CpInfo; use super::{ParseResult, StreamLabel, StreamLabelType, jar, vocab}; -use crate::sector::SectorReader; +use crate::sector::SectorSource; use crate::udf::UdfFs; use std::collections::BTreeMap; -/// dbp detect can't peek inside a jar without a SectorReader (the +/// dbp detect can't peek inside a jar without a SectorSource (the /// trait function only takes `&UdfFs`), so we trigger on the cheap /// signal "any top-level .jar in /BDMV/JAR/." That fires on every /// BD-J disc, but parse() does the real `com/dbp/` check and @@ -49,7 +49,7 @@ pub fn detect(udf: &UdfFs) -> bool { jar::has_any_top_level_jar(udf) } -pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option { +pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option { jar::for_each_jar(reader, udf, |_entry_name, archive| { if !jar::has_path_prefix(archive, "com/dbp/") { return None; diff --git a/src/labels/deluxe.rs b/src/labels/deluxe.rs index 3d92b10..7819a08 100644 --- a/src/labels/deluxe.rs +++ b/src/labels/deluxe.rs @@ -84,18 +84,18 @@ use super::class_reader::{ ICONST_2, ICONST_3, ICONST_4, ICONST_5, ICONST_M1, INVOKESPECIAL, LDC, LDC_W, NEW, SIPUSH, }; use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, jar, vocab}; -use crate::sector::SectorReader; +use crate::sector::SectorSource; use crate::udf::UdfFs; use std::collections::{HashMap, HashSet}; pub fn detect(udf: &UdfFs) -> bool { // Cheap pre-check at the dir level; the real signal is // `com/bydeluxe/` inside any top-level jar's central directory, - // which `parse()` confirms when given a `SectorReader`. + // which `parse()` confirms when given a `SectorSource`. jar::has_any_top_level_jar(udf) } -pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option { +pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option { jar::for_each_jar(reader, udf, |entry_name, archive| { if !jar::has_path_prefix(archive, "com/bydeluxe/") { return None; diff --git a/src/labels/jar.rs b/src/labels/jar.rs index d9788e0..a475898 100644 --- a/src/labels/jar.rs +++ b/src/labels/jar.rs @@ -13,7 +13,7 @@ #![allow(dead_code)] use super::class_reader::ClassFile; -use crate::sector::SectorReader; +use crate::sector::SectorSource; use crate::udf::UdfFs; use std::io::Cursor; use zip::ZipArchive; @@ -26,7 +26,7 @@ pub type Jar = ZipArchive>>; /// True if `/BDMV/JAR/` contains at least one top-level `.jar` file /// (not under a subdir). Used by `detect()` in parsers whose real /// signal lives inside a jar — they can't open the jar without a -/// `SectorReader`, so they use this cheap pre-check and do the real +/// `SectorSource`, so they use this cheap pre-check and do the real /// `com//` discriminator in `parse()`. pub fn has_any_top_level_jar(udf: &UdfFs) -> bool { let Some(jar_dir) = udf.find_dir("/BDMV/JAR") else { @@ -49,7 +49,7 @@ pub fn has_any_top_level_jar(udf: &UdfFs) -> bool { /// /// Entries that fail to read from UDF or that aren't valid zips are /// silently skipped — same defensive shape as the existing dbp parser. -pub fn for_each_jar(reader: &mut dyn SectorReader, udf: &UdfFs, mut f: F) -> Option +pub fn for_each_jar(reader: &mut dyn SectorSource, udf: &UdfFs, mut f: F) -> Option where F: FnMut(&str, &mut Jar) -> Option, { diff --git a/src/labels/mod.rs b/src/labels/mod.rs index 2c05c13..30fa0e7 100644 --- a/src/labels/mod.rs +++ b/src/labels/mod.rs @@ -4,7 +4,7 @@ //! To add a new format: //! 1. Create `src/labels/myformat.rs` //! 2. Implement `pub fn detect(udf: &UdfFs) -> bool` -//! 3. Implement `pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option>` +//! 3. Implement `pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option>` //! 4. Add `mod myformat;` below and one line to `PARSERS` array mod bdmt; @@ -18,13 +18,12 @@ pub(crate) mod jar; mod mpls_universal; mod paramount; mod pixelogic; -mod png_filenames; pub(crate) mod text; pub mod vocab; pub(crate) mod xml; use crate::disc::{DiscTitle, Stream}; -use crate::sector::SectorReader; +use crate::sector::SectorSource; use crate::udf::UdfFs; // Re-export bdmt's public type so callers can construct/inspect @@ -88,7 +87,7 @@ pub enum LabelQualifier { // to array order on confidence ties. type DetectFn = fn(&UdfFs) -> bool; -type ParseFn = fn(&mut dyn SectorReader, &UdfFs) -> Option; +type ParseFn = fn(&mut dyn SectorSource, &UdfFs) -> Option; /// Per-parser claim of how reliable its output is. Used by the /// registry to pick between parsers when more than one matches (e.g. @@ -176,7 +175,7 @@ const PARSERS: &[(&str, DetectFn, ParseFn)] = &[ /// Search disc for config files, extract labels, apply to streams. /// This is 100% optional — if anything fails, streams are untouched. -pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle]) { +pub fn apply(reader: &mut dyn SectorSource, udf: &UdfFs, titles: &mut [DiscTitle]) { let labels = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| extract(reader, udf))) .unwrap_or_default(); if labels.is_empty() { @@ -192,7 +191,7 @@ pub fn apply(reader: &mut dyn SectorReader, udf: &UdfFs, titles: &mut [DiscTitle /// `forced` flag. /// /// Extracted from `apply()` so the matching logic is unit-testable -/// without needing a SectorReader / UdfFs. +/// without needing a SectorSource / UdfFs. pub(crate) fn apply_labels(labels: &[StreamLabel], titles: &mut [DiscTitle]) { for title in titles.iter_mut() { let mut audio_idx: u16 = 0; @@ -370,7 +369,7 @@ fn generate_audio_label( } } -fn extract(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec { +fn extract(reader: &mut dyn SectorSource, udf: &UdfFs) -> Vec { let mut best: Option<(&'static str, ParseResult)> = None; for (name, detect, parse) in PARSERS { if !detect(udf) { @@ -483,7 +482,7 @@ fn type_tag(t: StreamLabelType) -> u8 { /// authoring tool left out of the published playlist. fn append_clpi_orphans( labels: &mut Vec, - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, udf: &UdfFs, ) -> usize { // Index existing labels by PID — but StreamLabel doesn't carry @@ -608,7 +607,7 @@ fn append_clpi_orphans( /// the return shape is richer (includes confidence, all detected /// parsers, and any parsers that produced empty results). #[doc(hidden)] -pub fn analyze(reader: &mut dyn SectorReader, udf: &UdfFs) -> LabelAnalysis { +pub fn analyze(reader: &mut dyn SectorSource, udf: &UdfFs) -> LabelAnalysis { let inventory = jar_inventory(udf); let mut parsers_detected: Vec<&'static str> = Vec::new(); let mut all_results: Vec<(&'static str, ParseResult)> = Vec::new(); @@ -699,7 +698,7 @@ pub fn analyze(reader: &mut dyn SectorReader, udf: &UdfFs) -> LabelAnalysis { /// playlist filename. Skipped entries (read error, parse error, no /// marks) silently dropped — this is a diagnostic field, not a /// correctness-critical one. -fn collect_chapter_summary(reader: &mut dyn SectorReader, udf: &UdfFs) -> Vec { +fn collect_chapter_summary(reader: &mut dyn SectorSource, udf: &UdfFs) -> Vec { let Some(playlist_dir) = udf.find_dir("/BDMV/PLAYLIST") else { return Vec::new(); }; @@ -842,7 +841,7 @@ pub(crate) fn find_jar_file(udf: &UdfFs, filename: &str) -> Option { /// Read a file from any BDMV/JAR subdirectory by filename. pub(crate) fn read_jar_file( - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, udf: &UdfFs, filename: &str, ) -> Option> { @@ -1073,7 +1072,7 @@ mod gap_fill_tests { // ── apply() integration tests ────────────────────────────────────────────── // // End-to-end coverage for the apply_labels + fill_defaults pipeline -// without needing a SectorReader / UdfFs. Synthetic DiscTitle + +// without needing a SectorSource / UdfFs. Synthetic DiscTitle + // StreamLabel inputs, assert on the resulting Stream field values. #[cfg(test)] diff --git a/src/labels/mpls_universal.rs b/src/labels/mpls_universal.rs index 950909b..8ea506b 100644 --- a/src/labels/mpls_universal.rs +++ b/src/labels/mpls_universal.rs @@ -24,7 +24,7 @@ use super::{ LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, vocab::{self, LangInfo}, }; -use crate::sector::SectorReader; +use crate::sector::SectorSource; use crate::udf::UdfFs; /// True iff `/BDMV/PLAYLIST/` exists and contains at least one @@ -45,7 +45,7 @@ pub fn detect(udf: &UdfFs) -> bool { /// Returns `None` if no labels could be produced (e.g. no .mpls files /// parsed successfully, or every parsed stream was a type we skip /// like IG / DV EL). -pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option { +pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option { let playlist_dir = udf.find_dir("/BDMV/PLAYLIST")?; // Collect mpls filenames first so we don't hold a borrow on udf diff --git a/src/labels/paramount.rs b/src/labels/paramount.rs index ea9a190..590a00a 100644 --- a/src/labels/paramount.rs +++ b/src/labels/paramount.rs @@ -13,14 +13,14 @@ //! ``` use super::{LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, xml}; -use crate::sector::SectorReader; +use crate::sector::SectorSource; use crate::udf::UdfFs; pub fn detect(udf: &UdfFs) -> bool { super::jar_file_exists(udf, "playlists.xml") } -pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option { +pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option { let data = super::read_jar_file(reader, udf, "playlists.xml")?; let text = std::str::from_utf8(&data).ok()?; diff --git a/src/labels/pixelogic.rs b/src/labels/pixelogic.rs index 9c75e2e..c8e0fc6 100644 --- a/src/labels/pixelogic.rs +++ b/src/labels/pixelogic.rs @@ -9,7 +9,7 @@ use super::{ Confidence, LabelPurpose, LabelQualifier, ParseResult, StreamLabel, StreamLabelType, text, vocab, }; -use crate::sector::SectorReader; +use crate::sector::SectorSource; use crate::udf::UdfFs; use std::sync::atomic::{AtomicBool, Ordering}; @@ -24,7 +24,7 @@ pub fn detect(udf: &UdfFs) -> bool { super::jar_file_exists(udf, "bluray_project.bin") } -pub fn parse(reader: &mut dyn SectorReader, udf: &UdfFs) -> Option { +pub fn parse(reader: &mut dyn SectorSource, udf: &UdfFs) -> Option { let data = super::read_jar_file(reader, udf, "bluray_project.bin")?; // min_len=4 matches the prior local extract_strings impl. The token // grammar is `{lang3}_{codec?}_{purpose?}_{region?}_` so the diff --git a/src/labels/png_filenames.rs b/src/labels/png_filenames.rs deleted file mode 100644 index eee838b..0000000 --- a/src/labels/png_filenames.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! PNG-filename language token parser — stubbed (noop) pending need. -//! -//! ## What this would do -//! -//! Some discs encode per-language menu localization as pre-rendered PNG -//! menu buttons, one per language, with the language token embedded in -//! the filename. Examples observed in the 2026-05-10 corpus: -//! -//! - **disc-01 (The Amateur)** — `___.png` -//! Region prefix: `USA` / `UK` / `JPN` / etc. -//! Lang tokens (3-char, uppercase): `ENG`, `FRC`, `FRP`, `DEU`, `DUT`, -//! `ITA`, `JPN`, `LAS`, `CSP`, `POL`, `CZE` (11 languages) -//! -//! - **disc-09 (Dune orig)** — `_<variant>_<lang>_Composite<N>.png` -//! Lang tokens (3-char, mixed-case): `Eng`, `Ger` (2 languages) -//! -//! ## Why stubbed -//! -//! MPLS already gives per-stream `language` + `coding_type` + stream-type -//! (audio vs subtitle) on every disc. For the 2 unknown-framework discs -//! that PNG filenames would close (disc-01, disc-09), MPLS will produce -//! a strict superset of what filenames could give us, because MPLS knows -//! per-stream attribution while filenames only know "the disc offers -//! these N language buttons." -//! -//! The **only** thing PNG filenames give us that MPLS doesn't is **studio -//! variant disambiguation**: -//! - `FRC` (French Canadian) vs `FRP` (French Parisian) — MPLS just says `fra` -//! - `LAS` (Latin American Spanish) vs `CSP` (Castilian Spanish) — MPLS just says `spa` -//! -//! That's niche enough that it doesn't justify implementing right now. -//! Reactivate this parser only when: -//! 1. We hit a disc where MPLS is malformed/empty AND PNG filenames are -//! the only language hint, OR -//! 2. A downstream consumer needs the studio variant suffix for output -//! naming (e.g. `Title (French Canadian).mkv` vs `Title (French).mkv`). -//! -//! ## When reactivating -//! -//! Implement `parse` to: -//! 1. Iterate top-level PNG paths in `/BDMV/JAR/` (and `<id>/` subdirs). -//! 2. Tokenize each filename on `_` / `-` / `.` -//! 3. Match each token against an alias table: -//! - ISO 639-1 / 639-2 standard codes -//! - Studio variants: `FRC`/`FRP` → `fra-CA`/`fra-FR`, -//! `LAS`/`CSP` → `spa-419`/`spa-ES`, -//! mixed-case shortforms `Eng`/`Ger`/`Fra`/`Spa`/`Jpn` → ISO 639-2 -//! - Country prefix filter: drop `USA`/`UK`/`JPN`/`AUS`/`GER`/`FR` when -//! they appear in position 0 (those are region markers, not langs). -//! 4. Deduplicate. Confidence stays `Low` because we still don't know -//! per-stream codec or audio/subtitle attribution. -//! -//! Wire as ENRICHMENT after MPLS in `mod.rs::analyze`, not as a primary -//! parser: PNG filenames upgrade `lang=fra` to `lang=fra-CA` when both -//! sources agree on the disc; they should never overwrite MPLS data. - -use super::ParseResult; -use crate::sector::SectorReader; -use crate::udf::UdfFs; - -/// Stub: returns false so the dispatcher never calls `parse`. Reactivate -/// by checking for the patterns described in the module docs. -#[allow(dead_code)] // module-level noop, not wired into PARSERS until needed -pub fn detect(_udf: &UdfFs) -> bool { - false -} - -/// Stub: returns None. See module docs for the implementation sketch. -#[allow(dead_code)] // module-level noop, not wired into PARSERS until needed -pub fn parse(_reader: &mut dyn SectorReader, _udf: &UdfFs) -> Option<ParseResult> { - None -} diff --git a/src/lib.rs b/src/lib.rs index 5e1d4f8..24b2de9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -179,7 +179,7 @@ pub use disc::{ // All stream types implement `pes::Stream` — read PES frames from a source, // write PES frames to a sink. Pick the right type at construction: // -// - `DiscStream` — physical drive or ISO (any `SectorReader`). Read-only. +// - `DiscStream` — physical drive or ISO (any `SectorSource`). Read-only. // - `MkvStream` — Matroska container. Read on `open()`, write on `create()`. // - `M2tsStream` — Blu-ray Transport Stream. Read on `open()`, write on `create()`. // - `NetworkStream` — TCP. Read on `listen()`, write on `connect()`. @@ -206,18 +206,14 @@ pub use mux::{InputOptions, StreamUrl, input, output, parse_url}; // ─── Lower-level surfaces ─────────────────────────────────────────────────── // // `ScsiTransport` is the platform-abstraction trait Drive uses; expose for -// out-of-tree platform backends. `SectorSource` / `SectorSink` are the 0.18 +// out-of-tree platform backends. `SectorSource` / `SectorSink` are the // direction-typed read/write traits; `FileSectorSource` and `FileSectorSink` // are the ISO-on-disk implementations. [`DecryptingSectorSource`] is the // single decrypt-on-read decorator (AACS / CSS / none) — wrap any -// `SectorSource` to get plaintext sectors out. The legacy `SectorReader` / -// `FileSectorReader` names stay re-exported through the 0.18 migration -// window so existing call sites compile unchanged; a blanket impl makes -// every `SectorReader` automatically usable as a `SectorSource`. +// `SectorSource` to get plaintext sectors out. pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives}; pub use sector::{ - DecryptingSectorSource, FileSectorReader, FileSectorSink, FileSectorSource, SectorReader, - SectorSink, SectorSource, + DecryptingSectorSource, FileSectorSink, FileSectorSource, SectorSink, SectorSource, }; pub use speed::DriveSpeed; pub use udf::{UdfFs, read_filesystem}; diff --git a/src/mux/disc.rs b/src/mux/disc.rs index c9cebd4..539b723 100644 --- a/src/mux/disc.rs +++ b/src/mux/disc.rs @@ -1,6 +1,6 @@ //! DiscStream — read any disc (physical drive or ISO file) → PES frames. //! -//! One stream type for all disc sources. The source is a SectorReader — +//! One stream type for all disc sources. The source is a SectorSource — //! Drive (hardware) or IsoSectorReader (file). DiscStream doesn't care. //! //! Read-only. For disc→ISO (raw sector copy), use `Disc::copy()`. @@ -9,7 +9,7 @@ use crate::disc::{Disc, DiscTitle, Extent}; use crate::drive::extract_scsi_context; use crate::event::{BatchSizeReason, Event, EventKind}; use crate::halt::Halt; -use crate::sector::{DecryptingSectorSource, SectorReader, SectorSource}; +use crate::sector::{DecryptingSectorSource, SectorSource}; use std::io; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -97,7 +97,7 @@ impl AdaptiveBatch { /// Disc stream. Reads sectors from any source → PES frames. /// -/// Sources: physical drive, ISO file, or any SectorReader. +/// Sources: physical drive, ISO file, or any SectorSource. /// Decrypt, demux, and codec parsing happen internally. pub struct DiscStream { /// Underlying sector source wrapped in the 0.18 @@ -105,7 +105,7 @@ pub struct DiscStream { /// call yields plaintext, so `fill_extents` no longer needs an /// inline `decrypt::decrypt_sectors` step. `DecryptKeys::None` /// (raw / unencrypted disc) makes the decorator a pass-through. - reader: DecryptingSectorSource<Box<dyn SectorReader>>, + reader: DecryptingSectorSource<Box<dyn SectorSource>>, title: DiscTitle, disc: Option<Disc>, /// Mirror of the keys handed in at construction. The decorator @@ -160,11 +160,11 @@ pub struct DiscStream { impl DiscStream { /// Create a disc stream from any sector reader. /// - /// Works with physical drives and ISO files — both implement SectorReader. + /// Works with physical drives and ISO files — both implement SectorSource. /// The caller opens the source, scans for titles/keys, and passes them in. /// The stream handles demuxing, decryption, and codec parsing internally. pub fn new( - reader: Box<dyn SectorReader>, + reader: Box<dyn SectorSource>, title: DiscTitle, decrypt_keys: crate::decrypt::DecryptKeys, batch_sectors: u16, @@ -177,7 +177,7 @@ impl DiscStream { tracing::debug!( target: "mux", "DiscStream constructed with reader type: {}", - std::any::type_name::<dyn SectorReader>() + std::any::type_name::<dyn SectorSource>() ); let mut pids = Vec::new(); @@ -595,14 +595,14 @@ mod tests { /// Static-assert `DiscStream: Send`. The `Stream` trait has `Send` as a /// supertrait — if a future field on `DiscStream` is non-`Send` (e.g. - /// a `Box<dyn Read>` instead of `Box<dyn SectorReader>`), this fails + /// a `Box<dyn Read>` instead of `Box<dyn SectorSource>`), this fails /// at compile time, before the runtime trait-object test below. fn _assert_disc_stream_is_send() { fn requires_send<T: Send>() {} requires_send::<DiscStream>(); } - /// Trivial `SectorReader` that yields zeroed sectors. Empty title means + /// Trivial `SectorSource` that yields zeroed sectors. Empty title means /// the demuxer produces no PES frames, so `read()` walks the extents to /// EOF and returns `Ok(None)`. That's enough to exercise the trait-object /// dispatch — the goal here is the bridge, not the demuxer. @@ -610,7 +610,7 @@ mod tests { capacity: u32, } - impl crate::sector::SectorReader for ZeroReader { + impl crate::sector::SectorSource for ZeroReader { fn read_sectors( &mut self, _lba: u32, @@ -623,7 +623,7 @@ mod tests { Ok(bytes) } - fn capacity(&self) -> u32 { + fn capacity_sectors(&self) -> u32 { self.capacity } } diff --git a/src/mux/iso.rs b/src/mux/iso.rs index 9e0ee12..5e82f1a 100644 --- a/src/mux/iso.rs +++ b/src/mux/iso.rs @@ -1,10 +1,10 @@ -//! ISO sector reader — file-backed SectorReader for Blu-ray ISO images. +//! ISO sector reader — file-backed SectorSource for Blu-ray ISO images. //! //! An ISO is a flat image of 2048-byte sectors. Sector N starts at byte offset N * 2048. //! Used by DiscStream::open_iso() and Disc::scan_image(). use crate::error::{Error, Result}; -use crate::sector::SectorReader; +use crate::sector::SectorSource; use std::fs::File; use std::io::{Read, Seek, SeekFrom}; use std::path::Path; @@ -32,12 +32,12 @@ impl IsoSectorReader { Ok(Self { file, capacity }) } - pub fn capacity(&self) -> u32 { + pub fn capacity_sectors(&self) -> u32 { self.capacity } } -impl SectorReader for IsoSectorReader { +impl SectorSource for IsoSectorReader { fn read_sectors( &mut self, lba: u32, @@ -73,7 +73,7 @@ mod tests { std::fs::write(&dir, &data).unwrap(); let mut reader = IsoSectorReader::open(dir.to_str().unwrap()).unwrap(); - assert_eq!(reader.capacity(), 4); + assert_eq!(reader.capacity_sectors(), 4); let mut buf = [0u8; 2048]; reader.read_sectors(0, 1, &mut buf, true).unwrap(); @@ -94,7 +94,7 @@ mod tests { std::fs::write(&dir, &data).unwrap(); let reader = IsoSectorReader::open(dir.to_str().unwrap()).unwrap(); - assert_eq!(reader.capacity(), 10); + assert_eq!(reader.capacity_sectors(), 10); std::fs::remove_file(&dir).ok(); } diff --git a/src/mux/resolve.rs b/src/mux/resolve.rs index 044ffb9..a29ee6c 100644 --- a/src/mux/resolve.rs +++ b/src/mux/resolve.rs @@ -186,7 +186,7 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S None => crate::disc::ScanOptions::default(), }; let mut reader = super::iso::IsoSectorReader::open(&path.to_string_lossy())?; - let capacity = reader.capacity(); + let capacity = reader.capacity_sectors(); let disc = crate::disc::Disc::scan_image(&mut reader, capacity, &scan_opts) .map_err(|e| -> io::Error { e.into() })?; if disc.titles.is_empty() { diff --git a/src/sector/file.rs b/src/sector/file.rs index 78256cb..4b2a932 100644 --- a/src/sector/file.rs +++ b/src/sector/file.rs @@ -13,7 +13,7 @@ use std::path::Path; use crate::error::{Error, Result}; -use super::{SectorReader, SectorSink}; +use super::{SectorSink, SectorSource}; /// SectorSource backed by a file (ISO image). /// @@ -49,14 +49,14 @@ impl FileSectorSource { } } -// Implement the legacy `SectorReader` trait. The blanket impl in +// Implement the legacy `SectorSource` trait. The blanket impl in // `super` produces the `SectorSource` impl automatically — no need // to write both, and writing both would conflict. This keeps the -// 0.17 method-resolution path intact (callers with `SectorReader` +// 0.17 method-resolution path intact (callers with `SectorSource` // in scope can still write `fsr.read_sectors(..)` against a // `FileSectorSource`). -impl SectorReader for FileSectorSource { - fn capacity(&self) -> u32 { +impl SectorSource for FileSectorSource { + fn capacity_sectors(&self) -> u32 { self.capacity } @@ -147,8 +147,8 @@ impl SectorSink for FileSectorSink { #[cfg(test)] mod tests { // Bring the 0.18 trait into scope (not super::*: the super - // module also re-exports the legacy `SectorReader`, and - // having both `SectorReader::read_sectors` and + // module also re-exports the legacy `SectorSource`, and + // having both `SectorSource::read_sectors` and // `SectorSource::read_sectors` visible would force every // call site to disambiguate). External consumers see the // same surface this test exercises. diff --git a/src/sector/mod.rs b/src/sector/mod.rs index d5213eb..6c9b3ec 100644 --- a/src/sector/mod.rs +++ b/src/sector/mod.rs @@ -1,29 +1,17 @@ //! Sector-level I/O traits. //! -//! 0.18 splits the unidirectional read trait from a write trait at -//! the sector layer, so the type system catches "wrong direction" -//! mistakes at compile time instead of runtime. See -//! `(internal)/memory/0_18_redesign.md`. +//! The sector layer is direction-typed: [`SectorSource`] reads +//! 2048-byte sectors, [`SectorSink`] writes them. Concrete impls +//! never do both — physical drives are read-only, file-backed +//! ISO images are opened for read OR write at construction time. //! -//! - [`SectorSource`] reads 2048-byte sectors. Implemented by -//! `Drive` (via the legacy [`SectorReader`] alias) and -//! [`FileSectorSource`] (ISO-backed). -//! - [`SectorSink`] writes 2048-byte sectors. Implemented by -//! [`FileSectorSink`] (ISO-backed) and, in later commits, by -//! sweep/patch consumer adapters. +//! - [`SectorSource`] is implemented by `Drive` (hardware) and +//! [`FileSectorSource`] / `IsoSectorReader` (file-backed). +//! - [`SectorSink`] is implemented by [`FileSectorSink`] +//! (ISO-backed) and sweep/patch consumer adapters. //! - [`DecryptingSectorSource`] is a decorator that wraps any -//! `SectorSource` and applies the existing AACS / CSS in-place -//! decrypt to plaintext-out. -//! -//! [`SectorReader`] is the 0.17 read trait. It stays on through -//! the 0.18 migration window so existing call sites -//! (`Drive`, `IsoSectorReader`, `BufferedSectorReader`, -//! `DiscStream`, `verify`) compile unchanged. A blanket impl -//! forwards every `SectorReader` impl to `SectorSource`, so new -//! code should target `SectorSource` / `SectorSink` directly. The -//! formal `#[deprecated]` attribute lands once the internal -//! callers have migrated; see the comment on `SectorReader` for -//! why this commit holds it back. +//! `SectorSource` and applies AACS / CSS in-place decrypt to +//! yield plaintext sectors. pub mod decrypting; pub mod file; @@ -32,13 +20,14 @@ use crate::error::Result; /// Read 2048-byte sectors from a disc, image, or composed source. /// -/// Direction-typed: a `SectorSource` cannot be written to. Wrap the -/// inner source in [`DecryptingSectorSource`] to get plaintext -/// sectors out of an encrypted disc. +/// Wrap the inner source in [`DecryptingSectorSource`] to get +/// plaintext sectors out of an encrypted disc. pub trait SectorSource: Send { - /// Total capacity in sectors, if known. Returns 0 when unknown + /// Total capacity in sectors, if known. Default `0` = unknown /// (e.g. live drives that haven't completed `READ CAPACITY` yet). - fn capacity_sectors(&self) -> u32; + fn capacity_sectors(&self) -> u32 { + 0 + } /// Read `count` sectors starting at `lba` into `buf`. /// `buf` must be at least `count * 2048` bytes. @@ -60,10 +49,52 @@ pub trait SectorSource: Send { fn set_speed(&mut self, _kbs: u16) {} } +// Forwarding impls so `Box<dyn SectorSource>` and `&mut dyn SectorSource` +// satisfy the `SectorSource` trait bound when wrapped by generic +// decorators like `DecryptingSectorSource<S: SectorSource>`. +impl SectorSource for Box<dyn SectorSource> { + fn capacity_sectors(&self) -> u32 { + (**self).capacity_sectors() + } + + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + ) -> Result<usize> { + (**self).read_sectors(lba, count, buf, recovery) + } + + fn set_speed(&mut self, kbs: u16) { + (**self).set_speed(kbs) + } +} + +impl SectorSource for &mut (dyn SectorSource + '_) { + fn capacity_sectors(&self) -> u32 { + (**self).capacity_sectors() + } + + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + ) -> Result<usize> { + (**self).read_sectors(lba, count, buf, recovery) + } + + fn set_speed(&mut self, kbs: u16) { + (**self).set_speed(kbs) + } +} + /// Write 2048-byte sectors to a disc image or composed sink. /// -/// Direction-typed: a `SectorSink` cannot be read from. The -/// terminal [`finish`] takes `Box<Self>` so it can run on `dyn +/// The terminal [`finish`] takes `Box<Self>` so it can run on `dyn /// SectorSink` and consume the sink (`fsync` + close). /// /// [`finish`]: SectorSink::finish @@ -78,125 +109,5 @@ pub trait SectorSink: Send { fn finish(self: Box<Self>) -> Result<()>; } -/// 0.17 read trait. Slated for removal once internal call sites -/// migrate to [`SectorSource`] in follow-up commits; until then -/// it remains the trait that `Drive`, `IsoSectorReader`, -/// `BufferedSectorReader`, and existing `&mut dyn SectorReader` -/// signatures use unchanged. -/// -/// New code should implement [`SectorSource`] directly. The -/// blanket impl below makes any `SectorReader` automatically -/// usable wherever a `SectorSource` is expected, so a one-way -/// migration off `SectorReader` is possible per-callsite without -/// touching the impls. -// -// NOTE: not marked `#[deprecated]` in this commit — `cargo clippy -// -- -D warnings` (the CI gauntlet) treats deprecation as an -// error, and the existing `Drive` / `udf::BufferedSectorReader` / -// `mux::DiscStream` / `verify` call sites all go through this -// trait. The deprecation attribute lands together with the -// migration commits that move those call sites to -// `SectorSource`. The behavioural contract — "this trait is -// going away in 0.18" — is documented above and tracked in -// `(internal)/memory/0_18_redesign.md`. -pub trait SectorReader: Send { - /// Read `count` sectors starting at `lba` into `buf`. - /// See [`SectorSource::read_sectors`] for semantics. - fn read_sectors( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - recovery: bool, - ) -> Result<usize>; - - /// Total capacity in sectors, if known. - fn capacity(&self) -> u32 { - 0 - } - - fn set_speed(&mut self, _kbs: u16) {} -} - -// Blanket impl: anything implementing the legacy `SectorReader` -// trait automatically satisfies `SectorSource`. This is what keeps -// existing impls (`Drive`, `IsoSectorReader`, `BufferedSectorReader`, -// etc.) compiling without source changes during the migration. The -// reverse direction (impl SectorReader for SectorSource) is -// intentionally NOT provided — new code targets the new trait. -impl<T: SectorReader + ?Sized> SectorSource for T { - fn capacity_sectors(&self) -> u32 { - <T as SectorReader>::capacity(self) - } - - fn read_sectors( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - recovery: bool, - ) -> Result<usize> { - <T as SectorReader>::read_sectors(self, lba, count, buf, recovery) - } - - fn set_speed(&mut self, kbs: u16) { - <T as SectorReader>::set_speed(self, kbs) - } -} - -// Forwarding impls so callers can wrap `&mut dyn SectorReader` / -// `Box<dyn SectorReader>` in [`DecryptingSectorSource`] without -// having to unbox or re-borrow inside the lib's hot paths. The -// generic `&mut T` / `Box<T>` blankets would conflict with the -// `SectorReader → SectorSource` blanket above (a downstream crate -// could `impl SectorReader for &mut U`); the specific -// `dyn SectorReader` instantiations are unambiguous because -// `SectorReader` is the very trait whose `dyn` we're targeting. -impl SectorSource for &mut (dyn SectorReader + '_) { - fn capacity_sectors(&self) -> u32 { - <dyn SectorReader as SectorReader>::capacity(*self) - } - - fn read_sectors( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - recovery: bool, - ) -> Result<usize> { - <dyn SectorReader as SectorReader>::read_sectors(*self, lba, count, buf, recovery) - } - - fn set_speed(&mut self, kbs: u16) { - <dyn SectorReader as SectorReader>::set_speed(*self, kbs) - } -} - -impl SectorSource for Box<dyn SectorReader> { - fn capacity_sectors(&self) -> u32 { - <dyn SectorReader as SectorReader>::capacity(&**self) - } - - fn read_sectors( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - recovery: bool, - ) -> Result<usize> { - <dyn SectorReader as SectorReader>::read_sectors(&mut **self, lba, count, buf, recovery) - } - - fn set_speed(&mut self, kbs: u16) { - <dyn SectorReader as SectorReader>::set_speed(&mut **self, kbs) - } -} - pub use decrypting::DecryptingSectorSource; pub use file::{FileSectorSink, FileSectorSource}; - -// Backwards-compat alias for the public API. `FileSectorReader` is -// the 0.17 name; new code uses `FileSectorSource`. Both point at -// the same type. The `#[deprecated]` attribute lands together with -// the migration commits that retire the alias from internal uses. -pub type FileSectorReader = FileSectorSource; diff --git a/src/udf.rs b/src/udf.rs index 44e627c..61364eb 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -19,7 +19,7 @@ //! BD-ROM Part 3 — Blu-ray filesystem profile use crate::error::{Error, Result}; -use crate::sector::SectorReader; +use crate::sector::SectorSource; /// A UDF filesystem parsed from disc. #[derive(Debug)] @@ -84,7 +84,7 @@ impl UdfFs { /// Reads sector by sector from disc — no buffering. /// Get the absolute starting LBA of a file on disc. /// Used by the rip pipeline to locate m2ts content sectors. - pub fn file_start_lba(&self, reader: &mut dyn SectorReader, path: &str) -> Result<u32> { + pub fn file_start_lba(&self, reader: &mut dyn SectorSource, path: &str) -> Result<u32> { let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); let mut current = &self.root; for part in &parts[..parts.len() - 1] { @@ -115,7 +115,7 @@ impl UdfFs { Ok(self.partition_start + data_lba) } - pub fn read_file(&self, reader: &mut dyn SectorReader, path: &str) -> Result<Vec<u8>> { + pub fn read_file(&self, reader: &mut dyn SectorSource, path: &str) -> Result<Vec<u8>> { let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); let mut current = &self.root; @@ -175,7 +175,7 @@ impl UdfFs { /// /// Skips: STREAM/ (video), BACKUP/, DUPLICATE/, /// MKB_RO.inf, ContentHash*, ContentRevocation* - pub fn metadata_sector_ranges(&self, reader: &mut dyn SectorReader) -> Result<Vec<(u32, u32)>> { + pub fn metadata_sector_ranges(&self, reader: &mut dyn SectorSource) -> Result<Vec<(u32, u32)>> { let mut ranges = Vec::new(); // UDF structure: sector 0 through end of metadata partition @@ -194,7 +194,7 @@ impl UdfFs { /// All sector ranges that contain data (metadata + all files including STREAM). /// For full disc-to-ISO dumps — reads only allocated sectors, skips gaps. - pub fn all_sector_ranges(&self, reader: &mut dyn SectorReader) -> Result<Vec<(u32, u32)>> { + pub fn all_sector_ranges(&self, reader: &mut dyn SectorSource) -> Result<Vec<(u32, u32)>> { let mut ranges = Vec::new(); // UDF structure sectors @@ -212,7 +212,7 @@ impl UdfFs { fn collect_all_file_ranges( &self, - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>, ) -> Result<()> { @@ -238,7 +238,7 @@ impl UdfFs { fn collect_file_ranges( &self, - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, entry: &DirEntry, ranges: &mut Vec<(u32, u32)>, ) -> Result<()> { @@ -276,7 +276,7 @@ impl UdfFs { /// Read an Extended File Entry (tag 266) or File Entry (tag 261) /// and return its first allocation extent: (data_lba, data_length). /// The data_lba is partition-relative. - fn read_icb_extent(&self, reader: &mut dyn SectorReader, meta_lba: u32) -> Result<(u32, u32)> { + fn read_icb_extent(&self, reader: &mut dyn SectorSource, meta_lba: u32) -> Result<(u32, u32)> { let extents = self.read_icb_extents(reader, meta_lba)?; extents.first().copied().ok_or(Error::DiscRead { sector: 0, @@ -290,7 +290,7 @@ impl UdfFs { /// Handles files with many extents (e.g. 88 GB m2ts files have ~90 extents). fn read_icb_extents( &self, - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, meta_lba: u32, ) -> Result<Vec<(u32, u32)>> { let mut icb = [0u8; 2048]; @@ -367,7 +367,7 @@ impl UdfFs { /// Returns Vec of (absolute_lba, sector_count) covering the entire file. pub fn file_extents( &self, - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, path: &str, ) -> Result<Vec<(u32, u32)>> { let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); @@ -417,7 +417,7 @@ impl UdfFs { /// 3. Metadata partition file → metadata content location /// 4. FSD → root directory ICB /// 5. Root directory → file tree -pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> { +pub fn read_filesystem(reader: &mut dyn SectorSource) -> Result<UdfFs> { // Step 1: Anchor Volume Descriptor Pointer at sector 256 // ECMA-167 §10.2 — always at sector 256 let mut avdp = [0u8; 2048]; @@ -593,7 +593,7 @@ pub fn read_filesystem(reader: &mut dyn SectorReader) -> Result<UdfFs> { /// and points to its ICB. #[allow(clippy::only_used_in_recursion)] fn read_directory( - reader: &mut dyn SectorReader, + reader: &mut dyn SectorSource, part_start: u32, meta_start: u32, meta_lba: u32, @@ -758,7 +758,7 @@ fn read_directory( } /// Read file size (info_length) from an Extended File Entry ICB. -fn read_file_size(reader: &mut dyn SectorReader, meta_start: u32, meta_lba: u32) -> Result<u64> { +fn read_file_size(reader: &mut dyn SectorSource, meta_start: u32, meta_lba: u32) -> Result<u64> { let mut icb = [0u8; 2048]; read_sector(reader, meta_start + meta_lba, &mut icb)?; @@ -874,7 +874,7 @@ fn parse_dstring(data: &[u8]) -> String { /// Each SCSI command has ~500ms overhead on USB drives, so reading 32 sectors /// at once (one command) is 32x faster than 32 individual reads. pub(crate) struct BufferedSectorReader<'a> { - inner: &'a mut dyn SectorReader, + inner: &'a mut dyn SectorSource, cache_start: u32, cache: Vec<u8>, cache_sectors: u32, @@ -884,7 +884,7 @@ pub(crate) struct BufferedSectorReader<'a> { } impl<'a> BufferedSectorReader<'a> { - pub(crate) fn new(inner: &'a mut dyn SectorReader, batch: u16) -> Self { + pub(crate) fn new(inner: &'a mut dyn SectorSource, batch: u16) -> Self { Self { inner, cache_start: u32::MAX, @@ -952,7 +952,7 @@ impl BufferedSectorReader<'_> { } } -impl SectorReader for BufferedSectorReader<'_> { +impl SectorSource for BufferedSectorReader<'_> { fn read_sectors( &mut self, lba: u32, @@ -996,7 +996,7 @@ impl SectorReader for BufferedSectorReader<'_> { } } -fn read_sector(reader: &mut dyn SectorReader, lba: u32, buf: &mut [u8]) -> Result<()> { +fn read_sector(reader: &mut dyn SectorSource, lba: u32, buf: &mut [u8]) -> Result<()> { reader.read_sectors(lba, 1, buf, true)?; Ok(()) } diff --git a/src/verify.rs b/src/verify.rs index 7726b98..607ba4a 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -2,7 +2,7 @@ use crate::disc::{Chapter, DiscTitle}; use crate::progress::Progress; -use crate::sector::SectorReader; +use crate::sector::SectorSource; use std::time::Instant; /// Health status of a single sector read. @@ -82,7 +82,7 @@ impl VerifyResult { /// 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 SectorReader, + reader: &mut dyn SectorSource, title: &DiscTitle, batch_sectors: u16, on_progress: Option<&dyn Progress>, diff --git a/tests/disc_tests.rs b/tests/disc_tests.rs index 9ea7117..7006ea1 100644 --- a/tests/disc_tests.rs +++ b/tests/disc_tests.rs @@ -1,6 +1,6 @@ //! Disc scanning pipeline tests. -use libfreemkv::SectorReader; +use libfreemkv::SectorSource; use libfreemkv::error::Result; use libfreemkv::{Disc, DiscTitle, ScanOptions}; use std::collections::HashMap; @@ -20,7 +20,7 @@ impl MockSectorReader { } } -impl SectorReader for MockSectorReader { +impl SectorSource for MockSectorReader { fn read_sectors( &mut self, lba: u32, diff --git a/tests/integration_progress_and_halt.rs b/tests/integration_progress_and_halt.rs index 54530fe..40739a6 100644 --- a/tests/integration_progress_and_halt.rs +++ b/tests/integration_progress_and_halt.rs @@ -5,8 +5,8 @@ use libfreemkv::disc::{CopyOptions, DiscRegion}; use libfreemkv::error::Result; use libfreemkv::pes::Stream as PesStream; use libfreemkv::{ - ContentFormat, Disc, DiscFormat, DiscStream, DiscTitle, EventKind, Extent, FileSectorReader, - SectorReader, + ContentFormat, Disc, DiscFormat, DiscStream, DiscTitle, EventKind, Extent, FileSectorSource, + SectorSource, }; use std::io::Write; use std::sync::Arc; @@ -32,7 +32,7 @@ impl ZeroSectorReader { } } -impl SectorReader for ZeroSectorReader { +impl SectorSource for ZeroSectorReader { fn read_sectors( &mut self, _lba: u32, @@ -46,7 +46,7 @@ impl SectorReader for ZeroSectorReader { Ok(bytes) } - fn capacity(&self) -> u32 { + fn capacity_sectors(&self) -> u32 { self.capacity } } @@ -67,7 +67,7 @@ impl SlowZeroSectorReader { } } -impl SectorReader for SlowZeroSectorReader { +impl SectorSource for SlowZeroSectorReader { fn read_sectors( &mut self, _lba: u32, @@ -81,7 +81,7 @@ impl SectorReader for SlowZeroSectorReader { Ok(bytes) } - fn capacity(&self) -> u32 { + fn capacity_sectors(&self) -> u32 { self.capacity } } @@ -304,7 +304,7 @@ fn test_drop_impls_do_not_panic_or_block() { panic!("DiscStream drop did not complete within 100ms"); } -// ── 5. FileSectorReader round trip ──────────────────────────────────────── +// ── 5. FileSectorSource round trip ──────────────────────────────────────── #[test] fn test_file_sector_reader_round_trip() { @@ -321,9 +321,13 @@ fn test_file_sector_reader_round_trip() { tmp.flush().expect("flush"); let path = tmp.path().to_path_buf(); - let mut fsr = FileSectorReader::open(&path).expect("open FileSectorReader"); + let mut fsr = FileSectorSource::open(&path).expect("open FileSectorSource"); - assert_eq!(fsr.capacity(), N_SECTORS as u32, "capacity mismatch"); + assert_eq!( + fsr.capacity_sectors(), + N_SECTORS as u32, + "capacity mismatch" + ); // Read each sector individually and compare. let mut buf = vec![0u8; SECTOR_SIZE]; @@ -391,7 +395,7 @@ impl FailingSectorReader { } } -impl SectorReader for FailingSectorReader { +impl SectorSource for FailingSectorReader { fn read_sectors( &mut self, _lba: u32, @@ -418,7 +422,7 @@ impl SectorReader for FailingSectorReader { }) } - fn capacity(&self) -> u32 { + fn capacity_sectors(&self) -> u32 { self.capacity } } @@ -567,7 +571,7 @@ struct BlockSizeFailingReader { capacity: u32, } -impl SectorReader for BlockSizeFailingReader { +impl SectorSource for BlockSizeFailingReader { fn read_sectors( &mut self, lba: u32, @@ -593,7 +597,7 @@ impl SectorReader for BlockSizeFailingReader { } } - fn capacity(&self) -> u32 { + fn capacity_sectors(&self) -> u32 { self.capacity } } diff --git a/tests/pass_n_size_aware_skip.rs b/tests/pass_n_size_aware_skip.rs index 72bb8c5..de19de6 100644 --- a/tests/pass_n_size_aware_skip.rs +++ b/tests/pass_n_size_aware_skip.rs @@ -15,7 +15,7 @@ use libfreemkv::disc::CopyOptions; use libfreemkv::disc::DiscRegion; use libfreemkv::disc::mapfile::{Mapfile, SectorStatus}; use libfreemkv::error::Result; -use libfreemkv::{ContentFormat, Disc, DiscFormat, SectorReader}; +use libfreemkv::{ContentFormat, Disc, DiscFormat, SectorSource}; use std::collections::HashSet; use std::sync::{Arc, Mutex}; @@ -46,7 +46,7 @@ impl PatternedSectorReader { } } -impl SectorReader for PatternedSectorReader { +impl SectorSource for PatternedSectorReader { fn read_sectors( &mut self, lba: u32, @@ -82,7 +82,7 @@ impl SectorReader for PatternedSectorReader { Ok(buf.len()) } - fn capacity(&self) -> u32 { + fn capacity_sectors(&self) -> u32 { self.capacity } } diff --git a/tests/udf_tests.rs b/tests/udf_tests.rs index 50afdab..db05b36 100644 --- a/tests/udf_tests.rs +++ b/tests/udf_tests.rs @@ -1,7 +1,7 @@ //! UDF parser tests using a MockSectorReader. use libfreemkv::error::Result; -use libfreemkv::{SectorReader, read_filesystem}; +use libfreemkv::{SectorSource, read_filesystem}; use std::collections::HashMap; const SECTOR_SIZE: usize = 2048; @@ -38,7 +38,7 @@ impl MockSectorReader { } } -impl SectorReader for MockSectorReader { +impl SectorSource for MockSectorReader { fn read_sectors( &mut self, lba: u32, @@ -463,11 +463,11 @@ fn find_dir_case_insensitive() { #[test] fn sector_reader_is_object_safe() { - // Verify SectorReader can be used as a trait object + // Verify SectorSource can be used as a trait object let mut reader = MockSectorReader::new(); reader.set_sector(0, vec![42u8; SECTOR_SIZE]); - let dyn_reader: &mut dyn SectorReader = &mut reader; + let dyn_reader: &mut dyn SectorSource = &mut reader; let mut buf = vec![0u8; SECTOR_SIZE]; let n = dyn_reader.read_sectors(0, 1, &mut buf, true).unwrap(); assert_eq!(n, SECTOR_SIZE);