diff --git a/src/consts.rs b/src/consts.rs index 7437cc2..a954739 100644 --- a/src/consts.rs +++ b/src/consts.rs @@ -20,6 +20,10 @@ pub const SECTOR_BYTES: usize = 2048; /// the workspace reads as `sectors * SECTOR_BYTES_U64` with no per-site cast. pub const SECTOR_BYTES_U64: u64 = SECTOR_BYTES as u64; +/// Milliseconds per second. For turning a byte count ÷ bytes-per-second into a +/// movie-time figure (`bytes / bps * MILLIS_PER_SEC`) without a bare `1000.0`. +pub const MILLIS_PER_SEC: f64 = 1_000.0; + /// Bytes per MPEG-2 transport-stream packet. Common to all MPEG-TS, not just /// Blu-ray — prefixed by the format, not a disc type. pub const TS_PACKET_BYTES: usize = 188; diff --git a/src/disc/extract.rs b/src/disc/extract.rs index ea15a61..1e842d5 100644 --- a/src/disc/extract.rs +++ b/src/disc/extract.rs @@ -686,6 +686,7 @@ fn report(opts: &ExtractOptions, done: u64, total: u64) -> bool { bytes_bad_in_main_title: 0, main_title_duration_secs: None, main_title_size_bytes: None, + located: Default::default(), }; p.report(&pp) } diff --git a/src/disc/mod.rs b/src/disc/mod.rs index f753989..e045f27 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -588,6 +588,103 @@ pub fn bytes_bad_in_title(title: &DiscTitle, bad_ranges: &[(u64, u64)]) -> u64 { total } +/// Byte offset of `lba` within `title`'s extents (concatenated in order), or +/// `None` if the LBA falls outside every extent. The title is a virtual +/// contiguous stream; this maps a disc LBA into that stream so a chapter/time +/// lookup can place it. (Moved from autorip — clients must not re-derive it.) +fn byte_offset_in_title(lba: u32, title: &DiscTitle) -> Option { + use crate::consts::SECTOR_BYTES_U64; + let mut cumulative = 0u64; + for ext in &title.extents { + if lba >= ext.start_lba && lba < ext.start_lba + ext.sector_count { + return Some(cumulative + (lba - ext.start_lba) as u64 * SECTOR_BYTES_U64); + } + cumulative += ext.sector_count as u64 * SECTOR_BYTES_U64; + } + None +} + +/// The 1-based chapter + movie-time offset an LBA falls in, or `(None, None)` +/// if it isn't inside the title. +fn range_chapter(lba: u32, title: &DiscTitle) -> (Option, Option) { + if let Some(byte_offset) = byte_offset_in_title(lba, title) { + if let Some((ch, t)) = crate::verify::VerifyResult::chapter_at_offset( + &title.chapters, + byte_offset, + title.duration_secs, + title.size_bytes, + ) { + return (Some(ch as u32), Some(t)); + } + } + (None, None) +} + +/// Annotate raw bad byte-ranges with chapter + movie time, producing the +/// rendered drilldown ([`crate::progress::LocatedProgress`]) a client draws. +/// `raw` is the mapfile's `(byte_pos, byte_len)` set for whichever statuses the +/// caller cares about (the live "Maybe" set during a patch, or terminal +/// `Unreadable` for the verdict). The list is sorted largest-movie-time first +/// and capped at 50; `truncated` reports the overflow. `bps` (title bytes/sec) +/// is derived from the title so callers don't thread it. +/// +/// This is the single place range→chapter/time annotation happens; autorip used +/// to own it and read the mapfile to do so. Now the library computes it from +/// its in-memory mapfile + title, and the client renders the result verbatim. +pub fn locate_ranges(raw: &[(u64, u64)], title: &DiscTitle) -> crate::progress::LocatedProgress { + use crate::consts::{MILLIS_PER_SEC, SECTOR_BYTES_U64}; + use crate::progress::{LocatedProgress, LocatedRange}; + const MAX_LOCATED: usize = 50; + let bps = if title.duration_secs > 0.0 { + title.size_bytes as f64 / title.duration_secs + } else { + 0.0 + }; + let num_ranges = raw.len() as u32; + let mut ranges: Vec = raw + .iter() + .map(|(pos, size)| { + let lba = pos / SECTOR_BYTES_U64; + let count = (size / SECTOR_BYTES_U64) as u32; + let duration_ms = if bps > 0.0 { + (*size as f64) / bps * MILLIS_PER_SEC + } else { + 0.0 + }; + let (chapter, time_offset_secs) = range_chapter(lba as u32, title); + LocatedRange { + lba, + count, + duration_ms, + chapter, + time_offset_secs, + } + }) + .collect(); + ranges.sort_by(|a, b| { + b.duration_ms + .partial_cmp(&a.duration_ms) + .unwrap_or(std::cmp::Ordering::Equal) + }); + let largest_gap_ms = ranges.first().map(|r| r.duration_ms).unwrap_or(0.0); + let truncated = ranges.len().saturating_sub(MAX_LOCATED) as u32; + ranges.truncate(MAX_LOCATED); + // At-risk movie time = duration of the ranges that intersect the title + // extents (the others are menus/extras → no movie impact). + let main_at_risk_ms = if bps > 0.0 { + bytes_bad_in_title(title, raw) as f64 * MILLIS_PER_SEC / bps + } else { + 0.0 + }; + LocatedProgress { + ranges, + num_ranges, + truncated, + main_at_risk_ms, + largest_gap_ms, + } +} + // ─── Display helpers ──────────────────────────────────────────────────────── impl Codec { @@ -3512,6 +3609,15 @@ impl Disc { 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), + // Rendered drilldown from the consumer's in-memory + // snapshot (bad ranges) + title; empty until the first + // snapshot arrives. + located: match &cached_snapshot { + Some(snap) => main_title + .map(|t| locate_ranges(&snap.bad_ranges, t)) + .unwrap_or_default(), + None => crate::progress::LocatedProgress::default(), + }, }; if !reporter.report(&pp) { halt_requested = true; diff --git a/src/disc/patch.rs b/src/disc/patch.rs index a2cb561..7146ed2 100644 --- a/src/disc/patch.rs +++ b/src/disc/patch.rs @@ -2124,6 +2124,12 @@ impl Disc { 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), + // The rendered drilldown — located ranges + at-risk movie time — + // computed here from the in-memory bad-range set + title so the + // client renders it verbatim and never reads the mapfile. + located: main_title + .map(|t| crate::disc::locate_ranges(&bad_ranges_now, t)) + .unwrap_or_default(), }; !reporter.report(&pp) } diff --git a/src/progress.rs b/src/progress.rs index fad4f7d..f1e6b23 100644 --- a/src/progress.rs +++ b/src/progress.rs @@ -35,6 +35,51 @@ pub enum PassKind { Verify, } +/// One located bad/not-yet-good range, annotated with the chapter and movie +/// time it falls in. This is the *rendered* drilldown a client draws: the LBA +/// and sector count place it on the disc map, while `chapter` and +/// `time_offset_secs` tell the user *what* is affected. Computed by the library +/// (which owns the mapfile and title) so no client ever re-derives it. If the +/// mapfile becomes a mapdb, this type and its producer change; clients don't. +#[derive(Debug, Clone)] +pub struct LocatedRange { + /// First sector (LBA) of the range. + pub lba: u64, + /// Length of the range in sectors. + pub count: u32, + /// Movie time this range spans, in milliseconds (range bytes ÷ title + /// bytes/sec). Used to sort the drilldown and size the "largest gap". + pub duration_ms: f64, + /// 1-based chapter the range falls in, if it lands inside the title. + pub chapter: Option, + /// Movie time offset (seconds) where the range begins, if in-title. + pub time_offset_secs: Option, +} + +/// The fully-rendered "where is the damage" view for one progress sample: the +/// located drilldown plus the derived movie-time figures. A client maps this +/// straight to its UI — it never touches the mapfile. `Default` is the empty +/// (no-damage / not-applicable) view, used by phases that don't locate ranges +/// (verify, extract, mux-label). +#[derive(Debug, Clone, Default)] +pub struct LocatedProgress { + /// Located not-yet-good ranges, largest-movie-time first, capped (see + /// `truncated`). + pub ranges: Vec, + /// Total number of located ranges before the cap (so a client can say + /// "N sections"). + pub num_ranges: u32, + /// How many ranges were dropped by the display cap (`ranges.len()` is the + /// kept count; this is the "+X more"). + pub truncated: u32, + /// Main-feature movie time still at risk: duration of the not-yet-good + /// ranges that intersect the title extents, in milliseconds. `0` when all + /// damage is out-of-feature (menus/extras). + pub main_at_risk_ms: f64, + /// Movie time of the single largest range, in milliseconds. + pub largest_gap_ms: f64, +} + /// One progress sample from a pipeline phase. /// /// `work_done / work_total` is the per-pass percentage — always 0..=100% @@ -48,7 +93,12 @@ pub enum PassKind { /// - `bytes_good_total` = good + slow + recovered sectors × 2048 /// - `bytes_unreadable_total` = bad sectors × 2048 /// - `bytes_pending_total` = 0 (verify processes sequentially, nothing pending) -#[derive(Debug, Clone, Copy)] +/// +/// NOT `Copy`: `located` carries a `Vec`. Constructed once per (throttled) +/// emission and passed by reference to `Progress::report`, so this costs one +/// small heap alloc per UI tick — cheap, and it makes `PassProgress` the single +/// complete contract a client renders from. +#[derive(Debug, Clone)] pub struct PassProgress { pub kind: PassKind, pub work_done: u64, @@ -72,6 +122,11 @@ pub struct PassProgress { pub main_title_duration_secs: Option, /// Main title size in bytes (sum of extent sizes). pub main_title_size_bytes: Option, + /// The fully-rendered "where is the damage" drilldown for this sample: + /// located ranges + at-risk movie time. Empty (`Default`) for phases that + /// don't locate ranges. A client renders the disc map + section list from + /// this and NEVER reads the mapfile itself. + pub located: LocatedProgress, } impl PassProgress { diff --git a/src/verify.rs b/src/verify.rs index b22f1bc..27ae23d 100644 --- a/src/verify.rs +++ b/src/verify.rs @@ -221,6 +221,7 @@ pub fn verify_title( bytes_bad_in_main_title: 0, main_title_duration_secs: Some(title.duration_secs), main_title_size_bytes: Some(total_sectors * 2048), + located: Default::default(), }; if !cb.report(&pp) { break 'outer; @@ -271,6 +272,7 @@ pub fn verify_title( bytes_bad_in_main_title: 0, main_title_duration_secs: Some(title.duration_secs), main_title_size_bytes: Some(total_sectors * 2048), + located: Default::default(), }) }) }); @@ -335,6 +337,7 @@ pub fn verify_title( bytes_bad_in_main_title: 0, main_title_duration_secs: Some(title.duration_secs), main_title_size_bytes: Some(total_sectors * 2048), + located: Default::default(), }; if !cb.report(&pp) { break 'outer;