diff --git a/src/disc/patch.rs b/src/disc/patch.rs index f9af681..b23cafb 100644 --- a/src/disc/patch.rs +++ b/src/disc/patch.rs @@ -59,8 +59,8 @@ use crate::io::pipeline::{Flow, Sink}; use super::mapfile::{self, MapStats, Mapfile, SectorStatus}; use super::section_recover::{ - Bisect, HandlerCtx, HandlerOutcome, HandlerScoreboard, Jump, Linear, RecoverySink, - SectionHandler, run_handlers, + Bisect, Direction, HandlerCtx, HandlerOutcome, HandlerScoreboard, Jump, Linear, ReadParams, + RecoverySink, SectionHandler, run_handlers, }; /// Wall-clock budget one recovery handler gets on a section before the chain @@ -426,8 +426,11 @@ pub(super) fn compute_initial_state( /// 0, so the widened start is always unit-aligned. All recovery /// accounting upstream (pos, block_bytes, dispatched lba/count) is /// unchanged — only the physical read widens, so the cursor cannot -/// desync. `recovery` selects the SCSI timeout (true = 60 s deep -/// recovery, false = the fast path). +/// desync. `recovery` selects the SCSI timeout (true = 60 s deep recovery, +/// false = the fast path); `fua` forces the drive to bypass its readahead cache +/// and re-fetch +/// the medium (a Pass-N marginal-sector lever — see +/// [`crate::sector::SectorSource::read_sectors_fua`]). pub(super) fn recovery_read( reader: &mut R, decrypt_is_aacs: bool, @@ -435,6 +438,7 @@ pub(super) fn recovery_read( count: u16, buf: &mut [u8], recovery: bool, + fua: bool, ) -> Result { let bytes = count as usize * 2048; if decrypt_is_aacs && (lba % 3 != 0 || count % 3 != 0) { @@ -444,11 +448,11 @@ pub(super) fn recovery_read( let span = head + count as usize; let aligned_count = span + ((U as usize - span % U as usize) % U as usize); let mut scratch = vec![0u8; aligned_count * 2048]; - reader.read_sectors(aligned_lba, aligned_count as u16, &mut scratch, recovery)?; + reader.read_sectors_fua(aligned_lba, aligned_count as u16, &mut scratch, recovery, fua)?; buf[..bytes].copy_from_slice(&scratch[head * 2048..head * 2048 + bytes]); Ok(bytes) } else { - reader.read_sectors(lba, count, &mut buf[..bytes], recovery) + reader.read_sectors_fua(lba, count, &mut buf[..bytes], recovery, fua) } } @@ -740,6 +744,57 @@ struct PatchCtx<'a, 'o> { wedge_streak: u32, } +/// Build the handler chain for one breadth-first tier. Each config is named by +/// its FULL parameterisation (`build_tier_handlers` picks the roster; the +/// scorecard re-orders WITHIN a tier per rip). The engine hardcodes no +/// conclusion: every technique is always present at its tier, and a technique +/// that doesn't fit this disc self-deprioritises (scores low, yields after 4 +/// unproductive reads) rather than being removed. +/// +/// - **Tier 0 — fast scouts** (`fast`: max speed, 10 s, cache on): grab the +/// readable bulk across every range. +/// - **Tier 1 — slow-deep** (`deep`: max speed, 60 s ECC budget): deep-recover +/// the easy residual. +/// - **Tier 2 — marginal specialists**: the physical-failure-mode matrix +/// (SlowSpin / FuaRetry / SlowFua / CachePrime / Oscillate / SpeedSweep), run +/// ONLY on what tiers 0-1 leave. +fn build_tier_handlers(tier: usize) -> Vec> { + match tier { + // Tier 0 — fast scouts. Bisect leads by default (probing a range's + // MIDDLE finds a readable island in one read); Jump blows through large + // dead runs; the fast linear sweeps mop up. The scorecard re-orders. + 0 => vec![ + Box::new(Bisect { + params: ReadParams::fast(), + }), + Box::new(Jump { + params: ReadParams::fast(), + }), + Box::new(Linear { + direction: Direction::Reverse, + params: ReadParams::fast(), + }), + Box::new(Linear { + direction: Direction::Forward, + params: ReadParams::fast(), + }), + ], + // Tier 1 — slow deep recovery on the small residue tier 0 leaves. + 1 => vec![ + Box::new(Linear { + direction: Direction::Reverse, + params: ReadParams::deep(), + }), + Box::new(Linear { + direction: Direction::Forward, + params: ReadParams::deep(), + }), + ], + // Tier 2 — marginal specialists (wired in when PATCH_TIERS reaches 3). + _ => Vec::new(), + } +} + impl PatchCtx<'_, '_> { /// Orchestrator (one pass): walk the ordered bad ranges. Apply the /// inter-range cooldown only after a range that grinded, then recover @@ -822,42 +877,15 @@ impl PatchCtx<'_, '_> { "entering patch range" ); - // Enter at max read speed; a handler drops to the deep-recovery read - // itself via its `fast` flag. + // Enter at max read speed. A handler picks its own speed / FUA / timeout + // via its `ReadParams`; `read_span` restores max after each handler, so + // every tier starts from the streaming default. self.reader.set_speed(0xFFFF); - // Tier 0 = FAST scouts, ordered best-first by the rip scorecard (see - // run_handlers). Bisect leads by default because probing the MIDDLE of a - // range finds a readable island in one read, where Jump has to grind the - // dead front to reach it; Jump then blows through large dead runs, and - // the fast linear sweeps mop up. The scorecard re-orders these as it - // learns which one is actually winning on THIS disc. Tier 1 = slow deep - // recovery on the small residue tier 0 leaves. - let mut handlers: Vec> = if tier == 0 { - vec![ - Box::new(Bisect), - Box::new(Jump), - Box::new(Linear { - reverse: true, - fast: true, - }), - Box::new(Linear { - reverse: false, - fast: true, - }), - ] - } else { - vec![ - Box::new(Linear { - reverse: true, - fast: false, - }), - Box::new(Linear { - reverse: false, - fast: false, - }), - ] - }; + // The tier roster (see `build_tier_handlers`): tier 0 fast scouts, tier 1 + // slow-deep, tier 2 marginal specialists. `run_handlers` orders WITHIN a + // tier best-first by the rip scorecard, which re-learns per disc. + let mut handlers: Vec> = build_tier_handlers(tier); // Clock seam: handlers read wall time through this so tests can wind a // fake clock (the same seam the pass uses for its own timing). @@ -903,6 +931,8 @@ impl PatchCtx<'_, '_> { // Carry the pass-level wedge streak in so a fast-fail wedge is // caught across many small sections, not reset each one. wedge_streak: self.wedge_streak, + // Drive was just reset to max above; read_span tracks changes. + cur_speed: 0xFFFF, }; let o = run_handlers(&mut ctx, &mut handlers, bad, &mut self.scoreboard, |_bad| { now_ptr() + std::time::Duration::from_secs(PER_HANDLER_BUDGET_SECS) @@ -1399,7 +1429,7 @@ mod tests { }; let mut buf = vec![0u8; 2048]; // Request lba=4 (4 % 3 == 1, mid-unit), count=1. - let n = recovery_read(&mut rr, true, 4, 1, &mut buf, true).unwrap(); + let n = recovery_read(&mut rr, true, 4, 1, &mut buf, true, false).unwrap(); assert_eq!(n, 2048); assert_eq!(rr.saw_lba, 3, "widened down to the unit-aligned start"); assert_eq!(rr.saw_count, 3, "widened to a whole 3-sector unit"); diff --git a/src/disc/section_recover.rs b/src/disc/section_recover.rs index c9e9d97..709c73c 100644 --- a/src/disc/section_recover.rs +++ b/src/disc/section_recover.rs @@ -86,6 +86,102 @@ const WEDGE_ABORT_STREAK: u32 = 16; /// a true fast-fail. const WEDGE_FASTFAIL_MS: u64 = 500; +/// Max read speed sentinel for `SET CD SPEED` (0xFFFF = "as fast as the drive +/// will go"). The default for every read; a handler that wants to slow the +/// spindle passes [`SpeedPref::Min`] and [`read_span`] restores this on exit. +const SPEED_MAX_KBS: u16 = 0xFFFF; + +/// Min read speed (~DVD 1×; the drive clamps up to its own supported minimum). +/// Slower rotation gives the servo more dwell and the ECC engine more +/// integration time per sector — the SlowSpin / SpeedSweep lever. The exact +/// value only has to be well below max; the drive rounds it to a supported step. +const SPEED_MIN_KBS: u16 = 1385; + +/// Which spindle speed a read requests. `Max` is the streaming default; `Min` +/// slows the spindle for marginal-sector recovery (more servo dwell + ECC +/// integration). `Mid` is reserved for a future resonance step (SpeedSweep). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum SpeedPref { + Max, + Min, +} + +impl SpeedPref { + /// The `SET CD SPEED` value (KB/s) this preference maps to. + fn kbs(self) -> u16 { + match self { + SpeedPref::Max => SPEED_MAX_KBS, + SpeedPref::Min => SPEED_MIN_KBS, + } + } +} + +/// Which SCSI read timeout a read requests. `Fast` is the 10 s single-attempt +/// budget (scouting); `Deep` is the 60 s ECC-recovery budget (deep recovery). +/// Maps onto `recovery_read`'s `recovery` bool. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum TimeoutPref { + Fast, + Deep, +} + +impl TimeoutPref { + /// The `recovery` bool (true = 60 s deep) this timeout maps to. + fn recovery(self) -> bool { + matches!(self, TimeoutPref::Deep) + } +} + +/// The per-read knobs a handler hands to [`read_span`]. A handler is a point in +/// the (direction × speed × cache × timeout) space; `ReadParams` carries the +/// speed / cache(FUA) / timeout axes (direction is the handler's own walk), so +/// the SAME read primitive serves every handler — a new technique is a new +/// *parameterisation*, never a bypass of the wedge-safe read path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct ReadParams { + pub speed: SpeedPref, + pub fua: bool, + pub timeout: TimeoutPref, +} + +impl ReadParams { + /// Tier-0 scout read: max speed, cache on, 10 s single-attempt. + pub(super) fn fast() -> Self { + Self { + speed: SpeedPref::Max, + fua: false, + timeout: TimeoutPref::Fast, + } + } + + /// Tier-1 deep read: max speed, cache on, 60 s ECC-recovery budget. + pub(super) fn deep() -> Self { + Self { + speed: SpeedPref::Max, + fua: false, + timeout: TimeoutPref::Deep, + } + } + + /// Scorecard tag for the speed / cache / timeout axes, e.g. `min:fua:deep`. + /// The handler prepends its own name + direction (`linear:fwd:` + tag). + fn tag(&self) -> String { + let speed = match self.speed { + SpeedPref::Max => "max", + SpeedPref::Min => "min", + }; + let timeout = match self.timeout { + TimeoutPref::Fast => "fast", + TimeoutPref::Deep => "deep", + }; + if self.fua { + format!("{speed}:fua:{timeout}") + } else { + format!("{speed}:{timeout}") + } + } +} + /// Where a handler left the section after its bounded attempt. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum HandlerOutcome { @@ -139,6 +235,13 @@ pub(super) struct HandlerCtx<'a> { /// escalates to `Transport`. Seeded from and read back into the pass-level /// counter so the streak spans sections; a Good or non-wedge read resets it. pub wedge_streak: u32, + /// The spindle speed (`SET CD SPEED` KB/s) currently programmed into the + /// drive. [`read_span`] issues `SET CD SPEED` only when a read's requested + /// speed DIFFERS from this (a `SET CD SPEED` per read would thrash the + /// spindle), and [`run_handlers`] restores [`SPEED_MAX_KBS`] after each + /// handler. Seeded to max — the caller resets the drive to max before the + /// chain runs. + pub cur_speed: u16, } impl HandlerCtx<'_> { @@ -196,7 +299,7 @@ fn read_span( buf: &mut [u8], pos: u64, count: u16, - recovery: bool, + params: ReadParams, ) -> ReadHit { let lba = (pos / SECTOR) as u32; let bytes = count as usize * SECTOR as usize; @@ -208,8 +311,24 @@ fn read_span( count >= 1 && pos % SECTOR == 0, "read_span requires a sector-aligned, >=1-sector span (pos={pos}, count={count})" ); + // Program the spindle speed ONLY when it changes — a `SET CD SPEED` per read + // would thrash the drive. `run_handlers` restores max after the handler. + let want_speed = params.speed.kbs(); + if want_speed != ctx.cur_speed { + ctx.reader.set_speed(want_speed); + ctx.cur_speed = want_speed; + } + let recovery = params.timeout.recovery(); let read_started = (ctx.now)(); - let hit = match recovery_read(ctx.reader, ctx.decrypt_is_aacs, lba, count, buf, recovery) { + let hit = match recovery_read( + ctx.reader, + ctx.decrypt_is_aacs, + lba, + count, + buf, + recovery, + params.fua, + ) { Ok(_) => { ctx.sink.recovered(pos, &buf[..bytes]); ReadHit::Good @@ -275,7 +394,11 @@ fn read_span( /// bad read leave it in `bad` and advance (skip-and-move-on); on a transport /// fault return [`HandlerOutcome::TransportFault`] immediately. pub(super) trait SectionHandler { - fn name(&self) -> &'static str; + /// Scorecard identity — the FULL config (technique + direction + speed + + /// cache + timeout), e.g. `linear:fwd:min:fua:deep`. The scoreboard keys on + /// this, so two instances of the same handler at different [`ReadParams`] + /// score independently and can flip past each other. + fn name(&self) -> String; fn recover( &mut self, ctx: &mut HandlerCtx, @@ -284,25 +407,43 @@ pub(super) trait SectionHandler { ) -> HandlerOutcome; } -/// Linear sweep of each bad sub-range. `reverse` walks end→start (the disc -/// sweep overshoots forward, so a NonTrimmed range's good data sits at its tail -/// — reverse hits it first); `!reverse` walks start→end (the front the reverse -/// pass kept dying on). `fast` selects the single-attempt read (`recovery = -/// false`) over the 60 s deep-recovery read. The two bools give backwards / -/// forwards / fast / slow from one handler. +/// Which end a [`Linear`] sweep walks from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Direction { + /// start→end (the front the reverse pass kept dying on). + Forward, + /// end→start (the disc sweep overshoots forward, so a NonTrimmed range's + /// good data sits at its tail — reverse hits it first). + Reverse, +} + +impl Direction { + fn is_reverse(self) -> bool { + matches!(self, Direction::Reverse) + } + + fn tag(self) -> &'static str { + match self { + Direction::Forward => "fwd", + Direction::Reverse => "rev", + } + } +} + +/// Linear batch sweep of each bad sub-range, in `direction`, at `params`. The +/// direction × the [`ReadParams`] axes (speed / FUA / timeout) give every +/// backwards/forwards × fast/slow × max/min × cache/FUA combination from one +/// handler — the tier-0 fast scouts, the tier-1 deep sweeps, and the tier-2 +/// SlowSpin / FuaRetry / SlowFua specialists are all just `Linear` at different +/// `params`. pub(super) struct Linear { - pub reverse: bool, - pub fast: bool, + pub direction: Direction, + pub params: ReadParams, } impl SectionHandler for Linear { - fn name(&self) -> &'static str { - match (self.reverse, self.fast) { - (true, true) => "linear:reverse:fast", - (true, false) => "linear:reverse:slow", - (false, true) => "linear:forward:fast", - (false, false) => "linear:forward:slow", - } + fn name(&self) -> String { + format!("linear:{}:{}", self.direction.tag(), self.params.tag()) } fn recover( @@ -311,13 +452,13 @@ impl SectionHandler for Linear { bad: &mut SubRanges, deadline: Instant, ) -> HandlerOutcome { - let recovery = !self.fast; + let reverse = self.direction.is_reverse(); let batch_bytes = BATCH_SECTORS * SECTOR; let mut buf = vec![0u8; batch_bytes as usize]; // Snapshot the sub-ranges: we mutate `bad` via remove() as we recover, // and iterating the snapshot keeps that from disturbing the walk. let mut snapshot: Vec<(u64, u64)> = bad.ranges().to_vec(); - if self.reverse { + if reverse { snapshot.reverse(); } @@ -332,13 +473,13 @@ impl SectionHandler for Linear { return HandlerOutcome::Remaining; } let span = batch_bytes.min(rl - done); - let pos = if self.reverse { + let pos = if reverse { rp + (rl - done - span) } else { rp + done }; let count = (span / SECTOR) as u16; - match read_span(ctx, &mut buf, pos, count, recovery) { + match read_span(ctx, &mut buf, pos, count, self.params) { ReadHit::Good => bad.remove(pos, span), // Keep reads at the full batch — no per-sector grind (proven // worse on the BU40N, and it's what stalled a handler on a @@ -366,13 +507,17 @@ impl SectionHandler for Linear { /// reads. The two failing ends become smaller bad sub-ranges, pushed back to be /// bisected again. A dead middle just splits into halves. This shreds one huge /// bad range into precisely-located small dead clusters (a handful of sectors) -/// instead of leaving the whole thing bad. Uses fast reads: it LOCATES readable -/// data; deep-recovering the dead sectors is the slow linear handlers' job. -pub(super) struct Bisect; +/// instead of leaving the whole thing bad. `params` is normally fast reads: it +/// LOCATES readable data; deep-recovering the dead sectors is the slow linear +/// handlers' job. Tier 2 also runs a Bisect at FUA/deep params to shred islands +/// under cache-bypass. +pub(super) struct Bisect { + pub params: ReadParams, +} impl SectionHandler for Bisect { - fn name(&self) -> &'static str { - "bisect" + fn name(&self) -> String { + format!("bisect:{}", self.params.tag()) } fn recover( @@ -401,7 +546,7 @@ impl SectionHandler for Bisect { } let end = rp + rl; let mid = rp + (rl / SECTOR / 2) * SECTOR; - match read_span(ctx, &mut probe, mid, 1, false) { + match read_span(ctx, &mut probe, mid, 1, self.params) { ReadHit::Good => { bad.remove(mid, SECTOR); // Expand FORWARD from mid+1 in batches until a read fails. @@ -416,7 +561,7 @@ impl SectionHandler for Bisect { } let span = step.min(end - fwd); let count = (span / SECTOR) as u16; - match read_span(ctx, &mut buf[..span as usize], fwd, count, false) { + match read_span(ctx, &mut buf[..span as usize], fwd, count, self.params) { ReadHit::Good => { bad.remove(fwd, span); fwd += span; @@ -448,7 +593,7 @@ impl SectionHandler for Bisect { let span = step.min(bwd - rp); let pos = bwd - span; let count = (span / SECTOR) as u16; - match read_span(ctx, &mut buf[..span as usize], pos, count, false) { + match read_span(ctx, &mut buf[..span as usize], pos, count, self.params) { ReadHit::Good => { bad.remove(pos, span); bwd = pos; @@ -509,11 +654,13 @@ impl SectionHandler for Bisect { /// pass). Without it a linear walk pays one up-to-10 s read per dead batch /// across the whole run, so a deadline-bounded pass never reaches readable data /// buried behind a big dead front (exactly the 192 MB range on Dune). -pub(super) struct Jump; +pub(super) struct Jump { + pub params: ReadParams, +} impl SectionHandler for Jump { - fn name(&self) -> &'static str { - "jump" + fn name(&self) -> String { + format!("jump:{}", self.params.tag()) } fn recover( @@ -538,7 +685,7 @@ impl SectionHandler for Jump { let span = batch.min(rl - off); let pos = rp + off; let count = (span / SECTOR) as u16; - match read_span(ctx, &mut buf[..span as usize], pos, count, false) { + match read_span(ctx, &mut buf[..span as usize], pos, count, self.params) { ReadHit::Good => { bad.remove(pos, span); consec_fail = 0; @@ -583,7 +730,7 @@ impl SectionHandler for Jump { /// before the ranking narrows to the winners ("try each quick, then prioritise"). #[derive(Default)] pub(super) struct HandlerScoreboard { - stats: std::collections::HashMap<&'static str, ScoreStat>, + stats: std::collections::HashMap, } #[derive(Default, Clone, Copy)] @@ -603,8 +750,8 @@ impl HandlerScoreboard { } /// Record one attempt: bytes recovered over `elapsed`. - fn record(&mut self, name: &'static str, recovered: u64, elapsed: std::time::Duration) { - let e = self.stats.entry(name).or_default(); + fn record(&mut self, name: &str, recovered: u64, elapsed: std::time::Duration) { + let e = self.stats.entry(name.to_string()).or_default(); e.recovered = e.recovered.saturating_add(recovered); e.nanos = e.nanos.saturating_add(elapsed.as_nanos()); e.attempts += 1; @@ -635,7 +782,7 @@ impl HandlerScoreboard { tracing::info!( target: "freemkv::disc", phase = "scorecard", - handler = *name, + handler = name.as_str(), recovered_mb = s.recovered as f64 / 1_048_576.0, attempts = s.attempts, mb_per_s = mbps, @@ -658,11 +805,12 @@ pub(super) fn run_handlers( section_deadline_for: impl Fn(&SubRanges) -> Instant, ) -> HandlerOutcome { // Best-first by recovery rate so far; untried handlers rank top (calibrate). - handlers.sort_by_key(|h| std::cmp::Reverse(scoreboard.rank(h.name()))); + handlers.sort_by_key(|h| std::cmp::Reverse(scoreboard.rank(&h.name()))); for handler in handlers.iter_mut() { if bad.is_empty() { return HandlerOutcome::Complete; } + let name = handler.name(); let before = bad.total_len(); let deadline = section_deadline_for(bad); let started = (ctx.now)(); @@ -670,13 +818,20 @@ pub(super) fn run_handlers( // the early-yield trips. ctx.unproductive = 0; let outcome = handler.recover(ctx, bad, deadline); + // A handler may have dropped the spindle (SlowSpin / SpeedSweep) or set + // FUA; restore max speed before the next handler so it starts from the + // streaming default (FUA is a per-read param, so nothing to unwind there). + if ctx.cur_speed != SPEED_MAX_KBS { + ctx.reader.set_speed(SPEED_MAX_KBS); + ctx.cur_speed = SPEED_MAX_KBS; + } let elapsed = (ctx.now)().duration_since(started); let after = bad.total_len(); - scoreboard.record(handler.name(), before.saturating_sub(after), elapsed); + scoreboard.record(&name, before.saturating_sub(after), elapsed); tracing::info!( target: "freemkv::disc", phase = "section_recover.handler", - handler = handler.name(), + handler = name.as_str(), bad_bytes_before = before, bad_bytes_after = after, recovered = before.saturating_sub(after), @@ -721,19 +876,59 @@ mod tests { clock_nanos: Arc, per_read: Duration, reads: Arc, + // ── Physical failure-mode models (all default-empty) ───────────────── + // Each conditional sector reads ONLY when the drive state the handler + // manipulates (speed / FUA / approach direction) matches — so a test + // that recovers it PROVES the technique was actually exercised, not that + // a plain read happened to work. + /// Current `SET CD SPEED` value (updated by `set_speed`); max at build. + speed: u16, + /// Reads ONLY at min speed (fails at max) → SlowSpin / SpeedSweep. + slow_only: HashSet, + /// Reads ONLY on the Nth *physical* (FUA) attempt; a cached (non-FUA) + /// re-read never gets it → FuaRetry. Maps LBA → attempts required. + fua_need: HashMap, + /// Physical (FUA) attempts observed so far, per LBA. + fua_seen: HashMap, + /// Reads ONLY when approached from ABOVE (the previous physical access + /// was a higher LBA) → Oscillate's reverse-into pass. + dir_reverse_only: HashSet, + /// Reads ONLY when the immediately-preceding sector was the previous + /// physical access (PLL/servo primed) → CachePrime. + prime_only: HashSet, + /// LBA of the last sector physically accessed (success or fail) — the + /// approach-direction / priming signal the specialists drive. + last_lba: Option, } impl SectorSource for FakeDisc { fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + ) -> Result { + // Bulk (non-FUA) path. + self.read_sectors_fua(lba, count, buf, recovery, false) + } + + fn read_sectors_fua( &mut self, lba: u32, count: u16, buf: &mut [u8], _recovery: bool, + fua: bool, ) -> Result { self.reads.fetch_add(1, Ordering::Relaxed); self.clock_nanos .fetch_add(self.per_read.as_nanos() as u64, Ordering::Relaxed); + // The head moved across this span; record where it ended so the NEXT + // read can see the approach direction / priming (both success and + // failure move the head). + let prev = self.last_lba; + self.last_lba = Some(lba + count as u32 - 1); if let Some(t) = self.transport_at { if (lba..lba + count as u32).contains(&t) { return Err(Error::ScsiError { @@ -767,6 +962,32 @@ mod tests { sense: None, }); } + // Marginal sector: reads only at min spindle speed. + if self.slow_only.contains(&l) && self.speed != SPEED_MIN_KBS { + return Err(bad_sector(l)); + } + // Stochastic sector: needs N physical (FUA) reads; a cached read + // can never land it (cache masks the good re-read). + if let Some(need) = self.fua_need.get(&l).copied() { + if !fua { + return Err(bad_sector(l)); + } + let seen = self.fua_seen.entry(l).or_insert(0); + *seen += 1; + if *seen < need { + return Err(bad_sector(l)); + } + } + // Direction-dependent tracking: reads only when approached from + // above (previous physical access was a higher LBA). + if self.dir_reverse_only.contains(&l) && prev.is_none_or(|p| p <= l) { + return Err(bad_sector(l)); + } + // Boundary sector: reads only when the preceding sector was the + // previous physical access (servo primed). + if self.prime_only.contains(&l) && prev != l.checked_sub(1) { + return Err(bad_sector(l)); + } } let bytes = count as usize * SECTOR as usize; for (i, b) in buf[..bytes].iter_mut().enumerate() { @@ -774,6 +995,20 @@ mod tests { } Ok(bytes) } + + fn set_speed(&mut self, kbs: u16) { + self.speed = kbs; + } + } + + /// The ordinary recoverable bad-sector error (CHECK CONDITION, no sense) the + /// conditional failure modes return when their precondition isn't met. + fn bad_sector(l: u32) -> Error { + Error::DiscRead { + sector: l as u64, + status: Some(0x02), + sense: None, + } } /// Records every recovered span so a test can assert which sectors came back. @@ -805,6 +1040,13 @@ mod tests { clock_nanos: clock_nanos.clone(), per_read, reads: reads.clone(), + speed: SPEED_MAX_KBS, + slow_only: HashSet::new(), + fua_need: HashMap::new(), + fua_seen: HashMap::new(), + dir_reverse_only: HashSet::new(), + prime_only: HashSet::new(), + last_lba: None, }; ( Harness { @@ -853,13 +1095,14 @@ mod tests { tick: None, unproductive: 0, wedge_streak: 0, + cur_speed: SPEED_MAX_KBS, }; let mut bad = SubRanges::from_section(0, 10 * SECTOR); let deadline = (ctx.now)() + Duration::from_secs(10); // Linear leaves the failed 10-sector batch whole. Linear { - reverse: false, - fast: false, + direction: Direction::Forward, + params: ReadParams::deep(), } .recover(&mut ctx, &mut bad, deadline); assert_eq!( @@ -869,7 +1112,10 @@ mod tests { ); // Bisect salvages the readable sectors around the dead ones. ctx.unproductive = 0; - let out = Bisect.recover(&mut ctx, &mut bad, deadline); + let out = Bisect { + params: ReadParams::fast(), + } + .recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Remaining); // Exactly the two dead sectors remain. assert_eq!(bad.total_len(), 2 * SECTOR); @@ -902,12 +1148,13 @@ mod tests { tick: None, unproductive: 0, wedge_streak: 0, + cur_speed: SPEED_MAX_KBS, }; let mut bad = SubRanges::from_section(0, 40 * SECTOR); let deadline = (ctx.now)() + Duration::from_secs(10); let mut lin = Linear { - reverse: false, - fast: false, + direction: Direction::Forward, + params: ReadParams::deep(), }; let out = lin.recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Remaining); @@ -940,12 +1187,13 @@ mod tests { tick: None, unproductive: 0, wedge_streak: 0, + cur_speed: SPEED_MAX_KBS, }; let mut bad = SubRanges::from_section(0, 1000 * SECTOR); let deadline = (ctx.now)() + Duration::from_secs(3); let mut lin = Linear { - reverse: false, - fast: true, + direction: Direction::Forward, + params: ReadParams::fast(), }; let out = lin.recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Remaining); @@ -976,10 +1224,13 @@ mod tests { tick: None, unproductive: 0, wedge_streak: 0, + cur_speed: SPEED_MAX_KBS, }; let mut bad = SubRanges::from_section(0, 9 * SECTOR); let deadline = (ctx.now)() + Duration::from_secs(10); - let mut bis = Bisect; + let mut bis = Bisect { + params: ReadParams::fast(), + }; let out = bis.recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Remaining); assert!( @@ -1013,18 +1264,21 @@ mod tests { tick: None, unproductive: 0, wedge_streak: 0, + cur_speed: SPEED_MAX_KBS, }; let mut bad = SubRanges::from_section(0, 16 * SECTOR); let mut handlers: Vec> = vec![ Box::new(Linear { - reverse: true, - fast: false, + direction: Direction::Reverse, + params: ReadParams::deep(), }), Box::new(Linear { - reverse: false, - fast: false, + direction: Direction::Forward, + params: ReadParams::deep(), + }), + Box::new(Bisect { + params: ReadParams::fast(), }), - Box::new(Bisect), ]; let deadline_base = (ctx.now)(); let mut scoreboard = HandlerScoreboard::default(); @@ -1055,11 +1309,12 @@ mod tests { tick: None, unproductive: 0, wedge_streak: 0, + cur_speed: SPEED_MAX_KBS, }; let mut bad = SubRanges::from_section(0, 64 * SECTOR); let mut handlers: Vec> = vec![Box::new(Linear { - reverse: false, - fast: true, + direction: Direction::Forward, + params: ReadParams::fast(), })]; let base = (ctx.now)(); let mut scoreboard = HandlerScoreboard::default(); @@ -1087,13 +1342,14 @@ mod tests { tick: None, unproductive: 0, wedge_streak: 0, + cur_speed: SPEED_MAX_KBS, }; // Single-sector batches so the transport LBA is hit directly. let mut bad = SubRanges::from_section(0, 8 * SECTOR); let deadline = (ctx.now)() + Duration::from_secs(10); let mut lin = Linear { - reverse: false, - fast: true, + direction: Direction::Forward, + params: ReadParams::fast(), }; let out = lin.recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::TransportFault); @@ -1122,21 +1378,26 @@ mod tests { tick: None, unproductive: 0, wedge_streak: 0, + cur_speed: SPEED_MAX_KBS, }; let mut bad = SubRanges::from_section(0, 1000 * SECTOR); // The full tier-0 chain: the wedge streak persists across handlers (only // `unproductive` resets per handler), so it reaches the abort threshold // even though each handler yields early on the dead streak. let mut handlers: Vec> = vec![ - Box::new(Bisect), - Box::new(Jump), - Box::new(Linear { - reverse: true, - fast: true, + Box::new(Bisect { + params: ReadParams::fast(), + }), + Box::new(Jump { + params: ReadParams::fast(), }), Box::new(Linear { - reverse: false, - fast: true, + direction: Direction::Reverse, + params: ReadParams::fast(), + }), + Box::new(Linear { + direction: Direction::Forward, + params: ReadParams::fast(), }), ]; let mut scoreboard = HandlerScoreboard::default(); @@ -1181,18 +1442,23 @@ mod tests { tick: None, unproductive: 0, wedge_streak: 0, + cur_speed: SPEED_MAX_KBS, }; let mut bad = SubRanges::from_section(0, 1000 * SECTOR); let mut handlers: Vec> = vec![ - Box::new(Bisect), - Box::new(Jump), - Box::new(Linear { - reverse: true, - fast: true, + Box::new(Bisect { + params: ReadParams::fast(), + }), + Box::new(Jump { + params: ReadParams::fast(), }), Box::new(Linear { - reverse: false, - fast: true, + direction: Direction::Reverse, + params: ReadParams::fast(), + }), + Box::new(Linear { + direction: Direction::Forward, + params: ReadParams::fast(), }), ]; let mut scoreboard = HandlerScoreboard::default(); @@ -1237,6 +1503,7 @@ mod tests { tick: None, unproductive: 0, wedge_streak: carried, + cur_speed: SPEED_MAX_KBS, }; // Distinct 100-sector section per iteration, all within the wedge set. let pos = (section as u64) * 100 * SECTOR; @@ -1244,12 +1511,12 @@ mod tests { // Tier-1 shape: two slow Linear handlers, nothing that reaches 16 alone. let mut handlers: Vec> = vec![ Box::new(Linear { - reverse: true, - fast: false, + direction: Direction::Reverse, + params: ReadParams::deep(), }), Box::new(Linear { - reverse: false, - fast: false, + direction: Direction::Forward, + params: ReadParams::deep(), }), ]; let mut sb = HandlerScoreboard::default(); @@ -1288,12 +1555,13 @@ mod tests { tick: None, unproductive: 0, wedge_streak: 0, + cur_speed: SPEED_MAX_KBS, }; let mut bad = SubRanges::from_section(0, 100 * SECTOR); let deadline = (ctx.now)() + Duration::from_secs(10); let mut lin = Linear { - reverse: false, - fast: true, + direction: Direction::Forward, + params: ReadParams::fast(), }; let out = lin.recover(&mut ctx, &mut bad, deadline); assert_eq!(out, HandlerOutcome::Halted); diff --git a/src/drive/mod.rs b/src/drive/mod.rs index 187ca12..b9dafe7 100644 --- a/src/drive/mod.rs +++ b/src/drive/mod.rs @@ -658,6 +658,25 @@ impl Drive { /// recovery layers (Disc::patch multi-pass, DiscStream batch halving) /// do not touch the wedge-prone reset path. pub fn read(&mut self, lba: u32, count: u16, buf: &mut [u8], recovery: bool) -> Result { + // Bulk path: FUA off (the drive cache IS the streaming throughput). + self.read_fua(lba, count, buf, recovery, false) + } + + /// [`read`], but with an explicit Force Unit Access request: `fua = true` + /// sets the READ(10) FUA bit so the drive re-fetches the medium instead of + /// returning a cached copy — the Pass-N marginal-sector lever (see + /// [`crate::sector::SectorSource::read_sectors_fua`]). The bulk sweep always + /// passes `false`; only a per-sector recovery handler asks for FUA. + /// + /// [`read`]: Drive::read + pub fn read_fua( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + fua: bool, + ) -> Result { let timeout_ms = if recovery { crate::scsi::READ_RECOVERY_TIMEOUT_MS } else { @@ -683,7 +702,7 @@ impl Drive { // read_one call with no behavior change. let max_sectors = (self.scsi.max_transfer_bytes() / 2048).max(1) as u32; if count as u32 <= max_sectors { - return self.read_one(lba, count, buf, timeout_ms, recovery); + return self.read_one(lba, count, buf, timeout_ms, recovery, fua); } // Large read: split into chunks of at most `max_sectors` sectors, @@ -699,7 +718,7 @@ impl Drive { let byte_off = done as usize * 2048; let byte_len = chunk as usize * 2048; let slice = &mut buf[byte_off..byte_off + byte_len]; - let n = self.read_one(cur_lba, chunk as u16, slice, timeout_ms, recovery)?; + let n = self.read_one(cur_lba, chunk as u16, slice, timeout_ms, recovery, fua)?; total += n; done += chunk; } @@ -722,20 +741,20 @@ impl Drive { // `recovery` gates only the Linux /dev/sr0 pread fallback below; on // other platforms it is intentionally unused. #[cfg_attr(not(target_os = "linux"), allow(unused_variables))] recovery: bool, + // FUA (Force Unit Access): when set, byte-1 bit 0x08 forces the drive to + // re-fetch the medium past its cache. + fua: bool, ) -> Result { - // FUA (Force Unit Access) is DISABLED for now — byte-1 bit 0x08 cleared. - // Unconditionally forcing every READ(10) past the drive cache disabled - // the drive's readahead/streaming cache on the bulk sequential sweep and - // collapsed throughput ~10x (UHD 15-25 → ~2 MB/s, DVD → ~0.5 MB/s), - // disc-type-agnostic — the cache IS the streaming throughput. - // - // TODO(#55): reintroduce FUA as a dedicated Pass-N recovery HANDLER that - // sets/clears it per marginal-sector re-read (where cache-masking of a - // stochastic sector actually matters), never blanket-applied to the bulk - // read path. + // FUA is OFF on the bulk path — unconditionally forcing every READ(10) + // past the cache disabled the drive's readahead/streaming cache on the + // sequential sweep and collapsed throughput ~10x (UHD 15-25 → ~2 MB/s, + // DVD → ~0.5 MB/s), disc-type-agnostic — the cache IS the streaming + // throughput. It is set ONLY when a Pass-N recovery handler (FuaRetry) + // asks for it per marginal-sector re-read, where cache-masking of a + // stochastic sector actually matters (#55). let cdb = [ crate::scsi::SCSI_READ_10, - 0x00, + if fua { 0x08 } else { 0x00 }, (lba >> 24) as u8, (lba >> 16) as u8, (lba >> 8) as u8, @@ -1021,6 +1040,17 @@ impl SectorSource for Drive { self.read(lba, count, buf, recovery) } + fn read_sectors_fua( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + fua: bool, + ) -> Result { + self.read_fua(lba, count, buf, recovery, fua) + } + fn set_speed(&mut self, kbs: u16) { Drive::set_speed(self, kbs); } @@ -1437,6 +1467,25 @@ mod command_tests { assert_eq!(&c[7..9], &[0x00, 0x02], "transfer length big-endian"); } + #[test] + fn read_fua_sets_the_force_unit_access_bit() { + // The Pass-N FuaRetry lever: read_fua(.., fua=true) sets READ(10) byte-1 + // bit 0x08 so the drive re-fetches the medium past its cache; fua=false + // leaves it clear (the bulk path). + let RecordingHarness { + drive: mut d, + cdb, + timeouts: _to, + } = recording(TransportOutcome::Ok(2048)); + let mut buf = vec![0u8; 2048]; + d.read_fua(0, 1, &mut buf, false, true).unwrap(); + assert_eq!( + cdb.lock().unwrap()[1], + 0x08, + "FUA requested — byte-1 bit 0x08 set so the drive bypasses its cache" + ); + } + #[test] fn read_recovery_flag_selects_60s_timeout() { // recovery=true must use READ_RECOVERY_TIMEOUT_MS (60 s); false diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index bf29edc..115bac7 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -470,6 +470,20 @@ impl SectorSource for DecryptingSectorSource { count: u16, buf: &mut [u8], recovery: bool, + ) -> Result { + // Bulk path: no Force Unit Access (the cache IS the streaming + // throughput). FUA is a Pass-N recovery lever threaded through + // `read_sectors_fua`. + self.read_sectors_fua(lba, count, buf, recovery, false) + } + + fn read_sectors_fua( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + fua: bool, ) -> Result { // Defense-in-depth: AACS aligned units are 3 sectors (6144 bytes) and // `decrypt_sectors` anchors units at buffer offset 0. A read that does @@ -488,7 +502,9 @@ impl SectorSource for DecryptingSectorSource { return Err(crate::error::Error::DecryptFailed); } let read_t0 = std::time::Instant::now(); - let n = self.inner.read_sectors(lba, count, buf, recovery)?; + let n = self + .inner + .read_sectors_fua(lba, count, buf, recovery, fua)?; let read_ms = read_t0.elapsed().as_millis() as u64; // Decrypt the bytes just read. Scheme-agnostic: `decrypt_sectors*` // dispatches on the keys (None / CSS / AACS) and returns the count of diff --git a/src/sector/mod.rs b/src/sector/mod.rs index f7fe038..1c5f55d 100644 --- a/src/sector/mod.rs +++ b/src/sector/mod.rs @@ -53,6 +53,33 @@ pub trait SectorSource: Send { recovery: bool, ) -> Result; + /// Like [`read_sectors`], but with an explicit Force Unit Access request. + /// + /// `fua = true` asks the drive to bypass its readahead cache and physically + /// re-fetch the medium — a Pass-N marginal-sector lever: a cached hit can + /// mask a *stochastic* sector that would land differently off the platter on + /// each physical read, so FuaRetry re-reads it FUA. It is never blanket- + /// applied to the bulk path (forcing every sequential read past the cache + /// collapses streaming throughput ~10×). + /// + /// The default ignores `fua` and delegates to [`read_sectors`]: only a live + /// [`Drive`] sets the CDB bit; file- / memory-backed sources have no drive + /// cache to bypass, so FUA is meaningless to them. + /// + /// [`read_sectors`]: SectorSource::read_sectors + /// [`Drive`]: crate::drive::Drive + fn read_sectors_fua( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + fua: bool, + ) -> Result { + let _ = fua; + self.read_sectors(lba, count, buf, recovery) + } + /// Optional speed control for sources that map to a physical /// drive. No-op for everything else. fn set_speed(&mut self, _kbs: u16) {} @@ -87,6 +114,17 @@ impl SectorSource for Box { (**self).read_sectors(lba, count, buf, recovery) } + fn read_sectors_fua( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + fua: bool, + ) -> Result { + (**self).read_sectors_fua(lba, count, buf, recovery, fua) + } + fn set_speed(&mut self, kbs: u16) { (**self).set_speed(kbs) } @@ -107,6 +145,17 @@ impl SectorSource for &mut (dyn SectorSource + '_) { (**self).read_sectors(lba, count, buf, recovery) } + fn read_sectors_fua( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + fua: bool, + ) -> Result { + (**self).read_sectors_fua(lba, count, buf, recovery, fua) + } + fn set_speed(&mut self, kbs: u16) { (**self).set_speed(kbs) }