libfreemkv: rc.5.1 DVD correctness fixes

- CSS: unlock scrambled-sector reads on enforcing drives via bus-auth
  only; classify sense 6F/03 as CSS-locked; early-bail on a fully locked
  scan; gate the AACS handshake off DVD discs.
- DVD first-play menu no longer prepended to the feature: read the title
  VOBS base from vtstt_vobs (0xC4), not the menu VOBS vtsm_vobs (0xC0).
- Interlaced field-duration (DefaultDecodedFieldDuration) written as a
  direct TrackEntry child rather than inside Video, so Windows reports
  the correct frame rate.
- Audio channel count read from the AC-3 bitstream; FieldOrder set to
  TFF; per-track BPS tags.
- Structured disc diagnostics at --log-level 3; reduced per-operation
  log spam.
This commit is contained in:
Matthew Jackson
2026-06-24 14:34:55 -07:00
parent 315276dd13
commit 6592f2a590
18 changed files with 1938 additions and 120 deletions
+28 -58
View File
@@ -1,12 +1,12 @@
//! CSS drive bus-authentication — read-unlock primitive.
//!
//! A CSS-enforcing DVD drive refuses to return scrambled sectors until a
//! CSS bus-auth handshake has run for the title. [`unlock_css_reads`]
//! issues that classic handshake (bus auth → disc-key REPORT KEY → bus
//! auth → title-key REPORT KEY) purely for its SCSI side effect of
//! unlocking scrambled-sector reads. The bytes the handshake returns are
//! NOT used as keys: the descramble title key is recovered keylessly by
//! the Stevenson known-plaintext attack (see [`super::crack_key`]).
//! CSS bus-auth handshake has set its Authentication Success Flag (ASF=1).
//! [`unlock_css_reads`] runs that bus-auth challenge-response (which is what
//! actually opens scrambled-sector reads), then a best-effort, non-fatal
//! disc-key REPORT KEY. The bytes are NOT used as keys: the descramble title
//! key is recovered keylessly by the Stevenson known-plaintext attack (see
//! [`super::crack_key`]).
use crate::drive::Drive;
use crate::error::{Error, Result};
@@ -119,11 +119,12 @@ const PERM_VARIANT: [[u8; 32]; 2] = [
/// CSS bus-auth **unlock** primitive.
///
/// Issues the full classic CSS handshake (bus auth → disc-key REPORT KEY →
/// bus auth → title-key REPORT KEY) purely to unlock the drive's
/// scrambled-sector read gating. The bytes returned by the handshake are
/// discarded — the descramble title key is recovered keylessly elsewhere
/// (the Stevenson known-plaintext attack in [`super::crack_key`]).
/// Runs the bus-auth challenge-response (which sets the drive's ASF=1 and is
/// what actually unlocks scrambled-sector reads), then a best-effort,
/// non-fatal disc-key REPORT KEY. The title-key REPORT KEY is NOT issued: it
/// is unnecessary (the descramble key is recovered keylessly by the Stevenson
/// attack in [`super::crack_key`]) and its hard failure on some USB bridges
/// used to abort the whole unlock (the 7014 bug). The bytes are discarded.
pub fn unlock_css_reads(drive: &mut Drive, lba: u32) -> Result<()> {
let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::css", phase = "unlock_css_reads", lba, "begin");
@@ -139,27 +140,24 @@ pub fn unlock_css_reads(drive: &mut Drive, lba: u32) -> Result<()> {
r
}
fn unlock_css_reads_inner(drive: &mut Drive, lba: u32) -> Result<()> {
tracing::debug!(target: "freemkv::css", lba, "css unlock: begin");
// Session 1: bus auth → disc-key REPORT KEY (AGID consumed by
// READ_DVD_STRUCTURE). The block contents are unused; this is issued
// purely for the bus-auth unlock side effect.
fn unlock_css_reads_inner(drive: &mut Drive, _lba: u32) -> Result<()> {
tracing::debug!(target: "freemkv::css", "css unlock: begin");
// The bus-auth challenge-response sets the drive's Authentication Success
// Flag (ASF=1), which is what opens scrambled-sector reads. This is the
// ONLY step required to unlock reads; a failure here is fatal — we
// genuinely cannot read scrambled sectors.
let (agid, _bus_key) = bus_auth(drive).inspect_err(|e| {
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: bus_auth(1) failed");
})?;
tracing::debug!(target: "freemkv::css", agid, "css unlock: bus_auth(1) ok");
read_disc_key(drive, agid).inspect_err(|e| {
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: read_disc_key failed");
})?;
tracing::debug!(target: "freemkv::css", "css unlock: disc-key REPORT KEY ok");
// Session 2: fresh bus auth → title-key REPORT KEY (needs separate AGID).
let (agid2, _bus_key2) = bus_auth(drive).inspect_err(|e| {
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: bus_auth(2) failed");
})?;
read_raw_title_key(drive, agid2, lba).inspect_err(|e| {
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: read_raw_title_key failed");
tracing::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: bus_auth failed");
})?;
tracing::debug!(target: "freemkv::css", agid, "css unlock: bus_auth ok");
// Disc-key REPORT KEY: issued BEST-EFFORT for any firmware that ties part
// of its read-unlock to it. The bytes are unused (the descramble key is
// recovered keylessly) and a failure is NON-FATAL — the gate is already
// open from bus-auth. This replaces the title-key REPORT KEY, whose hard
// failure used to abort the whole unlock (the 7014 bug on USB bridges).
if let Err(e) = read_disc_key(drive, agid) {
tracing::debug!(target: "freemkv::css", error_code = e.code(), "css unlock: disc-key REPORT KEY skipped (non-fatal)");
}
tracing::debug!(target: "freemkv::css", "css unlock: ok");
Ok(())
}
@@ -311,34 +309,6 @@ fn read_disc_key(drive: &mut Drive, agid: u8) -> Result<()> {
Ok(())
}
// ── Step 3: Title Key ─────────────────────────────────────────────────────
/// Issue the title-key REPORT KEY (format 0x04) purely for the bus-auth
/// unlock side effect. The returned key bytes are not used.
fn read_raw_title_key(drive: &mut Drive, agid: u8, lba: u32) -> Result<()> {
let scsi = drive.scsi_mut();
let mut cdb = [0u8; 12];
cdb[0] = crate::scsi::SCSI_REPORT_KEY;
cdb[2] = (lba >> 24) as u8;
cdb[3] = (lba >> 16) as u8;
cdb[4] = (lba >> 8) as u8;
cdb[5] = lba as u8;
cdb[8] = 0x00;
cdb[9] = 0x0C;
cdb[10] = (agid << 6) | 0x04;
let mut buf = [0u8; 12];
let result = scsi.execute(
&cdb,
crate::scsi::DataDirection::FromDevice,
&mut buf,
5_000,
);
result.map_err(|_| Error::CssAuthFailed)?;
Ok(())
}
// ── CSSCryptKey ───────────────────────────────────────────────────────────
fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] {
+117 -11
View File
@@ -22,6 +22,13 @@ pub(crate) mod tables;
use crate::disc::Extent;
use crate::sector::SectorSource;
/// Consecutive CSS-locked (`05/6F/03`) reads before the crack scan early-bails.
/// The bus-auth read gate is global (all-or-nothing), so a run this long means
/// it is shut and nothing here is crackable — bail instead of grinding the full
/// 50_000-sector budget (which is what made rc5 appear to hang on a wedged USB
/// bridge). The counter resets to 0 on any readable batch.
const CSS_LOCKED_BAIL: u32 = 64;
/// CSS decryption state for a DVD title.
#[derive(Debug, Clone)]
pub struct CssState {
@@ -105,7 +112,7 @@ pub fn crack_key_outcome(
batch_sectors: u16,
halt: Option<&crate::halt::Halt>,
) -> CrackOutcome {
crack_key_scan(reader, extents, batch_sectors, halt)
crack_key_scan(reader, extents, batch_sectors, halt, true)
}
/// [`crack_key`] with an optional cooperative-cancellation token.
@@ -122,7 +129,7 @@ pub fn crack_key_halt(
batch_sectors: u16,
halt: Option<&crate::halt::Halt>,
) -> Option<CssState> {
crack_key_scan(reader, extents, batch_sectors, halt).into_state()
crack_key_scan(reader, extents, batch_sectors, halt, false).into_state()
}
/// The crack scan, returning the full [`CrackOutcome`]. Tracks a
@@ -134,6 +141,10 @@ fn crack_key_scan(
extents: &[Extent],
batch_sectors: u16,
halt: Option<&crate::halt::Halt>,
// True only on the INITIAL scan: a fully CSS-locked (`05/6F/03`) result is a
// hard `ScrambledUncracked`. False on the per-VTS re-crack so a lapsed-AGID
// locked read returns None instead of killing a genuinely crackable title.
fail_on_locked: bool,
) -> CrackOutcome {
// Batch the reads: a live optical drive at 1 sector/read is glacial, and the
// crack only needs to FIND one scrambled sector whose 0x80 plaintext matches
@@ -161,6 +172,14 @@ fn crack_key_scan(
// NOT silently treat as unencrypted (which would mux scrambled MPEG as
// plaintext → garbage at exit 0). See `CrackOutcome::ScrambledUncracked`.
let mut saw_scrambled = false;
// A read rejected with sense `05/6F/03` ("scrambled sector without
// authentication") is positive proof of CSS encryption — never collapse it
// to "unencrypted". A run of consecutive locked reads means the bus-auth
// gate is shut (it is global, so reads are all-or-nothing), so the scan
// early-bails. `consecutive_locked` resets on any readable batch, so a
// crackable title (gate open) never trips it.
let mut saw_locked = false;
let mut consecutive_locked = 0u32;
'outer: for (extent_idx, ext) in extents.iter().enumerate() {
let mut i = 0u32;
@@ -189,6 +208,8 @@ fn crack_key_scan(
let want = n as usize * 2048;
match reader.read_sectors(ext.start_lba + i, n as u16, &mut buf[..want], true) {
Ok(_) => {
// A readable batch: the gate is open — reset the locked run.
consecutive_locked = 0;
for s in 0..n as usize {
tried += 1;
let sect = &buf[s * 2048..(s + 1) * 2048];
@@ -206,20 +227,36 @@ fn crack_key_scan(
}
}
}
// A failed batch (bad sectors) still counts toward the budget so a
// damaged region can't loop forever; skip ahead by the batch.
Err(_) => tried += n,
// A failed batch still counts toward the budget so a damaged
// region can't loop forever. A CSS-locked failure (`05/6F/03`)
// proves encryption and, in a long enough run, means the read
// gate is shut — track it and early-bail rather than grind.
Err(e) => {
tried += n;
if e.scsi_sense().is_some_and(|s| s.is_css_locked()) {
saw_locked = true;
consecutive_locked += 1;
if consecutive_locked >= CSS_LOCKED_BAIL {
break 'outer;
}
} else {
consecutive_locked = 0;
}
}
}
i += n;
}
}
// Budget exhausted / extents walked with no key recovered. Distinguish the
// two indistinguishable-in-`Option` cases: if scrambled sectors were seen
// (case b: crack failed; case c: scrambled but the crackable region was
// unreadable), this is encrypted-but-uncracked — a hard failure. Only a
// scan that NEVER saw a scrambled sector is genuinely unencrypted (case a).
if saw_scrambled {
// Budget exhausted / extents walked / early-bailed with no key recovered.
// The disc is ENCRYPTED-but-uncracked (a hard failure on the initial scan)
// when EITHER a scrambled sector was actually seen, OR — on the initial scan
// only (`fail_on_locked`) — every read was CSS-locked (`05/6F/03`), itself
// proof of scrambling. A re-crack (`fail_on_locked` false) stays soft: a
// lapsed-AGID locked read yields None, not a hard fail, so a crackable title
// in another VTS isn't killed. Only a scan that saw neither a scrambled
// sector nor a CSS-lock is genuinely unencrypted.
if saw_scrambled || (saw_locked && fail_on_locked) {
CrackOutcome::ScrambledUncracked
} else {
CrackOutcome::Unencrypted
@@ -309,6 +346,9 @@ mod tests {
reads: std::cell::RefCell<Vec<u32>>,
flag_byte: u8,
fail_all: bool,
/// Every read fails with CSS-locked sense `05/6F/03` (drive refusing
/// scrambled reads because the bus-auth gate isn't open).
lock_all: bool,
}
impl MockSource {
@@ -317,6 +357,7 @@ mod tests {
reads: std::cell::RefCell::new(Vec::new()),
flag_byte,
fail_all: false,
lock_all: false,
}
}
}
@@ -330,6 +371,17 @@ mod tests {
_recovery: bool,
) -> Result<usize> {
self.reads.borrow_mut().push(lba);
if self.lock_all {
return Err(Error::DiscRead {
sector: lba as u64,
status: Some(2),
sense: Some(crate::scsi::ScsiSense {
sense_key: 0x05,
asc: 0x6F,
ascq: 0x03,
}),
});
}
if self.fail_all {
return Err(Error::DecryptFailed);
}
@@ -433,6 +485,60 @@ mod tests {
);
}
/// Fix C (rc.5.1): on the INITIAL scan, a drive that refuses every read with
/// CSS-locked sense (`05/6F/03`) is encrypted-but-locked →
/// `ScrambledUncracked` (a hard failure), NOT `Unencrypted`. This is the
/// rc4.3 bug: every VOB read came back `6F/03`, so the scan saw no scrambled
/// sector and wrongly declared the disc unencrypted → 19 KB garbage.
#[test]
fn crack_outcome_css_locked_initial_is_scrambled_uncracked() {
let mut src = MockSource::new(0x30);
src.lock_all = true; // every read → 05/6F/03
let extents = [Extent {
start_lba: 0,
sector_count: 100,
}];
let outcome = crack_key_outcome(&mut src, &extents, 1, None);
assert!(
outcome.is_scrambled_uncracked(),
"every read 6F/03 on the initial scan → ScrambledUncracked, got {outcome:?}"
);
}
/// MISSING #1 guard: the re-crack path (the `Option`-returning `crack_key`,
/// `fail_on_locked == false`) must NOT hard-fail on a CSS-locked read — it
/// returns `None`. A lapsed-AGID re-crack of another VTS stays soft so a
/// genuinely crackable title isn't killed by a transient locked read.
#[test]
fn crack_key_recrack_locked_is_none_not_hard_fail() {
let mut src = MockSource::new(0x30);
src.lock_all = true;
let extents = [Extent {
start_lba: 0,
sector_count: 100,
}];
assert!(crack_key(&mut src, &extents, 1).is_none());
}
/// Fix F: a fully CSS-locked scan early-bails near `CSS_LOCKED_BAIL`
/// consecutive locked reads instead of grinding the whole 50_000-sector
/// budget (the rc5 "stuck Scanning…" hang on a wedged bridge).
#[test]
fn crack_css_locked_scan_early_bails() {
let mut src = MockSource::new(0x30);
src.lock_all = true;
let extents = [Extent {
start_lba: 0,
sector_count: 10_000,
}];
let _ = crack_key_outcome(&mut src, &extents, 1, None);
let n = src.reads.borrow().len();
assert!(
n <= (CSS_LOCKED_BAIL as usize) + 1,
"locked scan early-bails near {CSS_LOCKED_BAIL}, not 10000; read {n}"
);
}
/// The budget spans ALL extents, not per-extent: two extents summing past
/// the cap must still stop at 50_000 total reads.
///
+468
View File
@@ -0,0 +1,468 @@
//! Structured scan diagnostics — the `--log-level 3` self-diagnosing dump.
//!
//! A bug report log must be self-diagnosing: everything needed to explain
//! *why* freemkv made the choices it did at scan must be in the log, in a
//! compact, machine-parseable form. This module emits one terse line per row
//! (title, cell, stream, decision) under the `tracing` target
//! `freemkv::diag`, which the CLI routes to `log.txt` when `--log-level 3`
//! (debug) is set.
//!
//! Format conventions (stable, greppable):
//! - Every line is prefixed by a `tag=` so a log scraper can filter
//! (`disc`, `title`, `dvd.cell`, `dvd.vattr`, `dvd.aattr`, `bd.clip`,
//! `bd.mark`, `aacs`, `stream`, `decision`).
//! - Raw bytes are shown as `0xNN` next to their decode so a wrong decode
//! is obvious against the raw value.
//! - This module only READS already-parsed scan state — it never re-reads
//! the disc and never mutates anything.
//!
//! The DVD per-cell table (with the raw cell-category byte) is emitted from
//! the IFO scan itself ([`dump_dvd_cells`]), because the per-cell
//! `ifo::DvdCell` detail is lowered away before the `Disc` is built. The
//! `Disc`-level dump ([`dump_disc`]) covers everything that survives
//! lowering: titles, streams, the picked main feature, and AACS state.
use crate::disc::{
AudioChannels, ColorSpace, Disc, DiscTitle, FrameRate, HdrFormat, Resolution, SampleRate,
Stream,
};
use crate::ifo::{CellCategory, DvdTitle};
const DIAG: &str = "freemkv::diag";
// ── small format helpers (pure, unit-testable) ──────────────────────────────
/// Compact name for a [`Resolution`] with the interlace marker preserved.
pub fn res_str(r: Resolution) -> &'static str {
match r {
Resolution::R480i => "480i",
Resolution::R480p => "480p",
Resolution::R576i => "576i",
Resolution::R576p => "576p",
Resolution::R720p => "720p",
Resolution::R1080i => "1080i",
Resolution::R1080p => "1080p",
Resolution::R2160p => "2160p",
Resolution::R4320p => "4320p",
Resolution::Unknown => "res?",
}
}
/// Frames-per-second string for a [`FrameRate`].
pub fn fps_str(f: FrameRate) -> &'static str {
match f {
FrameRate::F23_976 => "23.976",
FrameRate::F24 => "24",
FrameRate::F25 => "25",
FrameRate::F29_97 => "29.97",
FrameRate::F30 => "30",
FrameRate::F50 => "50",
FrameRate::F59_94 => "59.94",
FrameRate::F60 => "60",
FrameRate::Unknown => "fps?",
}
}
/// PAL/NTSC field-rate family inferred from the frame rate (DVD has no
/// explicit field, so this is the colour/standard the muxer stamps).
pub fn tv_system_str(f: FrameRate) -> &'static str {
match f {
FrameRate::F25 | FrameRate::F50 => "PAL",
FrameRate::F23_976 | FrameRate::F29_97 | FrameRate::F59_94 => "NTSC",
_ => "",
}
}
/// CICP-ish short name for a [`ColorSpace`].
pub fn color_str(c: ColorSpace) -> &'static str {
match c {
ColorSpace::Bt709 => "BT.709",
ColorSpace::Bt2020 => "BT.2020",
ColorSpace::Bt470bg => "BT.470BG",
ColorSpace::Smpte170m => "SMPTE-170M",
ColorSpace::Unknown => "color?",
}
}
/// HDR format short name.
pub fn hdr_str(h: HdrFormat) -> &'static str {
match h {
HdrFormat::Sdr => "SDR",
HdrFormat::Hdr10 => "HDR10",
HdrFormat::Hdr10Plus => "HDR10+",
HdrFormat::DolbyVision => "DoVi",
HdrFormat::Hlg => "HLG",
}
}
/// Channel count from an [`AudioChannels`] layout (what lands in the MKV
/// `Channels` element).
pub fn channel_count(ch: AudioChannels) -> u8 {
match ch {
AudioChannels::Mono => 1,
AudioChannels::Stereo => 2,
AudioChannels::Stereo21 => 3,
AudioChannels::Quad => 4,
AudioChannels::Surround50 => 5,
AudioChannels::Surround51 => 6,
AudioChannels::Surround61 => 7,
AudioChannels::Surround71 => 8,
AudioChannels::Unknown => 0,
}
}
/// Sample-rate in Hz for a [`SampleRate`].
pub fn sample_rate_hz(s: SampleRate) -> u32 {
match s {
SampleRate::S44_1 => 44100,
SampleRate::S48 => 48000,
SampleRate::S96 => 96000,
SampleRate::S192 => 192000,
SampleRate::S48_96 => 96000,
SampleRate::S48_192 => 192000,
SampleRate::Unknown => 0,
}
}
// ── DVD cell-category dump (from the IFO scan, pre-lowering) ─────────────────
/// One formatted cell row for the DVD per-PGC cell table. Returned as a
/// string so it can be unit-tested without a logger.
///
/// Columns: `idx`, raw category (`cat=0xNN`) + decoded fields, first/last
/// sector, duration, and the keep/drop verdict from the bug-4 leading-cell
/// filter.
pub fn dvd_cell_row(idx: usize, cell: &crate::ifo::DvdCell, dropped: bool) -> String {
let c = CellCategory::decode(cell.category);
format!(
"tag=dvd.cell idx={idx} cat=0x{:02X} type={} block_mode={} block_type={} \
seamless={} ilv={} plain={} first={} last={} dur={:.1}s {}",
cell.category,
c.cell_type,
c.block_mode,
c.block_type,
c.seamless_play as u8,
c.interleaved as u8,
c.is_plain_feature() as u8,
cell.first_sector,
cell.last_sector,
cell.duration_secs,
if dropped { "DROP(non-feature)" } else { "keep" },
)
}
/// Emit the per-PGC cell table for one DVD title during the IFO scan.
///
/// `vts`/`title` identify the row group; `title` is the `DvdTitle` whose
/// cells (and bug-4 leading-cell verdict) are dumped. Called from
/// `scan_dvd_titles` while the `DvdTitle` is still in scope (the per-cell
/// category byte is lowered away before the `Disc` exists).
pub fn dump_dvd_cells(vts: u8, title_num: u16, title: &DvdTitle) {
if !tracing::enabled!(target: DIAG, tracing::Level::DEBUG) {
return;
}
let feature_start = title.feature_start_cell();
tracing::debug!(
target: DIAG,
"tag=dvd.pgc vts={vts} title={title_num} cells={} chapters={} \
dur={:.1}s feature_start_cell={feature_start}",
title.cells.len(),
title.chapters,
title.duration_secs,
);
for (i, cell) in title.cells.iter().enumerate() {
tracing::debug!(target: DIAG, "{}", dvd_cell_row(i, cell, i < feature_start));
}
// Chapter/PTT map (program → cumulative start time).
for (i, &t) in title.chapter_times.iter().enumerate() {
tracing::debug!(
target: DIAG,
"tag=dvd.chap vts={vts} title={title_num} ch={} time={:.1}s",
i + 1,
t,
);
}
}
/// Emit the IFO `video_attr` / `audio_attr` decode for one DVD title set,
/// showing the raw bytes next to their decoded meaning. Called from the IFO
/// scan with the still-parsed `ifo::DvdTitleSet` view.
pub fn dump_dvd_attrs(ts: &crate::ifo::DvdTitleSet) {
if !tracing::enabled!(target: DIAG, tracing::Level::DEBUG) {
return;
}
tracing::debug!(
target: DIAG,
"tag=dvd.vobs vts={} vob_start_sector={}",
ts.vts_number,
ts.vob_start_sector,
);
let v = &ts.video;
tracing::debug!(
target: DIAG,
"tag=dvd.vattr vts={} codec={:?} res={} aspect={:?} std={:?}",
ts.vts_number,
v.codec,
res_str(v.resolution),
v.aspect,
v.standard,
);
for (i, a) in ts.audio_streams.iter().enumerate() {
tracing::debug!(
target: DIAG,
"tag=dvd.aattr vts={} idx={i} codec={:?} ch={} sr={}Hz lang={:?} sub_id={:?}",
ts.vts_number,
a.codec,
a.channels,
a.sample_rate,
a.language,
a.sub_stream_id.map(|x| format!("0x{x:02X}")),
);
}
for (i, s) in ts.subtitle_streams.iter().enumerate() {
tracing::debug!(
target: DIAG,
"tag=dvd.sattr vts={} idx={i} lang={:?}",
ts.vts_number,
s.language,
);
}
}
// ── Disc-level dump (post-lowering: titles, streams, decisions, AACS) ────────
/// Emit the full scan diagnostic block for a built [`Disc`]. Terse, one line
/// per row, under target `freemkv::diag` at DEBUG. No-op unless that target
/// is enabled, so it costs nothing when `--log-level 3` is off.
pub fn dump_disc(disc: &Disc) {
if !tracing::enabled!(target: DIAG, tracing::Level::DEBUG) {
return;
}
tracing::debug!(
target: DIAG,
"tag=disc vol={:?} format={:?} content={:?} cap_sectors={} layers={} titles={} encrypted={}",
disc.volume_id,
disc.format,
disc.content_format,
disc.capacity_sectors,
disc.layers,
disc.titles.len(),
disc.encrypted,
);
dump_aacs(disc);
for (ti, title) in disc.titles.iter().enumerate() {
dump_title(ti, title);
}
// freemkv's top-level DECISION: which title is the main feature.
if let Some(main) = disc.titles.first() {
tracing::debug!(
target: DIAG,
"tag=decision pick=main_feature title_idx=0 playlist={:?} dur={:.1}s \
size={}B clips={} reason=canonical_title_order(fits-disc, fewest-clips, longest, richest-audio)",
main.playlist,
main.duration_secs,
main.size_bytes,
main.clips.len(),
);
}
}
fn dump_aacs(disc: &Disc) {
let Some(a) = disc.aacs.as_ref() else {
if disc.css.is_some() {
tracing::debug!(target: DIAG, "tag=aacs none crypto=CSS(DVD)");
} else if disc.encrypted {
tracing::debug!(target: DIAG, "tag=aacs none crypto=encrypted-no-keys");
} else {
tracing::debug!(target: DIAG, "tag=aacs none crypto=clear");
}
return;
};
// CPS-unit / unit-key counts: at scan `unit_keys` is empty (keys are
// resolved later); the unit-key count is the BE16 in the raw
// Unit_Key_RO.inf if captured. Report both: resolved count and raw len.
tracing::debug!(
target: DIAG,
"tag=aacs version={} bus_enc={} mkb_version={:?} disc_hash={} key_source={:?} \
vuk={} unit_keys_resolved={} uk_ro_bytes={} mkb_bytes={}",
a.version,
a.bus_encryption,
a.mkb_version,
a.disc_hash,
a.key_source.name(),
a.vuk.is_some(),
a.unit_keys.len(),
a.uk_ro.len(),
a.mkb.len(),
);
}
fn dump_title(ti: usize, title: &DiscTitle) {
let (mut nv, mut na, mut ns) = (0u32, 0u32, 0u32);
for s in &title.streams {
match s {
Stream::Video(_) => nv += 1,
Stream::Audio(_) => na += 1,
Stream::Subtitle(_) => ns += 1,
}
}
tracing::debug!(
target: DIAG,
"tag=title idx={ti} playlist={:?} id={} dur={:.1}s size={}B clips={} \
extents={} chapters={} v={nv} a={na} s={ns} fmt={:?}",
title.playlist,
title.playlist_id,
title.duration_secs,
title.size_bytes,
title.clips.len(),
title.extents.len(),
title.chapters.len(),
title.content_format,
);
// Per-clip rows (BD: PlayItem/CLPI; DVD has none).
for (ci, c) in title.clips.iter().enumerate() {
tracing::debug!(
target: DIAG,
"tag=clip title={ti} idx={ci} id={:?} in={} out={} dur={:.1}s src_packets={}",
c.clip_id,
c.in_time,
c.out_time,
c.duration_secs,
c.source_packets,
);
}
// Per-extent rows (the sectors freemkv will actually rip — the bug-4
// decision is visible here: leading non-feature cells are already gone).
for (ei, e) in title.extents.iter().enumerate() {
tracing::debug!(
target: DIAG,
"tag=extent title={ti} idx={ei} start_lba={} sectors={}",
e.start_lba,
e.sector_count,
);
}
// freemkv's per-stream DECISIONS (what the muxer will write).
for (si, s) in title.streams.iter().enumerate() {
match s {
Stream::Video(v) => tracing::debug!(
target: DIAG,
"tag=stream title={ti} idx={si} kind=video pid=0x{:04X} codec={:?} \
res={} interlaced={} fps={} std={} color={} hdr={} aspect={:?} secondary={}",
v.pid,
v.codec,
res_str(v.resolution),
v.resolution.is_interlaced(),
fps_str(v.frame_rate),
tv_system_str(v.frame_rate),
color_str(v.color_space),
hdr_str(v.hdr),
v.display_aspect,
v.secondary,
),
Stream::Audio(a) => tracing::debug!(
target: DIAG,
"tag=stream title={ti} idx={si} kind=audio pid=0x{:04X} codec={:?} \
channels={}({}) sr={}Hz lang={:?} secondary={}",
a.pid,
a.codec,
a.channels,
channel_count(a.channels),
sample_rate_hz(a.sample_rate),
a.language,
a.secondary,
),
Stream::Subtitle(sub) => tracing::debug!(
target: DIAG,
"tag=stream title={ti} idx={si} kind=subtitle pid=0x{:04X} codec={:?} \
lang={:?} forced={}",
sub.pid,
sub.codec,
sub.language,
sub.forced,
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn res_str_keeps_interlace_marker() {
assert_eq!(res_str(Resolution::R576i), "576i");
assert_eq!(res_str(Resolution::R480i), "480i");
assert_eq!(res_str(Resolution::R2160p), "2160p");
}
#[test]
fn fps_and_tv_system() {
assert_eq!(fps_str(FrameRate::F25), "25");
assert_eq!(tv_system_str(FrameRate::F25), "PAL");
assert_eq!(fps_str(FrameRate::F29_97), "29.97");
assert_eq!(tv_system_str(FrameRate::F29_97), "NTSC");
}
#[test]
fn color_and_hdr() {
assert_eq!(color_str(ColorSpace::Bt470bg), "BT.470BG");
assert_eq!(color_str(ColorSpace::Bt2020), "BT.2020");
assert_eq!(hdr_str(HdrFormat::Hdr10), "HDR10");
assert_eq!(hdr_str(HdrFormat::DolbyVision), "DoVi");
assert_eq!(hdr_str(HdrFormat::Sdr), "SDR");
}
#[test]
fn channel_count_matches_layout() {
assert_eq!(channel_count(AudioChannels::Mono), 1);
assert_eq!(channel_count(AudioChannels::Stereo), 2);
assert_eq!(channel_count(AudioChannels::Surround51), 6);
assert_eq!(channel_count(AudioChannels::Surround71), 8);
}
#[test]
fn sample_rate_hz_values() {
assert_eq!(sample_rate_hz(SampleRate::S48), 48000);
assert_eq!(sample_rate_hz(SampleRate::S96), 96000);
}
/// The cell row shows the raw category byte (0xNN) beside the decode, and
/// the keep/drop verdict. A plain feature cell (0x00) is "keep"; a leading
/// secondary-block cell flagged dropped reads "DROP".
#[test]
fn cell_row_shows_raw_byte_and_verdict() {
let plain = crate::ifo::DvdCell {
first_sector: 100,
last_sector: 199,
category: 0x00,
duration_secs: 12.5,
};
let row = dvd_cell_row(0, &plain, false);
assert!(row.contains("cat=0x00"), "{row}");
assert!(row.contains("type=0"), "{row}");
assert!(row.contains("first=100"), "{row}");
assert!(row.contains("last=199"), "{row}");
assert!(row.contains("dur=12.5s"), "{row}");
assert!(row.contains("keep"), "{row}");
assert!(!row.contains("DROP"), "{row}");
// 0x80 = middle-of-angle-block (cell_type=2), shown dropped.
let sec = crate::ifo::DvdCell {
first_sector: 0,
last_sector: 9,
category: 0x80,
duration_secs: 1.0,
};
let row = dvd_cell_row(0, &sec, true);
assert!(row.contains("cat=0x80"), "{row}");
assert!(row.contains("type=2"), "{row}");
assert!(row.contains("DROP(non-feature)"), "{row}");
}
}
+238 -5
View File
@@ -20,6 +20,10 @@ impl Disc {
let mut title_number: u16 = 0;
for ts in &dvd_info.title_sets {
// Diagnostic dump (--log-level 3): IFO video/audio attrs for this
// title set. No-op unless the freemkv::diag target is enabled.
crate::diag::dump_dvd_attrs(ts);
let video_stream = Stream::Video(VideoStream {
pid: 0xE0, // DVD video PID (standard MPEG PS video stream)
codec: ts.video.codec,
@@ -82,9 +86,36 @@ impl Disc {
for dvd_title in &ts.titles {
title_number += 1;
// Diagnostic dump (--log-level 3): per-cell category table +
// chapter map for this title, BEFORE lowering drops the
// per-cell IFO detail. No-op unless freemkv::diag is enabled.
crate::diag::dump_dvd_cells(ts.vts_number, title_number, dvd_title);
// Bug-4 leading-cell filter: drop any leading scene-index /
// interleaved-angle sub-block cells so the feature starts at the
// movie. Conservative — `feature_start_cell` only ever skips a
// prefix of secondary-block cells and never truncates a normal
// feature (category 0x00 on cell 0 → no-op). See
// `ifo::DvdTitle::feature_start_cell`.
let feature_start = dvd_title.feature_start_cell();
let dropped_secs: f64 = dvd_title.cells[..feature_start]
.iter()
.map(|c| c.duration_secs)
.sum();
if feature_start > 0 {
tracing::debug!(
target: "freemkv::scan",
vts = ts.vts_number,
title = title_number,
dropped_cells = feature_start,
dropped_secs,
"dvd: dropped leading non-feature cell(s)"
);
}
// Build extents from cell sector ranges (absolute = vob_start + cell offset)
let extents: Vec<Extent> = dvd_title
.cells
.feature_cells()
.iter()
.map(|cell| {
let start = ts.vob_start_sector.saturating_add(cell.first_sector);
@@ -133,12 +164,16 @@ impl Disc {
streams.extend(audio_streams.iter().cloned());
streams.extend(subtitle_streams);
// Chapter times are absolute from the PGC start. When leading
// cells are dropped the muxed video shifts earlier by exactly
// their total duration, so shift the chapter marks too (clamping
// any that fell inside the dropped head to 0).
let chapters: Vec<Chapter> = dvd_title
.chapter_times
.iter()
.enumerate()
.map(|(i, &t)| Chapter {
time_secs: t,
time_secs: (t - dropped_secs).max(0.0),
name: chapter_name(i),
})
.collect();
@@ -342,16 +377,25 @@ mod tests {
d
}
/// Cell playback info entry (24 bytes): BCD time@4..8 (unused here),
/// Cell playback info entry (24 bytes): category byte@0, BCD time@4..8,
/// first_sector(u32 BE)@8, last_sector(u32 BE)@20.
fn write_cell(buf: &mut [u8], off: usize, first_sector: u32, last_sector: u32) {
buf[off + 8..off + 12].copy_from_slice(&first_sector.to_be_bytes());
buf[off + 20..off + 24].copy_from_slice(&last_sector.to_be_bytes());
}
/// Like [`write_cell`] but also stamps the cell-category byte (`+0`) so a
/// test can build a leading scene-index / interleaved-angle sub-block cell.
fn write_cell_cat(buf: &mut [u8], off: usize, first: u32, last: u32, category: u8) {
write_cell(buf, off, first, last);
buf[off] = category;
}
/// Build a VTS_XX_0.IFO. Layout per ifo.rs:
/// magic "DVDVIDEO-VTS"@0
/// vob_start_sector(u32 BE)@0xC0
/// vtstt_vobs (Title VOBS start sector, u32 BE)@0xC4 — the production
/// `vob_start_sector` the cell sectors are relative to. (0xC0 is
/// `vtsm_vobs`, the menu VOBS, which the scan must NOT use.)
/// VTS_PGCIT sector ptr(u32 BE)@0xCC
/// video attr byte@0x200
/// num_audio(u16 BE)@0x202, audio blocks (8B) @0x204
@@ -377,7 +421,7 @@ mod tests {
let pgcit_sector = 2u32;
let mut d = vec![0u8; 4 * 2048];
d[0..12].copy_from_slice(b"DVDVIDEO-VTS");
d[0xC0..0xC4].copy_from_slice(&vob_start.to_be_bytes());
d[0xC4..0xC8].copy_from_slice(&vob_start.to_be_bytes()); // vtstt_vobs (Title VOBS)
d[0xCC..0xD0].copy_from_slice(&pgcit_sector.to_be_bytes());
d[0x200] = video_b0;
d[0x202..0x204].copy_from_slice(&(audio.len() as u16).to_be_bytes());
@@ -493,6 +537,56 @@ mod tests {
assert_eq!(t.content_format, ContentFormat::MpegPs);
}
/// Regression (first-play menu prepended to the feature): `vob_start` must
/// come from the **Title** VOBS pointer `vtstt_vobs` (VTS_IFO 0xC4), NOT the
/// **menu** VOBS pointer `vtsm_vobs` (0xC0). On discs with a per-title menu
/// — e.g. the Universal "the parental level has been set, press yes"
/// first-play still — `vtsm_vobs` points at that menu VOB, which sits just
/// before the title VOB. Cell `first_sector` values are relative to
/// `vtstt_vobs`; reading 0xC0 prepended the menu and shifted every extent
/// back by `vtstt_vobs - vtsm_vobs`, so the rip opened on the parental
/// prompt instead of the movie (Greenland NTSC R1: vtsm=44, vtstt=3640).
///
/// Here `build_vts` stamps `vtstt_vobs = 3640` (0xC4); we additionally stamp
/// a *different* `vtsm_vobs = 44` (0xC0). The extent must resolve from 3640.
#[test]
fn scan_dvd_titles_uses_title_vobs_not_menu_vobs() {
let mut disc = MemDisc::new();
let vmg = build_vmg(&[(1, 1, 1)]);
// vtstt_vobs (title) = 3640; cell 0 first_sector = 0.
let mut vts = build_vts(3640, 0x00, &[], &[], &[(0, 99)], false);
// Stamp a bogus vtsm_vobs (menu) at 0xC0 — the wrong pointer the bug
// used. It must be ignored.
vts[0xC0..0xC4].copy_from_slice(&44u32.to_be_bytes());
let udf = build_video_ts_fs(
&mut disc,
&[
FileSpec {
name: "VIDEO_TS.IFO".into(),
icb_lba: 60,
data_lba: 5000,
contents: vmg,
},
FileSpec {
name: "VTS_01_0.IFO".into(),
icb_lba: 62,
data_lba: 6000,
contents: vts,
},
],
);
let titles = Disc::scan_dvd_titles(&mut disc, &udf);
assert_eq!(titles.len(), 1);
let t = &titles[0];
assert_eq!(t.extents.len(), 1);
// Title VOBS (3640) + cell first_sector (0) = 3640 — NOT the menu 44.
assert_eq!(
t.extents[0].start_lba, 3640,
"extent must start at vtstt_vobs (0xC4), not vtsm_vobs (0xC0)"
);
assert_ne!(t.extents[0].start_lba, 44, "must not use the menu VOBS");
}
/// Multi-cell title: extents preserve cell order and each maps to its
/// own (vob_start + first .. last) range. mux reads cells in order.
#[test]
@@ -825,4 +919,143 @@ mod tests {
assert_eq!(t.chapters.len(), 1);
assert_eq!(t.chapters[0].name, chapter_name(0));
}
/// Build a VTS with explicit per-cell category bytes and an N-program map.
/// Returns the IFO bytes. Cells: `(first, last, category, dur_secs)`.
fn build_vts_cells(
vob_start: u32,
video_b0: u8,
cells: &[(u32, u32, u8, u8 /*BCD seconds*/)],
program_first_cells: &[u8],
) -> Vec<u8> {
let pgcit_sector = 2u32;
let mut d = vec![0u8; 4 * 2048];
d[0..12].copy_from_slice(b"DVDVIDEO-VTS");
d[0xC4..0xC8].copy_from_slice(&vob_start.to_be_bytes()); // vtstt_vobs (Title VOBS)
d[0xCC..0xD0].copy_from_slice(&pgcit_sector.to_be_bytes());
d[0x200] = video_b0;
// no audio / subs
let pg = pgcit_sector as usize * 2048;
d[pg..pg + 2].copy_from_slice(&1u16.to_be_bytes());
let pgc_rel: u32 = 0x100;
d[pg + 8 + 4..pg + 8 + 8].copy_from_slice(&pgc_rel.to_be_bytes());
let pgc = pg + pgc_rel as usize;
d[pgc + 0x02] = program_first_cells.len() as u8; // nr_of_programs
d[pgc + 0x03] = cells.len() as u8; // nr_of_cells
// Leave PGC-level BCD time zero → duration recomputed from cells.
let cell_tbl_rel: u16 = 0xF0;
let pgm_map_rel: u16 = 0xEC;
d[pgc + 0xE6..pgc + 0xE8].copy_from_slice(&pgm_map_rel.to_be_bytes());
d[pgc + 0xE8..pgc + 0xEA].copy_from_slice(&cell_tbl_rel.to_be_bytes());
for (i, &fc) in program_first_cells.iter().enumerate() {
d[pgc + pgm_map_rel as usize + i] = fc;
}
let cell_base = pgc + cell_tbl_rel as usize;
for (i, (first, last, cat, secs)) in cells.iter().enumerate() {
let off = cell_base + i * 24;
write_cell_cat(&mut d, off, *first, *last, *cat);
d[off + 6] = *secs; // BCD seconds in the cell time field
}
d
}
/// End-to-end bug-4 fix: a feature PGC that opens with a leading
/// interleaved-angle sub-block cell (category 0x80 = middle-of-angle-block)
/// must have that cell DROPPED from the muxed extents, so the rip starts at
/// the real feature. Chapters shift earlier by the dropped duration.
#[test]
fn scan_dvd_titles_drops_leading_scene_index_cell() {
let mut disc = MemDisc::new();
let vmg = build_vmg(&[(2, 1, 1)]);
// Cell 0: leading scene-index/angle sub-block (cat 0x80), 5s, sectors 0..9.
// Cell 1: feature start (cat 0x00), 59s, sectors 100..199.
// Cell 2: feature (cat 0x00), 59s, sectors 300..399.
// Programs: prog0 → cell 1 (feature start), prog1 → cell 3.
let vts = build_vts_cells(
1000,
0x00,
&[
(0, 9, 0x80, 0x05),
(100, 199, 0x00, 0x59),
(300, 399, 0x00, 0x59),
],
&[1, 3],
);
let udf = build_video_ts_fs(
&mut disc,
&[
FileSpec {
name: "VIDEO_TS.IFO".into(),
icb_lba: 60,
data_lba: 5000,
contents: vmg,
},
FileSpec {
name: "VTS_01_0.IFO".into(),
icb_lba: 62,
data_lba: 6000,
contents: vts,
},
],
);
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
// The leading 0x80 cell is dropped: 2 feature extents, not 3.
assert_eq!(t.extents.len(), 2, "leading angle sub-block cell dropped");
// First extent starts at the feature cell (vob 1000 + 100), not at 1000+0.
assert_eq!(t.extents[0].start_lba, 1000 + 100);
assert_eq!(t.extents[1].start_lba, 1000 + 300);
// Chapter times shift earlier by the dropped 5s. Program 0 was at the
// dropped head (clamped to 0); program 1 was at cell 3 =
// dur(cell0)+dur(cell1) = 5 + 59 = 64s, now 59s after the 5s shift.
assert_eq!(t.chapters.len(), 2);
assert!(
(t.chapters[0].time_secs - 0.0).abs() < 0.01,
"ch0 clamped to 0, got {}",
t.chapters[0].time_secs
);
assert!(
(t.chapters[1].time_secs - 59.0).abs() < 0.01,
"ch1 shifted by dropped 5s → 59s, got {}",
t.chapters[1].time_secs
);
}
/// Conservative guard end-to-end: a normal feature (every cell category
/// 0x00) is muxed in full — the filter drops nothing and chapters are
/// unshifted. This is the "Silence of the Lambs" case.
#[test]
fn scan_dvd_titles_plain_feature_untouched() {
let mut disc = MemDisc::new();
let vmg = build_vmg(&[(2, 1, 1)]);
let vts = build_vts_cells(
1000,
crate::ifo::v_atr_byte(crate::ifo::VIDEO_FORMAT_PAL, crate::ifo::ASPECT_16X9),
&[(0, 99, 0x00, 0x30), (200, 299, 0x00, 0x30)],
&[1, 2],
);
let udf = build_video_ts_fs(
&mut disc,
&[
FileSpec {
name: "VIDEO_TS.IFO".into(),
icb_lba: 60,
data_lba: 5000,
contents: vmg,
},
FileSpec {
name: "VTS_01_0.IFO".into(),
icb_lba: 62,
data_lba: 6000,
contents: vts,
},
],
);
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
// Nothing dropped: both cells become extents, starting at the very head.
assert_eq!(t.extents.len(), 2);
assert_eq!(t.extents[0].start_lba, 1000); // 1000 + 0, head intact
assert_eq!(t.extents[1].start_lba, 1200);
// Chapter 0 stays at 0.0 (no shift).
assert!((t.chapters[0].time_secs - 0.0).abs() < 0.01);
}
}
+21 -4
View File
@@ -1291,8 +1291,16 @@ impl Disc {
// AACS handshake (Blu-ray/UHD). Acquires the Volume ID via the
// cert-based mutual-auth handshake (the OEM route); drive unlock
// itself runs separately behind the pluggable `Unlocker` seam.
tracing::info!(target: "freemkv::scan", "phase: AACS handshake");
let (handshake, handshake_error) = Self::do_handshake(session, opts);
// AACS is Blu-ray/UHD only. A DVD uses CSS — skip the AACS handshake
// entirely (the drive already classified the disc as DVD at init), so a
// DVD never issues AACS OEM-VID / cert SCSI against the drive before the
// CSS bus-auth runs.
let (handshake, handshake_error) = if session.disc_is_dvd() {
(None, None)
} else {
tracing::info!(target: "freemkv::scan", "phase: AACS handshake");
Self::do_handshake(session, opts)
};
tracing::info!(target: "freemkv::scan", handshake = handshake.is_some(), "phase: handshake done");
// Request max read speed — removes riplock on DVD
@@ -1660,7 +1668,7 @@ impl Disc {
elapsed_ms = scan_with_t0.elapsed().as_millis() as u64,
"end"
);
Ok(Disc {
let disc = Disc {
volume_id: udf_fs.volume_id.clone(),
meta_title,
format,
@@ -1677,7 +1685,16 @@ impl Disc {
// which set this when they observe scrambled-but-uncracked content.
css_error: None,
content_format,
})
};
// Structured scan diagnostic block (--log-level 3). Emits the
// per-title / per-stream / decision / AACS rows under the
// `freemkv::diag` target; a no-op unless that target is enabled.
// (DVD per-cell category rows are emitted earlier from the IFO scan,
// before the per-cell detail is lowered away.)
crate::diag::dump_disc(&disc);
Ok(disc)
}
// ── Internal helpers ────────────────────────────────────────────────────
+1 -1
View File
@@ -370,7 +370,7 @@ impl Drive {
}
/// True when the mounted disc is a DVD (profile family `0x0010..=0x001F`).
fn disc_is_dvd(&mut self) -> bool {
pub(crate) fn disc_is_dvd(&mut self) -> bool {
matches!(self.current_profile(), Some(p) if (0x0010..=0x001F).contains(&p))
}
+293 -4
View File
@@ -58,6 +58,122 @@ pub struct DvdTitle {
pub struct DvdCell {
pub first_sector: u32,
pub last_sector: u32,
/// Raw cell-category byte at `cell_playback + 0` (DVD-Video spec).
/// Packs cell_type (bits 7-6), block_mode (bits 5-4), block_type
/// (bits 3-2), seamless_play (bit 1), interleaved (bit 0). Carried so
/// the extent builder can recognise non-feature leading cells
/// (scene-index / interleaved angle sub-blocks) and the diagnostic dump
/// can show why a cell was kept or dropped.
pub category: u8,
/// Per-cell playback duration in seconds (BCD time at `cell_playback + 4`).
/// Used by the diagnostic dump and the conservative leading-cell filter
/// (a short leading scene-index cell vs the multi-minute feature).
pub duration_secs: f64,
}
/// Decoded view of a cell-category byte (`cell_playback + 0`), per the
/// DVD-Video spec `cell_playback_information` layout.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CellCategory {
/// bits 7-6: 0=normal, 1=first cell of angle block, 2=middle, 3=last.
pub cell_type: u8,
/// bits 5-4: 0=not in block, 1=first cell of block, 2=in block, 3=last.
pub block_mode: u8,
/// bits 3-2: 0=not part of a block, 1=angle block.
pub block_type: u8,
/// bit 1: seamless playback (STC continuous).
pub seamless_play: bool,
/// bit 0: interleaved (multi-angle / seamless-branch interleave).
pub interleaved: bool,
}
impl CellCategory {
/// Decode the raw `cell_playback + 0` byte.
pub fn decode(raw: u8) -> Self {
CellCategory {
cell_type: (raw >> 6) & 0x03,
block_mode: (raw >> 4) & 0x03,
block_type: (raw >> 2) & 0x03,
seamless_play: (raw & 0x02) != 0,
interleaved: (raw & 0x01) != 0,
}
}
/// A plain feature cell: not part of any angle/interleave block. Every
/// cell of a normal single-angle feature decodes to this (`category`
/// byte `0x00`, or `0x00` in every block field with only the
/// seamless/interleaved flags possibly set). Such a cell is NEVER
/// dropped by the leading-cell filter.
pub fn is_plain_feature(&self) -> bool {
self.cell_type == 0 && self.block_mode == 0 && self.block_type == 0
}
/// Marks a non-first piece of an angle / interleaved block: a "middle" or
/// "last" cell of an angle block (`cell_type ∈ {2,3}`), or an
/// in-block / last-of-block cell (`block_mode ∈ {2,3}`). Concatenating
/// these back-to-back with the first angle duplicates content at the head
/// of the feature. Conservative: the FIRST cell of a block
/// (`cell_type==1` / `block_mode==1`) is NOT flagged — it is the angle we
/// keep.
pub fn is_secondary_block_piece(&self) -> bool {
matches!(self.cell_type, 2 | 3) || matches!(self.block_mode, 2 | 3)
}
}
impl DvdTitle {
/// Index of the first cell to include in the muxed feature.
///
/// Bug-4 (scene-selection / logo at the head of the feature): the main
/// feature's PGC can open with leading cells that are NOT part of the
/// movie — a scene-index segment or an interleaved-angle sub-block. Those
/// are recognisable by their cell-category byte: a leading cell flagged as
/// a *secondary* piece of an angle/interleave block
/// ([`CellCategory::is_secondary_block_piece`]) is not feature content.
///
/// This walks the leading run and returns the index of the first cell that
/// is a plain feature cell (category `0x00`-class). Cells before it that
/// are secondary block pieces are dropped from the feature extents.
///
/// **Conservative by construction — it can NEVER truncate a normal
/// feature:**
/// - It only ever skips a *prefix*; the scan stops at the first
/// plain-feature cell and keeps everything from there on.
/// - A normal single-angle feature has category `0x00` on cell 0, so the
/// scan stops immediately at index 0 and drops nothing.
/// - It never drops on duration or any heuristic — only on the spec
/// category bits — and it never drops the FIRST cell of an angle block
/// (the angle we keep).
/// - As a final guard it never returns past the last cell, and never drops
/// when that would leave zero cells.
///
/// For "The Silence of the Lambs" (every feature cell category `0x00`,
/// chapter 1 at 00:00:00) this returns 0 — a no-op — which is the correct
/// result: the disc's scene-index lives in a separate menu/title PGC, not
/// in leading cells of the feature PGC, so there is nothing to drop here.
pub fn feature_start_cell(&self) -> usize {
let n = self.cells.len();
if n == 0 {
return 0;
}
let mut idx = 0;
while idx < n {
let cat = CellCategory::decode(self.cells[idx].category);
// Stop at the first cell that is genuine feature content.
if !cat.is_secondary_block_piece() {
break;
}
idx += 1;
}
// Never drop everything: if every leading cell looked like a secondary
// block piece (pathological/corrupt category bytes), fall back to
// keeping all cells rather than producing an empty feature.
if idx >= n { 0 } else { idx }
}
/// The feature cells after the leading-cell filter ([`feature_start_cell`]).
pub fn feature_cells(&self) -> &[DvdCell] {
&self.cells[self.feature_start_cell()..]
}
}
/// DVD TV system, from VTS_V_ATR `video_format` (byte 0 bits 5-4).
@@ -305,11 +421,25 @@ fn parse_vts(
return Err(Error::IfoParse);
}
// VTS_PGCIT sector pointer
let pgcit_sector = be_u32(&vts_data, 0xCC)?;
// VTSI_MAT (VTS_xx_0.IFO header) field offsets — fixed by the DVD-Video
// spec (libdvdread `vtsi_mat_t`). The offsets are constant; the sector
// values they point to are per-disc.
const VTSTT_VOBS_OFFSET: usize = 0xC4; // VTS title VOBS start sector (feature)
const VTS_PGCIT_OFFSET: usize = 0xCC; // VTS_PGCIT sector pointer
// First VOB sector
let vob_start_sector = be_u32(&vts_data, 0xC0)?;
// VTS_PGCIT sector pointer
let pgcit_sector = be_u32(&vts_data, VTS_PGCIT_OFFSET)?;
// First sector of the VTS **Title** VOBS (`vtstt_vobs`, VTSTT_VOBS_OFFSET).
// The cell `first_sector` / `last_sector` values in the title PGCs are
// relative to this. Offset 0xC0 is `vtsm_vobs` — the VTS *menu* VOBS
// (VTS_xx_0.VOB), which on discs with a per-title menu (e.g. a Universal
// "the parental level has been set, press yes" first-play still) holds that
// interactive prompt. Reading the menu base instead prepended the menu VOB
// to the feature and shifted every cell extent back by
// `vtstt_vobs - vtsm_vobs` sectors, so the rip opened on the parental
// prompt instead of the movie. The title content lives at `vtstt_vobs`.
let vob_start_sector = be_u32(&vts_data, VTSTT_VOBS_OFFSET)?;
// Video attributes at offset 0x200 (2 bytes)
let video = parse_video_attr(&vts_data)?;
@@ -617,11 +747,15 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle>
if co + 24 > data.len() {
break;
}
let category = byte_at(data, co)?;
let duration_secs = bcd_to_secs(&data[co + 4..co + 8]);
let first_sector = be_u32(data, co + 8)?;
let last_sector = be_u32(data, co + 20)?;
cells.push(DvdCell {
first_sector,
last_sector,
category,
duration_secs,
});
}
}
@@ -781,6 +915,8 @@ mod tests {
let cell = DvdCell {
first_sector: 100,
last_sector: 200,
category: 0,
duration_secs: 0.0,
};
assert_eq!(cell.first_sector, 100);
assert_eq!(cell.last_sector, 200);
@@ -1349,6 +1485,159 @@ mod tests {
assert!(title.cells.is_empty());
}
// ─────────────────────────────────────────────────────────────────────
// Cell-category decode + bug-4 leading-cell filter.
// ─────────────────────────────────────────────────────────────────────
fn cell(first: u32, last: u32, category: u8) -> DvdCell {
DvdCell {
first_sector: first,
last_sector: last,
category,
duration_secs: 0.0,
}
}
/// CellCategory decodes the spec bitfields: cell_type (7-6), block_mode
/// (5-4), block_type (3-2), seamless (1), interleaved (0).
#[test]
fn cell_category_decode_bits() {
// 0x00 → plain feature, nothing set.
let c = CellCategory::decode(0x00);
assert_eq!(c.cell_type, 0);
assert_eq!(c.block_mode, 0);
assert_eq!(c.block_type, 0);
assert!(!c.seamless_play);
assert!(!c.interleaved);
assert!(c.is_plain_feature());
assert!(!c.is_secondary_block_piece());
// cell_type=1 (first of angle block), block_mode=1 (first of block):
// 0b01_01_00_0_0 = 0x50. This is the angle we KEEP — not secondary.
let c = CellCategory::decode(0b01_01_00_00);
assert_eq!(c.cell_type, 1);
assert_eq!(c.block_mode, 1);
assert!(!c.is_plain_feature());
assert!(!c.is_secondary_block_piece());
// cell_type=2 (middle of angle block): 0b10_00_00_00 = 0x80 → secondary.
assert!(CellCategory::decode(0b10_00_00_00).is_secondary_block_piece());
// cell_type=3 (last of angle block) → secondary.
assert!(CellCategory::decode(0b11_00_00_00).is_secondary_block_piece());
// block_mode=2 (in block) → secondary; block_mode=3 (last of block) → secondary.
assert!(CellCategory::decode(0b00_10_00_00).is_secondary_block_piece());
assert!(CellCategory::decode(0b00_11_00_00).is_secondary_block_piece());
// seamless (bit1) + interleaved (bit0) on an otherwise-plain cell must
// NOT make it secondary — they don't mark non-feature content.
let c = CellCategory::decode(0b00_00_00_11);
assert!(c.seamless_play);
assert!(c.interleaved);
assert!(c.is_plain_feature());
assert!(!c.is_secondary_block_piece());
}
/// A normal single-angle feature (every cell category 0x00) is never
/// filtered: feature_start_cell == 0, feature_cells == all cells. This is
/// the "Silence of the Lambs" case — the filter must be a no-op.
#[test]
fn feature_filter_noop_on_plain_feature() {
let t = DvdTitle {
chapters: 3,
duration_secs: 6780.0,
cells: vec![
cell(0, 99, 0x00),
cell(100, 199, 0x00),
cell(200, 299, 0x00),
],
chapter_times: vec![0.0, 100.0, 200.0],
palette: None,
};
assert_eq!(t.feature_start_cell(), 0);
assert_eq!(t.feature_cells().len(), 3);
}
/// A leading interleaved/angle-block sub-cell (category marks a secondary
/// block piece) is dropped; the scan stops at the first plain cell and
/// keeps the rest.
#[test]
fn feature_filter_drops_leading_secondary_block_cells() {
let t = DvdTitle {
chapters: 2,
duration_secs: 100.0,
cells: vec![
cell(0, 9, 0b10_00_00_00), // middle of angle block → drop
cell(10, 19, 0b00_11_00_00), // last of block → drop
cell(20, 119, 0x00), // feature starts here
cell(120, 219, 0x00),
],
chapter_times: vec![0.0, 50.0],
palette: None,
};
assert_eq!(t.feature_start_cell(), 2);
let fc = t.feature_cells();
assert_eq!(fc.len(), 2);
assert_eq!(fc[0].first_sector, 20);
}
/// Conservative guard: if EVERY cell looks like a secondary block piece
/// (corrupt/pathological category bytes), the filter refuses to drop them
/// all — it returns 0 and keeps every cell rather than emit an empty
/// feature.
#[test]
fn feature_filter_never_empties_title() {
let t = DvdTitle {
chapters: 1,
duration_secs: 100.0,
cells: vec![cell(0, 9, 0b10_00_00_00), cell(10, 19, 0b11_00_00_00)],
chapter_times: vec![0.0],
palette: None,
};
assert_eq!(t.feature_start_cell(), 0);
assert_eq!(t.feature_cells().len(), 2);
}
/// An empty title (no cells) returns 0 and an empty slice — no panic.
#[test]
fn feature_filter_empty_cells() {
let t = DvdTitle {
chapters: 0,
duration_secs: 0.0,
cells: vec![],
chapter_times: vec![],
palette: None,
};
assert_eq!(t.feature_start_cell(), 0);
assert!(t.feature_cells().is_empty());
}
/// parse_pgc populates the new `category` + `duration_secs` cell fields
/// from `cell_playback + 0` and the BCD time at `cell_playback + 4`.
#[test]
fn pgc_reads_cell_category_and_duration() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x02] = 1;
pgc[0x03] = 2; // 2 cells
pgc[0xE8] = 0x00;
pgc[0xE9] = 0xEA;
pgc.resize(0xEA + 48, 0);
// Cell 0: category byte = 0x80 (middle of angle block), 5s BCD.
pgc[0xEA] = 0x80;
pgc[0xEA + 6] = 0x05;
pgc[0xEA + 8..0xEA + 12].copy_from_slice(&10u32.to_be_bytes());
// Cell 1: category 0x00 (plain feature), 7s BCD.
pgc[0xEA + 24] = 0x00;
pgc[0xEA + 24 + 6] = 0x07;
pgc[0xEA + 24 + 8..0xEA + 24 + 12].copy_from_slice(&20u32.to_be_bytes());
let title = parse_pgc(&pgc, 0, 2).unwrap();
assert_eq!(title.cells[0].category, 0x80);
assert!((title.cells[0].duration_secs - 5.0).abs() < 0.01);
assert_eq!(title.cells[1].category, 0x00);
assert!((title.cells[1].duration_secs - 7.0).abs() < 0.01);
// The leading secondary-block cell is filtered out of the feature.
assert_eq!(title.feature_start_cell(), 1);
}
/// Regression: a crafted IFO whose program-map byte names a first_cell
/// index larger than the actual cell count must NOT panic. Before the fix,
/// `cell_durations[..first_cell.saturating_sub(1)]` would panic with an
+46 -4
View File
@@ -290,6 +290,16 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
let mut first_err: Option<Error> = None;
let mut stopped = false;
// Rolling apply-throughput summary. The per-item "apply: OK"
// line was 99% of the mux log; collapse it into a periodic
// summary (count, avg ms, items/s) emitted ~every 5 s while
// debug tracing is on. The individual slow-apply ("took … s")
// STALL events below stay visible — those are signal, not noise.
let mut summary_count: u64 = 0;
let mut summary_nanos: u128 = 0;
let mut summary_since = Instant::now();
const SUMMARY_INTERVAL: Duration = Duration::from_secs(5);
while let Ok(item) = rx.recv() {
let debug = debug_enabled();
if debug {
@@ -338,21 +348,50 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
if let Some(start) = apply_start {
let apply_elapsed = start.elapsed();
if apply_elapsed > Duration::from_millis(100) {
// STALL event — a single slow apply. Keep it visible:
// its presence is a signal, not per-frame noise.
tracing::debug!(
"Pipeline apply: took {:.2}s, item={}",
apply_elapsed.as_secs_f64(),
std::any::type_name::<I>()
);
} else {
}
// Benign per-item OK: roll into the periodic summary
// rather than logging one line per frame.
summary_count += 1;
summary_nanos += apply_elapsed.as_nanos();
if summary_since.elapsed() >= SUMMARY_INTERVAL && summary_count > 0 {
let secs = summary_since.elapsed().as_secs_f64();
let avg_ms = (summary_nanos as f64 / summary_count as f64) / 1_000_000.0;
tracing::debug!(
"Pipeline apply: OK in {:.3}ms, item={}",
apply_elapsed.as_micros(),
"Pipeline apply summary: {} items in {:.1}s, avg {:.3}ms, {:.0} items/s, type={}",
summary_count,
secs,
avg_ms,
summary_count as f64 / secs.max(1e-9),
std::any::type_name::<I>()
);
summary_count = 0;
summary_nanos = 0;
summary_since = Instant::now();
}
}
}
// Flush the residual apply-summary tail at end-of-stream so the
// last partial window's item count isn't silently dropped.
if summary_count > 0 && debug_enabled() {
let secs = summary_since.elapsed().as_secs_f64();
let avg_ms = (summary_nanos as f64 / summary_count as f64) / 1_000_000.0;
tracing::debug!(
"Pipeline apply summary (final): {} items in {:.1}s, avg {:.3}ms, type={}",
summary_count,
secs,
avg_ms,
std::any::type_name::<I>()
);
}
// Final abandonment check: the common leak case is a
// consumer wedged inside `apply` (a blocking write
// syscall). When that syscall finally returns, the
@@ -401,13 +440,16 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
if let Some(start) = start {
let elapsed = start.elapsed();
if elapsed > Duration::from_millis(10) {
// BLOCKED event — back-pressure stall. Keep visible.
tracing::debug!(
"Pipeline send: blocked {:.2}s, item={}",
elapsed.as_secs_f64(),
std::any::type_name::<I>()
);
} else {
tracing::debug!("Pipeline send: OK in {:.3}ms", elapsed.as_micros());
// Benign per-item OK: trace-level (L4) only; the
// apply-side rolling summary carries throughput.
tracing::trace!("Pipeline send: OK in {:.3}ms", elapsed.as_micros());
}
}
Ok(())
+22 -1
View File
@@ -101,6 +101,13 @@ pub(crate) struct WritebackFile {
file: File,
pipeline: WritebackPipeline,
pos: u64,
/// Count of position-moving seeks (for the finalize summary). The MKV muxer
/// seeks back occasionally (cluster size patching, Cues, Segment header
/// backpatch); the per-seek DEBUG line is trace-level now, and this rolls
/// the total into one finalize summary.
seek_count: u64,
/// Sum of |delta| over all position-moving seeks, in bytes.
seek_bytes: u64,
}
impl WritebackFile {
@@ -115,6 +122,8 @@ impl WritebackFile {
file,
pipeline,
pos,
seek_count: 0,
seek_bytes: 0,
})
}
@@ -176,6 +185,14 @@ impl WritebackFile {
/// then external commit/DB update) must not treat `Ok(())` as a
/// durability barrier.
pub(crate) fn sync_all(&mut self) -> io::Result<()> {
if self.seek_count > 0 {
tracing::debug!(
target: "mux",
"WritebackFile finalize: {} seeks, {} bytes seeked total",
self.seek_count,
self.seek_bytes
);
}
self.pipeline.finalize();
platform::durable_sync(&self.file)
}
@@ -219,10 +236,14 @@ impl Seek for WritebackFile {
let from_pos = self.pos;
let to_pos = p;
let delta: i64 = (to_pos as i64).wrapping_sub(from_pos as i64);
tracing::debug!(
// Per-seek detail is trace-level (L4) — benign and high-frequency.
// The aggregate (count + total bytes) is logged once at finalize.
tracing::trace!(
target: "mux",
"WritebackFile seek from={from_pos} to={to_pos} delta={delta}"
);
self.seek_count += 1;
self.seek_bytes += delta.unsigned_abs();
self.pipeline.handle_seek(p);
self.pos = p;
}
+1
View File
@@ -89,6 +89,7 @@ pub mod aacs;
pub(crate) mod clpi;
pub mod css;
pub mod decrypt;
pub mod diag;
pub mod disc;
pub mod drive;
pub mod error;
+181
View File
@@ -261,6 +261,81 @@ fn frame_duration_ns(data: &[u8], bsid: u8) -> u64 {
(samples * 1_000_000_000 + rate / 2) / rate
}
/// Base channel count per AC-3 `acmod` (A/52 Table 5.8), BEFORE the LFE.
/// Index is the 3-bit acmod value; add 1 when `lfeon` is set.
///
/// ```text
/// 0 = 1+1 (Ch1, Ch2) -> 2 4 = 3/0 (L,C,R) -> 3
/// 1 = 1/0 (C, mono) -> 1 5 = 2/1 (L,R,S) -> 3
/// 2 = 2/0 (L, R) -> 2 6 = 3/1 (L,C,R,S) -> 4
/// 3 = 3/0 (L,C,R) -> 3 7 = 3/2 (L,C,R,SL,SR) -> 5
/// ```
const ACMOD_CHANNELS: [u8; 8] = [2, 1, 2, 3, 3, 3, 4, 5];
/// Decode the channel count of an (E-)AC-3 frame from its bitstream `acmod` and
/// `lfeon`, starting at the 0x0B77 syncword. Returns `None` when the frame is
/// too short to carry the BSI bits.
///
/// This is the AUTHORITATIVE channel count for the track header: the DVD IFO
/// `audio_attr_t.channels` nibble is a well-known unreliable/stale field, so
/// the muxer prefers this over the IFO-claimed count (mirrors MakeMKV /
/// HandBrake, which never trust the IFO audio nibble). LFE adds one channel
/// (e.g. acmod=7 + lfeon → 6 = 5.1).
///
/// Bit layout from the syncword (A/52 §5.3.2 BSI):
///
/// ```text
/// byte 5: bsid(5) | bsmod(3)
/// byte 6: acmod(3) | [cmixlev(2) if acmod has a centre and acmod!=1]
/// | [surmixlev(2) if acmod has surround]
/// | [dsurmod(2) if acmod==2] | lfeon(1) | ...
/// ```
///
/// `acmod` therefore always occupies byte-6 bits 7-5; `lfeon` follows a
/// variable number of optional 2-bit fields, so we track the bit cursor.
pub(crate) fn acmod_channels(data: &[u8]) -> Option<u8> {
// Need at least bytes 0..=6 to read acmod (byte 6) and its trailing
// optional fields + lfeon (which never spills past byte 7 for any acmod).
if data.len() < 8 {
return None;
}
let bsid = get_bsid(data);
// E-AC-3 (bsid >= 11, Annex E) uses a different BSI layout. DVD audio is
// always legacy AC-3 (bsid <= 8); for E-AC-3 we don't decode acmod here
// and let the caller fall back to the passed channel count.
if bsid >= 11 {
return None;
}
// Bit cursor over `data`, MSB-first, starting at byte 6 bit 7 (= bit 48).
let mut bit = 6 * 8;
let read = |n: usize, bit: &mut usize| -> u32 {
let mut v = 0u32;
for _ in 0..n {
let byte = data[*bit / 8];
let shift = 7 - (*bit % 8);
v = (v << 1) | ((byte >> shift) & 1) as u32;
*bit += 1;
}
v
};
let acmod = read(3, &mut bit) as usize;
// cmixlev: present when acmod has a centre channel AND is not the 1/0
// (centre-only) mode — i.e. acmod & 0x1 != 0 && acmod != 0x1.
if (acmod & 0x1) != 0 && acmod != 0x1 {
let _cmixlev = read(2, &mut bit);
}
// surmixlev: present when acmod has a surround channel (acmod & 0x4).
if (acmod & 0x4) != 0 {
let _surmixlev = read(2, &mut bit);
}
// dsurmod: present only for the 2/0 (stereo) mode.
if acmod == 0x2 {
let _dsurmod = read(2, &mut bit);
}
let lfeon = read(1, &mut bit);
Some(ACMOD_CHANNELS[acmod] + lfeon as u8)
}
/// Find AC3/E-AC-3 syncword (0x0B77) in data.
fn find_ac3_sync(data: &[u8]) -> Option<usize> {
(0..data.len().saturating_sub(1)).find(|&i| data[i] == 0x0B && data[i + 1] == 0x77)
@@ -968,6 +1043,112 @@ mod tests {
assert!(parser.flush().is_empty());
}
// --- acmod_channels: channel count from the AC-3 BSI bitstream ---
/// Build a minimal AC-3 BSI header (8 bytes) with a given acmod + lfeon.
/// byte5 = bsid<<3 (bsmod=0); byte6 carries acmod in bits 7-5 followed by
/// the optional mix-level fields and lfeon. We construct byte6/7 by writing
/// bits MSB-first in the exact order acmod_channels reads them.
fn make_bsi(acmod: u8, lfeon: bool) -> Vec<u8> {
// Collect the bit sequence after byte 6 bit 7: acmod(3), [cmixlev(2)],
// [surmixlev(2)], [dsurmod(2)], lfeon(1). Mix-level/dsurmod bits are
// arbitrary (0 here) — only their PRESENCE shifts lfeon's position.
let mut bits: Vec<u8> = Vec::new();
for i in (0..3).rev() {
bits.push((acmod >> i) & 1);
}
if (acmod & 0x1) != 0 && acmod != 0x1 {
bits.push(0);
bits.push(0); // cmixlev
}
if (acmod & 0x4) != 0 {
bits.push(0);
bits.push(0); // surmixlev
}
if acmod == 0x2 {
bits.push(0);
bits.push(0); // dsurmod
}
bits.push(lfeon as u8); // lfeon
// Pack bits MSB-first starting at byte 6.
let mut frame = vec![0u8; 8];
frame[0] = 0x0B;
frame[1] = 0x77;
frame[5] = 8 << 3; // bsid = 8 (legacy AC-3), bsmod = 0
for (idx, &b) in bits.iter().enumerate() {
let bitpos = 6 * 8 + idx;
if b != 0 {
frame[bitpos / 8] |= 1 << (7 - (bitpos % 8));
}
}
frame
}
#[test]
fn acmod_channels_stereo_2_0_no_lfe() {
// acmod=2 (2/0 L,R), no LFE → 2 channels. Verifies the channel count is
// read from the AC-3 bitstream's acmod, independent of any IFO claim.
// (A disc whose IFO lists 5.1 but where the wrong physical substream is
// selected is a separate stream-SELECTION bug, not this label path —
// tracked for rc.5.2.)
assert_eq!(acmod_channels(&make_bsi(2, false)), Some(2));
}
#[test]
fn acmod_channels_5_1() {
// acmod=7 (3/2 L,C,R,SL,SR) + LFE → 6 channels (5.1).
assert_eq!(acmod_channels(&make_bsi(7, true)), Some(6));
// 3/2 without LFE → 5 channels.
assert_eq!(acmod_channels(&make_bsi(7, false)), Some(5));
}
#[test]
fn acmod_channels_mono_and_dual_mono() {
// acmod=1 (1/0 centre/mono) → 1; with LFE → 2.
assert_eq!(acmod_channels(&make_bsi(1, false)), Some(1));
assert_eq!(acmod_channels(&make_bsi(1, true)), Some(2));
// acmod=0 (1+1 dual mono) → 2 base channels.
assert_eq!(acmod_channels(&make_bsi(0, false)), Some(2));
}
#[test]
fn acmod_channels_3_0_and_2_1() {
// acmod=4 (3/0 L,C,R) → 3 (exercises cmixlev present, surmixlev absent).
assert_eq!(acmod_channels(&make_bsi(4, false)), Some(3));
// acmod=5 (2/1 L,R,S) → 3 (surmixlev present, no centre).
assert_eq!(acmod_channels(&make_bsi(5, false)), Some(3));
// acmod=6 (3/1) + LFE → 5; lfeon position shifts after both
// cmixlev (centre) and surmixlev (surround) 2-bit fields.
assert_eq!(acmod_channels(&make_bsi(6, true)), Some(5));
}
#[test]
fn acmod_channels_short_frame_is_none() {
// Fewer than 8 bytes cannot carry the BSI bits → None (caller falls
// back to the IFO-claimed channel count).
assert_eq!(acmod_channels(&[0x0B, 0x77, 0, 0, 0, 8 << 3]), None);
assert_eq!(acmod_channels(&[]), None);
}
#[test]
fn acmod_channels_eac3_is_none() {
// E-AC-3 (bsid >= 11) uses a different BSI layout; acmod_channels
// declines so the caller keeps the passed count.
let mut data = make_bsi(2, false);
data[5] = 16 << 3; // bsid = 16 (E-AC-3)
assert_eq!(acmod_channels(&data), None);
}
#[test]
fn acmod_channels_parses_real_built_frame() {
// A frame built by make_ac3_frame (fscod/frmsizecod set, acmod bits 0)
// decodes acmod=0 → 2 channels (dual mono), confirming the cursor lands
// on the right bytes for a fully-formed frame, not just a stub header.
let frame = make_ac3_frame(0, 2);
// make_ac3_frame leaves byte 6 = 0 → acmod=0, lfeon=0 → 2 channels.
assert_eq!(acmod_channels(&frame), Some(2));
}
// helper: PES with a generic pts for E-AC-3 tests
fn make_eac3_pes(data: Vec<u8>) -> PesPacket {
PesPacket {
+40 -12
View File
@@ -161,6 +161,10 @@ pub struct DiscStream {
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.
@@ -284,6 +288,7 @@ impl DiscStream {
halt: None,
event_fn: None,
eof: false,
dropped_nav_packets: 0,
bytes_read_total: 0,
bytes_total_extents,
ts_demuxer,
@@ -578,6 +583,13 @@ impl crate::pes::Stream for DiscStream {
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() {
@@ -603,12 +615,20 @@ impl crate::pes::Stream for DiscStream {
// 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 {
tracing::warn!(
target: "mux",
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
ps.stream_id,
ps.sub_stream_id,
);
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)) =
@@ -714,12 +734,20 @@ impl crate::pes::Stream for DiscStream {
// 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 {
tracing::warn!(
target: "mux",
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
ps.stream_id,
ps.sub_stream_id,
);
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)) =
+21
View File
@@ -419,6 +419,11 @@ pub const CODEC_ID: u32 = 0x86;
pub const CODEC_PRIVATE: u32 = 0x63A2;
pub const TRACK_NAME: u32 = 0x536E;
pub const DEFAULT_DURATION: u32 = 0x23_E383;
/// DefaultDecodedFieldDuration — nanoseconds per FIELD (half a frame for
/// interlaced content). Emitting it on an interlaced track tells a reader the
/// field rate so it stops halving the frame rate (Windows shell shows 12.5 fps
/// for a 25 fps 576i stream without it). RFC 9559 / Matroska v4.
pub const DEFAULT_DECODED_FIELD_DURATION: u32 = 0x23_4E7A;
// Video
pub const VIDEO: u32 = 0xE0;
@@ -434,6 +439,10 @@ pub const INTERLACED_PROGRESSIVE: u64 = 2;
// NTSC DVD (480i) and HD (1080i) are top-field-first; PAL DVD (576i) is
// bottom-field-first. 0xFF is our sentinel for "undetermined / omit".
pub const FIELD_ORDER_TFF: u8 = 2;
// Bottom-field-first. Retained for completeness/round-trip tests; the muxer
// emits TFF for all DVD/HD interlaced content (DV is the only common BFF
// source and freemkv does not produce it).
#[allow(dead_code)]
pub const FIELD_ORDER_BFF: u8 = 9;
pub const FIELD_ORDER_UNDETERMINED: u8 = 0xFF;
pub const DISPLAY_WIDTH: u32 = 0x54B0;
@@ -472,6 +481,18 @@ pub const CUE_TRACK_POSITIONS: u32 = 0xB7;
pub const CUE_TRACK: u32 = 0xF7;
pub const CUE_CLUSTER_POSITION: u32 = 0xF1;
// Tags — per-track statistics tags. mkvmerge convention: a `BPS` SimpleTag
// per track carries the bits-per-second so readers (Windows Explorer's MKV
// property handler) that read the container tag rather than computing from
// stream size show a bitrate for every track, not just CBR audio.
pub const TAGS: u32 = 0x1254_C367;
pub const TAG: u32 = 0x7373;
pub const TARGETS: u32 = 0x63C0;
pub const TAG_TRACK_UID: u32 = 0x63C5;
pub const SIMPLE_TAG: u32 = 0x67C8;
pub const TAG_NAME: u32 = 0x45A3;
pub const TAG_STRING: u32 = 0x4487;
// Chapters
pub const CHAPTERS: u32 = 0x1043_A770;
pub const EDITION_ENTRY: u32 = 0x45B9;
+337 -14
View File
@@ -36,6 +36,9 @@ pub struct MkvTrack {
// meaningful when interlaced; `FIELD_ORDER_UNDETERMINED` omits it.
pub interlaced: bool,
pub field_order: u8,
/// DefaultDecodedFieldDuration (ns per field) for interlaced video — half
/// the frame `default_duration_ns`. 0 = omit (progressive / unknown).
pub field_duration_ns: u64,
// Audio-specific
pub sample_rate: f64,
pub channels: u8,
@@ -131,17 +134,25 @@ impl MkvTrack {
colour_primaries: primaries,
colour_range: range,
interlaced: v.resolution.is_interlaced(),
// PAL DVD (576i) is bottom-field-first; NTSC DVD (480i) is
// top-field-first. HD interlaced (1080i) is top-field-first.
// Progressive content leaves the field order undetermined.
// PAL DVD (576i), NTSC DVD (480i), and HD interlaced (1080i) are
// all top-field-first ("almost everything but DV is TFF"). MediaInfo
// reads "Top Field First" off the MPEG-2 picture coding extension,
// so the container element must agree — emitting BFF here for 576i
// (the pre-rc.5.1 value) was a wrong container value that disagreed
// with the stream. Progressive content leaves the order undetermined.
field_order: if v.resolution.is_interlaced() {
match v.resolution {
Resolution::R576i => ebml::FIELD_ORDER_BFF,
_ => ebml::FIELD_ORDER_TFF,
}
ebml::FIELD_ORDER_TFF
} else {
ebml::FIELD_ORDER_UNDETERMINED
},
// One field is half a frame. For 576i 25 fps (40 ms frame) this is
// 20 ms; for 480i 29.97 fps (~33.4 ms frame) ~16.68 ms. Only set on
// interlaced tracks with a known frame duration.
field_duration_ns: if v.resolution.is_interlaced() && default_duration_ns > 0 {
default_duration_ns / 2
} else {
0
},
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
@@ -214,6 +225,7 @@ impl MkvTrack {
colour_range: 0,
interlaced: false,
field_order: ebml::FIELD_ORDER_UNDETERMINED,
field_duration_ns: 0,
sample_rate: sr,
channels: ch,
bit_depth: 0,
@@ -249,6 +261,7 @@ impl MkvTrack {
colour_range: 0,
interlaced: false,
field_order: ebml::FIELD_ORDER_UNDETERMINED,
field_duration_ns: 0,
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
@@ -305,6 +318,33 @@ pub struct MkvMuxer<W: Write + Seek> {
info_offset: u64,
tracks_offset: u64,
chapters_offset: Option<u64>,
/// Total payload bytes muxed PER TRACK (index = track_idx). Used to emit a
/// per-track `BPS` statistics tag (bytes*8/duration) at finalize so Windows
/// shows a bitrate for every track, not just CBR audio.
track_bytes: Vec<u64>,
/// Track UIDs in track order (parallels `track_bytes`), for the BPS Targets.
track_uids: Vec<u64>,
/// Segment duration in seconds (from `Info`), for the BPS denominator.
duration_secs: f64,
/// Per-AC-3-audio-track channel-correction state. The DVD IFO audio nibble
/// is unreliable, so the channel count written in the track header is
/// corrected from the AC-3 bitstream `acmod` of the first frame on the
/// track. Each entry records the file offset of the 1-byte Channels value
/// (to patch in place) and the IFO-claimed count (to warn on disagreement);
/// `corrected` flips once patched so we only act on the first frame.
ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup>,
}
/// Deferred AC-3 channel-count correction: the track header's `Channels` byte
/// is written up-front from the (unreliable) IFO count; on the first AC-3 frame
/// for the track the value is rewritten from the bitstream `acmod`.
struct Ac3ChannelFixup {
/// Absolute file offset of the 1-byte Channels value in the Tracks element.
value_offset: u64,
/// Channel count the IFO claimed (already written at `value_offset`).
claimed: u8,
/// True once the first frame has been parsed and the value finalised.
corrected: bool,
}
/// TimestampScale: nanoseconds per Matroska timestamp tick. 0.1 ms (100_000 ns).
@@ -645,10 +685,15 @@ impl<W: Write + Seek> MkvMuxer<W> {
let tracks_start = writer.stream_position()?;
let tracks_offset = tracks_start - segment_start;
let tracks_pos = ebml::start_master(&mut writer, ebml::TRACKS)?;
let mut track_uids: Vec<u64> = Vec::with_capacity(tracks.len());
let mut ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup> =
std::collections::HashMap::new();
for (i, track) in tracks.iter().enumerate() {
let track_uid = (i + 1) as u64 | 0x100_0000;
track_uids.push(track_uid);
let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?;
ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?;
ebml::write_uint(&mut writer, ebml::TRACK_UID, (i + 1) as u64 | 0x100_0000)?;
ebml::write_uint(&mut writer, ebml::TRACK_UID, track_uid)?;
ebml::write_uint(&mut writer, ebml::TRACK_TYPE, track.track_type)?;
ebml::write_uint(&mut writer, ebml::FLAG_LACING, 0)?;
ebml::write_string(&mut writer, ebml::CODEC_ID, track.codec_id)?;
@@ -682,6 +727,23 @@ impl<W: Write + Seek> MkvMuxer<W> {
)?;
}
// DefaultDecodedFieldDuration (one FIELD = half a frame) on
// interlaced tracks. Per the Matroska schema it is a DIRECT child
// of TrackEntry (NOT inside Video). Without it an interlace-aware
// reader (Windows shell) assumes "block = one field" and reports
// half the frame rate (12.5 instead of 25 for 576i). DefaultDuration
// above stays the full-frame period (40 ms); this is 20 ms.
if track.track_type == ebml::TRACK_TYPE_VIDEO
&& track.interlaced
&& track.field_duration_ns > 0
{
ebml::write_uint(
&mut writer,
ebml::DEFAULT_DECODED_FIELD_DURATION,
track.field_duration_ns,
)?;
}
// Video-specific
if track.track_type == ebml::TRACK_TYPE_VIDEO && track.pixel_width > 0 {
let vid_pos = ebml::start_master(&mut writer, ebml::VIDEO)?;
@@ -748,7 +810,23 @@ impl<W: Write + Seek> MkvMuxer<W> {
// Omit Channels when unknown (0) — Matroska defaults it to 1
// rather than us fabricating a 6-channel count.
if track.channels > 0 {
// Record the offset of the 1-byte Channels value so an AC-3
// track can correct it from the bitstream acmod on its first
// frame (the IFO nibble is unreliable). write_uint emits
// ID(0x9F, 1B) + size(0x81, 1B) + value(1B) for 1..=255, so
// the value byte sits 2 bytes after the element start.
let chan_elem_pos = writer.stream_position()?;
ebml::write_uint(&mut writer, ebml::CHANNELS, track.channels as u64)?;
if track.codec_id == ebml::CODEC_AC3 {
ac3_channel_fixups.insert(
i,
Ac3ChannelFixup {
value_offset: chan_elem_pos + 2,
claimed: track.channels,
corrected: false,
},
);
}
}
if track.bit_depth > 0 {
ebml::write_uint(&mut writer, ebml::BIT_DEPTH, track.bit_depth as u64)?;
@@ -803,6 +881,10 @@ impl<W: Write + Seek> MkvMuxer<W> {
info_offset,
tracks_offset,
chapters_offset,
track_bytes: vec![0u64; tracks.len()],
track_uids,
duration_secs,
ac3_channel_fixups,
})
}
@@ -969,6 +1051,42 @@ impl<W: Write + Seek> MkvMuxer<W> {
}
self.frame_count += 1;
// Per-track byte total for the finalize-time BPS statistics tag.
if let Some(b) = self.track_bytes.get_mut(track_idx) {
*b += data.len() as u64;
}
// Correct the AC-3 track's Channels element from the bitstream acmod on
// the FIRST frame of the track. The DVD IFO audio nibble is unreliable
// (it claims 5.1 on a 2.0 stream); the bitstream acmod is authoritative.
// Only the first frame triggers it; the byte width is unchanged so the
// patch is a single-byte in-place rewrite (then restore position).
if let Some(fixup) = self.ac3_channel_fixups.get_mut(&track_idx) {
if !fixup.corrected {
match super::codec::ac3::acmod_channels(data) {
Some(actual) if actual > 0 => {
if actual != fixup.claimed {
tracing::warn!(
target: "mux",
"AC-3 track {track_idx}: IFO claimed {} channels but bitstream acmod says {}; trusting the bitstream (possible wrong-stream selection)",
fixup.claimed,
actual,
);
let here = self.writer.stream_position()?;
self.writer
.seek(std::io::SeekFrom::Start(fixup.value_offset))?;
self.writer.write_all(&[actual])?;
self.writer.seek(std::io::SeekFrom::Start(here))?;
}
fixup.corrected = true;
}
// Frame too short to carry the BSI bits — keep the passed
// (IFO) value and try again on the next frame.
_ => {}
}
}
}
Ok(())
}
@@ -1015,6 +1133,12 @@ impl<W: Write + Seek> MkvMuxer<W> {
ebml::end_master(&mut self.writer, cues_pos)?;
}
// Per-track BPS statistics tags (mkvmerge convention). A reader that
// reads the container `BPS` tag (Windows Explorer's MKV property
// handler) rather than computing bitrate from stream size shows a
// bitrate for EVERY track this way, not just CBR audio.
self.write_bps_tags()?;
// Back-patch SeekHead SeekPosition values now that all element offsets are known.
for fixup in &self.seek_fixups {
let offset = match fixup.target_id {
@@ -1036,6 +1160,48 @@ impl<W: Write + Seek> MkvMuxer<W> {
Ok(())
}
/// Write a `Tags` master with a per-track `BPS` SimpleTag (bytes*8 /
/// duration_secs). Mirrors mkvmerge's per-track statistics tag so readers
/// that surface the container tag (Windows Explorer) show a bitrate for
/// every track. No-op when the duration is unknown (can't compute a rate)
/// or no track carried any bytes.
fn write_bps_tags(&mut self) -> io::Result<()> {
if self.duration_secs <= 0.0 {
return Ok(());
}
if self.track_bytes.iter().all(|&b| b == 0) {
return Ok(());
}
let tags_pos = ebml::start_master(&mut self.writer, ebml::TAGS)?;
// Snapshot to avoid borrowing self across the writer borrow.
let entries: Vec<(u64, u64)> = self
.track_uids
.iter()
.zip(self.track_bytes.iter())
.map(|(&uid, &bytes)| (uid, bytes))
.collect();
for (uid, bytes) in entries {
if bytes == 0 {
continue;
}
// bits per second = bytes * 8 / duration_secs, rounded to nearest.
let bps = ((bytes as f64) * 8.0 / self.duration_secs).round() as u64;
let tag_pos = ebml::start_master(&mut self.writer, ebml::TAG)?;
// Targets → TagTrackUID (this tag applies to one track).
let targets_pos = ebml::start_master(&mut self.writer, ebml::TARGETS)?;
ebml::write_uint(&mut self.writer, ebml::TAG_TRACK_UID, uid)?;
ebml::end_master(&mut self.writer, targets_pos)?;
// SimpleTag(TagName="BPS", TagString="<bps>").
let st_pos = ebml::start_master(&mut self.writer, ebml::SIMPLE_TAG)?;
ebml::write_string(&mut self.writer, ebml::TAG_NAME, "BPS")?;
ebml::write_string(&mut self.writer, ebml::TAG_STRING, &bps.to_string())?;
ebml::end_master(&mut self.writer, st_pos)?;
ebml::end_master(&mut self.writer, tag_pos)?;
}
ebml::end_master(&mut self.writer, tags_pos)?;
Ok(())
}
fn start_cluster(&mut self, ts_ticks: i64) -> io::Result<()> {
// Close previous cluster if open
if self.cluster_open {
@@ -1199,6 +1365,7 @@ mod tests {
colour_range: 0,
interlaced: false,
field_order: ebml::FIELD_ORDER_UNDETERMINED,
field_duration_ns: 0,
sample_rate: 0.0,
channels: 0,
bit_depth: 0,
@@ -1226,6 +1393,7 @@ mod tests {
colour_range: 0,
interlaced: false,
field_order: ebml::FIELD_ORDER_UNDETERMINED,
field_duration_ns: 0,
sample_rate: 48000.0,
channels: 6,
bit_depth: 0,
@@ -2986,12 +3154,12 @@ mod tests {
#[test]
fn video_emits_flag_interlaced_and_field_order() {
// An interlaced (576i PAL) track must emit FlagInterlaced=1 and
// FieldOrder=9 (bottom-field-first). A progressive track must emit
// FlagInterlaced=2 and NO FieldOrder.
// An interlaced track must emit FlagInterlaced=1 and its FieldOrder
// value. A progressive track must emit FlagInterlaced=2 and NO
// FieldOrder.
let mut interlaced = make_video_track();
interlaced.interlaced = true;
interlaced.field_order = ebml::FIELD_ORDER_BFF;
interlaced.field_order = ebml::FIELD_ORDER_TFF;
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[interlaced], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
let fi = find_id(&data, ebml::FLAG_INTERLACED).expect("FlagInterlaced present");
@@ -3004,8 +3172,8 @@ mod tests {
let fo = find_id(&data, ebml::FIELD_ORDER).expect("FieldOrder present");
assert_eq!(
data[fo + 2],
ebml::FIELD_ORDER_BFF,
"FieldOrder must be 9 (bottom-field-first) for PAL DVD"
ebml::FIELD_ORDER_TFF,
"FieldOrder value must round-trip through the writer"
);
// Progressive track: FlagInterlaced=2, no FieldOrder.
@@ -3030,6 +3198,161 @@ mod tests {
);
}
#[test]
fn video_576i_defaults_to_top_field_first() {
// PAL 576i must default to TFF (2), not BFF — the container element must
// agree with the MPEG-2 stream (MediaInfo reads "Top Field First" off
// the picture coding extension). The pre-rc.5.1 BFF(9) was a wrong value.
let v = VideoStream {
pid: 0xE0,
codec: Codec::Mpeg2,
resolution: Resolution::R576i,
frame_rate: crate::disc::FrameRate::F25,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt470bg,
display_aspect: Some((16, 9)),
secondary: false,
label: String::new(),
};
let t = MkvTrack::video(&v);
assert!(t.interlaced, "576i is interlaced");
assert_eq!(
t.field_order,
ebml::FIELD_ORDER_TFF,
"576i must default to top-field-first"
);
}
#[test]
fn interlaced_576i_emits_default_decoded_field_duration() {
// 576i @ 25 fps: DefaultDuration = 40 ms (frame), and
// DefaultDecodedFieldDuration = 20 ms (field = half a frame). The field
// element stops interlace-aware readers (Windows) halving the frame rate.
let v = VideoStream {
pid: 0xE0,
codec: Codec::Mpeg2,
resolution: Resolution::R576i,
frame_rate: crate::disc::FrameRate::F25,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt470bg,
display_aspect: None,
secondary: false,
label: String::new(),
};
let t = MkvTrack::video(&v);
assert_eq!(t.default_duration_ns, 40_000_000, "frame duration is 40 ms");
assert_eq!(t.field_duration_ns, 20_000_000, "field duration is 20 ms");
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
// DefaultDuration (frame) present and = 40 ms.
let dd = find_id(&data, ebml::DEFAULT_DURATION).expect("DefaultDuration present");
// [id 3B][size 0x84][4-byte value] — 40_000_000 needs 4 bytes.
let frame_ns = u32::from_be_bytes([data[dd + 4], data[dd + 5], data[dd + 6], data[dd + 7]]);
assert_eq!(frame_ns, 40_000_000, "DefaultDuration is the full frame");
// DefaultDecodedFieldDuration present and = 20 ms.
let fd =
find_id(&data, ebml::DEFAULT_DECODED_FIELD_DURATION).expect("field duration present");
let field_ns = u32::from_be_bytes([data[fd + 4], data[fd + 5], data[fd + 6], data[fd + 7]]);
assert_eq!(field_ns, 20_000_000, "field duration is half the frame");
}
#[test]
fn progressive_video_omits_field_duration() {
// A progressive track must NOT carry DefaultDecodedFieldDuration.
let t = make_video_track(); // progressive
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[t], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::DEFAULT_DECODED_FIELD_DURATION).is_none(),
"no field duration for progressive content"
);
}
#[test]
fn finalize_emits_per_track_bps_tags() {
// At finalize a Tags master with a per-track BPS SimpleTag is written.
// BPS = bytes*8/duration_secs. With a 10 s duration and a video frame of
// 1000 bytes, video BPS = 1000*8/10 = 800.
let tracks = [make_video_track(), make_audio_track()];
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, &tracks, None, 10.0, &[]).unwrap();
// Video keyframe 1000 bytes; audio frame 500 bytes.
muxer
.write_frame(0, 0, true, &vec![0xABu8; 1000], None)
.unwrap();
muxer
.write_frame(1, 0, false, &vec![0xCDu8; 500], None)
.unwrap();
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
// The Tags master must be present as a top-level Segment child.
let children = segment_children(&data);
assert!(
children.iter().any(|(id, _, _)| *id == ebml::TAGS),
"Tags element must be written at finalize"
);
// The BPS values must appear as TagString text. Video: 800, Audio: 400.
let text = String::from_utf8_lossy(&data);
assert!(text.contains("BPS"), "BPS TagName must be present");
assert!(
text.contains("800"),
"video BPS (1000*8/10) must be present"
);
assert!(text.contains("400"), "audio BPS (500*8/10) must be present");
}
#[test]
fn no_bps_tags_when_duration_unknown() {
// With duration 0 (unknown) the BPS rate can't be computed; no Tags.
let tracks = [make_video_track()];
let frames = vec![(0usize, 0i64, true, vec![0xABu8; 1000])];
let (data, _) = mux_to_bytes(&tracks, &[], &frames);
let children = segment_children(&data);
assert!(
!children.iter().any(|(id, _, _)| *id == ebml::TAGS),
"no Tags element when duration is unknown"
);
}
#[test]
fn ac3_channels_corrected_from_bitstream_acmod() {
// The audio track header claims 6 channels (IFO 5.1), but the AC-3
// bitstream's first frame has acmod=2 (2.0 stereo). The Channels element
// must be rewritten to 2 from the bitstream, not left at the IFO's 6.
let mut audio = make_audio_track(); // codec A_AC3, channels = 6
audio.channels = 6;
let video = make_video_track();
let shared = Arc::new(Mutex::new(Cursor::new(Vec::new())));
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, &[video, audio], None, 0.0, &[]).unwrap();
// A minimal AC-3 BSI with acmod=2 (2/0 stereo), no LFE → 2 channels.
// byte5 = bsid 8 (legacy AC-3). byte6: acmod(010) | dsurmod(00) |
// lfeon(0) = 0b0100_0000 = 0x40. acmod_channels only needs >= 8 bytes.
let ac3 = vec![0x0B, 0x77, 0x00, 0x00, 0x00, 8 << 3, 0x40, 0x00];
// Open a cluster with a video keyframe first (cluster invariant).
muxer.write_frame(0, 0, true, &[0x01, 0x02], None).unwrap();
muxer.write_frame(1, 0, false, &ac3, None).unwrap();
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
// Locate the Channels element (0x9F) WITHIN the Tracks body (so a stray
// 0x9F in cluster/AC-3 payload can't be mistaken for the element) and
// assert the value byte is 2.
let (tracks_start, tracks_size) = segment_children(&data)
.into_iter()
.find_map(|(id, off, sz)| (id == ebml::TRACKS).then_some((off, sz as usize)))
.expect("Tracks element present");
let tracks_body = &data[tracks_start..tracks_start + tracks_size];
let ch = find_id(tracks_body, ebml::CHANNELS).expect("Channels element present");
assert_eq!(
tracks_body[ch + 2],
2,
"Channels must be corrected to 2 (bitstream acmod), not 6 (IFO)"
);
}
#[test]
fn dolby_vision_track_emits_block_addition_mapping() {
// A DV track (dv_config set) must emit BlockAdditionMapping (0x41E4)
+25 -6
View File
@@ -64,6 +64,10 @@ pub struct PipelinedPesStream {
/// decrypt failure instead of reporting a perfect rip. `None` for pipelines
/// with no AACS decrypt step (e.g. the M2TS byte-stream path).
decrypt_loss: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
/// Count of dropped DVD navigation packets (private_stream_2, 0xBF). These
/// are expected on every disc; instead of a per-packet WARN they're tallied
/// and summarised once at EOF.
dropped_nav_packets: u64,
}
impl PipelinedPesStream {
@@ -93,6 +97,7 @@ impl PipelinedPesStream {
eof: false,
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
decrypt_loss: None,
dropped_nav_packets: 0,
}
}
@@ -174,12 +179,19 @@ impl PipelinedPesStream {
// subtitle sub-id 0x20+j with audio track j+1, feeding
// VobSub PES into the AC-3 parser.
let Some(pid) = ps.dvd_pid() else {
tracing::warn!(
target: "mux",
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
ps.stream_id,
ps.sub_stream_id,
);
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: its repetition is signal.
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()
@@ -227,6 +239,13 @@ impl Stream for PipelinedPesStream {
}
false => {
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
);
}
// Drain any access unit a parser buffered past the last
// PES (e.g. DTS-HD's final core+extension unit).
let pid_to_track = &self.pid_to_track;
+13
View File
@@ -24,6 +24,9 @@ const PROGRAM_END_ID: u8 = 0xB9;
/// Private stream 1 (AC3, DTS, LPCM, subtitles).
const PRIVATE_STREAM_1: u8 = 0xBD;
/// Private stream 2 (0xBF) — DVD navigation (PCI/DSI). Carries no muxable
/// elementary stream; expected to be dropped on every disc.
const PRIVATE_STREAM_2: u8 = 0xBF;
/// Hard cap on the demuxer's reassembly buffer. A length-0 (unbounded) video
/// PES is delimited by the next PS-layer boundary; if a corrupt stream declares
@@ -108,6 +111,16 @@ impl PsPacket {
_ => None,
}
}
/// Whether this is a DVD navigation packet (private_stream_2, 0xBF —
/// PCI/DSI). These carry no muxable elementary stream and are EXPECTED to
/// be dropped on every DVD, so a per-packet WARN is noise: the mux loops
/// count them and emit one finalize summary instead. A `dvd_pid()` of
/// `None` for any OTHER stream_id is unexpected (a possibly-dropped real
/// stream) and stays an individual WARN.
pub fn is_nav(&self) -> bool {
self.stream_id == PRIVATE_STREAM_2
}
}
/// MPEG-2 Program Stream demuxer.
+10
View File
@@ -219,6 +219,16 @@ impl ScsiSense {
self.sense_key == SENSE_KEY_ILLEGAL_REQUEST
}
/// `true` for sense `05/6F/03` — MMC "READ OF SCRAMBLED SECTOR WITHOUT
/// AUTHENTICATION". The drive is enforcing CSS and the bus-auth read gate
/// is not (or no longer) open. Unlike a bare ILLEGAL REQUEST, this is
/// positive proof the sector is CSS-scrambled — the CSS crack scan keys on
/// it to distinguish "encrypted but locked" from "unreadable", and must
/// never collapse it to "unencrypted".
pub fn is_css_locked(&self) -> bool {
self.sense_key == SENSE_KEY_ILLEGAL_REQUEST && self.asc == 0x6F && self.ascq == 0x03
}
/// `true` if `sense_key == ABORTED COMMAND (B)` — transient; one
/// retry is usually safe.
pub fn is_aborted_command(&self) -> bool {