From 966bc13dd2109d8a299debdd3b536bb2e77a8b4e Mon Sep 17 00:00:00 2001 From: Matthew Jackson Date: Wed, 29 Apr 2026 22:08:52 -0700 Subject: [PATCH] v0.13.46: damage-jump algorithm replaces probe, bridge degradation detection, ecc_sectors() --- CLAUDE.md | 14 ++--- src/disc/mod.rs | 135 +++++++++++++++++++++++++++++++++--------------- src/error.rs | 17 ++++++ 3 files changed, 116 insertions(+), 50 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 89583d1..1e2b48f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,17 +44,17 @@ Three failure modes on this USB bridge: 2. **Transport failure** (status=0xFF) — bridge crash, auto-recovers ~15s. Aborts copy. 3. **INCOMPATIBLE FORMAT** (ASC=0x30) wedge — ALL sectors fail, requires power cycle. -### Adaptive probe algorithm (Pass 1 sweep) +### Damage-jump algorithm (Pass 1 sweep) When `skip_on_error=true` (multipass mode): -- Read each ECC block sequentially. On success, reset consecutive error counter. -- On error: zero-fill, mark NonTrimmed, increment consecutive error counter. -- After 4 consecutive errors: **probe** 1 sector at 256×batch (8 MB) ahead. - - Probe succeeds: zero-fill the gap, mark it NonTrimmed, jump to probed position. - - Probe fails: stay put, accumulate 4 more errors, probe again. +- Read each ECC block sequentially. Track a sliding window of the last 50 ECC block results. +- On error: zero-fill, mark NonTrimmed, push `false` to window. +- On success: write data, mark Finished, push `true` to window. Track consecutive good count. +- When ≥25% of the 50-block window are failures → **jump** ahead by `256×batch×multiplier` sectors (8 MB base). Zero-fill the gap as NonTrimmed. Double the multiplier (8→16→32→64 MB...). +- When 50 consecutive good reads → reset multiplier to 1. - Only transport failures (bridge crash) abort the pass. -Design rationale: consecutive errors (not total) so isolated scattered bad blocks don't trigger probes. The 8 MB probe distance clears typical ~30 MB bad zones in 2-3 probes. Gaps are zero-filled and marked NonTrimmed for patch passes to recover later. +Two tuning knobs: `DAMAGE_WINDOW` (50 ECC blocks) and `DAMAGE_THRESHOLD_PCT` (25%). If the jump is too aggressive or too timid, adjust these. ## Public repo rules diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 6e45ee6..1d06b35 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1288,8 +1288,15 @@ impl Disc { let mut last_log_iter: u64 = 0; let mut not_ready_retries: u32 = 0; const NOT_READY_MAX_RETRIES: u32 = 3; - let mut error_count_in_zone: u64 = 0; - let mut probe_buf = vec![0u8; 2048]; + let mut bridge_degradation_count: u32 = 0; + const BRIDGE_DEGRADATION_MAX: u32 = 5; + const BRIDGE_DEGRADATION_COOLDOWN_SECS: u64 = 10; + const DAMAGE_WINDOW: usize = 50; + const DAMAGE_THRESHOLD_PCT: usize = 25; + const JUMP_SECTORS_FACTOR: u64 = 256; + let mut damage_window: Vec = Vec::with_capacity(DAMAGE_WINDOW); + let mut jump_multiplier: u64 = 1; + let mut consecutive_good: u64 = 0; tracing::trace!( target: "freemkv::disc", phase = "copy_start", @@ -1353,7 +1360,15 @@ impl Disc { if read_result.is_ok() { read_ok_count += 1; - error_count_in_zone = 0; + damage_window.push(true); + if damage_window.len() > DAMAGE_WINDOW { + damage_window.remove(0); + } + consecutive_good += 1; + if consecutive_good >= DAMAGE_WINDOW as u64 { + jump_multiplier = 1; + } + bridge_degradation_count = 0; if opts.decrypt { crate::decrypt::decrypt_sectors( @@ -1383,6 +1398,7 @@ impl Disc { } else { let err = read_result.err().unwrap(); read_err_count += 1; + consecutive_good = 0; if err.is_scsi_transport_failure() { tracing::warn!( @@ -1395,6 +1411,28 @@ impl Disc { return Err(err); } + if err.is_bridge_degradation() { + if bridge_degradation_count < BRIDGE_DEGRADATION_MAX { + bridge_degradation_count += 1; + tracing::warn!( + target: "freemkv::disc", + phase = "bridge_degradation", + lba = block_lba, + degradation_count = bridge_degradation_count, + error = %err, + "bridge degradation; cooling down 10s" + ); + std::thread::sleep(std::time::Duration::from_secs(BRIDGE_DEGRADATION_COOLDOWN_SECS)); + continue; + } + tracing::warn!( + target: "freemkv::disc", + phase = "bridge_degradation_exhausted", + lba = block_lba, + "bridge degradation retries exhausted; treating as bad sector" + ); + } + let sense = err.scsi_sense(); let sense_key = sense.map(|s| s.sense_key).unwrap_or(0); let asc = sense.map(|s| s.asc).unwrap_or(0); @@ -1437,48 +1475,51 @@ impl Disc { .map_err(|e| Error::IoError { source: e })?; bytes_done = bytes_done.saturating_add(block_bytes); - error_count_in_zone += 1; - if error_count_in_zone >= 4 { - let probe_offset_sectors = 256 * batch as u64; - let probe_lba = ((pos / 2048) + probe_offset_sectors) as u32; - let probe_end_lba = (region_end / 2048) as u32; - if probe_lba < probe_end_lba { - match reader.read_sectors(probe_lba, 1, &mut probe_buf, false) { - Ok(_) => { - let jump_pos = probe_lba as u64 * 2048; - let gap_start = pos + block_bytes; - let gap_bytes = jump_pos.saturating_sub(gap_start); - let jump_mb = gap_bytes / 1_048_576; - if gap_bytes > 0 { - tracing::warn!( - target: "freemkv::disc", - phase = "probe_jump", - from_lba = block_lba, - to_lba = probe_lba, - jump_mb, - errors = error_count_in_zone, - "probe found good media; jumping ahead" - ); - let zero = vec![0u8; 65536]; - let mut filled: u64 = 0; - while filled < gap_bytes { - let chunk = (gap_bytes - filled).min(zero.len() as u64); - file.seek(SeekFrom::Start(gap_start + filled)) - .map_err(|e| Error::IoError { source: e })?; - file.write_all(&zero[..chunk as usize]) - .map_err(|e| Error::IoError { source: e })?; - filled += chunk; - } - map.record(gap_start, gap_bytes, mapfile::SectorStatus::NonTrimmed) - .map_err(|e| Error::IoError { source: e })?; - bytes_done = bytes_done.saturating_add(gap_bytes); - } - pos = jump_pos; - did_skip_ahead = true; - error_count_in_zone = 0; + damage_window.push(false); + if damage_window.len() > DAMAGE_WINDOW { + damage_window.remove(0); + } + + let bad_count = damage_window.iter().filter(|&&b| !b).count(); + if damage_window.len() >= DAMAGE_WINDOW + && bad_count * 100 / damage_window.len() >= DAMAGE_THRESHOLD_PCT + { + let jump_sectors = JUMP_SECTORS_FACTOR * batch as u64 * jump_multiplier; + let jump_lba = ((pos / 2048) + jump_sectors) as u32; + let region_end_lba = (region_end / 2048) as u32; + if jump_lba < region_end_lba { + let jump_pos = jump_lba as u64 * 2048; + let gap_start = pos + block_bytes; + let gap_bytes = jump_pos.saturating_sub(gap_start); + let jump_mb = gap_bytes / 1_048_576; + tracing::warn!( + target: "freemkv::disc", + phase = "damage_jump", + from_lba = block_lba, + to_lba = jump_lba, + jump_mb, + bad_pct = bad_count * 100 / damage_window.len(), + multiplier = jump_multiplier, + "25%+ failures in last 50 blocks; jumping ahead" + ); + if gap_bytes > 0 { + let zero = vec![0u8; 65536]; + let mut filled: u64 = 0; + while filled < gap_bytes { + let chunk = (gap_bytes - filled).min(zero.len() as u64); + file.seek(SeekFrom::Start(gap_start + filled)) + .map_err(|e| Error::IoError { source: e })?; + file.write_all(&zero[..chunk as usize]) + .map_err(|e| Error::IoError { source: e })?; + filled += chunk; } - Err(_) => {} + map.record(gap_start, gap_bytes, mapfile::SectorStatus::NonTrimmed) + .map_err(|e| Error::IoError { source: e })?; + bytes_done = bytes_done.saturating_add(gap_bytes); } + pos = jump_pos; + jump_multiplier *= 2; + did_skip_ahead = true; } } } @@ -1861,6 +1902,14 @@ const MAX_BATCH_SECTORS: u16 = 510; const DEFAULT_BATCH_SECTORS: u16 = 60; const MIN_BATCH_SECTORS: u16 = 3; +pub fn ecc_sectors(format: DiscFormat) -> u16 { + match format { + DiscFormat::Uhd | DiscFormat::BluRay => 32, + DiscFormat::Dvd => 16, + DiscFormat::Unknown => 32, + } +} + /// Coarse damage tier for a finished or in-progress rip. Maps the /// observable signals (bad sector count + lost wallclock playback time) /// onto a small discrete classification so UIs can render a colored badge diff --git a/src/error.rs b/src/error.rs index def4385..2278d80 100644 --- a/src/error.rs +++ b/src/error.rs @@ -516,6 +516,23 @@ impl Error { ) } + /// True if this error indicates bridge degradation — the SCSI status + /// is neither GOOD (0x00), CHECK CONDITION (0x02), nor transport failure + /// (0xFF). Observed on the Initio INIC-1618L USB bridge preceding a full + /// crash: the bridge firmware returns non-standard status bytes (e.g. + /// 0x04, 0x05) with empty sense data. The caller should cool down + /// (10 s pause) and retry rather than hammering the bridge. + pub fn is_bridge_degradation(&self) -> bool { + let status = match self { + Error::ScsiError { status, .. } => *status, + Error::DiscRead { status, .. } => status.unwrap_or(0), + _ => return false, + }; + status != crate::scsi::SCSI_STATUS_GOOD + && status != crate::scsi::SCSI_STATUS_CHECK_CONDITION + && status != crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE + } + /// True if the underlying SCSI failure is a *marginal read* — the /// drive returned an error category in which smaller-granularity /// retries can sometimes recover the data: