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
+293 -4
View File
@@ -58,6 +58,122 @@ pub struct DvdTitle {
pub struct DvdCell {
pub first_sector: u32,
pub last_sector: u32,
/// Raw cell-category byte at `cell_playback + 0` (DVD-Video spec).
/// Packs cell_type (bits 7-6), block_mode (bits 5-4), block_type
/// (bits 3-2), seamless_play (bit 1), interleaved (bit 0). Carried so
/// the extent builder can recognise non-feature leading cells
/// (scene-index / interleaved angle sub-blocks) and the diagnostic dump
/// can show why a cell was kept or dropped.
pub category: u8,
/// Per-cell playback duration in seconds (BCD time at `cell_playback + 4`).
/// Used by the diagnostic dump and the conservative leading-cell filter
/// (a short leading scene-index cell vs the multi-minute feature).
pub duration_secs: f64,
}
/// Decoded view of a cell-category byte (`cell_playback + 0`), per the
/// DVD-Video spec `cell_playback_information` layout.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CellCategory {
/// bits 7-6: 0=normal, 1=first cell of angle block, 2=middle, 3=last.
pub cell_type: u8,
/// bits 5-4: 0=not in block, 1=first cell of block, 2=in block, 3=last.
pub block_mode: u8,
/// bits 3-2: 0=not part of a block, 1=angle block.
pub block_type: u8,
/// bit 1: seamless playback (STC continuous).
pub seamless_play: bool,
/// bit 0: interleaved (multi-angle / seamless-branch interleave).
pub interleaved: bool,
}
impl CellCategory {
/// Decode the raw `cell_playback + 0` byte.
pub fn decode(raw: u8) -> Self {
CellCategory {
cell_type: (raw >> 6) & 0x03,
block_mode: (raw >> 4) & 0x03,
block_type: (raw >> 2) & 0x03,
seamless_play: (raw & 0x02) != 0,
interleaved: (raw & 0x01) != 0,
}
}
/// A plain feature cell: not part of any angle/interleave block. Every
/// cell of a normal single-angle feature decodes to this (`category`
/// byte `0x00`, or `0x00` in every block field with only the
/// seamless/interleaved flags possibly set). Such a cell is NEVER
/// dropped by the leading-cell filter.
pub fn is_plain_feature(&self) -> bool {
self.cell_type == 0 && self.block_mode == 0 && self.block_type == 0
}
/// Marks a non-first piece of an angle / interleaved block: a "middle" or
/// "last" cell of an angle block (`cell_type ∈ {2,3}`), or an
/// in-block / last-of-block cell (`block_mode ∈ {2,3}`). Concatenating
/// these back-to-back with the first angle duplicates content at the head
/// of the feature. Conservative: the FIRST cell of a block
/// (`cell_type==1` / `block_mode==1`) is NOT flagged — it is the angle we
/// keep.
pub fn is_secondary_block_piece(&self) -> bool {
matches!(self.cell_type, 2 | 3) || matches!(self.block_mode, 2 | 3)
}
}
impl DvdTitle {
/// Index of the first cell to include in the muxed feature.
///
/// Bug-4 (scene-selection / logo at the head of the feature): the main
/// feature's PGC can open with leading cells that are NOT part of the
/// movie — a scene-index segment or an interleaved-angle sub-block. Those
/// are recognisable by their cell-category byte: a leading cell flagged as
/// a *secondary* piece of an angle/interleave block
/// ([`CellCategory::is_secondary_block_piece`]) is not feature content.
///
/// This walks the leading run and returns the index of the first cell that
/// is a plain feature cell (category `0x00`-class). Cells before it that
/// are secondary block pieces are dropped from the feature extents.
///
/// **Conservative by construction — it can NEVER truncate a normal
/// feature:**
/// - It only ever skips a *prefix*; the scan stops at the first
/// plain-feature cell and keeps everything from there on.
/// - A normal single-angle feature has category `0x00` on cell 0, so the
/// scan stops immediately at index 0 and drops nothing.
/// - It never drops on duration or any heuristic — only on the spec
/// category bits — and it never drops the FIRST cell of an angle block
/// (the angle we keep).
/// - As a final guard it never returns past the last cell, and never drops
/// when that would leave zero cells.
///
/// For "The Silence of the Lambs" (every feature cell category `0x00`,
/// chapter 1 at 00:00:00) this returns 0 — a no-op — which is the correct
/// result: the disc's scene-index lives in a separate menu/title PGC, not
/// in leading cells of the feature PGC, so there is nothing to drop here.
pub fn feature_start_cell(&self) -> usize {
let n = self.cells.len();
if n == 0 {
return 0;
}
let mut idx = 0;
while idx < n {
let cat = CellCategory::decode(self.cells[idx].category);
// Stop at the first cell that is genuine feature content.
if !cat.is_secondary_block_piece() {
break;
}
idx += 1;
}
// Never drop everything: if every leading cell looked like a secondary
// block piece (pathological/corrupt category bytes), fall back to
// keeping all cells rather than producing an empty feature.
if idx >= n { 0 } else { idx }
}
/// The feature cells after the leading-cell filter ([`feature_start_cell`]).
pub fn feature_cells(&self) -> &[DvdCell] {
&self.cells[self.feature_start_cell()..]
}
}
/// DVD TV system, from VTS_V_ATR `video_format` (byte 0 bits 5-4).
@@ -305,11 +421,25 @@ fn parse_vts(
return Err(Error::IfoParse);
}
// VTS_PGCIT sector pointer
let pgcit_sector = be_u32(&vts_data, 0xCC)?;
// VTSI_MAT (VTS_xx_0.IFO header) field offsets — fixed by the DVD-Video
// spec (libdvdread `vtsi_mat_t`). The offsets are constant; the sector
// values they point to are per-disc.
const VTSTT_VOBS_OFFSET: usize = 0xC4; // VTS title VOBS start sector (feature)
const VTS_PGCIT_OFFSET: usize = 0xCC; // VTS_PGCIT sector pointer
// First VOB sector
let vob_start_sector = be_u32(&vts_data, 0xC0)?;
// VTS_PGCIT sector pointer
let pgcit_sector = be_u32(&vts_data, VTS_PGCIT_OFFSET)?;
// First sector of the VTS **Title** VOBS (`vtstt_vobs`, VTSTT_VOBS_OFFSET).
// The cell `first_sector` / `last_sector` values in the title PGCs are
// relative to this. Offset 0xC0 is `vtsm_vobs` — the VTS *menu* VOBS
// (VTS_xx_0.VOB), which on discs with a per-title menu (e.g. a Universal
// "the parental level has been set, press yes" first-play still) holds that
// interactive prompt. Reading the menu base instead prepended the menu VOB
// to the feature and shifted every cell extent back by
// `vtstt_vobs - vtsm_vobs` sectors, so the rip opened on the parental
// prompt instead of the movie. The title content lives at `vtstt_vobs`.
let vob_start_sector = be_u32(&vts_data, VTSTT_VOBS_OFFSET)?;
// Video attributes at offset 0x200 (2 bytes)
let video = parse_video_attr(&vts_data)?;
@@ -617,11 +747,15 @@ fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle>
if co + 24 > data.len() {
break;
}
let category = byte_at(data, co)?;
let duration_secs = bcd_to_secs(&data[co + 4..co + 8]);
let first_sector = be_u32(data, co + 8)?;
let last_sector = be_u32(data, co + 20)?;
cells.push(DvdCell {
first_sector,
last_sector,
category,
duration_secs,
});
}
}
@@ -781,6 +915,8 @@ mod tests {
let cell = DvdCell {
first_sector: 100,
last_sector: 200,
category: 0,
duration_secs: 0.0,
};
assert_eq!(cell.first_sector, 100);
assert_eq!(cell.last_sector, 200);
@@ -1349,6 +1485,159 @@ mod tests {
assert!(title.cells.is_empty());
}
// ─────────────────────────────────────────────────────────────────────
// Cell-category decode + bug-4 leading-cell filter.
// ─────────────────────────────────────────────────────────────────────
fn cell(first: u32, last: u32, category: u8) -> DvdCell {
DvdCell {
first_sector: first,
last_sector: last,
category,
duration_secs: 0.0,
}
}
/// CellCategory decodes the spec bitfields: cell_type (7-6), block_mode
/// (5-4), block_type (3-2), seamless (1), interleaved (0).
#[test]
fn cell_category_decode_bits() {
// 0x00 → plain feature, nothing set.
let c = CellCategory::decode(0x00);
assert_eq!(c.cell_type, 0);
assert_eq!(c.block_mode, 0);
assert_eq!(c.block_type, 0);
assert!(!c.seamless_play);
assert!(!c.interleaved);
assert!(c.is_plain_feature());
assert!(!c.is_secondary_block_piece());
// cell_type=1 (first of angle block), block_mode=1 (first of block):
// 0b01_01_00_0_0 = 0x50. This is the angle we KEEP — not secondary.
let c = CellCategory::decode(0b01_01_00_00);
assert_eq!(c.cell_type, 1);
assert_eq!(c.block_mode, 1);
assert!(!c.is_plain_feature());
assert!(!c.is_secondary_block_piece());
// cell_type=2 (middle of angle block): 0b10_00_00_00 = 0x80 → secondary.
assert!(CellCategory::decode(0b10_00_00_00).is_secondary_block_piece());
// cell_type=3 (last of angle block) → secondary.
assert!(CellCategory::decode(0b11_00_00_00).is_secondary_block_piece());
// block_mode=2 (in block) → secondary; block_mode=3 (last of block) → secondary.
assert!(CellCategory::decode(0b00_10_00_00).is_secondary_block_piece());
assert!(CellCategory::decode(0b00_11_00_00).is_secondary_block_piece());
// seamless (bit1) + interleaved (bit0) on an otherwise-plain cell must
// NOT make it secondary — they don't mark non-feature content.
let c = CellCategory::decode(0b00_00_00_11);
assert!(c.seamless_play);
assert!(c.interleaved);
assert!(c.is_plain_feature());
assert!(!c.is_secondary_block_piece());
}
/// A normal single-angle feature (every cell category 0x00) is never
/// filtered: feature_start_cell == 0, feature_cells == all cells. This is
/// the "Silence of the Lambs" case — the filter must be a no-op.
#[test]
fn feature_filter_noop_on_plain_feature() {
let t = DvdTitle {
chapters: 3,
duration_secs: 6780.0,
cells: vec![
cell(0, 99, 0x00),
cell(100, 199, 0x00),
cell(200, 299, 0x00),
],
chapter_times: vec![0.0, 100.0, 200.0],
palette: None,
};
assert_eq!(t.feature_start_cell(), 0);
assert_eq!(t.feature_cells().len(), 3);
}
/// A leading interleaved/angle-block sub-cell (category marks a secondary
/// block piece) is dropped; the scan stops at the first plain cell and
/// keeps the rest.
#[test]
fn feature_filter_drops_leading_secondary_block_cells() {
let t = DvdTitle {
chapters: 2,
duration_secs: 100.0,
cells: vec![
cell(0, 9, 0b10_00_00_00), // middle of angle block → drop
cell(10, 19, 0b00_11_00_00), // last of block → drop
cell(20, 119, 0x00), // feature starts here
cell(120, 219, 0x00),
],
chapter_times: vec![0.0, 50.0],
palette: None,
};
assert_eq!(t.feature_start_cell(), 2);
let fc = t.feature_cells();
assert_eq!(fc.len(), 2);
assert_eq!(fc[0].first_sector, 20);
}
/// Conservative guard: if EVERY cell looks like a secondary block piece
/// (corrupt/pathological category bytes), the filter refuses to drop them
/// all — it returns 0 and keeps every cell rather than emit an empty
/// feature.
#[test]
fn feature_filter_never_empties_title() {
let t = DvdTitle {
chapters: 1,
duration_secs: 100.0,
cells: vec![cell(0, 9, 0b10_00_00_00), cell(10, 19, 0b11_00_00_00)],
chapter_times: vec![0.0],
palette: None,
};
assert_eq!(t.feature_start_cell(), 0);
assert_eq!(t.feature_cells().len(), 2);
}
/// An empty title (no cells) returns 0 and an empty slice — no panic.
#[test]
fn feature_filter_empty_cells() {
let t = DvdTitle {
chapters: 0,
duration_secs: 0.0,
cells: vec![],
chapter_times: vec![],
palette: None,
};
assert_eq!(t.feature_start_cell(), 0);
assert!(t.feature_cells().is_empty());
}
/// parse_pgc populates the new `category` + `duration_secs` cell fields
/// from `cell_playback + 0` and the BCD time at `cell_playback + 4`.
#[test]
fn pgc_reads_cell_category_and_duration() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x02] = 1;
pgc[0x03] = 2; // 2 cells
pgc[0xE8] = 0x00;
pgc[0xE9] = 0xEA;
pgc.resize(0xEA + 48, 0);
// Cell 0: category byte = 0x80 (middle of angle block), 5s BCD.
pgc[0xEA] = 0x80;
pgc[0xEA + 6] = 0x05;
pgc[0xEA + 8..0xEA + 12].copy_from_slice(&10u32.to_be_bytes());
// Cell 1: category 0x00 (plain feature), 7s BCD.
pgc[0xEA + 24] = 0x00;
pgc[0xEA + 24 + 6] = 0x07;
pgc[0xEA + 24 + 8..0xEA + 24 + 12].copy_from_slice(&20u32.to_be_bytes());
let title = parse_pgc(&pgc, 0, 2).unwrap();
assert_eq!(title.cells[0].category, 0x80);
assert!((title.cells[0].duration_secs - 5.0).abs() < 0.01);
assert_eq!(title.cells[1].category, 0x00);
assert!((title.cells[1].duration_secs - 7.0).abs() < 0.01);
// The leading secondary-block cell is filtered out of the feature.
assert_eq!(title.feature_start_cell(), 1);
}
/// Regression: a crafted IFO whose program-map byte names a first_cell
/// index larger than the actual cell count must NOT panic. Before the fix,
/// `cell_durations[..first_cell.saturating_sub(1)]` would panic with an