Files
libfreemkv/src/mux/disc.rs
T
Matthew Jackson 1894fa1a49 libfreemkv 1.7.4: single drive-owned AACS bus-removal point
Relocate AACS bus decryption to one spot at the bottom of the sector chain:
the Drive's SectorSource impl applies decrypt_bus_in_content (gated to
encrypted-content ranges), with the stage decided once from the unlock/
handshake result (BusStage::from_read_data_key). Remove read_data_key from
DecryptKeys::Aacs and AacsState and the two downstream decrypt_bus call sites;
every reader (key sampler, mux, unit-key validation) now sees already-de-bussed
content and never reasons about bus encryption. Add BusRemovalSectorSource
stream + tests; keep the AacsBusKeyUnavailable fail-loud gate.
2026-09-19 13:59:52 -07:00

2793 lines
111 KiB
Rust

//! DiscStream — read any disc (physical drive or ISO file) → PES frames.
//!
//! One stream type for all disc sources. The source is a SectorSource —
//! Drive (hardware) or FileSectorSource (file). DiscStream doesn't care.
//!
//! Read-only. For disc→ISO (raw sector copy), use `freemkv_engine::recovery::copy`.
use crate::disc::{DiscTitle, Extent};
use crate::drive::extract_scsi_context;
use crate::event::{BatchSizeReason, Event, EventKind};
use crate::halt::Halt;
use crate::sector::{DecryptingSectorSource, SectorSource};
use std::io;
#[cfg(test)]
use std::sync::Arc;
#[cfg(test)]
use std::sync::atomic::AtomicBool;
// Ramp back to the preferred batch size after this many clean sectors (100
// MiB = 51,200 sectors) — long enough that noisy zones can't trigger a
// premature probe, short enough an isolated failure doesn't lock size 1.
const PROBE_THRESHOLD_SECTORS: u32 = 100 * 1024 * 1024 / 2048;
/// Halve a batch size, keeping 3-sector alignment when >= 6
/// (3-sector alignment = one AACS unit). At sizes < 6 we descend
/// through 3 → 1 without intermediate unaligned sizes.
fn halve_batch_size(size: u16) -> u16 {
let h = (size / 2).max(1);
if h >= 6 { h - (h % 3) } else { h }
}
/// Double a batch size toward a preferred max, keeping 3-sector alignment
/// when the result is >= 6.
fn double_batch_size(size: u16, preferred: u16) -> u16 {
let d = size.saturating_mul(2).min(preferred);
if d >= 6 { d - (d % 3) } else { d }
}
/// Adaptive batch sizer. Shrinks on read failure, grows after a sustained
/// clean streak. Amortizes the cost of entering a bad zone — descent happens
/// once, not once per bad sector.
#[derive(Debug)]
struct AdaptiveBatch {
preferred: u16,
current: u16,
streak_sectors: u32,
}
impl AdaptiveBatch {
fn new(preferred: u16) -> Self {
Self {
preferred,
current: preferred,
streak_sectors: 0,
}
}
fn current(&self) -> u16 {
self.current
}
/// Record a successful read of `sectors`. Returns an event if the
/// sizer probed up to a larger batch size.
fn on_success(&mut self, sectors: u16) -> Option<EventKind> {
self.streak_sectors = self.streak_sectors.saturating_add(sectors as u32);
if self.current < self.preferred && self.streak_sectors >= PROBE_THRESHOLD_SECTORS {
let new_size = double_batch_size(self.current, self.preferred);
if new_size != self.current {
self.current = new_size;
self.streak_sectors = 0;
return Some(EventKind::BatchSizeChanged {
new_size,
reason: BatchSizeReason::Probed,
});
}
}
None
}
/// Record a read failure. Returns an event if the sizer shrank.
/// Does nothing at size 1 (caller handles skip/error).
fn on_failure(&mut self) -> Option<EventKind> {
self.streak_sectors = 0;
if self.current <= 1 {
return None;
}
let new_size = halve_batch_size(self.current);
self.current = new_size;
Some(EventKind::BatchSizeChanged {
new_size,
reason: BatchSizeReason::Shrunk,
})
}
}
/// Disc stream. Reads sectors from any source → PES frames.
///
/// 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
/// [`DecryptingSectorSource`] decorator. Every `read_sectors`
/// 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 SectorSource>>,
title: DiscTitle,
/// Mirror of the keys handed in at construction. The decorator
/// owns the cryptographic state; this field is kept for
/// metadata-side callers (`info()` and friends) that want to
/// know whether the disc was encrypted, without reaching through
/// the wrapper.
decrypt_keys: crate::decrypt::DecryptKeys,
/// Sector granularity the decrypt step requires each read buffer to start
/// on and span a multiple of. AACS decrypts whole 6144-byte (3-sector)
/// units keyed off the buffer's first 16 bytes, so every `read_sectors`
/// buffer must begin on a real on-disc unit boundary — hence reads and
/// error-skips must stay aligned to this. `3` for AACS, `1` for CSS /
/// unencrypted (per-sector, self-synchronizing). Mirrors the file-backed
/// highway's `PrefetchedSectorSource` guard; this is the inline live path.
unit_align: u16,
// Extents to read
extents: Vec<Extent>,
// Position
current_extent: usize,
current_offset: u32,
// Buffer
read_buf: Vec<u8>,
buf_valid: usize,
// Adaptive batch sizer — preferred comes from the caller
// (detect_max_batch_sectors), shrinks/grows based on read outcomes.
adaptive: AdaptiveBatch,
errors: u64,
/// Cumulative bytes actually skipped (zero-filled) on read error.
/// Distinct from `errors`, which counts skip *events*: one event can
/// cover a whole AACS unit (`unit_align` sectors = 6144 bytes), so
/// `errors * 2048` understates real loss by the alignment factor.
/// Consumers estimating lost video time must scale by this, not by
/// the event count.
lost_bytes: u64,
pub skip_errors: bool,
/// When set and the token is cancelled, fill_extents returns Err(Halted)
/// at the next retry boundary. Unlike skip_errors, this propagates the
/// error up so the rip terminates cleanly. Construct with
/// [`DiscStream::with_halt`], passing the same `Halt` clone handed to
/// sweep / patch / mux so every phase observes one Stop signal.
halt: Option<Halt>,
event_fn: Option<Box<dyn Fn(Event) + Send>>,
eof: bool,
/// Count of dropped DVD navigation packets (private_stream_2, 0xBF) — these
/// are expected on every disc; tallied and summarised once at EOF instead of
/// a per-packet WARN.
dropped_nav_packets: u64,
// Cumulative bytes successfully read from the source. Drives
// EventKind::BytesRead emission and autorip's per-device progress.
bytes_read_total: u64,
// Pre-computed total of all extents in bytes (or 0 if extents are
// empty). Carried in EventKind::BytesRead.total so consumers can show
// a percent without a separate API call.
bytes_total_extents: u64,
// PES output — single-threaded inline demux + codec parse. Pipeline-mode
// mux lives in [`super::pipelined_stream::PipelinedPesStream`]; this is
// the legacy in-thread path where bad-sector adaptive retry lives.
ts_demuxer: Option<super::ts::TsDemuxer>,
ps_demuxer: Option<super::ps::PsDemuxer>,
parsers: Vec<(u16, Box<dyn super::codec::CodecParser>)>,
pending_frames: std::collections::VecDeque<crate::pes::PesFrame>,
pid_to_track: Vec<(u16, usize)>,
/// Cached `FREEMKV_SKIP_PARSE` profiling flag. The env var cannot
/// change at runtime, and `std::env::var_os` takes a process-wide
/// lock; reading it once at construction keeps it out of the
/// per-batch read() hot loop.
skip_parse: bool,
/// Cached `FREEMKV_PROFILE` presence, read once at construction. When
/// false, the read() loop skips the four `Instant::now()` captures and the
/// `prof_tick` calls entirely, so profiling-off runs pay no per-iteration
/// timestamp cost or `prof_active()` env-var lookup (which takes a
/// process-wide lock).
profiling: bool,
/// Cumulative bytes fed to the demuxer across all read buffers,
/// used to stamp byte-exact provenance (`SourcePos`) onto demuxed packets.
fed_bytes: u64,
/// B1 drop-to-keyframe resync gate, one per stream — mirrors
/// `PipelinedPesStream`. After an AACS-concealed (undecryptable) gap stamps
/// a frame `discontinuity`, the gate drops forward inter-coded video frames
/// until the next keyframe so no dangling-reference frame reaches the muxer.
/// Without this the live-drive single-pass path emitted decode-broken output
/// on an undecryptable unit (the file-backed highway path is already gated).
resync: Vec<super::resync::ResyncGate>,
/// Whether each stream (by track index) is video — audio/subtitle frames are
/// independent and always admit.
is_video: Vec<bool>,
}
impl DiscStream {
/// Create a disc stream from any sector reader.
///
/// 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(
mut reader: Box<dyn SectorSource>,
title: DiscTitle,
mut decrypt_keys: crate::decrypt::DecryptKeys,
batch_sectors: u16,
content_format: crate::disc::ContentFormat,
raw: bool,
halt: Option<Halt>,
) -> std::io::Result<Self> {
let mut title = title;
let extents = title.extents.clone();
// Resolve this title's CSS key here (same step build_iso_pipeline uses,
// so passes descramble identically); no-op for AACS/clear/raw. `halt` is
// passed now, not via `with_halt`, since the scan runs before attach.
crate::css::resolve_dvd_title_key(
&mut *reader,
&extents,
&mut decrypt_keys,
batch_sectors,
content_format,
raw,
halt.as_ref(),
)?;
let bytes_total_extents: u64 = extents.iter().map(|e| e.sector_count as u64 * 2048).sum();
// Debug log reader type — helps diagnose mux reading from drive instead
// of ISO. `type_name_of_val` resolves the CONCRETE type behind the box,
// unlike `type_name::<dyn SectorSource>()` which prints the trait name.
tracing::debug!(
target: "mux",
"DiscStream constructed with reader type: {}",
std::any::type_name_of_val(&*reader)
);
// CSS/unencrypted content needs a decrypting wrapper to yield plaintext
// VOB bytes before the AC-3 probe reads real `acmod`s. A unit that
// decrypts to broken TS is the muxer's concern, not loss to conceal.
let mut reader = DecryptingSectorSource::new(reader, decrypt_keys.clone());
// Wrong-substream fix (Silence-of-the-Lambs): re-route the title's
// declared AC-3 audio onto the physically-correct `0x8x` sub-streams
// by probing real channel counts off the feature's head. No-op otherwise.
crate::disc::dvd_audio_probe::probe_and_remap(&mut reader, &mut title);
// Use the CANONICAL builder shared with the file-backed highway
// (`resolve::build_demux_state`): an open-coded copy of this once
// drifted and mis-routed Blu-ray 3D MVC dependent views on this path.
let (parsers, pid_to_track, ts_demuxer, ps_demuxer) =
super::resolve::build_demux_state(&title, content_format);
// AACS decrypts whole 6144-byte (3-sector) units keyed off each read
// buffer's first bytes, so reads/skips must stay 3-sector aligned; CSS/
// unencrypted is per-2048-byte, self-sync (align 1). Same rule as resolve.rs.
let unit_align: u16 = match &decrypt_keys {
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
_ => 1,
};
// `reader` is already wrapped above (DecryptKeys::None makes it a
// pass-through). Reset the unit base the probe read advanced so the
// first fill_extents read starts cleanly.
reader.set_unit_base(0);
// B1 resync gates: one per stream, video flagged so the gate only
// drop-to-keyframes video (audio/subtitle always admit). Computed before
// `title` is moved into the struct below.
let is_video: Vec<bool> = title
.streams
.iter()
.map(|s| matches!(s, crate::disc::Stream::Video(_)))
.collect();
let resync = (0..title.streams.len())
.map(|_| super::resync::ResyncGate::new())
.collect();
Ok(Self {
reader,
title,
decrypt_keys,
unit_align,
extents,
current_extent: 0,
current_offset: 0,
read_buf: Vec::with_capacity(batch_sectors as usize * 2048),
buf_valid: 0,
adaptive: AdaptiveBatch::new(batch_sectors),
errors: 0,
lost_bytes: 0,
skip_errors: false,
halt,
event_fn: None,
eof: false,
dropped_nav_packets: 0,
bytes_read_total: 0,
bytes_total_extents,
ts_demuxer,
ps_demuxer,
parsers,
pending_frames: std::collections::VecDeque::new(),
pid_to_track,
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
profiling: std::env::var_os("FREEMKV_PROFILE").is_some(),
fed_bytes: 0,
resync,
is_video,
})
}
/// Set event handler for sector-level events (binary search, skip, recover).
pub fn on_event(&mut self, f: impl Fn(Event) + Send + 'static) {
self.event_fn = Some(Box::new(f));
}
/// Constructor-time builder: attach a [`Halt`] token so that when
/// any clone is cancelled, the next read-retry boundary inside
/// `fill_extents` returns `Err(Halted)`. Required for Stop to work
/// during dense bad-sector regions (where the outer PES read() loop
/// can spend minutes inside fill_extents before emitting a frame).
///
/// Pass the same `Halt` clone you hand to sweep / patch / mux so every
/// phase observes a single Stop signal.
pub fn with_halt(mut self, halt: Halt) -> Self {
self.halt = Some(halt);
self
}
/// Install a proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap) on the inline
/// live-drive path — the counterpart to what
/// [`build_iso_pipeline`](crate::mux::resolve::build_iso_pipeline) does for the
/// file-backed highway. The map is the title's read plan: it decides which unit
/// each LBA is and, for an FMTS forensic segment, which phase is ours.
///
/// The extent walk is rewritten so only our-phase units are read off the
/// drive, and each unit decrypts with its mapped key. A non-forensic map
/// leaves the extents unchanged.
pub fn with_key_map(mut self, map: std::sync::Arc<crate::decrypt::AacsKeyMap>) -> Self {
self.extents = map.read_plan(&self.extents, self.unit_align.max(1) as u32);
self.bytes_total_extents = self
.extents
.iter()
.map(|e| e.sector_count as u64 * 2048)
.sum();
self.reader.set_key_map(map);
self
}
fn is_halted(&self) -> bool {
self.halt
.as_ref()
.map(|h| h.is_cancelled())
.unwrap_or(false)
}
fn emit(&self, kind: EventKind) {
if let Some(ref f) = self.event_fn {
f(Event { kind });
}
}
/// Skip decryption — return raw encrypted bytes. Updates both
/// the metadata-side key field and the wrapped reader's keys so
/// subsequent `read_sectors` calls become a pass-through.
pub fn set_raw(&mut self) {
self.decrypt_keys = crate::decrypt::DecryptKeys::None;
self.reader.set_keys(crate::decrypt::DecryptKeys::None);
}
// Commit a successful read_sectors, zero-filling+accounting a short read
// under skip_errors (never a silent hole) instead of trusting the count.
// See docs/mux-disc.md — commit_read.
fn commit_read(&mut self, lba: u32, got: usize, bytes: usize) -> io::Result<()> {
if got < bytes {
if !self.skip_errors {
return Err(crate::error::Error::DiscRead {
sector: lba as u64,
status: None,
sense: None,
}
.into());
}
// Clear the tail the source did not write — it holds stale bytes
// from a previous batch, which would mux as plausible garbage.
self.read_buf[got..bytes].fill(0);
self.errors += 1;
self.lost_bytes = self.lost_bytes.saturating_add((bytes - got) as u64);
self.emit(EventKind::SectorSkipped { sector: lba as u64 });
}
self.buf_valid = bytes;
// `bytes` is always a whole number of 2048-byte logical sectors (it is
// `sectors * 2048`), so this is the requested sector count — the cursor
// advances a whole unit span either way, keeping AACS alignment.
self.current_offset += (bytes / 2048) as u32;
// Only the bytes the source actually delivered count as read; the
// zero-filled tail is loss, already charged to `lost_bytes`.
self.bytes_read_total = self.bytes_read_total.saturating_add(got as u64);
self.emit(EventKind::BytesRead {
bytes: self.bytes_read_total,
total: self.bytes_total_extents,
});
Ok(())
}
fn fill_extents(&mut self) -> io::Result<bool> {
// Skip exhausted/zero-sector extents iteratively, not recursively: a
// malformed disc can declare thousands of empty extents, and a
// self-recursive version overflowed the stack, aborting the process.
let (ext_start, ext_sectors, remaining) = loop {
if self.current_extent >= self.extents.len() {
return Ok(false);
}
let ext = &self.extents[self.current_extent];
let remaining = ext.sector_count.saturating_sub(self.current_offset);
if remaining > 0 {
break (ext.start_lba, ext.sector_count, remaining);
}
self.current_extent += 1;
self.current_offset = 0;
};
// start_lba comes from UDF/MPLS extents; a malformed extent near
// u32::MAX would overflow (debug panic / release wrap to a wrong LBA).
// Saturate for consistency with the rest of the file's arithmetic.
let lba = ext_start.saturating_add(self.current_offset);
// AACS aligned units are anchored at this extent's start LBA — gate the
// decrypt-on-read source relative to it (clip-anchored), not absolute
// disc LBA 0. No-op for CSS / None.
self.reader.set_unit_base(ext_start);
// Adaptive sizer shrinks on failure, grows on success; one 5s attempt
// per try, no retry loops. Halt is checked every iteration since a bad
// zone can spend minutes here, else Stop waits for the outer read() loop.
let start_time = std::time::Instant::now();
loop {
if self.is_halted() {
return Err(crate::error::Error::Halted.into());
}
// Debug: log slow reads during mux — helps diagnose stalls
if cfg!(debug_assertions) && start_time.elapsed().as_secs() > 5 {
tracing::debug!(target: "mux", "fill_extents waiting at LBA {} ({}s elapsed, sectors={})", lba, start_time.elapsed().as_secs(), remaining);
}
// Every read buffer must start on a real on-disc unit boundary: AACS
// (align=3) decrypts whole units keyed off the buffer's first bytes,
// so a sub-unit mid-extent read would desync the rest of the title.
let align = self.unit_align.max(1) as u32;
let want = remaining.min(self.adaptive.current() as u32);
let sectors: u16 = if align <= 1 {
want as u16
} else if remaining < align {
remaining as u16
} else if want < align {
align as u16
} else {
(want - want % align) as u16
};
let bytes = sectors as usize * 2048;
self.read_buf.resize(bytes, 0);
let res = self
.reader
.read_sectors(lba, sectors, &mut self.read_buf[..bytes], false);
if let Ok(&got) = res.as_ref() {
// read_sectors returns bytes written into buf. All in-tree
// sources are full-or-error, but a short count would leave
// stale/zeroed tail bytes in read_buf.
debug_assert!(got <= bytes, "read_sectors over-reported byte count");
if let Some(ev) = self.adaptive.on_success(sectors) {
self.emit(ev);
}
let got = got.min(bytes);
self.commit_read(lba, got, bytes)?;
break;
}
// Transport failure (status=0xFF: bridge crash/disconnect) is NOT a
// skippable bad sector — the bridge is wedged and every subsequent
// read fails, so skipping just marches the disc producing no MKV.
if let Some(e) = res.as_ref().err()
&& e.is_scsi_transport_failure()
{
let (status, sense) = extract_scsi_context(e);
return Err(crate::error::Error::DiscRead {
sector: lba as u64,
status: Some(status),
sense,
}
.into());
}
// The read SOURCE is gone (e.g. a terminated prefetch thread), not
// bad media — skip_errors would zero-fill the rest of the title and
// report success. Abort with the terminal error, not a fake status.
if let Some(e) = res.as_ref().err()
&& e.is_source_terminated()
{
return Err(crate::error::Error::SourceTerminated.into());
}
if (sectors as u32) <= align {
// Bottomed out with no later pass to recover — give the drive one
// full ECC recovery read (~60s) before skip/bail. A single bounded
// read, never a loop (hard rule #2: retry loops fast-fail the BU40N).
tracing::debug!(
target: "mux",
"fill_extents last-chance recovery read at LBA {} ({} sectors, 60s ECC)",
lba,
sectors
);
let rec = self
.reader
.read_sectors(lba, sectors, &mut self.read_buf[..bytes], true);
if let Ok(&got) = rec.as_ref() {
debug_assert!(got <= bytes, "recovery read over-reported byte count");
if let Some(ev) = self.adaptive.on_success(sectors) {
self.emit(ev);
}
let got = got.min(bytes);
// Same short-read rule as the first-attempt read above —
// one shared commit, so the two cannot drift apart.
self.commit_read(lba, got, bytes)?;
break;
}
// Recovery read also failed: re-check `rec` for a transport
// failure (bridge crash) same as `res` above — the 60s recovery
// read can wedge the bridge on its own; abort rather than skip.
if let Some(e) = rec.as_ref().err()
&& e.is_scsi_transport_failure()
{
let (status, sense) = extract_scsi_context(e);
return Err(crate::error::Error::DiscRead {
sector: lba as u64,
status: Some(status),
sense,
}
.into());
}
// Same rule as the first-attempt read: the recovery read can
// also be the call that discovers the source is dead. Skipping
// would zero-fill the rest of the title as fabricated content.
if let Some(e) = rec.as_ref().err()
&& e.is_source_terminated()
{
return Err(crate::error::Error::SourceTerminated.into());
}
// Recovery read also failed. Skip the WHOLE failed unit, not one
// sector: advancing by the full unit keeps current_offset
// AACS-aligned (a partial skip here would desync the rest).
if self.skip_errors {
let zb = sectors as usize * 2048;
self.read_buf.resize(zb, 0);
self.read_buf[..zb].fill(0);
self.buf_valid = zb;
self.errors += 1;
// `errors` counts skip events, `lost_bytes` the bytes
// zero-filled. One AACS event can skip a whole 6144-byte
// unit, so loss estimates must use lost_bytes, not errors*2048.
self.lost_bytes = self.lost_bytes.saturating_add(zb as u64);
self.emit(EventKind::SectorSkipped { sector: lba as u64 });
self.current_offset += sectors as u32;
break;
} else {
// Build the error from the recovery failure we now hold
// (falling back to the original 10s-read failure).
let err = rec.err().or(res.err());
let (status, sense) =
err.as_ref().map(extract_scsi_context).unwrap_or((0, None));
return Err(crate::error::Error::DiscRead {
sector: lba as u64,
status: Some(status),
sense,
}
.into());
}
}
// Shrink and retry at the same LBA with a smaller batch.
if let Some(ev) = self.adaptive.on_failure() {
self.emit(ev);
}
}
if self.current_offset >= ext_sectors {
self.current_extent += 1;
self.current_offset = 0;
}
Ok(true)
}
}
// Per-stage profiling state, populated only when FREEMKV_PROFILE is set.
// Logs a percentage breakdown via tracing (target "mux") every
// PROFILE_INTERVAL. Zero overhead otherwise.
struct StageProf {
started: std::time::Instant,
last_dump: std::time::Instant,
fill_ns: u128,
feed_ns: u128,
consume_ns: u128,
bytes_in: u64,
}
const PROFILE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5);
thread_local! {
static STAGE_PROF: std::cell::RefCell<Option<StageProf>> = const { std::cell::RefCell::new(None) };
}
fn prof_active() -> bool {
std::env::var_os("FREEMKV_PROFILE").is_some()
}
fn prof_tick(stage: &str, ns: u128, bytes: u64) {
STAGE_PROF.with(|cell| {
let mut slot = cell.borrow_mut();
if slot.is_none() {
if !prof_active() {
return;
}
let now = std::time::Instant::now();
*slot = Some(StageProf {
started: now,
last_dump: now,
fill_ns: 0,
feed_ns: 0,
consume_ns: 0,
bytes_in: 0,
});
}
let p = slot.as_mut().unwrap();
match stage {
"fill" => p.fill_ns += ns,
"feed" => p.feed_ns += ns,
"consume" => p.consume_ns += ns,
_ => {}
}
p.bytes_in += bytes;
let now = std::time::Instant::now();
if now.duration_since(p.last_dump) < PROFILE_INTERVAL {
return;
}
let elapsed_ms = now.duration_since(p.started).as_millis().max(1);
let fill_pct = p.fill_ns / 10_000 / elapsed_ms;
let feed_pct = p.feed_ns / 10_000 / elapsed_ms;
let consume_pct = p.consume_ns / 10_000 / elapsed_ms;
let mbps = p.bytes_in as u128 * 1000 / 1_000_000 / elapsed_ms;
tracing::debug!(
target: "mux",
"[profile] elapsed={}ms in={}MB/s fill={}% feed={}% consume={}%",
elapsed_ms, mbps, fill_pct, feed_pct, consume_pct,
);
p.last_dump = now;
});
}
impl crate::pes::Stream for DiscStream {
fn read(&mut self) -> io::Result<Option<crate::pes::PesFrame>> {
if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame));
}
if self.eof {
return Ok(None);
}
loop {
// Profiling timestamps only when FREEMKV_PROFILE is set; otherwise
// these stay None and no Instant::now() is taken in the hot loop.
let t0 = self.profiling.then(std::time::Instant::now);
if !self.fill_extents()? {
self.eof = true;
if self.dropped_nav_packets > 0 {
tracing::debug!(
target: "mux",
"dropped {} DVD navigation packets (private_stream_2/0xBF) — expected, carry no elementary stream",
self.dropped_nav_packets
);
}
// Flush demuxer — last PES packet may still be in the assembler
if let Some(ref mut demuxer) = self.ts_demuxer {
for pes in &demuxer.flush() {
if let Some((_, track)) =
self.pid_to_track.iter().find(|(pid, _)| *pid == pes.pid)
&& let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
let resync = &mut self.resync;
let is_video = &self.is_video;
let pending = &mut self.pending_frames;
for frame in parser.parse(pes) {
// Same B1 gate — a concealed gap can leave a
// post-gap frame in the demuxer's final flush.
let emit = match resync.get_mut(*track) {
Some(gate) => gate.admit(
is_video.get(*track).copied().unwrap_or(false),
frame.discontinuity,
frame.keyframe,
),
None => true,
};
if emit {
pending.push_back(crate::pes::PesFrame::from_codec_frame(
*track, frame,
));
}
}
}
}
}
// PS demuxer flush (DVD)
if let Some(ref mut demuxer) = self.ps_demuxer {
for ps in &demuxer.flush() {
// Route by the REAL DVD PID (see consume_ps in
// pipelined_stream.rs); the old (sub_id & 0x1F)+1
// heuristic mis-routed VobSub into the AC-3 parser.
let Some(pid) = ps.dvd_pid() else {
if ps.is_nav() {
// Expected DVD navigation packet (PCI/DSI) —
// tally, no WARN.
self.dropped_nav_packets += 1;
} else {
// Unexpected unmappable stream_id (a
// possibly-dropped real stream). Keep the WARN.
tracing::warn!(
target: "mux",
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
ps.stream_id,
ps.sub_stream_id,
);
}
continue;
};
let Some((_, track)) =
self.pid_to_track.iter().find(|(p, _)| *p == pid).copied()
else {
tracing::warn!(
target: "mux",
"dropping PS packet for unmapped PID {:#06x} (stream_id={:#04x}, sub_stream_id={:?})",
pid,
ps.stream_id,
ps.sub_stream_id,
);
continue;
};
let pes = super::ts::PesPacket {
source: ps.source,
pid,
pts: ps.pts.map(|p| p as i64),
dts: ps.dts.map(|d| d as i64),
data: ps.data.clone(),
// PS (DVD/CSS) path: no AACS conceal, no gap flag.
discontinuity: false,
};
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid)
{
for frame in parser.parse(&pes) {
self.pending_frames.push_back(
crate::pes::PesFrame::from_codec_frame(track, frame),
);
}
}
}
}
// Drain any access unit a codec parser buffered past the last
// PES (DTS-HD's final core+extension unit, assembled across
// PES boundaries).
let pid_to_track = &self.pid_to_track;
let pending = &mut self.pending_frames;
let resync = &mut self.resync;
let is_video = &self.is_video;
for (pid, parser) in self.parsers.iter_mut() {
let Some(&(_, track)) = pid_to_track.iter().find(|(p, _)| p == pid) else {
continue;
};
// Flush frames carry their own per-frame `discontinuity`, so
// route them through the SAME B1 gate as the in-stream path —
// otherwise a trailing dangling-reference frame bypasses resync.
for frame in parser.flush() {
let emit = match resync.get_mut(track) {
Some(gate) => gate.admit(
is_video.get(track).copied().unwrap_or(false),
frame.discontinuity,
frame.keyframe,
),
None => true,
};
if emit {
pending.push_back(crate::pes::PesFrame::from_codec_frame(track, frame));
}
}
}
// A gate still armed at EOF dropped post-gap frames that never
// reached a keyframe (a concealed gap in the final GOP). Surface
// it once so the loss is visible, not silent.
for (track, gate) in self.resync.iter().enumerate() {
if gate.is_armed() {
tracing::warn!(
target: "mux",
track,
dropped = gate.dropped_in_run(),
"B1: stream ended while dropping to a keyframe after a concealed gap (no trailing keyframe)"
);
}
}
return Ok(self.pending_frames.pop_front());
}
let bytes = self.buf_valid;
let t1 = self.profiling.then(std::time::Instant::now);
if let (Some(t0), Some(t1)) = (t0, t1) {
prof_tick("fill", t1.duration_since(t0).as_nanos(), bytes as u64);
}
// Plaintext already: DecryptingSectorSource applied AACS/CSS
// in-place during fill_extents' read_sectors call (the pre-0.18
// inline decrypt step used to live here).
let buf_base = self.fed_bytes;
self.fed_bytes = self.fed_bytes.saturating_add(bytes as u64);
if let Some(ref mut demuxer) = self.ts_demuxer {
let packets = demuxer.feed_at(buf_base, &self.read_buf[..bytes]);
let t2 = self.profiling.then(std::time::Instant::now);
if let (Some(t1), Some(t2)) = (t1, t2) {
prof_tick("feed", t2.duration_since(t1).as_nanos(), 0);
}
let skip_parse = self.skip_parse;
for pes in packets {
if let Some((_, track)) = self
.pid_to_track
.iter()
.find(|(pid, _)| *pid == pes.pid)
.copied()
{
if skip_parse {
// Profiling escape hatch — bypass the codec parser,
// pass raw PES bytes through as one PesFrame, so we
// can attribute time to demux+framing vs codec parse.
self.pending_frames.push_back(crate::pes::PesFrame {
coding: None,
source: None,
track,
pts: pes.pts.map(super::codec::pts_to_ns).unwrap_or(0),
keyframe: false,
data: pes.data,
duration_ns: None,
});
} else if let Some((_, parser)) =
self.parsers.iter_mut().find(|(pid, _)| *pid == pes.pid)
{
// Disjoint field borrows: the resync gate + is_video
// reads coexist with the mutable `self.parsers` drain.
let resync = &mut self.resync;
let is_video = &self.is_video;
let pending = &mut self.pending_frames;
for frame in parser.parse(&pes) {
// B1: after a concealed/lost gap, drop forward to
// the next keyframe on a video track so no frame
// with a dangling reference is emitted.
let emit = match resync.get_mut(track) {
Some(gate) => gate.admit(
is_video.get(track).copied().unwrap_or(false),
frame.discontinuity,
frame.keyframe,
),
None => true,
};
if emit {
pending.push_back(crate::pes::PesFrame::from_codec_frame(
track, frame,
));
}
}
}
}
}
let t3 = self.profiling.then(std::time::Instant::now);
if let (Some(t2), Some(t3)) = (t2, t3) {
prof_tick("consume", t3.duration_since(t2).as_nanos(), 0);
}
} else if let Some(ref mut demuxer) = self.ps_demuxer {
let packets = demuxer.feed_at(buf_base, &self.read_buf[..bytes]);
for ps in &packets {
// Route by the REAL DVD PID (see consume_ps in
// pipelined_stream.rs); the old (sub_id & 0x1F)+1
// heuristic mis-routed VobSub into the AC-3 parser.
let Some(pid) = ps.dvd_pid() else {
if ps.is_nav() {
// Expected DVD navigation packet (PCI/DSI) — tally,
// no WARN.
self.dropped_nav_packets += 1;
} else {
// Unexpected unmappable stream_id (a possibly-dropped
// real stream). Keep the individual WARN.
tracing::warn!(
target: "mux",
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
ps.stream_id,
ps.sub_stream_id,
);
}
continue;
};
let Some((_, track)) =
self.pid_to_track.iter().find(|(p, _)| *p == pid).copied()
else {
tracing::warn!(
target: "mux",
"dropping PS packet for unmapped PID {:#06x} (stream_id={:#04x}, sub_stream_id={:?})",
pid,
ps.stream_id,
ps.sub_stream_id,
);
continue;
};
let pes = super::ts::PesPacket {
source: ps.source,
pid,
pts: ps.pts.map(|p| p as i64),
dts: ps.dts.map(|d| d as i64),
data: ps.data.clone(),
// PS (DVD/CSS) path: no AACS conceal, no gap flag.
discontinuity: false,
};
if let Some((_, parser)) = self.parsers.iter_mut().find(|(p, _)| *p == pid) {
for frame in parser.parse(&pes) {
self.pending_frames
.push_back(crate::pes::PesFrame::from_codec_frame(track, frame));
}
}
}
}
self.buf_valid = 0;
if let Some(frame) = self.pending_frames.pop_front() {
return Ok(Some(frame));
}
}
}
fn write(&mut self, _frame: &crate::pes::PesFrame) -> io::Result<()> {
Err(crate::error::Error::StreamReadOnly.into())
}
fn finish(&mut self) -> io::Result<()> {
Ok(())
}
fn info(&self) -> &DiscTitle {
&self.title
}
fn codec_private(&self, track: usize) -> Option<Vec<u8>> {
let pid = self
.pid_to_track
.iter()
.find(|(_, idx)| *idx == track)
.map(|(pid, _)| *pid)?;
self.parsers
.iter()
.find(|(p, _)| *p == pid)
.and_then(|(_, parser)| parser.codec_private())
}
fn headers_ready(&self) -> bool {
// FREEMKV_SKIP_PARSE bypasses codec parsers entirely, so
// codec_private is never populated — pretend headers are ready
// immediately in that mode so the CLI loop doesn't hang.
if self.skip_parse {
return true;
}
for (idx, s) in self.title.streams.iter().enumerate() {
if let crate::disc::Stream::Video(v) = s
&& !v.secondary
&& self.codec_private(idx).is_none()
{
return false;
}
}
true
}
fn errors(&self) -> u64 {
// Read errors PLUS every frame the B1 resync gates discarded. Summed
// here rather than at each of three `admit` call sites (one place can't
// drift); `dropped_total` catches gaps that RESOLVE, which most do.
self.errors
+ self
.resync
.iter()
.map(super::resync::ResyncGate::dropped_total)
.sum::<u64>()
}
fn lost_bytes(&self) -> u64 {
// Read-error zero-fill loss only — the abort gate needs real missing
// content. No decrypt-loss term: undecryptable units pass through as
// broken TS (the muxer's concern), indistinguishable from bad authoring.
self.lost_bytes
}
}
#[cfg(test)]
mod tests {
//! `DiscStream` is the only read-only `Stream` impl in tree (every
//! other concrete impl in `mux/*` is bidirectional or write-only).
//! These tests lock down a static `Send` assertion plus a
//! `Box<dyn Stream>` round trip exercising every method through the
//! trait object, so future Send-breaking edits to `DiscStream`'s
//! interior types fail at compile time.
use super::*;
use crate::disc::{ContentFormat, DiscTitle};
use crate::pes::Stream;
// Static-assert DiscStream: Send (Stream has Send as a supertrait) — a
// future non-Send field fails this 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 SectorSource yielding zeroed sectors. Empty title -> no PES
// frames -> read() walks to EOF -> Ok(None); enough to exercise the
// trait-object dispatch (the goal is the bridge, not the demuxer).
struct ZeroReader {
capacity: u32,
}
impl crate::sector::SectorSource for ZeroReader {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
let bytes = count as usize * 2048;
buf[..bytes].fill(0);
Ok(bytes)
}
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
// A SectorSource that UNDER-reports: writes the whole requested span but
// reports only the first sector valid, modeling a non-full-or-error
// source — the case fill_extents handled inconsistently pre-fix.
struct ShortReader {
capacity: u32,
}
impl crate::sector::SectorSource for ShortReader {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
let bytes = count as usize * 2048;
// Stale marker across the whole span; only the first sector is
// reported as actually delivered.
buf[..bytes].fill(0xAB);
Ok(2048usize.min(bytes))
}
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
fn short_read_stream(skip_errors: bool) -> DiscStream {
let mut s = DiscStream::new(
Box::new(ShortReader { capacity: 64 }),
synthetic_title(64),
crate::decrypt::DecryptKeys::None,
8, // request 8 sectors (16384 B); the source delivers 1 (2048 B)
ContentFormat::BdTs,
false,
None,
)
.unwrap();
s.skip_errors = skip_errors;
s
}
// A short read must never become a silent gap: advancing current_offset
// by the full requested count would drop the undelivered tail with no
// error. Without skip_errors it's a hard read error: E6000.
#[test]
fn short_read_without_skip_errors_is_reported_not_silently_skipped() {
let mut s = short_read_stream(false);
let err = s
.fill_extents()
.expect_err("a short read must not be reported as a clean fill");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
assert!(
err.to_string().contains("E6000"),
"expected the disc-read code E6000, got {err}"
);
}
// With skip_errors the undelivered tail is zero-filled and accounted, not
// left stale or dropped silently. current_offset still advances by the
// full request to stay on an AACS unit boundary.
#[test]
fn short_read_with_skip_errors_is_zero_filled_and_accounted() {
let mut s = short_read_stream(true);
assert!(
s.fill_extents()
.expect("skip_errors absorbs the short read")
);
// 8 sectors requested at 2048 B/sector (the ECMA-167 / UDF logical
// sector size) = 16384 B of buffer; the source delivered 2048 B.
assert_eq!(s.buf_valid, 16_384, "the whole requested span stays valid");
assert_eq!(s.current_offset, 8, "the cursor advances a whole unit span");
assert_eq!(
s.lost_bytes, 14_336,
"16384 requested - 2048 delivered = 14336 lost bytes must be counted"
);
assert_eq!(s.errors, 1, "the gap is counted as one skip event");
// The delivered sector survives; the undelivered tail is zeroed, not
// the stale 0xAB the source left behind.
assert!(s.read_buf[..2048].iter().all(|&b| b == 0xAB));
assert!(
s.read_buf[2048..16_384].iter().all(|&b| b == 0),
"the undelivered tail must be zero-filled, not stale bytes"
);
}
// A malformed disc can declare thousands of zero-sector extents; the old
// self-recursive skip overflowed the stack (fatal, unlike iterative EOF).
// See docs/mux-disc.md — a_long_run_of_empty_extents_does_not_recurse_per_extent.
#[test]
fn a_long_run_of_empty_extents_does_not_recurse_per_extent() {
let title = DiscTitle {
extents: (0..50_000)
.map(|_| crate::disc::Extent {
start_lba: 0,
sector_count: 0,
})
.collect(),
..DiscTitle::empty()
};
let handle = std::thread::Builder::new()
.stack_size(256 * 1024)
.spawn(move || {
let mut s = DiscStream::new(
Box::new(ZeroReader { capacity: 0 }),
title,
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
s.fill_extents()
})
.unwrap();
assert!(
!handle.join().unwrap().unwrap(),
"an all-empty extent list is EOF, not an error"
);
}
fn synthetic_title(sector_count: u32) -> DiscTitle {
DiscTitle {
extents: vec![crate::disc::Extent {
start_lba: 0,
sector_count,
}],
..DiscTitle::empty()
}
}
/// Smallest credible witness that `DiscStream` flows through `dyn Stream`:
/// build a `Box<dyn Stream>`, drive `read()` to EOF, exercise `info()` /
/// `headers_ready()` / `codec_private()` through the trait object.
#[test]
fn stream_via_dyn_object() {
let reader = ZeroReader { capacity: 8 };
let title = synthetic_title(8);
let stream = DiscStream::new(
Box::new(reader),
title,
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
let mut src: Box<dyn Stream> = Box::new(stream);
// Empty-title fixture has no streams configured, so headers are
// trivially ready and codec_private() yields nothing on track 0.
assert!(src.headers_ready());
assert!(src.codec_private(0).is_none());
let _ = src.info();
// Drive read() to EOF through the trait object — empty-title fixture
// produces no frames, but the call still routes through the blanket
// dispatch into Stream::read.
let mut frames = 0usize;
while src.read().expect("read").is_some() {
frames += 1;
if frames > 1024 {
panic!("unexpected unbounded frame stream from empty title");
}
}
assert_eq!(frames, 0);
}
/// A Blu-ray 3D title: an AVC base view plus the MVC dependent (right-eye)
/// view, marked by `MVC_DEPENDENT_LABEL`.
fn mvc_title() -> DiscTitle {
use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream};
let view = |pid: u16, label: &str| {
crate::disc::Stream::Video(VideoStream {
pid,
codec: Codec::H264,
resolution: Resolution::R1080p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: None,
secondary: false,
label: label.to_string(),
measured_cicp: None,
})
};
let mut t = synthetic_title(8);
t.streams = vec![
view(0x1011, ""), // base view
view(0x1012, crate::disc::MVC_DEPENDENT_LABEL), // dependent view
];
t.content_format = ContentFormat::BdTs;
t
}
// A dependent-view access unit: PPS (NAL 8) + coded-slice-extension (NAL
// 20), no IDR. MVC passthrough keeps every parameter set in-band (ISO/IEC
// 14496-10 Annex H); the base-view parser strips PPS from non-keyframes.
fn mvc_dependent_au_pes(pid: u16) -> crate::mux::ts::PesPacket {
let nal = |t: u8, body: &[u8]| {
let mut v = vec![0x00, 0x00, 0x01, t];
v.extend_from_slice(body);
v
};
let mut data = Vec::new();
data.extend_from_slice(&nal(0x68, &[0xCE, 0x01])); // PPS (8)
data.extend_from_slice(&nal(0x74, &[0x11, 0x22])); // slice extension (20)
crate::mux::ts::PesPacket {
source: None,
pid,
pts: Some(90_000),
dts: None,
data,
discontinuity: false,
}
}
/// Whether a parser's output for a dependent-view AU still carries the PPS —
/// the observable signature of the MVC param-set-passthrough parser.
fn keeps_pps_inband(parser: &mut dyn super::super::codec::CodecParser, pid: u16) -> bool {
let frames = parser.parse(&mvc_dependent_au_pes(pid));
assert_eq!(frames.len(), 1, "one access unit in, one frame out");
// Frame payload is length-prefixed NALs (4-byte BE length + NAL).
let d = &frames[0].data;
let mut i = 0;
while i + 4 <= d.len() {
let len = u32::from_be_bytes([d[i], d[i + 1], d[i + 2], d[i + 3]]) as usize;
i += 4;
if i + len > d.len() {
break;
}
if len > 0 && d[i] & 0x1F == 8 {
return true;
}
i += len;
}
false
}
// The live disc:// path must dispatch the Blu-ray 3D MVC dependent view
// to the passthrough parser, same as the ISO path (build_demux_state) —
// before sharing this, the same 3D disc muxed wrong from a drive.
#[test]
fn live_path_dispatches_mvc_dependent_view_to_passthrough_parser() {
let title = mvc_title();
let mut stream = DiscStream::new(
Box::new(ZeroReader { capacity: 8 }),
title.clone(),
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
let idx_of = |parsers: &Vec<(u16, Box<dyn super::super::codec::CodecParser>)>, pid: u16| {
parsers.iter().position(|(p, _)| *p == pid).unwrap()
};
// Live path: dependent view keeps its PPS in-band, base view does not.
let dep = idx_of(&stream.parsers, 0x1012);
assert!(
keeps_pps_inband(stream.parsers[dep].1.as_mut(), 0x1012),
"live disc:// path must give the MVC dependent view the passthrough parser"
);
let base = idx_of(&stream.parsers, 0x1011);
assert!(
!keeps_pps_inband(stream.parsers[base].1.as_mut(), 0x1011),
"base view keeps the ordinary parser (discriminator is real, not vacuous)"
);
// And it must agree with the ISO path, which is the canonical builder.
let (mut iso_parsers, _, _, _) =
crate::mux::resolve::build_demux_state(&title, ContentFormat::BdTs);
let iso_dep = idx_of(&iso_parsers, 0x1012);
assert!(
keeps_pps_inband(iso_parsers[iso_dep].1.as_mut(), 0x1012),
"ISO path reference behaviour"
);
}
/// `is_halted()` must observe a cancellation signal installed via
/// `with_halt(Halt)` — flipping the token must cause the next
/// `fill_extents` retry boundary to bail.
#[test]
fn halt_via_with_halt_observed_by_is_halted() {
let halt = Halt::new();
let stream = DiscStream::new(
Box::new(ZeroReader { capacity: 8 }),
synthetic_title(8),
crate::decrypt::DecryptKeys::None,
8,
crate::disc::ContentFormat::BdTs,
false,
None,
)
.unwrap()
.with_halt(halt.clone());
assert!(!stream.is_halted());
halt.cancel();
assert!(
stream.is_halted(),
"with_halt token cancellation must be observed by is_halted()"
);
}
/// `with_key_map` on the inline live-drive path applies the same FMTS read plan
/// the file-backed highway uses: within a forensic segment only our-phase units
/// survive the extent walk, so the alternate device-group units are never read.
#[test]
fn with_key_map_reads_only_our_phase_units() {
use crate::decrypt::{AacsKeyMap, DecryptKeys, Phase};
// AACS keys → unit_align = 3, so a unit is 3 sectors and the phase filter
// engages. Key contents are irrelevant to the read plan.
let aacs = DecryptKeys::Aacs {
unit_keys: vec![(0, [0u8; 16]), (1, [1u8; 16])],
format: ContentFormat::BdTs,
};
// 100 units (300 sectors). A 10-unit Even forensic segment at LBA [30,60):
// even units (30,36,42,48,54) are ours; odd (33,39,45,51,57) are dropped.
let map = AacsKeyMap::from_ranges_phased(vec![(30, 60, 1, Phase::Even)]);
let stream = DiscStream::new(
Box::new(ZeroReader { capacity: 300 }),
synthetic_title(300),
aacs,
8,
ContentFormat::BdTs,
false,
None,
)
.unwrap()
.with_key_map(std::sync::Arc::new(map));
let total: u32 = stream.extents.iter().map(|e| e.sector_count).sum();
assert_eq!(
total,
300 - 5 * 3,
"exactly the 5 alternate-phase units (15 sectors) are dropped from the read walk"
);
assert!(
stream.extents.len() > 1,
"the forensic segment split the single extent into our-phase-only runs"
);
// The progress denominator tracks the reduced read set.
assert_eq!(stream.bytes_total_extents, total as u64 * 2048);
}
// Recording SectorSource: logs every (lba, count) request, errors when the
// range covers bad_sector. Successful reads return zeroed sectors (not
// flagged scrambled, so DecryptingSectorSource passes them through as-is).
struct RecordingReader {
capacity: u32,
bad_sector: u32,
log: std::sync::Arc<std::sync::Mutex<Vec<(u32, u16)>>>,
}
impl crate::sector::SectorSource for RecordingReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
self.log.lock().unwrap().push((lba, count));
let end = lba + count as u32;
if self.bad_sector >= lba && self.bad_sector < end {
return Err(crate::error::Error::DiscRead {
sector: self.bad_sector as u64,
status: Some(0x02),
sense: None,
});
}
let bytes = count as usize * 2048;
buf[..bytes].fill(0);
Ok(bytes)
}
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
// SectorSource that fails every read covering bad_sector with a SCSI
// transport failure (status=0xFF, the USB-bridge-crash sentinel Drive::read
// surfaces). Logs each (lba, count) so a test can prove it wasn't retried.
struct TransportFailReader {
capacity: u32,
bad_sector: u32,
log: std::sync::Arc<std::sync::Mutex<Vec<(u32, u16)>>>,
}
impl crate::sector::SectorSource for TransportFailReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
self.log.lock().unwrap().push((lba, count));
let end = lba + count as u32;
if self.bad_sector >= lba && self.bad_sector < end {
return Err(crate::error::Error::DiscRead {
sector: self.bad_sector as u64,
status: Some(crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE),
sense: None,
});
}
let bytes = count as usize * 2048;
buf[..bytes].fill(0);
Ok(bytes)
}
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
// SectorSource mirroring a marginal sector recoverable only with the
// drive's full ECC budget: fails while recovery=false, succeeds once
// recovery=true. Exercises fill_extents' last-chance recovery-read branch.
struct RecoverableReader {
capacity: u32,
bad_sector: u32,
/// `(lba, count, recovery)` for every issued read.
log: std::sync::Arc<std::sync::Mutex<Vec<(u32, u16, bool)>>>,
}
impl crate::sector::SectorSource for RecoverableReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> crate::error::Result<usize> {
self.log.lock().unwrap().push((lba, count, recovery));
let end = lba + count as u32;
let covers_bad = self.bad_sector >= lba && self.bad_sector < end;
// Fail on the fast (non-recovery) pass; the 60s ECC recovery read
// succeeds. Distinct non-0x02 sense byte so a transport-failure
// re-check (status 0xFF) is provably NOT triggered here.
if covers_bad && !recovery {
return Err(crate::error::Error::DiscRead {
sector: self.bad_sector as u64,
status: Some(0x02),
sense: None,
});
}
let bytes = count as usize * 2048;
buf[..bytes].fill(0);
Ok(bytes)
}
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
// Coverage for the recovery-read SUCCESS branch (rc.5.2 audit #3): a
// sector failing the fast read but clean on the 60s ECC pass must mux the
// RECOVERED data — cursor advances, no skip counted. unit_align=1 (None).
#[test]
fn recovery_read_success_muxes_recovered_data_no_skip() {
const COUNT: u32 = 10;
let bad = 4u32;
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let reader = RecoverableReader {
capacity: COUNT,
bad_sector: bad,
log: log.clone(),
};
let mut stream = DiscStream::new(
Box::new(reader),
synthetic_title(COUNT),
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
// skip_errors=false: if the recovery read did NOT succeed, fill_extents
// would return Err — so reaching EOF cleanly proves recovery worked.
stream.skip_errors = false;
let mut guard = 0;
loop {
match stream.fill_extents() {
Ok(true) => {}
Ok(false) => break,
Err(e) => panic!("recovery read should have succeeded, got: {e}"),
}
guard += 1;
assert!(guard < 1000, "fill_extents did not reach EOF");
}
// No skip counted: the recovered unit was muxed, not zero-filled.
assert_eq!(
stream.errors, 0,
"a successful recovery read must not count as a skipped sector"
);
assert_eq!(
stream.lost_bytes, 0,
"a successful recovery read loses no bytes"
);
// All COUNT sectors' worth of bytes were read through to the cursor end.
assert_eq!(
stream.bytes_read_total,
COUNT as u64 * 2048,
"every sector (including the recovered one) must be counted as read"
);
// The bad sector was retried with recovery=true and that read SUCCEEDED.
let reads = log.lock().unwrap();
assert!(
reads
.iter()
.any(|&(lba, count, rec)| rec && lba == bad && count == 1),
"expected a recovery=true single-sector read at the bad sector; got {reads:?}"
);
// And the fast pass at the bad sector did happen with recovery=false.
assert!(
reads.iter().any(|&(lba, _c, rec)| !rec && lba == bad),
"expected a non-recovery read to have first failed at the bad sector"
);
}
// SectorSource that fails the fast read at bad_sector with status 0x02,
// then fails the 60s ECC recovery read with transport failure (0xFF) —
// models a bridge wedging during the last-chance recovery read.
struct RecoveryTransportFailReader {
capacity: u32,
bad_sector: u32,
log: std::sync::Arc<std::sync::Mutex<Vec<(u32, u16, bool)>>>,
}
impl crate::sector::SectorSource for RecoveryTransportFailReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
recovery: bool,
) -> crate::error::Result<usize> {
self.log.lock().unwrap().push((lba, count, recovery));
let end = lba + count as u32;
if self.bad_sector >= lba && self.bad_sector < end {
let status = if recovery {
crate::scsi::SCSI_STATUS_TRANSPORT_FAILURE
} else {
0x02
};
return Err(crate::error::Error::DiscRead {
sector: self.bad_sector as u64,
status: Some(status),
sense: None,
});
}
let bytes = count as usize * 2048;
buf[..bytes].fill(0);
Ok(bytes)
}
fn capacity_sectors(&self) -> u32 {
self.capacity
}
}
// Regression (rc.5.2 audit #2): a transport failure on the 60s ECC
// RECOVERY read (not just the initial read) must ABORT even under
// skip_errors=true, not fall into skip/advance and march forever.
#[test]
fn transport_failure_on_recovery_read_aborts_even_with_skip_errors() {
const COUNT: u32 = 10;
let bad = 4u32;
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let reader = RecoveryTransportFailReader {
capacity: COUNT,
bad_sector: bad,
log: log.clone(),
};
let mut stream = DiscStream::new(
Box::new(reader),
synthetic_title(COUNT),
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
stream.skip_errors = true;
// Drive fill_extents across batches: the batch covering the bad sector
// shrinks to size 1, fails the fast read (0x02), then the bottom-out
// recovery read returns the transport failure (0xFF), which must abort.
let mut res = Ok(true);
for _ in 0..1000 {
res = stream.fill_extents();
if !matches!(res, Ok(true)) {
break;
}
}
assert!(
res.is_err(),
"a transport failure on the recovery read must abort fill_extents, got {res:?}"
);
assert_eq!(
stream.errors, 0,
"a recovery-read transport-failure abort must NOT count as a skip"
);
assert_eq!(
stream.lost_bytes, 0,
"a transport-failure abort zero-fills nothing"
);
// Prove the bottom-out recovery read was actually reached and aborted on.
let reads = log.lock().unwrap();
assert!(
reads
.iter()
.any(|&(lba, count, rec)| rec && lba == bad && count == 1),
"expected a recovery=true read at the bad sector to have been attempted; got {reads:?}"
);
}
// Regression: a USB-bridge transport crash (0xFF) during a single-pass
// disc://->mkv:// rip must ABORT immediately, even under skip_errors=true
// — mirrors the multipass sweep's short-circuit; exactly one read issued.
#[test]
fn transport_failure_aborts_single_pass_even_with_skip_errors() {
const COUNT: u32 = 10;
let bad = 4u32;
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let reader = TransportFailReader {
capacity: COUNT,
bad_sector: bad,
log: log.clone(),
};
let mut stream = DiscStream::new(
Box::new(reader),
synthetic_title(COUNT),
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
stream.skip_errors = true;
let res = stream.fill_extents();
assert!(
res.is_err(),
"transport failure must abort fill_extents, not skip past it"
);
assert_eq!(
stream.errors, 0,
"a transport-failure abort must NOT count as a skipped sector"
);
let reads = log.lock().unwrap();
assert_eq!(
reads.len(),
1,
"transport failure must abort after the first failed read with no \
shrink/retry/skip-ahead; got reads {reads:?}"
);
}
// REGRESSION (round-4 audit): an ordinary MEDIUM ERROR bad sector must
// keep its identity crossing the prefetch channel, not become a fabricated
// transport failure. See docs/mux-disc.md — bad_sector_keeps_its_identity_across_the_prefetch_channel.
#[test]
fn bad_sector_keeps_its_identity_across_the_prefetch_channel() {
const COUNT: u32 = 9;
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let reader = RecordingReader {
capacity: COUNT,
bad_sector: 4,
log: log.clone(),
};
let mut prefetched = crate::sector::PrefetchedSectorSource::new_with_events(
reader,
vec![crate::disc::Extent {
start_lba: 0,
sector_count: COUNT,
}],
8,
1,
None,
None,
)
.expect("spawn producer");
let mut buf = vec![0u8; 8 * 2048];
let err = crate::sector::SectorSource::read_sectors(&mut prefetched, 0, 8, &mut buf, false)
.expect_err("the batch covering the bad sector must fail");
assert!(
!err.is_scsi_transport_failure(),
"a MEDIUM ERROR bad sector is not a dead bus; got {err:?}"
);
assert!(
matches!(
err,
crate::error::Error::DiscRead {
sector: 4,
status: Some(0x02),
..
}
),
"the producer's typed error must survive the channel intact; got {err:?}"
);
}
// The prefetch producer dies permanently on its first read error; driving
// fill_extents to exhaustion after that must NOT look like a completed
// pass. See docs/mux-disc.md — dead_prefetch_producer_does_not_silently_zero_fill_the_title.
#[test]
fn dead_prefetch_producer_does_not_silently_zero_fill_the_title() {
const COUNT: u32 = 30;
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let reader = RecordingReader {
capacity: COUNT,
bad_sector: 4,
log: log.clone(),
};
let prefetched = crate::sector::PrefetchedSectorSource::new_with_events(
reader,
vec![crate::disc::Extent {
start_lba: 0,
sector_count: COUNT,
}],
8,
1,
None,
None,
)
.expect("spawn producer");
let mut stream = DiscStream::new(
Box::new(prefetched),
synthetic_title(COUNT),
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
stream.skip_errors = true;
// Drive the whole title. Bounded so a regression cannot hang the suite.
let mut completed_clean = false;
for _ in 0..(COUNT as usize * 4) {
match stream.fill_extents() {
Ok(true) => continue,
Ok(false) => {
completed_clean = true;
break;
}
Err(_) => break,
}
}
assert!(
!completed_clean,
"the producer died at sector 4, so sectors 4..{COUNT} were never \
read — reporting the pass as complete zero-fills {} of {} bytes \
and calls it success",
stream.lost_bytes,
COUNT as u64 * 2048
);
}
// AACS unit-alignment skip: with unit_align=3 and skip_errors=true, a
// single bad mid-extent sector must not desync the title — every read
// starts on a 3-sector unit boundary and skips advance a whole unit.
#[test]
fn aacs_reads_stay_unit_aligned_and_skip_whole_units() {
const COUNT: u32 = 30;
const ALIGN: u32 = 3;
// Bad sector at offset 13 — inside unit 4 (offsets 12,13,14). The
// whole unit must be skipped, keeping the cursor unit-aligned.
let bad = 13u32;
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let reader = RecordingReader {
capacity: COUNT,
bad_sector: bad,
log: log.clone(),
};
let title = synthetic_title(COUNT);
let keys = crate::decrypt::DecryptKeys::Aacs {
unit_keys: vec![(0, [0u8; 16])],
format: crate::disc::ContentFormat::BdTs,
};
let mut stream = DiscStream::new(
Box::new(reader),
title,
keys,
8,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
stream.skip_errors = true;
assert_eq!(
stream.unit_align, ALIGN as u16,
"AACS keys must set unit_align=3"
);
// Drive fill_extents to EOF (no PES demux needed — we observe the
// raw read pattern directly).
let ext_start = 0u32;
let mut guard = 0;
loop {
match stream.fill_extents() {
Ok(true) => {}
Ok(false) => break,
Err(e) => panic!("fill_extents errored unexpectedly: {e}"),
}
guard += 1;
assert!(guard < 1000, "fill_extents did not reach EOF");
}
let reads = log.lock().unwrap();
assert!(!reads.is_empty(), "expected at least one read");
for &(lba, count) in reads.iter() {
assert_eq!(
(lba - ext_start) % ALIGN,
0,
"read at lba {lba} is not unit-aligned (offset {} % {ALIGN} != 0)",
lba - ext_start
);
// Non-tail reads must be a whole number of units; the only permitted
// short read is a final partial unit. A mid-stream non-unit-multiple
// read would straddle AACS unit boundaries and decrypt wrong.
assert!(
(count as u32).is_multiple_of(ALIGN) || (count as u32) < ALIGN,
"read count {count} is neither a whole number of units nor a sub-unit tail"
);
}
// At least one error was skipped (the bad unit) and a SectorSkipped
// event was emitted; errors counter advanced by exactly the bad units.
assert!(stream.errors >= 1, "expected the bad unit to be skipped");
// Regression: `lost_bytes` must account for the WHOLE skipped unit
// (3 sectors = 6144 bytes), not one sector — `errors * 2048` would
// undercount AACS loss ~3x, the single-pass abort-gate bug this guards.
assert_eq!(
stream.lost_bytes,
stream.errors * ALIGN as u64 * 2048,
"AACS skip must record a whole unit (6144 B) per skip event, not 2048"
);
assert!(
stream.lost_bytes > stream.errors * 2048,
"lost_bytes must exceed the errors*2048 undercount for AACS units"
);
// Crucial anti-desync assertion: the read that bottomed out and was
// skipped must have been a single 3-sector unit starting at offset 12
// (the unit boundary at/below bad sector 13), NOT a 1-sector read at 13.
assert!(
reads
.iter()
.any(|&(lba, count)| lba == 12 && count == ALIGN as u16),
"expected a unit-aligned (lba=12,count=3) read over the bad unit; got {reads:?}"
);
// And NO single-sector read at the bad sector itself (would be a desync).
assert!(
!reads.iter().any(|&(lba, count)| lba == bad && count == 1),
"a 1-sector read at the bad sector {bad} would desync the AACS unit stream"
);
}
/// `unit_align == 1` (DecryptKeys::None) variant: single-sector skips
/// still work (CSS/raw is self-synchronizing, so a 1-sector skip is
/// correct there — contrast with the AACS whole-unit skip above).
#[test]
fn unencrypted_single_sector_skip_works() {
const COUNT: u32 = 10;
let bad = 4u32;
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let reader = RecordingReader {
capacity: COUNT,
bad_sector: bad,
log: log.clone(),
};
let mut stream = DiscStream::new(
Box::new(reader),
synthetic_title(COUNT),
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
stream.skip_errors = true;
assert_eq!(stream.unit_align, 1, "None keys must leave unit_align=1");
let mut guard = 0;
loop {
match stream.fill_extents() {
Ok(true) => {}
Ok(false) => break,
Err(e) => panic!("fill_extents errored unexpectedly: {e}"),
}
guard += 1;
assert!(guard < 1000, "fill_extents did not reach EOF");
}
let reads = log.lock().unwrap();
// The bad sector must have been retried down to a single sector and
// skipped at count==1 — the self-synchronizing per-sector path.
assert!(
reads.iter().any(|&(lba, count)| lba == bad && count == 1),
"align=1 must bottom out at a 1-sector read over the bad sector; got {reads:?}"
);
assert!(stream.errors >= 1);
// align=1: a skip event covers exactly one sector, so lost_bytes
// and errors*2048 agree (the AACS undercount does not apply here).
assert_eq!(
stream.lost_bytes,
stream.errors * 2048,
"single-sector (align=1) skip must record exactly 2048 B per event"
);
}
#[test]
fn halt_via_with_halt_from_arc_observed_by_is_halted() {
let arc = Arc::new(AtomicBool::new(false));
let stream = DiscStream::new(
Box::new(ZeroReader { capacity: 8 }),
synthetic_title(8),
crate::decrypt::DecryptKeys::None,
8,
crate::disc::ContentFormat::BdTs,
false,
None,
)
.unwrap()
.with_halt(Halt::from_arc(arc.clone()));
assert!(!stream.is_halted());
arc.store(true, std::sync::atomic::Ordering::Relaxed);
assert!(
stream.is_halted(),
"with_halt(Halt::from_arc) must observe Arc-side flips"
);
}
/// Every read fails CSS-locked (`05/6F/03`) — a scrambled DVD whose title key
/// can't be cracked. Drives `resolve_dvd_title_key` to `ScrambledUncracked`.
struct LockedReader;
impl crate::sector::SectorSource for LockedReader {
fn read_sectors(
&mut self,
lba: u32,
_count: u16,
_buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
Err(crate::error::Error::DiscRead {
sector: lba as u64,
status: Some(2),
sense: Some(crate::scsi::ScsiSense {
sense_key: 0x05,
asc: 0x6F,
ascq: 0x03,
}),
})
}
fn capacity_sectors(&self) -> u32 {
64
}
}
fn mpegps_title(sector_count: u32) -> DiscTitle {
let mut t = synthetic_title(sector_count);
t.content_format = ContentFormat::MpegPs;
t
}
// PARITY with build_iso_pipeline_dvd_none_keys_scrambled_hard_fails: the
// live-drive constructor must ALSO hard-fail on a None-keyed scrambled
// MPEG-PS DVD, not build a scrambled-passthrough stream.
#[test]
fn disc_stream_new_dvd_none_scrambled_hard_fails() {
let res = DiscStream::new(
Box::new(LockedReader),
mpegps_title(8),
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::MpegPs,
false,
None,
);
assert!(
res.is_err(),
"single-pass DiscStream must hard-fail on a scrambled, keyless CSS DVD"
);
}
/// `raw` must bypass the CSS crack at the DiscStream boundary too: the same
/// scrambled-uncrackable input that hard-fails above must CONSTRUCT in raw
/// mode (ciphertext passthrough), never hard-fail.
#[test]
fn disc_stream_new_raw_bypasses_css_crack() {
let res = DiscStream::new(
Box::new(LockedReader),
mpegps_title(8),
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::MpegPs,
true, // raw
None,
);
assert!(
res.is_ok(),
"raw single-pass must construct without cracking, even on scrambled-uncrackable input"
);
}
mod stream_surface_tests {
//! The `impl crate::pes::Stream for DiscStream` surface plus the adaptive
//! batch sizer. These are the accessors the CLI's abort gate and the
//! header-wait loop read; a constant in any of them hides read loss or
//! stalls the mux, so each is constrained against a value that is neither
//! the mutation constant nor the initial state.
use super::*;
use crate::pes::Stream as PesStream;
// ── errors() / lost_bytes(): honest loss reporting ─────────────────
// The ONLY channel a caller learns bytes went missing; two short
// reads drive both counters to distinct non-0/1 values to prove it.
#[test]
fn errors_and_lost_bytes_report_real_short_read_loss_through_the_trait() {
let mut s = short_read_stream(true);
// Before any read the stream is clean — establishes the accessors are
// not simply echoing a preloaded value.
assert_eq!(PesStream::errors(&s), 0);
assert_eq!(PesStream::lost_bytes(&s), 0);
for _ in 0..2 {
assert!(
s.fill_extents()
.expect("skip_errors absorbs the short read")
);
}
// Two 8-sector (16384 B) requests, 2048 B delivered each: two skip
// events, 2 * (16384 - 2048) = 28672 bytes lost.
assert_eq!(
PesStream::errors(&s),
2,
"errors() must report BOTH short reads, not a constant"
);
assert_eq!(
PesStream::lost_bytes(&s),
28_672,
"lost_bytes() must report the byte total, not an event count or a constant"
);
assert_ne!(
PesStream::errors(&s),
PesStream::lost_bytes(&s),
"the two accessors must read different fields"
);
}
// Frames the B1 resync gate discards must reach errors(), including
// after the gap resolves (ResyncGate::dropped zeroes on resync). See
// docs/mux-disc.md — errors_reports_frames_the_resync_gate_dropped_after_the_gap_resolves.
#[test]
fn errors_reports_frames_the_resync_gate_dropped_after_the_gap_resolves() {
let mut s = short_read_stream(true);
assert_eq!(PesStream::errors(&s), 0, "clean before anything happens");
// Drive a gate directly: a discontinuity, two dropped inter-coded
// frames, then a keyframe that resyncs and zeroes the per-run count
// (the short-read fixture's title has no streams, so give it a gate).
s.resync.push(crate::mux::resync::ResyncGate::new());
let gate = s.resync.last_mut().expect("just pushed");
assert!(!gate.admit(true, true, false));
assert!(!gate.admit(true, false, false));
assert!(gate.admit(true, false, true));
assert_eq!(
gate.dropped_in_run(),
0,
"the resync zeroed the per-run counter, which is the whole trap"
);
assert_eq!(
PesStream::errors(&s),
2,
"the two discarded frames must still reach the caller after the \
gap resolved; reading the per-run counter here would report 0"
);
}
// ── write(): DiscStream is read-only ──────────────────────────────
// Ok(()) would make a caller muxing INTO it believe frames landed;
// it must refuse with the numeric code E_STREAM_READ_ONLY.
#[test]
fn write_refuses_with_the_read_only_code() {
let mut s = short_read_stream(false);
let frame = crate::pes::PesFrame {
coding: None,
source: None,
track: 0,
pts: 0,
keyframe: true,
data: vec![0u8; 4],
duration_ns: None,
};
let err = PesStream::write(&mut s, &frame)
.expect_err("a read-only stream must never accept a frame");
assert_eq!(err.kind(), std::io::ErrorKind::Unsupported);
let code = format!("E{}", crate::error::Error::StreamReadOnly.code());
assert!(
err.to_string().contains(&code),
"expected the read-only code {code}, got {err}"
);
}
// ── codec_private() / headers_ready() ─────────────────────────────
/// MPEG-2 sequence header (ISO/IEC 13818-2 §6.2.2.1) — start code `0x000001B3`
/// followed by 12-bit horizontal_size, 12-bit vertical_size, then
/// aspect_ratio_information / frame_rate_code and the bit-rate/VBV tail.
fn seq_header(width: u16, height: u16) -> Vec<u8> {
let mut h = vec![0x00, 0x00, 0x01, 0xB3];
h.push((width >> 4) as u8);
h.push((((width & 0x0F) as u8) << 4) | ((height >> 8) & 0x0F) as u8);
h.push((height & 0xFF) as u8);
h.push((3 << 4) | 4); // aspect_ratio_information=3, frame_rate_code=4
h.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0x00]);
h
}
/// MPEG-2 picture header (ISO/IEC 13818-2 §6.2.3), coding type in bits 3..5
/// of the sixth byte. Type 1 = I-picture.
fn picture_header(coding_type: u8) -> Vec<u8> {
vec![
0x00,
0x00,
0x01,
0x00,
0x00,
(coding_type & 0x07) << 3,
0,
0,
]
}
// One video (MPEG-2, track 1) behind one audio (AC-3, track 0). Video
// is deliberately NOT track 0 so a codec_private that mishandled the
// track arg or pid-map direction would answer with the audio parser.
fn audio_then_video_title() -> DiscTitle {
use crate::disc::{
AudioChannels, AudioStream, Codec, ColorSpace, FrameRate, HdrFormat, LabelPurpose,
Resolution, SampleRate, VideoStream,
};
let mut t = DiscTitle {
extents: vec![crate::disc::Extent {
start_lba: 0,
sector_count: 8,
}],
..DiscTitle::empty()
};
t.content_format = ContentFormat::MpegPs;
t.streams = vec![
crate::disc::Stream::Audio(AudioStream {
pid: 0x00BD,
codec: Codec::Ac3,
channels: AudioChannels::Stereo,
language: "eng".to_string(),
sample_rate: SampleRate::S48,
secondary: false,
purpose: LabelPurpose::Normal,
label: String::new(),
}),
crate::disc::Stream::Video(VideoStream {
pid: 0x00E0,
codec: Codec::Mpeg2,
resolution: Resolution::R480i,
frame_rate: FrameRate::F29_97,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Smpte170m,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
}),
];
t
}
fn mixed_stream() -> DiscStream {
DiscStream::new(
Box::new(ZeroReader { capacity: 8 }),
audio_then_video_title(),
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::MpegPs,
false,
None,
)
.unwrap()
}
// headers_ready() gates the CLI's "wait for codec private" loop: a
// constant true starts the mux with empty CodecPrivate (unplayable
// per RFC 9559 §5.1.4.1.5); constant false hangs forever. Both pinned.
#[test]
fn headers_ready_follows_the_video_codec_private_and_flips_both_ways() {
let mut s = mixed_stream();
assert!(
PesStream::codec_private(&s, 1).is_none(),
"no sequence header parsed yet"
);
assert!(
!PesStream::headers_ready(&s),
"a non-secondary video track without codec private must NOT be reported ready"
);
// Feed the video parser a complete access unit: sequence header +
// I-picture, closed by a following picture so the AU boundary is hit.
let vpid = 0x00E0u16;
let (_, parser) = s
.parsers
.iter_mut()
.find(|(p, _)| *p == vpid)
.expect("video parser present");
let mut au = seq_header(720, 480);
au.extend_from_slice(&picture_header(1));
au.extend_from_slice(&[0xAA; 16]);
let pes = |data: Vec<u8>, pts: Option<i64>| crate::mux::ts::PesPacket {
source: None,
pid: vpid,
pts,
dts: None,
data,
discontinuity: false,
};
let _ = parser.parse(&pes(au, Some(0)));
let mut next = picture_header(3);
next.extend_from_slice(&[0xBB; 16]);
let _ = parser.parse(&pes(next, None));
let cp = PesStream::codec_private(&s, 1)
.expect("codec_private must reach the VIDEO track's parser, not track 0's");
assert_eq!(
&cp[..4],
&[0x00, 0x00, 0x01, 0xB3],
"codec private is the MPEG-2 sequence header"
);
assert_eq!(&cp[4..7], &[0x2D, 0x01, 0xE0], "720x480 as authored");
assert!(
PesStream::headers_ready(&s),
"with the video's codec private present the mux may start"
);
// The AC-3 track genuinely has none — so the Some() above is a real
// per-track lookup, not a fixed answer.
assert!(PesStream::codec_private(&s, 0).is_none());
// And a track index past the end of the pid map has none either.
assert!(PesStream::codec_private(&s, 7).is_none());
}
// ── on_event() / emit() ───────────────────────────────────────────
/// `on_event()` installs the sink and `emit()` feeds it. If either is a
/// no-op the CLI's progress bar never moves and skipped sectors are never
/// reported — the rip looks clean and stalled at 0 %.
#[test]
fn installed_event_sink_receives_skip_and_progress_events() {
let log = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let sink = log.clone();
let mut s = short_read_stream(true);
s.on_event(move |e| {
let tag = match e.kind {
EventKind::SectorSkipped { sector } => format!("skip:{sector}"),
EventKind::BytesRead { bytes, total } => format!("bytes:{bytes}/{total}"),
other => format!("other:{other:?}"),
};
sink.lock().unwrap().push(tag);
});
assert!(s.fill_extents().expect("short read absorbed"));
let got = log.lock().unwrap().clone();
assert!(
got.contains(&"skip:0".to_string()),
"the skipped unit at LBA 0 must reach the installed sink; got {got:?}"
);
// 2048 B delivered out of a 64-sector (131072 B) title.
assert!(
got.contains(&"bytes:2048/131072".to_string()),
"progress must report DELIVERED bytes against the extent total; got {got:?}"
);
}
// ── set_raw() ─────────────────────────────────────────────────────
// Must flip BOTH key holders — mirror and wrapped reader — or an
// AACS stream keeps decrypting when --raw asked for ciphertext.
#[test]
fn set_raw_clears_both_the_mirror_and_the_readers_keys() {
let mut s = DiscStream::new(
Box::new(ZeroReader { capacity: 8 }),
synthetic_title(8),
crate::decrypt::DecryptKeys::Aacs {
unit_keys: vec![(0, [0x11u8; 16])],
format: ContentFormat::BdTs,
},
3,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
assert!(
s.decrypt_keys.is_encrypted(),
"fixture must start encrypted or the test proves nothing"
);
// Behavioural witness for the WRAPPED reader's keys: while AACS keys
// are installed, a read not starting on a 6144-byte unit boundary
// hard-fails (it would mis-decrypt every following unit).
let mut buf = vec![0u8; 2048];
let before = s.reader.read_sectors(1, 1, &mut buf, false);
assert!(
matches!(before, Err(crate::error::Error::DecryptFailed)),
"the encrypted fixture must reject a mid-unit read; got {before:?}"
);
s.set_raw();
assert!(
!s.decrypt_keys.is_encrypted(),
"the metadata mirror must report the stream as raw"
);
let after = s.reader.read_sectors(1, 1, &mut buf, false);
assert_eq!(
after.expect("raw mode must pass ciphertext straight through"),
2048,
"the wrapped reader must stop decrypting, not just the mirror"
);
}
#[test]
fn disc_stream_stamps_source_provenance() {
use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream};
use crate::pes::Stream;
// Build 1 TS packet containing a complete PES for PID 0x1011
// 4-byte BD prefix + 188-byte TS packet (PUSI=1, PID=0x1011)
let mut ts_pkt = vec![0x00, 0x00, 0x00, 0x00, 0x47, 0x50, 0x11, 0x10];
// PES header: 00 00 01 E0, length 0x000E (14 bytes), flags 0x80 0x80 (PTS present), header_len 5, PTS 0
ts_pkt.extend_from_slice(&[
0x00, 0x00, 0x01, 0xE0, 0x00, 0x0E, 0x80, 0x80, 0x05, 0x21, 0x00, 0x01, 0x00, 0x01,
]);
// Annex B AU: SPS + PPS + IDR slice
ts_pkt.extend_from_slice(&[
0x00, 0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x0A, // SPS
0x00, 0x00, 0x00, 0x01, 0x68, 0xCE, 0x38, 0x80, // PPS
0x00, 0x00, 0x00, 0x01, 0x65, 0x88, 0x80, // IDR slice
]);
ts_pkt.resize(192, 0xFF);
let mut sector = ts_pkt;
sector.resize(2048, 0xFF);
struct SectorPayloadReader(Vec<u8>);
impl crate::sector::SectorSource for SectorPayloadReader {
fn read_sectors(
&mut self,
_lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
let n = (count as usize * 2048).min(self.0.len()).min(buf.len());
buf[..n].copy_from_slice(&self.0[..n]);
Ok(n)
}
fn capacity_sectors(&self) -> u32 {
1
}
}
let mut title = synthetic_title(1);
title.streams = vec![crate::disc::Stream::Video(VideoStream {
pid: 0x1011,
codec: Codec::H264,
resolution: Resolution::R1080p,
frame_rate: FrameRate::F23_976,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: None,
secondary: false,
label: String::new(),
measured_cicp: None,
})];
title.content_format = ContentFormat::BdTs;
let reader = SectorPayloadReader(sector);
let mut s = DiscStream::new(
Box::new(reader),
title,
crate::decrypt::DecryptKeys::None,
1,
ContentFormat::BdTs,
false,
None,
)
.unwrap();
let frame = s.read().unwrap().expect("expected 1 frame");
assert!(
frame.source.is_some(),
"DiscStream must stamp byte provenance (source) on demuxed frames"
);
assert_eq!(frame.source.unwrap().byte, 0);
}
// ── PS-path provenance ────────────────────────────────────────────
/// A 14-byte MPEG-2 PS pack header (`00 00 01 BA …`), no stuffing.
fn ps_pack_header() -> Vec<u8> {
let mut p = vec![0x00, 0x00, 0x01, 0xBAu8];
p.extend_from_slice(&[0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3]);
p.push(0xF8); // pack_stuffing_length = 0 (low 3 bits)
p
}
/// One coded MPEG-2 picture: picture header (coding_type, temporal_ref) +
/// a picture-coding extension + slice padding, so the real `Mpeg2Parser`
/// treats it as a complete picture.
fn ps_pic(coding_type: u8, tr: u16) -> Vec<u8> {
let b4 = ((tr >> 2) & 0xFF) as u8;
let b5 = (((tr & 0x03) as u8) << 6) | ((coding_type & 0x07) << 3);
let mut au = vec![0x00, 0x00, 0x01, 0x00u8, b4, b5, 0x00, 0x00];
au.extend_from_slice(&[0x00, 0x00, 0x01, 0xB5u8, 0x80, 0x00, 0x03, 0x80, 0x00]);
au.extend_from_slice(&[0xAA; 32]);
au
}
/// One GOP's ES: seq header + GOP + I/P/B pictures.
fn ps_gop_es() -> Vec<u8> {
let mut es = seq_header(720, 480);
es.extend_from_slice(&[0x00, 0x00, 0x01, 0xB8u8, 0x00, 0x00, 0x00, 0x00]); // GOP header
es.extend_from_slice(&ps_pic(1, 0)); // I (keyframe)
es.extend_from_slice(&ps_pic(2, 2)); // P
es.extend_from_slice(&ps_pic(3, 1)); // B
es
}
/// A bounded video PES (stream_id 0xE0) carrying `es` with a 90 kHz PTS.
fn ps_video_pes(es: &[u8], pts: u64) -> Vec<u8> {
let mut pes = vec![0x00, 0x00, 0x01, 0xE0u8];
let mut body = vec![0x80u8, 0x80, 0x05]; // flags1, flags2 (PTS only), header_data_len
let p = pts & 0x1_FFFF_FFFF;
body.push(0x21 | (((p >> 30) & 0x07) << 1) as u8);
body.push(((p >> 22) & 0xFF) as u8);
body.push((0x01 | (((p >> 15) & 0x7F) << 1)) as u8);
body.push(((p >> 7) & 0xFF) as u8);
body.push((0x01 | ((p & 0x7F) << 1)) as u8);
body.extend_from_slice(es);
let len = body.len() as u16;
pes.extend_from_slice(&len.to_be_bytes());
pes.extend_from_slice(&body);
pes
}
/// Serves a fixed byte image sector-by-sector, mapping LBA to its slice.
struct ImageReader(Vec<u8>);
impl crate::sector::SectorSource for ImageReader {
fn read_sectors(
&mut self,
lba: u32,
count: u16,
buf: &mut [u8],
_recovery: bool,
) -> crate::error::Result<usize> {
let start = lba as usize * 2048;
let want = count as usize * 2048;
for (i, b) in buf[..want].iter_mut().enumerate() {
*b = self.0.get(start + i).copied().unwrap_or(0);
}
Ok(want)
}
fn capacity_sectors(&self) -> u32 {
(self.0.len() / 2048) as u32
}
}
fn mpeg2_video_title(sector_count: u32) -> DiscTitle {
use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream};
let mut t = synthetic_title(sector_count);
t.content_format = ContentFormat::MpegPs;
t.streams = vec![crate::disc::Stream::Video(VideoStream {
pid: 0xE0, // DVD video stream id
codec: Codec::Mpeg2,
resolution: Resolution::R480i,
frame_rate: FrameRate::F29_97,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Smpte170m,
display_aspect: Some((4, 3)),
secondary: false,
label: String::new(),
measured_cicp: None,
})];
t
}
/// The PS path (`ps_demuxer` + `Mpeg2Parser`) must forward `ps.source`
/// onto the `PesPacket` so demuxed frames carry byte provenance — the
/// second half of commit a0d4dd3 (the first half is the TS path above).
#[test]
fn ps_stream_stamps_source_provenance() {
use crate::pes::Stream;
let mut sector = ps_pack_header();
sector.extend_from_slice(&ps_video_pes(&ps_gop_es(), 0));
sector.resize(2048, 0xFF);
let mut s = DiscStream::new(
Box::new(ImageReader(sector)),
mpeg2_video_title(1),
crate::decrypt::DecryptKeys::None,
8,
ContentFormat::MpegPs,
false,
None,
)
.unwrap();
let frame = s
.read()
.unwrap()
.expect("expected a frame from the PS path");
assert!(
frame.source.is_some(),
"PS path must forward ps.source so demuxed frames carry provenance"
);
}
// fed_bytes accumulates ACROSS read buffers, not reset per read: with
// one GOP per sector (batch_sectors=1), a frame from sector 1 must
// carry source.byte >= 2048, proving the base carried over.
#[test]
fn ps_stream_carries_feed_base_across_reads() {
use crate::pes::Stream;
let mut image = ps_pack_header();
image.extend_from_slice(&ps_video_pes(&ps_gop_es(), 0));
image.resize(2048, 0xFF); // sector 0
let mut sector1 = ps_pack_header();
sector1.extend_from_slice(&ps_video_pes(&ps_gop_es(), 3003));
sector1.resize(2048, 0xFF);
image.extend_from_slice(&sector1); // sector 1
let mut s = DiscStream::new(
Box::new(ImageReader(image)),
mpeg2_video_title(2),
crate::decrypt::DecryptKeys::None,
1, // one sector per read → separate feed_at calls per sector
ContentFormat::MpegPs,
false,
None,
)
.unwrap();
let mut max_byte = 0u64;
let mut min_byte = u64::MAX;
while let Some(frame) = s.read().unwrap() {
if let Some(src) = frame.source {
max_byte = max_byte.max(src.byte);
min_byte = min_byte.min(src.byte);
}
}
assert!(
min_byte < 2048,
"a frame from the first sector must be stamped below one sector"
);
assert!(
max_byte >= 2048,
"a frame from the second sector must carry the carried-over base \
(source.byte >= 2048), not a per-read-reset offset"
);
}
// ── AdaptiveBatch ─────────────────────────────────────────────────
// Batch sizes >= 6 must stay a multiple of 3 (AACS units) or the next
// read straddles a boundary; below 6 the ladder descends 3 -> 1.
#[test]
fn halve_batch_size_keeps_unit_alignment_and_bottoms_out_at_one() {
assert_eq!(
halve_batch_size(64),
30,
"32 rounded down to a unit multiple"
);
assert_eq!(halve_batch_size(30), 15);
assert_eq!(halve_batch_size(12), 6, "6 is already unit-aligned");
assert_eq!(halve_batch_size(11), 5, "below 6: no alignment rounding");
assert_eq!(halve_batch_size(6), 3);
assert_eq!(halve_batch_size(3), 1);
assert_eq!(halve_batch_size(2), 1);
assert_eq!(
halve_batch_size(1),
1,
"must never reach 0 — a 0-sector read"
);
for size in 1u16..=4096 {
let h = halve_batch_size(size);
assert!(h >= 1, "halve({size}) must never be 0");
assert!(h <= size, "halve({size}) = {h} must not grow");
assert!(
h < 6 || h.is_multiple_of(3),
"halve({size}) = {h} is unit-unaligned"
);
}
}
#[test]
fn double_batch_size_grows_toward_preferred_without_breaking_alignment() {
assert_eq!(double_batch_size(30, 64), 60);
assert_eq!(
double_batch_size(4, 64),
6,
"8 rounded down to a unit multiple"
);
assert_eq!(
double_batch_size(1, 64),
2,
"below 6: no alignment rounding"
);
assert_eq!(
double_batch_size(60, 64),
63,
"clamped to preferred, then aligned"
);
for size in 1u16..=2048 {
let d = double_batch_size(size, 4096);
assert!(d >= size, "double({size}) = {d} must not shrink");
assert!(
d < 6 || d.is_multiple_of(3),
"double({size}) = {d} is unit-unaligned"
);
}
}
// The sizer must probe back up: after a failure drops the batch, a
// sustained clean run must return BatchSizeChanged{Probed} AND raise
// current — never probing locks the rip at reduced size forever.
#[test]
fn on_success_probes_up_after_a_sustained_clean_run_and_resets_the_streak() {
let mut b = AdaptiveBatch::new(64);
assert!(
matches!(
b.on_failure(),
Some(EventKind::BatchSizeChanged {
new_size: 30,
reason: BatchSizeReason::Shrunk
})
),
"a failure must shrink 64 -> 30"
);
assert_eq!(b.current(), 30);
// Just under the 51200-sector probe threshold: still silent.
let mut fed = 0u32;
while fed + 30 < PROBE_THRESHOLD_SECTORS {
assert!(
b.on_success(30).is_none(),
"no probe before {PROBE_THRESHOLD_SECTORS} clean sectors (at {fed})"
);
fed += 30;
}
assert_eq!(b.current(), 30, "still at the reduced size");
// The read that crosses the threshold probes up.
let ev = b
.on_success(30)
.expect("a sustained clean run must probe the batch size back up");
assert!(
matches!(
ev,
EventKind::BatchSizeChanged {
new_size: 60,
reason: BatchSizeReason::Probed
}
),
"expected a Probed grow to 60, got {ev:?}"
);
assert_eq!(
b.current(),
60,
"the sizer must actually adopt the new size"
);
assert_eq!(
b.streak_sectors, 0,
"the streak resets so the next probe needs a fresh clean run"
);
// At the preferred size a clean run must NOT keep firing events.
let mut b = AdaptiveBatch::new(64);
for _ in 0..(PROBE_THRESHOLD_SECTORS / 64 + 2) {
assert!(
b.on_success(64).is_none(),
"no probe is possible when already at the preferred size"
);
}
assert_eq!(b.current(), 64);
}
}
}