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
+76
View File
@@ -1,5 +1,81 @@
# Changelog # Changelog
## [1.0.0-rc.5.1]
### Fixed
- **CSS reads unlocked on enforcing drives.** CSS-protected DVDs on
drives that enforce CSS authentication previously produced an empty MKV
at exit 0, or hung indefinitely. The read path now issues the bus-auth
handshake (`css::auth::unlock_css_reads`) to unlock scrambled-sector
reads before attempting any data transfer, so the drive gates lift
correctly.
- **Keyless title-key recovery always runs.** The Stevenson known-plaintext
attack (`css::crack_key` / `src/css/stevenson.rs`) now recovers the
title key even when the bus-auth scan detects a CSS drive, removing a
code path that fell through to locked reads on certain disc/drive
combinations. A wrong key still fails cleanly (confirmed by a sector
descramble check) rather than producing silent garbage.
- **Early bail on undecryptable discs.** When CSS authentication succeeds
but no valid title key can be recovered, the mux path now terminates
with a clear error code instead of writing an empty (or zero-byte)
output file.
- **DVD audio channel count from AC-3 bitstream.** The audio channel count
is now parsed from the AC-3 elementary-stream bitfield rather than from
the IFO audio attributes, so the reported channel count always matches the
actual muxed audio even when the IFO attribute disagrees. Passthrough only
— no downmix is performed. (Selecting the correct audio sub-stream on discs
with non-standard ordering is a separate item — see Known issues.)
- **Interlaced MKV frame rate on Windows.** Interlaced content (576i/480i)
now emits a `DefaultDecodedFieldDuration` element in the MKV track
header, which Windows Media Foundation and Explorer use to derive the
display frame rate. Without it, players reported an incorrect or zero
frame rate on interlaced tracks.
- **Per-track `BPS` bitrate tags populated.** The `BPS` tag is written for
each track so players and shell extensions (Windows Explorer, MPC-HC,
etc.) can display the per-stream bitrate without reading the full file.
- **Interlaced field order corrected to TFF.** 576i tracks were written
with a bottom-field-first (BFF) container flag that disagreed with the
top-field-first order carried in the MPEG-2 stream; the MKV `FieldOrder`
element now matches the stream (TFF) so deinterlacers use the correct
field parity.
- **DVD first-play menu no longer prepended to the feature.** The title
VOBS base sector was read from the VTS menu-VOBS pointer (`vtsm_vobs`,
offset 0xC0) instead of the title-VOBS pointer (`vtstt_vobs`, 0xC4), so on
a disc that authors a per-title menu the entire menu VOB — e.g. a studio
first-play "the parental level has been set, press yes" prompt — was
prepended to the movie and every cell extent shifted back. The rip now
opens on the feature's first frame.
### Changed
- **AACS handshake skipped on DVDs.** The AACS authentication sequence is
no longer attempted on DVD discs (it never applied to CSS-encrypted
media); attempting it on a DVD drive was a no-op at best and surfaced
spurious errors at worst.
### Added
- **Structured disc diagnostics at `--log-level 3`.** A new diagnostic
pass emits structured log events at INFO level when the log level is 3
or higher: DVD PGC/cell layout and IFO video/audio attributes; BD/UHD
playlist, clip, and AACS metadata. Provides a single-command snapshot
for diagnosing mux or authentication issues without instrumenting the
source.
- **Reduced per-operation log spam.** Mux-read and seek operations are
demoted to TRACE (were DEBUG); benign navigation-packet drops are
summarized as a single counter at the end of the title rather than
logged per-packet.
### Known issues
- **Wrong audio track on discs with non-standard substream ordering.**
Audio sub-stream ids are assigned by per-codec ordinal rather than read
from the IFO/PGC stream-number table, so a disc whose physical substream
order diverges from the convention may select the wrong audio track
(e.g. a 2.0 stream in place of 5.1). Diagnose with
`freemkv info disc://… --log-level 3`; fix tracked for the next release.
## [1.0.0-rc.4.2] ## [1.0.0-rc.4.2]
### Fixed ### Fixed
+28 -58
View File
@@ -1,12 +1,12 @@
//! CSS drive bus-authentication — read-unlock primitive. //! CSS drive bus-authentication — read-unlock primitive.
//! //!
//! A CSS-enforcing DVD drive refuses to return scrambled sectors until a //! A CSS-enforcing DVD drive refuses to return scrambled sectors until a
//! CSS bus-auth handshake has run for the title. [`unlock_css_reads`] //! CSS bus-auth handshake has set its Authentication Success Flag (ASF=1).
//! issues that classic handshake (bus auth → disc-key REPORT KEY → bus //! [`unlock_css_reads`] runs that bus-auth challenge-response (which is what
//! auth → title-key REPORT KEY) purely for its SCSI side effect of //! actually opens scrambled-sector reads), then a best-effort, non-fatal
//! unlocking scrambled-sector reads. The bytes the handshake returns are //! disc-key REPORT KEY. The bytes are NOT used as keys: the descramble title
//! NOT used as keys: the descramble title key is recovered keylessly by //! key is recovered keylessly by the Stevenson known-plaintext attack (see
//! the Stevenson known-plaintext attack (see [`super::crack_key`]). //! [`super::crack_key`]).
use crate::drive::Drive; use crate::drive::Drive;
use crate::error::{Error, Result}; use crate::error::{Error, Result};
@@ -119,11 +119,12 @@ const PERM_VARIANT: [[u8; 32]; 2] = [
/// CSS bus-auth **unlock** primitive. /// CSS bus-auth **unlock** primitive.
/// ///
/// Issues the full classic CSS handshake (bus auth → disc-key REPORT KEY → /// Runs the bus-auth challenge-response (which sets the drive's ASF=1 and is
/// bus auth → title-key REPORT KEY) purely to unlock the drive's /// what actually unlocks scrambled-sector reads), then a best-effort,
/// scrambled-sector read gating. The bytes returned by the handshake are /// non-fatal disc-key REPORT KEY. The title-key REPORT KEY is NOT issued: it
/// discarded — the descramble title key is recovered keylessly elsewhere /// is unnecessary (the descramble key is recovered keylessly by the Stevenson
/// (the Stevenson known-plaintext attack in [`super::crack_key`]). /// 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<()> { pub fn unlock_css_reads(drive: &mut Drive, lba: u32) -> Result<()> {
let t0 = std::time::Instant::now(); let t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::css", phase = "unlock_css_reads", lba, "begin"); 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 r
} }
fn unlock_css_reads_inner(drive: &mut Drive, lba: u32) -> Result<()> { fn unlock_css_reads_inner(drive: &mut Drive, _lba: u32) -> Result<()> {
tracing::debug!(target: "freemkv::css", lba, "css unlock: begin"); tracing::debug!(target: "freemkv::css", "css unlock: begin");
// Session 1: bus auth → disc-key REPORT KEY (AGID consumed by // The bus-auth challenge-response sets the drive's Authentication Success
// READ_DVD_STRUCTURE). The block contents are unused; this is issued // Flag (ASF=1), which is what opens scrambled-sector reads. This is the
// purely for the bus-auth unlock side effect. // 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| { 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::warn!(target: "freemkv::css", error_code = e.code(), "css unlock: bus_auth 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::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"); tracing::debug!(target: "freemkv::css", "css unlock: ok");
Ok(()) Ok(())
} }
@@ -311,34 +309,6 @@ fn read_disc_key(drive: &mut Drive, agid: u8) -> Result<()> {
Ok(()) 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 ─────────────────────────────────────────────────────────── // ── CSSCryptKey ───────────────────────────────────────────────────────────
fn crypt_key(key_type: usize, variant: u8, challenge: &[u8; 10]) -> [u8; 5] { 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::disc::Extent;
use crate::sector::SectorSource; 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. /// CSS decryption state for a DVD title.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct CssState { pub struct CssState {
@@ -105,7 +112,7 @@ pub fn crack_key_outcome(
batch_sectors: u16, batch_sectors: u16,
halt: Option<&crate::halt::Halt>, halt: Option<&crate::halt::Halt>,
) -> CrackOutcome { ) -> 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. /// [`crack_key`] with an optional cooperative-cancellation token.
@@ -122,7 +129,7 @@ pub fn crack_key_halt(
batch_sectors: u16, batch_sectors: u16,
halt: Option<&crate::halt::Halt>, halt: Option<&crate::halt::Halt>,
) -> Option<CssState> { ) -> 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 /// The crack scan, returning the full [`CrackOutcome`]. Tracks a
@@ -134,6 +141,10 @@ fn crack_key_scan(
extents: &[Extent], extents: &[Extent],
batch_sectors: u16, batch_sectors: u16,
halt: Option<&crate::halt::Halt>, 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 { ) -> CrackOutcome {
// Batch the reads: a live optical drive at 1 sector/read is glacial, and the // 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 // 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 // NOT silently treat as unencrypted (which would mux scrambled MPEG as
// plaintext → garbage at exit 0). See `CrackOutcome::ScrambledUncracked`. // plaintext → garbage at exit 0). See `CrackOutcome::ScrambledUncracked`.
let mut saw_scrambled = false; 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() { 'outer: for (extent_idx, ext) in extents.iter().enumerate() {
let mut i = 0u32; let mut i = 0u32;
@@ -189,6 +208,8 @@ fn crack_key_scan(
let want = n as usize * 2048; let want = n as usize * 2048;
match reader.read_sectors(ext.start_lba + i, n as u16, &mut buf[..want], true) { match reader.read_sectors(ext.start_lba + i, n as u16, &mut buf[..want], true) {
Ok(_) => { Ok(_) => {
// A readable batch: the gate is open — reset the locked run.
consecutive_locked = 0;
for s in 0..n as usize { for s in 0..n as usize {
tried += 1; tried += 1;
let sect = &buf[s * 2048..(s + 1) * 2048]; 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 // A failed batch still counts toward the budget so a damaged
// damaged region can't loop forever; skip ahead by the batch. // region can't loop forever. A CSS-locked failure (`05/6F/03`)
Err(_) => tried += n, // 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; i += n;
} }
} }
// Budget exhausted / extents walked with no key recovered. Distinguish the // Budget exhausted / extents walked / early-bailed with no key recovered.
// two indistinguishable-in-`Option` cases: if scrambled sectors were seen // The disc is ENCRYPTED-but-uncracked (a hard failure on the initial scan)
// (case b: crack failed; case c: scrambled but the crackable region was // when EITHER a scrambled sector was actually seen, OR — on the initial scan
// unreadable), this is encrypted-but-uncracked — a hard failure. Only a // only (`fail_on_locked`) — every read was CSS-locked (`05/6F/03`), itself
// scan that NEVER saw a scrambled sector is genuinely unencrypted (case a). // proof of scrambling. A re-crack (`fail_on_locked` false) stays soft: a
if saw_scrambled { // 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 CrackOutcome::ScrambledUncracked
} else { } else {
CrackOutcome::Unencrypted CrackOutcome::Unencrypted
@@ -309,6 +346,9 @@ mod tests {
reads: std::cell::RefCell<Vec<u32>>, reads: std::cell::RefCell<Vec<u32>>,
flag_byte: u8, flag_byte: u8,
fail_all: bool, 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 { impl MockSource {
@@ -317,6 +357,7 @@ mod tests {
reads: std::cell::RefCell::new(Vec::new()), reads: std::cell::RefCell::new(Vec::new()),
flag_byte, flag_byte,
fail_all: false, fail_all: false,
lock_all: false,
} }
} }
} }
@@ -330,6 +371,17 @@ mod tests {
_recovery: bool, _recovery: bool,
) -> Result<usize> { ) -> Result<usize> {
self.reads.borrow_mut().push(lba); 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 { if self.fail_all {
return Err(Error::DecryptFailed); 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 budget spans ALL extents, not per-extent: two extents summing past
/// the cap must still stop at 50_000 total reads. /// 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; let mut title_number: u16 = 0;
for ts in &dvd_info.title_sets { 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 { let video_stream = Stream::Video(VideoStream {
pid: 0xE0, // DVD video PID (standard MPEG PS video stream) pid: 0xE0, // DVD video PID (standard MPEG PS video stream)
codec: ts.video.codec, codec: ts.video.codec,
@@ -82,9 +86,36 @@ impl Disc {
for dvd_title in &ts.titles { for dvd_title in &ts.titles {
title_number += 1; 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) // Build extents from cell sector ranges (absolute = vob_start + cell offset)
let extents: Vec<Extent> = dvd_title let extents: Vec<Extent> = dvd_title
.cells .feature_cells()
.iter() .iter()
.map(|cell| { .map(|cell| {
let start = ts.vob_start_sector.saturating_add(cell.first_sector); 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(audio_streams.iter().cloned());
streams.extend(subtitle_streams); 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 let chapters: Vec<Chapter> = dvd_title
.chapter_times .chapter_times
.iter() .iter()
.enumerate() .enumerate()
.map(|(i, &t)| Chapter { .map(|(i, &t)| Chapter {
time_secs: t, time_secs: (t - dropped_secs).max(0.0),
name: chapter_name(i), name: chapter_name(i),
}) })
.collect(); .collect();
@@ -342,16 +377,25 @@ mod tests {
d 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. /// first_sector(u32 BE)@8, last_sector(u32 BE)@20.
fn write_cell(buf: &mut [u8], off: usize, first_sector: u32, last_sector: u32) { 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 + 8..off + 12].copy_from_slice(&first_sector.to_be_bytes());
buf[off + 20..off + 24].copy_from_slice(&last_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: /// Build a VTS_XX_0.IFO. Layout per ifo.rs:
/// magic "DVDVIDEO-VTS"@0 /// 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 /// VTS_PGCIT sector ptr(u32 BE)@0xCC
/// video attr byte@0x200 /// video attr byte@0x200
/// num_audio(u16 BE)@0x202, audio blocks (8B) @0x204 /// num_audio(u16 BE)@0x202, audio blocks (8B) @0x204
@@ -377,7 +421,7 @@ mod tests {
let pgcit_sector = 2u32; let pgcit_sector = 2u32;
let mut d = vec![0u8; 4 * 2048]; let mut d = vec![0u8; 4 * 2048];
d[0..12].copy_from_slice(b"DVDVIDEO-VTS"); 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[0xCC..0xD0].copy_from_slice(&pgcit_sector.to_be_bytes());
d[0x200] = video_b0; d[0x200] = video_b0;
d[0x202..0x204].copy_from_slice(&(audio.len() as u16).to_be_bytes()); 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); 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 /// Multi-cell title: extents preserve cell order and each maps to its
/// own (vob_start + first .. last) range. mux reads cells in order. /// own (vob_start + first .. last) range. mux reads cells in order.
#[test] #[test]
@@ -825,4 +919,143 @@ mod tests {
assert_eq!(t.chapters.len(), 1); assert_eq!(t.chapters.len(), 1);
assert_eq!(t.chapters[0].name, chapter_name(0)); 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);
}
} }
+20 -3
View File
@@ -1291,8 +1291,16 @@ impl Disc {
// AACS handshake (Blu-ray/UHD). Acquires the Volume ID via the // AACS handshake (Blu-ray/UHD). Acquires the Volume ID via the
// cert-based mutual-auth handshake (the OEM route); drive unlock // cert-based mutual-auth handshake (the OEM route); drive unlock
// itself runs separately behind the pluggable `Unlocker` seam. // itself runs separately behind the pluggable `Unlocker` seam.
// 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"); tracing::info!(target: "freemkv::scan", "phase: AACS handshake");
let (handshake, handshake_error) = Self::do_handshake(session, opts); Self::do_handshake(session, opts)
};
tracing::info!(target: "freemkv::scan", handshake = handshake.is_some(), "phase: handshake done"); tracing::info!(target: "freemkv::scan", handshake = handshake.is_some(), "phase: handshake done");
// Request max read speed — removes riplock on DVD // Request max read speed — removes riplock on DVD
@@ -1660,7 +1668,7 @@ impl Disc {
elapsed_ms = scan_with_t0.elapsed().as_millis() as u64, elapsed_ms = scan_with_t0.elapsed().as_millis() as u64,
"end" "end"
); );
Ok(Disc { let disc = Disc {
volume_id: udf_fs.volume_id.clone(), volume_id: udf_fs.volume_id.clone(),
meta_title, meta_title,
format, format,
@@ -1677,7 +1685,16 @@ impl Disc {
// which set this when they observe scrambled-but-uncracked content. // which set this when they observe scrambled-but-uncracked content.
css_error: None, css_error: None,
content_format, 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 ──────────────────────────────────────────────────── // ── Internal helpers ────────────────────────────────────────────────────
+1 -1
View File
@@ -370,7 +370,7 @@ impl Drive {
} }
/// True when the mounted disc is a DVD (profile family `0x0010..=0x001F`). /// 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)) 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 struct DvdCell {
pub first_sector: u32, pub first_sector: u32,
pub last_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). /// 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); return Err(Error::IfoParse);
} }
// VTS_PGCIT sector pointer // VTSI_MAT (VTS_xx_0.IFO header) field offsets — fixed by the DVD-Video
let pgcit_sector = be_u32(&vts_data, 0xCC)?; // 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 // VTS_PGCIT sector pointer
let vob_start_sector = be_u32(&vts_data, 0xC0)?; 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) // Video attributes at offset 0x200 (2 bytes)
let video = parse_video_attr(&vts_data)?; 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() { if co + 24 > data.len() {
break; 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 first_sector = be_u32(data, co + 8)?;
let last_sector = be_u32(data, co + 20)?; let last_sector = be_u32(data, co + 20)?;
cells.push(DvdCell { cells.push(DvdCell {
first_sector, first_sector,
last_sector, last_sector,
category,
duration_secs,
}); });
} }
} }
@@ -781,6 +915,8 @@ mod tests {
let cell = DvdCell { let cell = DvdCell {
first_sector: 100, first_sector: 100,
last_sector: 200, last_sector: 200,
category: 0,
duration_secs: 0.0,
}; };
assert_eq!(cell.first_sector, 100); assert_eq!(cell.first_sector, 100);
assert_eq!(cell.last_sector, 200); assert_eq!(cell.last_sector, 200);
@@ -1349,6 +1485,159 @@ mod tests {
assert!(title.cells.is_empty()); 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 /// 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, /// index larger than the actual cell count must NOT panic. Before the fix,
/// `cell_durations[..first_cell.saturating_sub(1)]` would panic with an /// `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 first_err: Option<Error> = None;
let mut stopped = false; 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() { while let Ok(item) = rx.recv() {
let debug = debug_enabled(); let debug = debug_enabled();
if debug { if debug {
@@ -338,21 +348,50 @@ impl<I: Send + 'static, R: Send + 'static> Pipeline<I, R> {
if let Some(start) = apply_start { if let Some(start) = apply_start {
let apply_elapsed = start.elapsed(); let apply_elapsed = start.elapsed();
if apply_elapsed > Duration::from_millis(100) { 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!( tracing::debug!(
"Pipeline apply: took {:.2}s, item={}", "Pipeline apply: took {:.2}s, item={}",
apply_elapsed.as_secs_f64(), apply_elapsed.as_secs_f64(),
std::any::type_name::<I>() 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!( tracing::debug!(
"Pipeline apply: OK in {:.3}ms, item={}", "Pipeline apply summary: {} items in {:.1}s, avg {:.3}ms, {:.0} items/s, type={}",
apply_elapsed.as_micros(), summary_count,
secs,
avg_ms,
summary_count as f64 / secs.max(1e-9),
std::any::type_name::<I>() 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 // Final abandonment check: the common leak case is a
// consumer wedged inside `apply` (a blocking write // consumer wedged inside `apply` (a blocking write
// syscall). When that syscall finally returns, the // 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 { if let Some(start) = start {
let elapsed = start.elapsed(); let elapsed = start.elapsed();
if elapsed > Duration::from_millis(10) { if elapsed > Duration::from_millis(10) {
// BLOCKED event — back-pressure stall. Keep visible.
tracing::debug!( tracing::debug!(
"Pipeline send: blocked {:.2}s, item={}", "Pipeline send: blocked {:.2}s, item={}",
elapsed.as_secs_f64(), elapsed.as_secs_f64(),
std::any::type_name::<I>() std::any::type_name::<I>()
); );
} else { } 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(()) Ok(())
+22 -1
View File
@@ -101,6 +101,13 @@ pub(crate) struct WritebackFile {
file: File, file: File,
pipeline: WritebackPipeline, pipeline: WritebackPipeline,
pos: u64, 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 { impl WritebackFile {
@@ -115,6 +122,8 @@ impl WritebackFile {
file, file,
pipeline, pipeline,
pos, pos,
seek_count: 0,
seek_bytes: 0,
}) })
} }
@@ -176,6 +185,14 @@ impl WritebackFile {
/// then external commit/DB update) must not treat `Ok(())` as a /// then external commit/DB update) must not treat `Ok(())` as a
/// durability barrier. /// durability barrier.
pub(crate) fn sync_all(&mut self) -> io::Result<()> { 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(); self.pipeline.finalize();
platform::durable_sync(&self.file) platform::durable_sync(&self.file)
} }
@@ -219,10 +236,14 @@ impl Seek for WritebackFile {
let from_pos = self.pos; let from_pos = self.pos;
let to_pos = p; let to_pos = p;
let delta: i64 = (to_pos as i64).wrapping_sub(from_pos as i64); 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", target: "mux",
"WritebackFile seek from={from_pos} to={to_pos} delta={delta}" "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.pipeline.handle_seek(p);
self.pos = p; self.pos = p;
} }
+1
View File
@@ -89,6 +89,7 @@ pub mod aacs;
pub(crate) mod clpi; pub(crate) mod clpi;
pub mod css; pub mod css;
pub mod decrypt; pub mod decrypt;
pub mod diag;
pub mod disc; pub mod disc;
pub mod drive; pub mod drive;
pub mod error; 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 (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. /// Find AC3/E-AC-3 syncword (0x0B77) in data.
fn find_ac3_sync(data: &[u8]) -> Option<usize> { fn find_ac3_sync(data: &[u8]) -> Option<usize> {
(0..data.len().saturating_sub(1)).find(|&i| data[i] == 0x0B && data[i + 1] == 0x77) (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()); 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 // helper: PES with a generic pts for E-AC-3 tests
fn make_eac3_pes(data: Vec<u8>) -> PesPacket { fn make_eac3_pes(data: Vec<u8>) -> PesPacket {
PesPacket { PesPacket {
+28
View File
@@ -161,6 +161,10 @@ pub struct DiscStream {
halt: Option<Halt>, halt: Option<Halt>,
event_fn: Option<Box<dyn Fn(Event) + Send>>, event_fn: Option<Box<dyn Fn(Event) + Send>>,
eof: bool, 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 // Cumulative bytes successfully read from the source. Drives
// EventKind::BytesRead emission and autorip's per-device progress. // EventKind::BytesRead emission and autorip's per-device progress.
@@ -284,6 +288,7 @@ impl DiscStream {
halt: None, halt: None,
event_fn: None, event_fn: None,
eof: false, eof: false,
dropped_nav_packets: 0,
bytes_read_total: 0, bytes_read_total: 0,
bytes_total_extents, bytes_total_extents,
ts_demuxer, ts_demuxer,
@@ -578,6 +583,13 @@ impl crate::pes::Stream for DiscStream {
let t0 = self.profiling.then(std::time::Instant::now); let t0 = self.profiling.then(std::time::Instant::now);
if !self.fill_extents()? { if !self.fill_extents()? {
self.eof = true; 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 // Flush demuxer — last PES packet may still be in the assembler
if let Some(ref mut demuxer) = self.ts_demuxer { if let Some(ref mut demuxer) = self.ts_demuxer {
for pes in &demuxer.flush() { for pes in &demuxer.flush() {
@@ -603,12 +615,20 @@ impl crate::pes::Stream for DiscStream {
// pipelined_stream.rs); the old (sub_id & 0x1F)+1 // pipelined_stream.rs); the old (sub_id & 0x1F)+1
// heuristic mis-routed VobSub into the AC-3 parser. // heuristic mis-routed VobSub into the AC-3 parser.
let Some(pid) = ps.dvd_pid() else { 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!( tracing::warn!(
target: "mux", target: "mux",
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})", "dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
ps.stream_id, ps.stream_id,
ps.sub_stream_id, ps.sub_stream_id,
); );
}
continue; continue;
}; };
let Some((_, track)) = let Some((_, track)) =
@@ -714,12 +734,20 @@ impl crate::pes::Stream for DiscStream {
// pipelined_stream.rs); the old (sub_id & 0x1F)+1 // pipelined_stream.rs); the old (sub_id & 0x1F)+1
// heuristic mis-routed VobSub into the AC-3 parser. // heuristic mis-routed VobSub into the AC-3 parser.
let Some(pid) = ps.dvd_pid() else { 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!( tracing::warn!(
target: "mux", target: "mux",
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})", "dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
ps.stream_id, ps.stream_id,
ps.sub_stream_id, ps.sub_stream_id,
); );
}
continue; continue;
}; };
let Some((_, track)) = let Some((_, track)) =
+21
View File
@@ -419,6 +419,11 @@ pub const CODEC_ID: u32 = 0x86;
pub const CODEC_PRIVATE: u32 = 0x63A2; pub const CODEC_PRIVATE: u32 = 0x63A2;
pub const TRACK_NAME: u32 = 0x536E; pub const TRACK_NAME: u32 = 0x536E;
pub const DEFAULT_DURATION: u32 = 0x23_E383; 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 // Video
pub const VIDEO: u32 = 0xE0; 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 // NTSC DVD (480i) and HD (1080i) are top-field-first; PAL DVD (576i) is
// bottom-field-first. 0xFF is our sentinel for "undetermined / omit". // bottom-field-first. 0xFF is our sentinel for "undetermined / omit".
pub const FIELD_ORDER_TFF: u8 = 2; 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_BFF: u8 = 9;
pub const FIELD_ORDER_UNDETERMINED: u8 = 0xFF; pub const FIELD_ORDER_UNDETERMINED: u8 = 0xFF;
pub const DISPLAY_WIDTH: u32 = 0x54B0; 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_TRACK: u32 = 0xF7;
pub const CUE_CLUSTER_POSITION: u32 = 0xF1; 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 // Chapters
pub const CHAPTERS: u32 = 0x1043_A770; pub const CHAPTERS: u32 = 0x1043_A770;
pub const EDITION_ENTRY: u32 = 0x45B9; 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. // meaningful when interlaced; `FIELD_ORDER_UNDETERMINED` omits it.
pub interlaced: bool, pub interlaced: bool,
pub field_order: u8, 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 // Audio-specific
pub sample_rate: f64, pub sample_rate: f64,
pub channels: u8, pub channels: u8,
@@ -131,17 +134,25 @@ impl MkvTrack {
colour_primaries: primaries, colour_primaries: primaries,
colour_range: range, colour_range: range,
interlaced: v.resolution.is_interlaced(), interlaced: v.resolution.is_interlaced(),
// PAL DVD (576i) is bottom-field-first; NTSC DVD (480i) is // PAL DVD (576i), NTSC DVD (480i), and HD interlaced (1080i) are
// top-field-first. HD interlaced (1080i) is top-field-first. // all top-field-first ("almost everything but DV is TFF"). MediaInfo
// Progressive content leaves the field order undetermined. // 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() { field_order: if v.resolution.is_interlaced() {
match v.resolution { ebml::FIELD_ORDER_TFF
Resolution::R576i => ebml::FIELD_ORDER_BFF,
_ => ebml::FIELD_ORDER_TFF,
}
} else { } else {
ebml::FIELD_ORDER_UNDETERMINED 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, sample_rate: 0.0,
channels: 0, channels: 0,
bit_depth: 0, bit_depth: 0,
@@ -214,6 +225,7 @@ impl MkvTrack {
colour_range: 0, colour_range: 0,
interlaced: false, interlaced: false,
field_order: ebml::FIELD_ORDER_UNDETERMINED, field_order: ebml::FIELD_ORDER_UNDETERMINED,
field_duration_ns: 0,
sample_rate: sr, sample_rate: sr,
channels: ch, channels: ch,
bit_depth: 0, bit_depth: 0,
@@ -249,6 +261,7 @@ impl MkvTrack {
colour_range: 0, colour_range: 0,
interlaced: false, interlaced: false,
field_order: ebml::FIELD_ORDER_UNDETERMINED, field_order: ebml::FIELD_ORDER_UNDETERMINED,
field_duration_ns: 0,
sample_rate: 0.0, sample_rate: 0.0,
channels: 0, channels: 0,
bit_depth: 0, bit_depth: 0,
@@ -305,6 +318,33 @@ pub struct MkvMuxer<W: Write + Seek> {
info_offset: u64, info_offset: u64,
tracks_offset: u64, tracks_offset: u64,
chapters_offset: Option<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). /// 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_start = writer.stream_position()?;
let tracks_offset = tracks_start - segment_start; let tracks_offset = tracks_start - segment_start;
let tracks_pos = ebml::start_master(&mut writer, ebml::TRACKS)?; 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() { 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)?; 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_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::TRACK_TYPE, track.track_type)?;
ebml::write_uint(&mut writer, ebml::FLAG_LACING, 0)?; ebml::write_uint(&mut writer, ebml::FLAG_LACING, 0)?;
ebml::write_string(&mut writer, ebml::CODEC_ID, track.codec_id)?; 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 // Video-specific
if track.track_type == ebml::TRACK_TYPE_VIDEO && track.pixel_width > 0 { if track.track_type == ebml::TRACK_TYPE_VIDEO && track.pixel_width > 0 {
let vid_pos = ebml::start_master(&mut writer, ebml::VIDEO)?; 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 // Omit Channels when unknown (0) — Matroska defaults it to 1
// rather than us fabricating a 6-channel count. // rather than us fabricating a 6-channel count.
if track.channels > 0 { 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)?; 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 { if track.bit_depth > 0 {
ebml::write_uint(&mut writer, ebml::BIT_DEPTH, track.bit_depth as u64)?; 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, info_offset,
tracks_offset, tracks_offset,
chapters_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; 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(()) Ok(())
} }
@@ -1015,6 +1133,12 @@ impl<W: Write + Seek> MkvMuxer<W> {
ebml::end_master(&mut self.writer, cues_pos)?; 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. // Back-patch SeekHead SeekPosition values now that all element offsets are known.
for fixup in &self.seek_fixups { for fixup in &self.seek_fixups {
let offset = match fixup.target_id { let offset = match fixup.target_id {
@@ -1036,6 +1160,48 @@ impl<W: Write + Seek> MkvMuxer<W> {
Ok(()) 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<()> { fn start_cluster(&mut self, ts_ticks: i64) -> io::Result<()> {
// Close previous cluster if open // Close previous cluster if open
if self.cluster_open { if self.cluster_open {
@@ -1199,6 +1365,7 @@ mod tests {
colour_range: 0, colour_range: 0,
interlaced: false, interlaced: false,
field_order: ebml::FIELD_ORDER_UNDETERMINED, field_order: ebml::FIELD_ORDER_UNDETERMINED,
field_duration_ns: 0,
sample_rate: 0.0, sample_rate: 0.0,
channels: 0, channels: 0,
bit_depth: 0, bit_depth: 0,
@@ -1226,6 +1393,7 @@ mod tests {
colour_range: 0, colour_range: 0,
interlaced: false, interlaced: false,
field_order: ebml::FIELD_ORDER_UNDETERMINED, field_order: ebml::FIELD_ORDER_UNDETERMINED,
field_duration_ns: 0,
sample_rate: 48000.0, sample_rate: 48000.0,
channels: 6, channels: 6,
bit_depth: 0, bit_depth: 0,
@@ -2986,12 +3154,12 @@ mod tests {
#[test] #[test]
fn video_emits_flag_interlaced_and_field_order() { fn video_emits_flag_interlaced_and_field_order() {
// An interlaced (576i PAL) track must emit FlagInterlaced=1 and // An interlaced track must emit FlagInterlaced=1 and its FieldOrder
// FieldOrder=9 (bottom-field-first). A progressive track must emit // value. A progressive track must emit FlagInterlaced=2 and NO
// FlagInterlaced=2 and NO FieldOrder. // FieldOrder.
let mut interlaced = make_video_track(); let mut interlaced = make_video_track();
interlaced.interlaced = true; 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 muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[interlaced], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner(); let data = muxer.writer.into_inner();
let fi = find_id(&data, ebml::FLAG_INTERLACED).expect("FlagInterlaced present"); 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"); let fo = find_id(&data, ebml::FIELD_ORDER).expect("FieldOrder present");
assert_eq!( assert_eq!(
data[fo + 2], data[fo + 2],
ebml::FIELD_ORDER_BFF, ebml::FIELD_ORDER_TFF,
"FieldOrder must be 9 (bottom-field-first) for PAL DVD" "FieldOrder value must round-trip through the writer"
); );
// Progressive track: FlagInterlaced=2, no FieldOrder. // 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] #[test]
fn dolby_vision_track_emits_block_addition_mapping() { fn dolby_vision_track_emits_block_addition_mapping() {
// A DV track (dv_config set) must emit BlockAdditionMapping (0x41E4) // A DV track (dv_config set) must emit BlockAdditionMapping (0x41E4)
+19
View File
@@ -64,6 +64,10 @@ pub struct PipelinedPesStream {
/// decrypt failure instead of reporting a perfect rip. `None` for pipelines /// decrypt failure instead of reporting a perfect rip. `None` for pipelines
/// with no AACS decrypt step (e.g. the M2TS byte-stream path). /// with no AACS decrypt step (e.g. the M2TS byte-stream path).
decrypt_loss: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>, 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 { impl PipelinedPesStream {
@@ -93,6 +97,7 @@ impl PipelinedPesStream {
eof: false, eof: false,
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(), skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
decrypt_loss: None, decrypt_loss: None,
dropped_nav_packets: 0,
} }
} }
@@ -174,12 +179,19 @@ impl PipelinedPesStream {
// subtitle sub-id 0x20+j with audio track j+1, feeding // subtitle sub-id 0x20+j with audio track j+1, feeding
// VobSub PES into the AC-3 parser. // VobSub PES into the AC-3 parser.
let Some(pid) = ps.dvd_pid() else { 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: its repetition is signal.
tracing::warn!( tracing::warn!(
target: "mux", target: "mux",
"dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})", "dropping unmappable PS packet (stream_id={:#04x}, sub_stream_id={:?})",
ps.stream_id, ps.stream_id,
ps.sub_stream_id, ps.sub_stream_id,
); );
}
continue; continue;
}; };
let Some((_, track)) = self.pid_to_track.iter().find(|(p, _)| *p == pid).copied() let Some((_, track)) = self.pid_to_track.iter().find(|(p, _)| *p == pid).copied()
@@ -227,6 +239,13 @@ impl Stream for PipelinedPesStream {
} }
false => { false => {
self.eof = true; 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 // Drain any access unit a parser buffered past the last
// PES (e.g. DTS-HD's final core+extension unit). // PES (e.g. DTS-HD's final core+extension unit).
let pid_to_track = &self.pid_to_track; 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). /// Private stream 1 (AC3, DTS, LPCM, subtitles).
const PRIVATE_STREAM_1: u8 = 0xBD; 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 /// 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 /// PES is delimited by the next PS-layer boundary; if a corrupt stream declares
@@ -108,6 +111,16 @@ impl PsPacket {
_ => None, _ => 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. /// MPEG-2 Program Stream demuxer.
+10
View File
@@ -219,6 +219,16 @@ impl ScsiSense {
self.sense_key == SENSE_KEY_ILLEGAL_REQUEST 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 /// `true` if `sense_key == ABORTED COMMAND (B)` — transient; one
/// retry is usually safe. /// retry is usually safe.
pub fn is_aborted_command(&self) -> bool { pub fn is_aborted_command(&self) -> bool {