Files
libfreemkv/src/ifo.rs
T
Matthew Jackson 794d88f6e7 libfreemkv: rc.5.2 DVD test coverage — depth-aware mux, colour codes, CSS scan
Implements the rc.5.2 quick-units list from the DVD coverage audit and
corrects the "passes-but-encodes-the-bug" tests that could not
distinguish correct from wrong behaviour.

New tests (each with the bug it guards):

mux/mkv.rs
- field_duration_is_direct_trackentry_child_not_in_video: depth-aware
  check that DefaultDecodedFieldDuration (and DefaultDuration) are direct
  TrackEntry children, NOT nested in the Video master. Replaces the flat
  find_id byte-scan that passed either way. Adds master_children /
  first_track_entry depth-walking helpers.
- pal_576i_emits_bt470bg_colour_codes / ntsc_480i_emits_smpte170m_colour_codes:
  assert the actual CICP tuples written into the MKV Colour master —
  PAL (5,5,5,1) vs NTSC (6,6,6,1) — not just stream-layer ColorSpace.
- ntsc_480i_field_order_is_tff_and_encoded: pins NTSC 480i hardcoded TFF
  and its ~33.37ms/16.68ms frame/field durations, asserting the encoded
  FlagInterlaced/FieldOrder bytes (480i was never exercised before).

ifo.rs
- video_attr_absolute_bytes_pin_real_layout: drives parse_video_attr with
  HARDCODED real DVD-Video bytes (PAL/NTSC x 4:3/16:9, plus mpeg_version
  in bits 7-6) instead of v_atr_byte, so a co-edit of the shift constants
  can't re-seed the PAL-as-NTSC bug. Anchors that permitted_df bits (1-0)
  are not read as the TV system.

disc/dvd.rs
- scan_dvd_titles_lpcm_routes_to_a0_pid_range: LPCM (coding 4) → sub-id
  0xA0 → PID 0xBDA0, disjoint from the AC-3 0xBD8x space, channels kept.
- scan_dvd_titles_multiple_vobsub_tracks_distinct_pids: three VobSub
  tracks → distinct 0x20+ordinal PIDs, per-language, shared palette.

css/mod.rs
- crack_outcome_reaches_cracked_with_span: drives the full crack scan to
  CrackOutcome::Cracked via a Stevenson-crackable synthetic sector and
  asserts crack_span recording (the Cracked branch was never exercised).
- recrack_succeeds_on_other_vts_extents: per-VTS re-crack SUCCESS path.
- all_locked_synthetic_iso_yields_css_key_missing_signal: all-locked
  multi-extent ISO → ScrambledUncracked, the signal the scan converts to
  css_error = Some(CssKeyMissing).

Corrected fixtures (passes-but-encodes-the-bug):
- scan_dvd_titles_mixed_audio_codecs_distinct_pids: real channel nibbles
  (AC-3 5.1 = 6ch, DTS 2.0 = 2ch) replacing the 1ch placeholders; asserts
  channel counts and exact canonical PIDs (0xBD80 / 0xBD88).
- ebml.rs FieldOrder comment: drop the stale "PAL DVD (576i) is
  bottom-field-first" line that contradicted the TFF-for-all code.
2026-06-24 15:41:26 -07:00

1734 lines
66 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! IFO parser — DVD title structure.
//!
//! DVD discs use IFO files to describe the title structure:
//! - `VIDEO_TS/VIDEO_TS.IFO` — top-level VMG with title search pointer table
//! - `VIDEO_TS/VTS_XX_0.IFO` — per-title-set with PGC chains, cell addresses, streams
//!
//! The parser reads IFO files via UDF and extracts enough information
//! to build DiscTitle structs (parallel to MPLS for Blu-ray).
use crate::disc::{Codec, Resolution};
use crate::error::{Error, Result};
use crate::sector::SectorSource;
use crate::udf::UdfFs;
// ── Public types ────────────────────────────────────────────────────────────
/// Top-level DVD info parsed from VIDEO_TS.IFO + all VTS IFO files.
#[derive(Debug)]
pub struct DvdInfo {
pub title_sets: Vec<DvdTitleSet>,
}
/// One title set (VTS_XX_0.IFO).
#[derive(Debug)]
pub struct DvdTitleSet {
/// 1-based title set number (XX in VTS_XX_0.IFO)
pub vts_number: u8,
/// First VOB sector in UDF
pub vob_start_sector: u32,
/// Video stream attributes
pub video: DvdVideoAttr,
/// Audio stream attributes (up to 8)
pub audio_streams: Vec<DvdAudioAttr>,
/// Subtitle stream attributes (up to 32)
pub subtitle_streams: Vec<DvdSubtitleAttr>,
/// Titles within this set
pub titles: Vec<DvdTitle>,
}
/// A single title (from PGC + TT_SRPT chapter count).
#[derive(Debug)]
#[allow(dead_code)]
pub struct DvdTitle {
/// Number of chapters (PTTs)
pub chapters: u16,
/// Total playback duration in seconds
pub duration_secs: f64,
/// Cell sector ranges
pub cells: Vec<DvdCell>,
/// Chapter start times in seconds (derived from program map + cell times)
pub chapter_times: Vec<f64>,
/// Subtitle palette from PGC: 16 entries of [padding, Y, Cb, Cr].
pub palette: Option<Vec<[u8; 4]>>,
}
/// A cell — contiguous sector range within a VOB.
#[derive(Debug, Clone)]
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).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TvSystem {
Ntsc,
Pal,
}
/// DVD display aspect ratio, from VTS_V_ATR `display_aspect_ratio`
/// (byte 0 bits 3-2). The pixels are anamorphic 720x480/576 either way;
/// this is the intended *display* shape.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DvdAspect {
R4x3,
R16x9,
}
/// DVD video stream attributes.
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct DvdVideoAttr {
pub codec: Codec,
pub resolution: Resolution,
pub aspect: DvdAspect,
pub standard: TvSystem,
}
/// DVD audio stream attributes.
#[derive(Debug, Clone)]
pub struct DvdAudioAttr {
pub codec: Codec,
pub channels: u8,
pub sample_rate: u32,
pub language: String,
/// The PES `private_stream_1` sub-stream id this audio stream carries
/// on the wire (AC-3: `0x80..=0x87`, DTS: `0x88..=0x8F`, LPCM:
/// `0xA0..=0xA7`), assigned by per-codec ordinal during the scan.
/// `None` for codecs carried as a regular MPEG-audio PES (MP1/MP2,
/// stream_id `0xC0..`) which don't use a private-stream-1 sub-id.
/// This is the single routing key shared with the muxer's `dvd_pid()`
/// so the two never disagree on a mixed-codec title.
pub sub_stream_id: Option<u8>,
}
/// DVD subtitle stream attributes.
#[derive(Debug, Clone)]
pub struct DvdSubtitleAttr {
pub language: String,
}
// ── Constants ───────────────────────────────────────────────────────────────
const VMG_MAGIC: &[u8; 12] = b"DVDVIDEO-VMG";
const VTS_MAGIC: &[u8; 12] = b"DVDVIDEO-VTS";
const SECTOR_SIZE: usize = 2048;
// ── Helper: safe binary reads ───────────────────────────────────────────────
/// Read a big-endian u16 from `data` at `offset`, with bounds check.
fn be_u16(data: &[u8], offset: usize) -> Result<u16> {
if offset + 2 > data.len() {
return Err(Error::IfoParse);
}
Ok(u16::from_be_bytes([data[offset], data[offset + 1]]))
}
/// Read a big-endian u32 from `data` at `offset`, with bounds check.
fn be_u32(data: &[u8], offset: usize) -> Result<u32> {
if offset + 4 > data.len() {
return Err(Error::IfoParse);
}
Ok(u32::from_be_bytes([
data[offset],
data[offset + 1],
data[offset + 2],
data[offset + 3],
]))
}
/// Read a single byte with bounds check.
fn byte_at(data: &[u8], offset: usize) -> Result<u8> {
data.get(offset).copied().ok_or(Error::IfoParse)
}
/// Get a sub-slice with bounds check.
fn sub_slice(data: &[u8], offset: usize, len: usize) -> Result<&[u8]> {
if offset.saturating_add(len) > data.len() {
return Err(Error::IfoParse);
}
Ok(&data[offset..offset + len])
}
// ── BCD time parsing ────────────────────────────────────────────────────────
/// Convert DVD BCD playback time (4 bytes) to seconds.
///
/// Format: `[hours_bcd, minutes_bcd, seconds_bcd, frames_and_rate]`
/// - Byte 0: hours in BCD (e.g. 0x01 = 1 hour, 0x12 = 12 hours)
/// - Byte 1: minutes in BCD
/// - Byte 2: seconds in BCD
/// - Byte 3: bits 7-6 = frame rate flag (01=25fps, 11=29.97fps),
/// bits 5-0 = frame count in BCD
///
/// Returns 0.0 for invalid BCD digits rather than erroring,
/// since some authoring tools produce malformed time fields.
pub fn bcd_to_secs(bcd: &[u8]) -> f64 {
if bcd.len() < 4 {
return 0.0;
}
let hours = bcd_byte(bcd[0]);
let minutes = bcd_byte(bcd[1]);
let seconds = bcd_byte(bcd[2]);
let rate_flag = (bcd[3] >> 6) & 0x03;
let frame_count = bcd_byte(bcd[3] & 0x3F);
let fps: f64 = match rate_flag {
0x01 => 25.0,
0x03 => 29.97,
_ => 0.0, // unknown rate — ignore frame contribution
};
let total = (hours as f64) * 3600.0 + (minutes as f64) * 60.0 + (seconds as f64);
if fps > 0.0 {
total + (frame_count as f64) / fps
} else {
total
}
}
/// Decode one BCD byte to its decimal value.
/// Returns 0 for invalid BCD (digit > 9).
fn bcd_byte(b: u8) -> u32 {
let hi = (b >> 4) as u32;
let lo = (b & 0x0F) as u32;
if hi > 9 || lo > 9 {
return 0;
}
hi * 10 + lo
}
// ── Top-level entry point ───────────────────────────────────────────────────
/// Parse VIDEO_TS.IFO and all VTS_XX_0.IFO files to build a complete DvdInfo.
///
/// Reads the VMG (Video Manager) to discover title sets, then reads each
/// VTS IFO to extract PGC chains, cell addresses, and stream attributes.
pub fn parse_vmg(reader: &mut dyn SectorSource, udf: &UdfFs) -> Result<DvdInfo> {
let vmg_data = udf.read_file(reader, "/VIDEO_TS/VIDEO_TS.IFO")?;
// Validate VMG magic
if vmg_data.len() < 12 || &vmg_data[0..12] != VMG_MAGIC {
return Err(Error::IfoParse);
}
// Minimum size: need at least through the TT_SRPT pointer at offset 0xC4
if vmg_data.len() < 0xC8 {
return Err(Error::IfoParse);
}
// TT_SRPT sector pointer at bytes 0xC4 (offset 196, documented as bytes 62-65
// in some references, but the canonical IFO spec uses 0xC4).
// NOTE: The user spec says bytes 62-65, which is offset 0x3E.
// Let's use the value from the spec provided.
let tt_srpt_sector = be_u32(&vmg_data, 0xC4)?;
// Read TT_SRPT — it's at the given sector offset relative to the start of VIDEO_TS.IFO.
// In the IFO file data we already have, sector offsets are relative to the IFO start.
let tt_srpt_offset = (tt_srpt_sector as usize)
.checked_mul(SECTOR_SIZE)
.ok_or(Error::IfoParse)?;
// TT_SRPT may be beyond what we read; if so, it's embedded in the file data
// (IFO files are typically small, a few sectors). Check bounds.
if tt_srpt_offset + 8 > vmg_data.len() {
return Err(Error::IfoParse);
}
let num_titles = be_u16(&vmg_data, tt_srpt_offset)?;
// Parse title entries — each is 12 bytes, starting at tt_srpt_offset + 8
let entries_start = tt_srpt_offset + 8;
let mut title_set_map: std::collections::BTreeMap<u8, Vec<(u16, u8)>> =
std::collections::BTreeMap::new();
for i in 0..num_titles as usize {
let base = entries_start + i * 12;
if base + 12 > vmg_data.len() {
break; // truncated — parse what we can
}
let num_chapters = be_u16(&vmg_data, base + 2)?;
let vts_number = byte_at(&vmg_data, base + 6)?;
let vts_title_num = byte_at(&vmg_data, base + 7)?;
if vts_number == 0 {
continue; // invalid
}
title_set_map
.entry(vts_number)
.or_default()
.push((num_chapters, vts_title_num));
}
// Parse each VTS IFO
let mut title_sets = Vec::new();
for (&vts_number, titles_info) in &title_set_map {
match parse_vts(reader, udf, vts_number, titles_info) {
Ok(ts) => title_sets.push(ts),
Err(_) => {
// Skip unreadable title sets — some DVDs have placeholder entries.
continue;
}
}
}
Ok(DvdInfo { title_sets })
}
// ── VTS parser ──────────────────────────────────────────────────────────────
/// Parse VTS_XX_0.IFO for one title set.
///
/// `titles_info` is a list of (chapter_count, vts_title_number) from TT_SRPT.
fn parse_vts(
reader: &mut dyn SectorSource,
udf: &UdfFs,
vts_number: u8,
titles_info: &[(u16, u8)],
) -> Result<DvdTitleSet> {
let path = format!("/VIDEO_TS/VTS_{vts_number:02}_0.IFO");
let vts_data = udf.read_file(reader, &path)?;
// Validate VTS magic
if vts_data.len() < 12 || &vts_data[0..12] != VTS_MAGIC {
return Err(Error::IfoParse);
}
// Need at least 0x204 bytes for header fields
if vts_data.len() < 0x204 {
return Err(Error::IfoParse);
}
// 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
// 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)?;
// Audio streams: count at 0x202 (u16 BE), then 8 bytes each starting at 0x204
let num_audio = be_u16(&vts_data, 0x200 + 2)?;
let num_audio = std::cmp::min(num_audio, 8) as usize; // cap at 8
let mut audio_streams = Vec::with_capacity(num_audio);
for i in 0..num_audio {
let aoff = 0x204 + i * 8;
if aoff + 8 > vts_data.len() {
break;
}
audio_streams.push(parse_audio_attr(&vts_data, aoff)?);
}
// Assign each audio stream its on-wire private_stream_1 sub-stream id
// by per-codec ordinal — the same convention DVD authoring uses (AC-3
// 0x80+, DTS 0x88+, LPCM 0xA0+). This is the routing key shared with
// the muxer; per-codec ordinals (not the positional index) are what
// keep mixed-codec titles from colliding.
assign_audio_sub_stream_ids(&mut audio_streams);
// Subtitle streams: count at 0x254 (u16 BE), then 6 bytes each starting at 0x256
let num_subs = if vts_data.len() >= 0x256 {
be_u16(&vts_data, 0x254).unwrap_or(0)
} else {
0
};
let num_subs = std::cmp::min(num_subs, 32) as usize; // cap at 32
let mut subtitle_streams = Vec::with_capacity(num_subs);
for i in 0..num_subs {
let soff = 0x256 + i * 6;
if soff + 6 > vts_data.len() {
break;
}
subtitle_streams.push(parse_subtitle_attr(&vts_data, soff)?);
}
// Parse PGC information table
let pgcit_offset = (pgcit_sector as usize)
.checked_mul(SECTOR_SIZE)
.ok_or(Error::IfoParse)?;
let titles = parse_pgcit(&vts_data, pgcit_offset, titles_info)?;
Ok(DvdTitleSet {
vts_number,
vob_start_sector,
video,
audio_streams,
subtitle_streams,
titles,
})
}
// ── Attribute parsers ───────────────────────────────────────────────────────
// ── VTS_V_ATR byte 0 bitfield layout (DVD-Video spec, MSB first) ──────────
// bits 7-6 mpeg_version | bits 5-4 video_format | bits 3-2 display_aspect
// | bits 1-0 permitted_df
// Naming the positions is the guard against the original bug: video_format is
// bits 5-4, NOT bits 1-0 (those are the pan&scan/letterbox permission). Reading
// the low two bits mis-detected every PAL disc as NTSC → 720x480 not 720x576.
const V_ATR_VIDEO_FORMAT_SHIFT: u8 = 4;
const V_ATR_ASPECT_SHIFT: u8 = 2;
const V_ATR_FIELD_MASK: u8 = 0x03;
// video_format field values (2/3 are reserved → parsed as NTSC).
pub(crate) const VIDEO_FORMAT_NTSC: u8 = 0;
pub(crate) const VIDEO_FORMAT_PAL: u8 = 1;
// display_aspect_ratio field values (1/2 are reserved → parsed as 4:3).
pub(crate) const ASPECT_4X3: u8 = 0;
pub(crate) const ASPECT_16X9: u8 = 3;
/// Compose a VTS_V_ATR byte 0 from its `video_format` / `display_aspect`
/// fields, mirroring the layout [`parse_video_attr`] reads. Test-only — keeps
/// fixtures self-documenting (`v_atr_byte(VIDEO_FORMAT_PAL, ASPECT_16X9)`)
/// instead of opaque packed hex.
#[cfg(test)]
pub(crate) fn v_atr_byte(video_format: u8, display_aspect: u8) -> u8 {
(video_format << V_ATR_VIDEO_FORMAT_SHIFT) | (display_aspect << V_ATR_ASPECT_SHIFT)
}
/// Parse video attributes from VTS header offset 0x200.
fn parse_video_attr(data: &[u8]) -> Result<DvdVideoAttr> {
let b0 = byte_at(data, 0x200)?;
// video_format (bits 5-4): NTSC / PAL; reserved values (2/3) → NTSC.
let standard = match (b0 >> V_ATR_VIDEO_FORMAT_SHIFT) & V_ATR_FIELD_MASK {
VIDEO_FORMAT_PAL => TvSystem::Pal,
VIDEO_FORMAT_NTSC => TvSystem::Ntsc,
_ => TvSystem::Ntsc,
};
// display_aspect_ratio (bits 3-2): 4:3 / 16:9; reserved values (1/2) → 4:3.
let aspect = match (b0 >> V_ATR_ASPECT_SHIFT) & V_ATR_FIELD_MASK {
ASPECT_16X9 => DvdAspect::R16x9,
ASPECT_4X3 => DvdAspect::R4x3,
_ => DvdAspect::R4x3,
};
let resolution = match standard {
TvSystem::Pal => Resolution::R576i,
TvSystem::Ntsc => Resolution::R480i,
};
Ok(DvdVideoAttr {
codec: Codec::Mpeg2,
resolution,
aspect,
standard,
})
}
/// Parse one audio stream attribute block (8 bytes at `offset`).
fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAttr> {
let b0 = byte_at(data, offset)?;
let b1 = byte_at(data, offset + 1)?;
let coding_mode = (b0 >> 5) & 0x07;
let codec = match coding_mode {
0 => Codec::Ac3,
2 => Codec::Mpeg1,
3 => Codec::Mp2,
4 => Codec::Lpcm,
6 => Codec::Dts,
_ => Codec::Unknown(coding_mode),
};
let sample_rate_flag = (b0 >> 3) & 0x03;
let sample_rate = match sample_rate_flag {
0 => 48000,
1 => 96000,
_ => 48000,
};
let channels = (b1 & 0x07) + 1; // (channels - 1) in low 3 bits of byte 1
// Language code: bytes 2-3 as ISO 639
let lang_bytes = sub_slice(data, offset + 2, 2)?;
let language = if lang_bytes[0] >= b'a'
&& lang_bytes[0] <= b'z'
&& lang_bytes[1] >= b'a'
&& lang_bytes[1] <= b'z'
{
String::from_utf8_lossy(lang_bytes).to_string()
} else if lang_bytes[0] == 0 && lang_bytes[1] == 0 {
String::new()
} else {
// Try to interpret as printable ASCII
let s: String = lang_bytes
.iter()
.filter(|&&b| b.is_ascii_alphanumeric())
.map(|&b| b as char)
.collect();
s
};
Ok(DvdAudioAttr {
codec,
channels,
sample_rate,
language,
// Assigned by `assign_audio_sub_stream_ids` once all streams in the
// title set are known (the sub-id is a per-codec ordinal).
sub_stream_id: None,
})
}
/// Assign the on-wire `private_stream_1` sub-stream id to each audio
/// stream by per-codec ordinal, matching DVD authoring convention and the
/// muxer's `dvd_pid()` routing:
/// - AC-3 → `0x80 + n` (n = 0-based index among AC-3 streams)
/// - DTS → `0x88 + n`
/// - LPCM → `0xA0 + n`
/// - MP1/MP2 and anything else → `None` (regular MPEG-audio PES, not a
/// private-stream-1 sub-id).
///
/// Indices saturate at the codec range ceiling (8 AC-3/DTS, 8 LPCM) so a
/// malformed over-count never produces an out-of-range sub-id.
fn assign_audio_sub_stream_ids(streams: &mut [DvdAudioAttr]) {
let mut n_ac3 = 0u8;
let mut n_dts = 0u8;
let mut n_lpcm = 0u8;
for s in streams.iter_mut() {
s.sub_stream_id = match s.codec {
Codec::Ac3 => {
let id = 0x80 + n_ac3.min(7);
n_ac3 = n_ac3.saturating_add(1);
Some(id)
}
Codec::Dts => {
let id = 0x88 + n_dts.min(7);
n_dts = n_dts.saturating_add(1);
Some(id)
}
Codec::Lpcm => {
let id = 0xA0 + n_lpcm.min(7);
n_lpcm = n_lpcm.saturating_add(1);
Some(id)
}
_ => None,
};
}
}
/// Parse one subtitle stream attribute block (6 bytes at `offset`).
fn parse_subtitle_attr(data: &[u8], offset: usize) -> Result<DvdSubtitleAttr> {
// Language code: bytes 2-3 as ISO 639
let lang_bytes = sub_slice(data, offset + 2, 2)?;
let language = if lang_bytes[0] >= b'a'
&& lang_bytes[0] <= b'z'
&& lang_bytes[1] >= b'a'
&& lang_bytes[1] <= b'z'
{
String::from_utf8_lossy(lang_bytes).to_string()
} else if lang_bytes[0] == 0 && lang_bytes[1] == 0 {
String::new()
} else {
let s: String = lang_bytes
.iter()
.filter(|&&b| b.is_ascii_alphanumeric())
.map(|&b| b as char)
.collect();
s
};
Ok(DvdSubtitleAttr { language })
}
// ── PGC parser ──────────────────────────────────────────────────────────────
/// Parse VTS_PGCIT (Program Chain Information Table) to extract titles.
fn parse_pgcit(
data: &[u8],
pgcit_offset: usize,
titles_info: &[(u16, u8)],
) -> Result<Vec<DvdTitle>> {
if pgcit_offset + 8 > data.len() {
return Err(Error::IfoParse);
}
let num_pgcs = be_u16(data, pgcit_offset)?;
// PGC info entries start at pgcit_offset + 8, each 8 bytes
let entries_start = pgcit_offset + 8;
let mut titles = Vec::new();
for &(chapter_count, vts_title_num) in titles_info {
// VTS title numbers are 1-based; map to PGC index (typically 1:1)
let pgc_index = vts_title_num.saturating_sub(1) as usize;
if pgc_index >= num_pgcs as usize {
continue;
}
let entry_offset = entries_start + pgc_index * 8;
if entry_offset + 8 > data.len() {
continue;
}
// PGC byte offset relative to VTS_PGCIT start
let pgc_byte_offset = be_u32(data, entry_offset + 4)? as usize;
let pgc_abs = pgcit_offset
.checked_add(pgc_byte_offset)
.ok_or(Error::IfoParse)?;
match parse_pgc(data, pgc_abs, chapter_count) {
Ok(title) => titles.push(title),
// By design: a single unparseable PGC (truncated/corrupt entry,
// authoring-tool quirk) must not lose the whole title list.
// Skip it and keep collecting the titles that do parse.
Err(_) => continue,
}
}
Ok(titles)
}
/// Parse a single PGC (Program Chain) to extract duration and cells.
fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle> {
// PGC needs at least 0xE8 bytes for the cell playback info offset
if pgc_offset + 0xEA > data.len() {
return Err(Error::IfoParse);
}
// PGC layout:
// 0x00-0x01: misc flags
// 0x02: nr_of_programs
// 0x03: nr_of_cells
// 0x04-0x07: playback_time (4 BCD bytes)
let num_cells = byte_at(data, pgc_offset + 0x03)? as usize;
let time_bytes = sub_slice(data, pgc_offset + 0x04, 4)?;
let duration_secs = bcd_to_secs(time_bytes);
// Cell playback info table offset (relative to PGC start)
let cell_playback_offset = be_u16(data, pgc_offset + 0xE8)? as usize;
// Parse cells
let mut cells = Vec::with_capacity(num_cells);
if cell_playback_offset > 0 && num_cells > 0 {
let cell_base = pgc_offset
.checked_add(cell_playback_offset)
.ok_or(Error::IfoParse)?;
for i in 0..num_cells {
let co = cell_base + i * 24;
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,
});
}
}
// Recalculate duration from cell times if PGC-level time is zero
let duration_secs = if duration_secs == 0.0 && !cells.is_empty() && cell_playback_offset > 0 {
let cell_base = pgc_offset + cell_playback_offset;
let mut total = 0.0;
for i in 0..cells.len() {
// Cell playback info: 24 bytes per cell, BCD time at offset 4-7
let co = cell_base + i * 24;
if co + 8 <= data.len() {
total += bcd_to_secs(&data[co + 4..co + 8]);
}
}
total
} else {
duration_secs
};
// Extract chapter times from program map + cell durations
// PGC program map offset at 0xE6, maps program_number → first cell_number
let chapter_times = {
let pgm_map_offset = be_u16(data, pgc_offset + 0xE6).unwrap_or(0) as usize;
let nr_of_programs = byte_at(data, pgc_offset + 0x02).unwrap_or(0) as usize;
let mut times = Vec::new();
if pgm_map_offset > 0 && nr_of_programs > 0 && cell_playback_offset > 0 {
let pgm_base = pgc_offset + pgm_map_offset;
// Collect cell durations
let mut cell_durations = Vec::with_capacity(num_cells);
let cell_base = pgc_offset + cell_playback_offset;
for i in 0..num_cells {
let co = cell_base + i * 24;
if co + 8 <= data.len() {
cell_durations.push(bcd_to_secs(&data[co + 4..co + 8]));
} else {
cell_durations.push(0.0);
}
}
// Program map: each byte is the first cell number (1-based) for that program
for p in 0..nr_of_programs {
if pgm_base + p >= data.len() {
break;
}
let first_cell = data[pgm_base + p] as usize;
// Chapter time = sum of cell durations before this program's first cell.
// Clamp to cell_durations.len(): a crafted/corrupt IFO can set first_cell
// beyond the actual cell count, which would panic the slice index.
let end = first_cell.saturating_sub(1).min(cell_durations.len());
let time: f64 = cell_durations[..end].iter().sum();
times.push(time);
}
}
times
};
// Extract subtitle palette at PGC offset 0xA4: 16 colors × 4 bytes [padding, Y, Cb, Cr]
let palette = if pgc_offset + 0xA4 + 64 <= data.len() {
let mut colors = Vec::with_capacity(16);
for i in 0..16 {
let co = pgc_offset + 0xA4 + i * 4;
colors.push([data[co], data[co + 1], data[co + 2], data[co + 3]]);
}
// Only include palette if it's not all zeros (some DVDs have empty palettes)
if colors.iter().any(|c| c[1] != 0 || c[2] != 0 || c[3] != 0) {
Some(colors)
} else {
None
}
} else {
None
};
Ok(DvdTitle {
chapters,
duration_secs,
cells,
chapter_times,
palette,
})
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bcd_to_secs_basic() {
// 1 hour, 23 minutes, 45 seconds, 0 frames at 25fps
let bcd = [0x01, 0x23, 0x45, 0b01_000000];
let secs = bcd_to_secs(&bcd);
let expected = 1.0 * 3600.0 + 23.0 * 60.0 + 45.0;
assert!((secs - expected).abs() < 0.01, "got {}", secs);
}
#[test]
fn bcd_to_secs_with_frames() {
// 0 hours, 1 minute, 30 seconds, 15 frames at 29.97fps
let bcd = [0x00, 0x01, 0x30, 0b11_010101];
let secs = bcd_to_secs(&bcd);
// 0b010101 = 0x15, BCD = 15 frames
let expected = 0.0 + 60.0 + 30.0 + 15.0 / 29.97;
assert!((secs - expected).abs() < 0.01, "got {}", secs);
}
#[test]
fn bcd_to_secs_zero() {
let bcd = [0x00, 0x00, 0x00, 0x00];
assert_eq!(bcd_to_secs(&bcd), 0.0);
}
#[test]
fn bcd_to_secs_short_input() {
assert_eq!(bcd_to_secs(&[0x01, 0x02]), 0.0);
assert_eq!(bcd_to_secs(&[]), 0.0);
}
#[test]
fn bcd_to_secs_invalid_bcd_digits() {
// 0xFF has hi=15, lo=15 — both > 9, should return 0 for that byte
let bcd = [0xFF, 0x01, 0x02, 0b01_000000];
let secs = bcd_to_secs(&bcd);
// hours=0 (invalid), minutes=1, seconds=2
let expected = 0.0 + 60.0 + 2.0;
assert!((secs - expected).abs() < 0.01, "got {}", secs);
}
#[test]
fn bcd_byte_valid() {
assert_eq!(bcd_byte(0x00), 0);
assert_eq!(bcd_byte(0x09), 9);
assert_eq!(bcd_byte(0x10), 10);
assert_eq!(bcd_byte(0x59), 59);
assert_eq!(bcd_byte(0x99), 99);
}
#[test]
fn bcd_byte_invalid() {
assert_eq!(bcd_byte(0xAA), 0);
assert_eq!(bcd_byte(0x0F), 0);
assert_eq!(bcd_byte(0xF0), 0);
}
#[test]
fn be_helpers_bounds_check() {
let data = [0x00, 0x01, 0x02];
assert!(be_u16(&data, 0).is_ok());
assert!(be_u16(&data, 1).is_ok());
assert!(be_u16(&data, 2).is_err()); // only 1 byte left
assert!(be_u32(&data, 0).is_err()); // only 3 bytes
}
#[test]
fn struct_construction() {
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);
let title = DvdTitle {
chapters: 5,
duration_secs: 3600.0,
cells: vec![cell.clone()],
chapter_times: Vec::new(),
palette: None,
};
assert_eq!(title.chapters, 5);
assert!((title.duration_secs - 3600.0).abs() < 0.01);
assert_eq!(title.cells.len(), 1);
let video = DvdVideoAttr {
codec: Codec::Mpeg2,
resolution: Resolution::R480i,
aspect: DvdAspect::R16x9,
standard: TvSystem::Ntsc,
};
assert_eq!(video.codec, Codec::Mpeg2);
let audio = DvdAudioAttr {
codec: Codec::Ac3,
channels: 6,
sample_rate: 48000,
language: "en".to_string(),
sub_stream_id: Some(0x80),
};
assert_eq!(audio.channels, 6);
let ts = DvdTitleSet {
vts_number: 1,
vob_start_sector: 512,
video,
audio_streams: vec![audio],
subtitle_streams: Vec::new(),
titles: vec![title],
};
assert_eq!(ts.vts_number, 1);
assert_eq!(ts.audio_streams.len(), 1);
let info = DvdInfo {
title_sets: vec![ts],
};
assert_eq!(info.title_sets.len(), 1);
}
#[test]
fn pgc_parses_duration_from_correct_offset() {
// Build a minimal PGC: 0xEA bytes minimum
// PGC layout: 0x02 = nr_programs, 0x03 = nr_cells, 0x04-0x07 = BCD time
let mut pgc = vec![0u8; 0xEA];
pgc[0x02] = 1; // 1 program
pgc[0x03] = 2; // 2 cells
// 1h 59m 30s at 29.97fps, 0 frames
pgc[0x04] = 0x01; // hours BCD
pgc[0x05] = 0x59; // minutes BCD
pgc[0x06] = 0x30; // seconds BCD
pgc[0x07] = 0b11_000000; // 29.97fps, 0 frames
// Cell playback info offset at PGC+0xE8
let cell_offset: u16 = 0xEA; // right after minimum header
pgc[0xE8] = (cell_offset >> 8) as u8;
pgc[0xE9] = cell_offset as u8;
// Add 2 cells (24 bytes each)
pgc.resize(pgc.len() + 48, 0);
// Cell 0: sectors 100-200
let co = 0xEA;
pgc[co + 8] = 0;
pgc[co + 9] = 0;
pgc[co + 10] = 0;
pgc[co + 11] = 100; // first sector
pgc[co + 20] = 0;
pgc[co + 21] = 0;
pgc[co + 22] = 0;
pgc[co + 23] = 200; // last sector
// Cell 1: sectors 300-400
let co = 0xEA + 24;
pgc[co + 8] = 0;
pgc[co + 9] = 0;
pgc[co + 10] = 1;
pgc[co + 11] = 44; // first sector = 300
pgc[co + 20] = 0;
pgc[co + 21] = 0;
pgc[co + 22] = 1;
pgc[co + 23] = 144; // last sector = 400
let title = parse_pgc(&pgc, 0, 5).unwrap();
let expected = 1.0 * 3600.0 + 59.0 * 60.0 + 30.0;
assert!(
(title.duration_secs - expected).abs() < 0.1,
"expected ~{expected}s, got {}s",
title.duration_secs
);
assert_eq!(title.chapters, 5);
assert_eq!(title.cells.len(), 2);
assert_eq!(title.cells[0].first_sector, 100);
assert_eq!(title.cells[0].last_sector, 200);
assert_eq!(title.cells[1].first_sector, 300);
assert_eq!(title.cells[1].last_sector, 400);
}
#[test]
fn video_attr_parsing() {
let mut data = vec![0u8; 0x204];
data[0x200] = v_atr_byte(VIDEO_FORMAT_NTSC, ASPECT_16X9);
let attr = parse_video_attr(&data).unwrap();
assert_eq!(attr.standard, TvSystem::Ntsc);
assert_eq!(attr.aspect, DvdAspect::R16x9);
assert_eq!(attr.resolution, Resolution::R480i);
assert_eq!(attr.codec, Codec::Mpeg2);
}
#[test]
fn video_attr_pal() {
let mut data = vec![0u8; 0x204];
data[0x200] = v_atr_byte(VIDEO_FORMAT_PAL, ASPECT_4X3);
let attr = parse_video_attr(&data).unwrap();
assert_eq!(attr.standard, TvSystem::Pal);
assert_eq!(attr.aspect, DvdAspect::R4x3);
assert_eq!(attr.resolution, Resolution::R576i);
}
/// Real-world regression: a PAL 16:9 anamorphic disc (the Silence of the
/// Lambs UK SKU). Must parse as PAL / 16:9 / 576i. The old code read the TV
/// system from bits 1-0 (permitted_df, here 0) and reported NTSC/480i — the
/// case that shipped broken because only NTSC discs (where the wrong bits
/// coincide on 0) were ever tested.
#[test]
fn video_attr_pal_16x9_anamorphic() {
let mut data = vec![0u8; 0x204];
data[0x200] = v_atr_byte(VIDEO_FORMAT_PAL, ASPECT_16X9);
let attr = parse_video_attr(&data).unwrap();
assert_eq!(attr.standard, TvSystem::Pal);
assert_eq!(attr.aspect, DvdAspect::R16x9);
assert_eq!(attr.resolution, Resolution::R576i);
}
/// ABSOLUTE-BYTE pin (audit §3 #2): the existing video-attr tests build the
/// byte via `v_atr_byte(...)`, which uses the SAME shift constants the parser
/// reads with — a co-edit of constant + helper would silently re-introduce
/// the PAL-as-NTSC bug and every test would still pass. This test feeds
/// `parse_video_attr` HARDCODED bytes captured from real DVD-Video layouts
/// (DVD spec / libdvdread `video_attr_t`: mpeg_version[7-6] video_format[5-4]
/// display_aspect[3-2] permitted_df[1-0]) — no `v_atr_byte`. If the parser's
/// bit positions drift, these fail.
#[test]
fn video_attr_absolute_bytes_pin_real_layout() {
// (byte @0x200, expected standard, expected aspect, expected resolution).
// PAL 16:9 anamorphic = mpeg(00) format(01=PAL) aspect(11=16:9) df(00)
// = 0b0001_1100 = 0x1C (e.g. a PAL 16:9 R2 feature disc).
// PAL 4:3 = 0b0001_0000 = 0x10.
// NTSC 16:9 = 0b0000_1100 = 0x0C.
// NTSC 4:3 = 0b0000_0000 = 0x00.
// A real disc also sets mpeg_version=01 (MPEG-2) in bits 7-6, which the
// parser must IGNORE; OR it in (|0x40) to prove it doesn't leak into the
// video_format read.
let cases: &[(u8, TvSystem, DvdAspect, Resolution)] = &[
(0x1C, TvSystem::Pal, DvdAspect::R16x9, Resolution::R576i),
(0x10, TvSystem::Pal, DvdAspect::R4x3, Resolution::R576i),
(0x0C, TvSystem::Ntsc, DvdAspect::R16x9, Resolution::R480i),
(0x00, TvSystem::Ntsc, DvdAspect::R4x3, Resolution::R480i),
// mpeg_version=2 (MPEG-2) in bits 7-6 must not perturb the read.
(0x5C, TvSystem::Pal, DvdAspect::R16x9, Resolution::R576i),
];
for &(b0, std, aspect, res) in cases {
let mut data = vec![0u8; 0x204];
data[0x200] = b0;
let attr = parse_video_attr(&data).unwrap();
assert_eq!(attr.standard, std, "byte {b0:#04x} → standard");
assert_eq!(attr.aspect, aspect, "byte {b0:#04x} → aspect");
assert_eq!(attr.resolution, res, "byte {b0:#04x} → resolution");
}
// Anti-bug anchor: the original bug read the TV system from bits 1-0
// (permitted_df). A PAL byte whose low 2 bits are 0 (0x1C) must NOT be
// misread as NTSC — and a byte with low bits set but format=NTSC
// (0x03 = NTSC, df=11) must stay NTSC, proving the low bits are ignored.
let mut df = vec![0u8; 0x204];
df[0x200] = 0x03; // format=NTSC(00), df=11
assert_eq!(
parse_video_attr(&df).unwrap().standard,
TvSystem::Ntsc,
"permitted_df bits (1-0) must NOT be read as the TV system"
);
}
#[test]
fn audio_attr_parsing() {
let mut data = vec![0u8; 16];
// AC3 (coding=0), 48kHz (rate=0), 6 channels (stored as 5)
// b0: bits 7-5=000(AC3), bits 4-3=00(48k) => 0x00
data[0] = 0x00;
// b1: bits 2-0=101 (channels-1=5) => 0x05
data[1] = 0x05;
// language "en"
data[2] = b'e';
data[3] = b'n';
let attr = parse_audio_attr(&data, 0).unwrap();
assert_eq!(attr.codec, Codec::Ac3);
assert_eq!(attr.sample_rate, 48000);
assert_eq!(attr.channels, 6);
assert_eq!(attr.language, "en");
}
#[test]
fn mixed_codec_sub_stream_ids_are_distinct() {
// A title mixing AC-3, DTS and LPCM must get per-codec ordinal
// sub-ids (0x80, 0x88, 0xA0...), all distinct — this is the
// routing key that keeps mixed-codec audio from colliding.
let mut streams = vec![
DvdAudioAttr {
codec: Codec::Ac3,
channels: 6,
sample_rate: 48000,
language: "en".into(),
sub_stream_id: None,
},
DvdAudioAttr {
codec: Codec::Dts,
channels: 6,
sample_rate: 48000,
language: "en".into(),
sub_stream_id: None,
},
DvdAudioAttr {
codec: Codec::Lpcm,
channels: 2,
sample_rate: 48000,
language: "fr".into(),
sub_stream_id: None,
},
DvdAudioAttr {
codec: Codec::Ac3,
channels: 2,
sample_rate: 48000,
language: "es".into(),
sub_stream_id: None,
},
];
assign_audio_sub_stream_ids(&mut streams);
assert_eq!(streams[0].sub_stream_id, Some(0x80)); // AC-3 #0
assert_eq!(streams[1].sub_stream_id, Some(0x88)); // DTS #0
assert_eq!(streams[2].sub_stream_id, Some(0xA0)); // LPCM #0
assert_eq!(streams[3].sub_stream_id, Some(0x81)); // AC-3 #1
// All sub-ids unique.
let ids: Vec<u8> = streams.iter().filter_map(|s| s.sub_stream_id).collect();
let mut sorted = ids.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(ids.len(), sorted.len(), "sub-stream ids must be unique");
}
#[test]
fn audio_attr_dts() {
let mut data = vec![0u8; 16];
// DTS (coding=6), 96kHz (rate=1), 2 channels (stored as 1)
// b0: bits 7-5=110(DTS), bits 4-3=01(96k) => 0b110_01_000 = 0xC8
data[0] = 0xC8;
// b1: bits 2-0=001 (channels-1=1) => 0x01
data[1] = 0x01;
data[2] = b'f';
data[3] = b'r';
let attr = parse_audio_attr(&data, 0).unwrap();
assert_eq!(attr.codec, Codec::Dts);
assert_eq!(attr.sample_rate, 96000);
assert_eq!(attr.channels, 2);
assert_eq!(attr.language, "fr");
}
// ─────────────────────────────────────────────────────────────────────
// Added hardening tests. Grounded in the DVD-Video IFO spec
// (dvd_udf / libdvdread ifo_types.h; http://dvd.sourceforge.net).
// ─────────────────────────────────────────────────────────────────────
/// BCD frame-rate flag: bits 7-6 of byte[3]. 0b01 = 25fps (PAL),
/// 0b11 = 29.97fps (NTSC). 0b00/0b10 are "unknown" → frames ignored.
/// Verify the 25fps branch contributes frames correctly.
#[test]
fn bcd_25fps_frame_contribution() {
// 0h 0m 0s, 12 frames at 25fps → 12/25 = 0.48s.
let bcd = [0x00, 0x00, 0x00, 0b01_010010]; // frame BCD 0x12 = 12
let secs = bcd_to_secs(&bcd);
assert!((secs - 12.0 / 25.0).abs() < 0.001, "got {secs}");
}
/// BCD rate_flag 0b00 (and 0b10) → fps 0.0 → frame count ignored
/// entirely (only H/M/S counted). Source: `_ => 0.0` arm.
#[test]
fn bcd_unknown_rate_ignores_frames() {
// 0h 1m 0s with frame bits set but rate_flag 0b00.
let bcd = [0x00, 0x01, 0x00, 0b00_011001]; // frames present, rate unknown
let secs = bcd_to_secs(&bcd);
assert!((secs - 60.0).abs() < 0.001, "got {secs}");
// rate_flag 0b10 also unknown.
let bcd2 = [0x00, 0x01, 0x00, 0b10_011001];
assert!((bcd_to_secs(&bcd2) - 60.0).abs() < 0.001);
}
/// BCD frame count is the LOW 6 bits of byte[3] (bits 5-0), decoded as
/// BCD. The 2 high bits (rate flag) must not leak into the frame value.
/// 0b11_100101: rate=29.97, frame BCD = 0x25 = 25 frames.
#[test]
fn bcd_frame_count_masks_rate_bits() {
let bcd = [0x00, 0x00, 0x00, 0b11_100101]; // 0x25 BCD = 25 frames
let secs = bcd_to_secs(&bcd);
assert!((secs - 25.0 / 29.97).abs() < 0.001, "got {secs}");
}
/// BCD hours can exceed 12 (long titles): 0x12 BCD = 12 → but test a
/// value where hi/lo are both valid digits, e.g. 0x10 = 10 hours.
/// Ensures hours aren't capped or treated as hex.
#[test]
fn bcd_double_digit_hours() {
let bcd = [0x10, 0x00, 0x00, 0x00]; // 10 hours BCD
let secs = bcd_to_secs(&bcd);
assert!((secs - 10.0 * 3600.0).abs() < 0.01, "got {secs}");
}
/// sub_slice uses saturating_add so an offset near usize::MAX cannot
/// wrap and bypass the bounds check. Must return Err, not panic/OOB.
#[test]
fn sub_slice_no_overflow_wrap() {
let data = [0u8; 8];
assert!(sub_slice(&data, usize::MAX, 4).is_err());
assert!(sub_slice(&data, 4, 4).is_ok());
assert!(sub_slice(&data, 5, 4).is_err()); // 5+4 > 8
}
/// byte_at returns Err for an out-of-range index (uses .get()).
#[test]
fn byte_at_out_of_range() {
let data = [0xAA, 0xBB];
assert_eq!(byte_at(&data, 0).unwrap(), 0xAA);
assert_eq!(byte_at(&data, 1).unwrap(), 0xBB);
assert!(byte_at(&data, 2).is_err());
}
/// A reserved video_format value (2/3) falls into the NTSC default.
#[test]
fn video_attr_reserved_standard_defaults_ntsc() {
let mut data = vec![0u8; 0x204];
// A reserved value is anything past PAL (2 or 3).
data[0x200] = v_atr_byte(VIDEO_FORMAT_PAL + 1, ASPECT_4X3);
let attr = parse_video_attr(&data).unwrap();
assert_eq!(attr.standard, TvSystem::Ntsc);
assert_eq!(attr.resolution, Resolution::R480i);
}
/// A reserved display_aspect value (1/2) falls into the 4:3 default.
#[test]
fn video_attr_reserved_aspect_defaults_4_3() {
let mut data = vec![0u8; 0x204];
// A reserved aspect value is between 4:3 (0) and 16:9 (3).
data[0x200] = v_atr_byte(VIDEO_FORMAT_NTSC, ASPECT_4X3 + 1);
let attr = parse_video_attr(&data).unwrap();
assert_eq!(attr.aspect, DvdAspect::R4x3);
}
/// Audio coding_mode (b0>>5 & 0x07): 0=AC3, 2=MPEG1, 3=MP2, 4=LPCM,
/// 6=DTS; everything else → Unknown(mode). Verify LPCM (4) and an
/// unknown mode (1) map per the spec table.
#[test]
fn audio_attr_lpcm_and_unknown_coding() {
let mut data = vec![0u8; 8];
// LPCM: coding=4 → b0 bits 7-5 = 0b100 → 0x80
data[0] = 0x80;
data[2] = b'e';
data[3] = b'n';
let attr = parse_audio_attr(&data, 0).unwrap();
assert_eq!(attr.codec, Codec::Lpcm);
// coding=1 (reserved/unknown) → Unknown(1)
let mut data2 = vec![0u8; 8];
data2[0] = 0b001_00000; // coding=1
let attr2 = parse_audio_attr(&data2, 0).unwrap();
assert_eq!(attr2.codec, Codec::Unknown(1));
}
/// Audio language bytes [offset+2..+4]: when both bytes are 0x00 the
/// language is the empty string (unspecified), per source.
#[test]
fn audio_attr_zero_language_is_empty() {
let mut data = vec![0u8; 8];
data[0] = 0x00;
data[2] = 0x00;
data[3] = 0x00;
let attr = parse_audio_attr(&data, 0).unwrap();
assert_eq!(attr.language, "");
}
/// Audio sample_rate flag (b0>>3 & 0x03): 0=48kHz, 1=96kHz, else 48kHz.
/// Verify flag 2/3 fall back to 48kHz (catch-all).
#[test]
fn audio_attr_reserved_rate_defaults_48k() {
let mut data = vec![0u8; 8];
data[0] = 0b0001_0000; // sample-rate flag (bits 4-3) = 0b10
let attr = parse_audio_attr(&data, 0).unwrap();
assert_eq!(attr.sample_rate, 48000);
}
/// Subtitle language is at [offset+2..+4]. Verify a valid 2-letter code
/// and the all-zero → empty case.
#[test]
fn subtitle_attr_language() {
let mut data = vec![0u8; 6];
data[2] = b'd';
data[3] = b'e';
let attr = parse_subtitle_attr(&data, 0).unwrap();
assert_eq!(attr.language, "de");
let zero = vec![0u8; 6];
let attr2 = parse_subtitle_attr(&zero, 0).unwrap();
assert_eq!(attr2.language, "");
}
/// assign_audio_sub_stream_ids: MP1/MP2 and other non-private-stream-1
/// codecs must get `None` (regular MPEG-audio PES, not a sub-id).
/// Source maps only AC3/DTS/LPCM to Some(_).
#[test]
fn mp2_audio_gets_no_sub_stream_id() {
let mut streams = vec![
DvdAudioAttr {
codec: Codec::Mp2,
channels: 2,
sample_rate: 48000,
language: "en".into(),
sub_stream_id: None,
},
DvdAudioAttr {
codec: Codec::Ac3,
channels: 6,
sample_rate: 48000,
language: "en".into(),
sub_stream_id: None,
},
];
assign_audio_sub_stream_ids(&mut streams);
assert_eq!(streams[0].sub_stream_id, None); // MP2 → no sub-id
assert_eq!(streams[1].sub_stream_id, Some(0x80)); // AC3 #0
}
/// assign_audio_sub_stream_ids saturates the per-codec ordinal at the
/// range ceiling (min(7)) so a malformed over-count never produces an
/// out-of-range sub-id. 9 AC-3 streams: the 9th still ≤ 0x87.
#[test]
fn audio_sub_stream_id_saturates_at_ceiling() {
let mut streams: Vec<DvdAudioAttr> = (0..9)
.map(|_| DvdAudioAttr {
codec: Codec::Ac3,
channels: 2,
sample_rate: 48000,
language: String::new(),
sub_stream_id: None,
})
.collect();
assign_audio_sub_stream_ids(&mut streams);
for s in &streams {
let id = s.sub_stream_id.unwrap();
assert!(
(0x80..=0x87).contains(&id),
"AC-3 sub-id out of range: {id:#x}"
);
}
// 8th and 9th both saturate at 0x87.
assert_eq!(streams[7].sub_stream_id, Some(0x87));
assert_eq!(streams[8].sub_stream_id, Some(0x87));
}
/// parse_pgc requires `pgc_offset + 0xEA <= data.len()` (needs the cell
/// playback offset at 0xE8). A PGC shorter than 0xEA → IfoParse error,
/// not panic.
#[test]
fn pgc_too_short_errs() {
let pgc = vec![0u8; 0xE9]; // one byte short of 0xEA
assert!(parse_pgc(&pgc, 0, 1).is_err());
}
/// parse_pgc cell loop stops when a cell record runs past the buffer
/// (`co + 24 > data.len()` → break), parsing only complete cells.
/// Declare 3 cells but supply bytes for 2.
#[test]
fn pgc_truncated_cell_table_stops() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x02] = 1;
pgc[0x03] = 3; // claims 3 cells
pgc[0xE8] = 0x00;
pgc[0xE9] = 0xEA;
// Only room for 2 full cells (48 bytes).
pgc.resize(0xEA + 48, 0);
pgc[0xEA + 8..0xEA + 12].copy_from_slice(&10u32.to_be_bytes());
pgc[0xEA + 24 + 8..0xEA + 24 + 12].copy_from_slice(&20u32.to_be_bytes());
let title = parse_pgc(&pgc, 0, 1).unwrap();
// Only 2 cells parsed; the 3rd had no bytes.
assert_eq!(title.cells.len(), 2);
assert_eq!(title.cells[0].first_sector, 10);
assert_eq!(title.cells[1].first_sector, 20);
}
/// parse_pgc palette: at PGC+0xA4, 16 colors × 4 bytes [pad, Y, Cb, Cr].
/// A palette with at least one non-zero Y/Cb/Cr is returned as Some;
/// an all-zero palette returns None (source filters empty palettes).
#[test]
fn pgc_palette_present_and_empty() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x03] = 0; // no cells
// Set color 0's Y byte (offset 0xA4 + 1) non-zero.
pgc[0xA4 + 1] = 0x80;
let title = parse_pgc(&pgc, 0, 1).unwrap();
let pal = title.palette.expect("non-empty palette should be Some");
assert_eq!(pal.len(), 16);
assert_eq!(pal[0], [0x00, 0x80, 0x00, 0x00]);
// All-zero palette → None.
let mut pgc2 = vec![0u8; 0xEA];
pgc2[0x03] = 0;
let title2 = parse_pgc(&pgc2, 0, 1).unwrap();
assert!(title2.palette.is_none());
}
/// parse_pgc palette layout: each color is [padding, Y, Cb, Cr] and the
/// "non-empty" test ignores the padding byte (index 0). A palette whose
/// ONLY non-zero bytes are padding must still be treated as empty (None).
#[test]
fn pgc_palette_padding_only_is_empty() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x03] = 0;
// Set padding byte (index 0) of color 0 non-zero, but Y/Cb/Cr zero.
pgc[0xA4] = 0xFF;
let title = parse_pgc(&pgc, 0, 1).unwrap();
assert!(
title.palette.is_none(),
"padding-only palette must be treated as empty"
);
}
/// parse_pgc chapter_times: the program map (at PGC+0xE6) holds, per
/// program, the 1-based first cell number. chapter_time[p] = sum of
/// cell durations BEFORE that program's first cell. Verify a 2-program,
/// 3-cell layout: program 0 starts at cell 1 (time 0), program 1 starts
/// at cell 3 (time = dur(cell0)+dur(cell1)).
#[test]
fn pgc_chapter_times_from_program_map() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x02] = 2; // nr_programs = 2
pgc[0x03] = 3; // nr_cells = 3
// program map offset at 0xE6 (u16 BE)
let pgm_off: u16 = 0xEA;
pgc[0xE6] = (pgm_off >> 8) as u8;
pgc[0xE7] = pgm_off as u8;
// cell playback offset at 0xE8
let cell_off: u16 = 0xEA + 2; // after the 2-byte program map
pgc[0xE8] = (cell_off >> 8) as u8;
pgc[0xE9] = cell_off as u8;
// Layout: [0xEA..0xEC] = program map (2 bytes), then 3 cells × 24.
pgc.resize(cell_off as usize + 3 * 24, 0);
// Program map: program0 first cell = 1, program1 first cell = 3.
pgc[0xEA] = 1;
pgc[0xEB] = 3;
// Cell durations: cell0 = 5s, cell1 = 7s, cell2 = 9s (BCD seconds).
let cb = cell_off as usize;
pgc[cb + 6] = 0x05; // cell0 sec
pgc[cb + 24 + 6] = 0x07; // cell1 sec
pgc[cb + 48 + 6] = 0x09; // cell2 sec
let title = parse_pgc(&pgc, 0, 2).unwrap();
assert_eq!(title.chapter_times.len(), 2);
// Program 0 → before cell 1 → 0s.
assert!((title.chapter_times[0] - 0.0).abs() < 0.01);
// Program 1 → before cell 3 → dur(cell0)+dur(cell1) = 5+7 = 12s.
assert!(
(title.chapter_times[1] - 12.0).abs() < 0.01,
"got {}",
title.chapter_times[1]
);
}
/// parse_pgc duration: when the PGC-level BCD time is NON-zero it is
/// used directly and NOT overwritten by cell-sum recomputation
/// (the recompute only fires when duration_secs == 0.0).
#[test]
fn pgc_nonzero_duration_not_recomputed() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x02] = 1;
pgc[0x03] = 1;
// PGC-level time = 1m 0s at 25fps.
pgc[0x05] = 0x01; // minutes BCD 1
pgc[0x07] = 0b01_000000; // 25fps, 0 frames
pgc[0xE8] = 0x00;
pgc[0xE9] = 0xEA;
pgc.resize(0xEA + 24, 0);
// Give the cell a bogus huge duration that must be IGNORED.
pgc[0xEA + 6] = 0x59; // 59s — would change result if recomputed
let title = parse_pgc(&pgc, 0, 1).unwrap();
assert!(
(title.duration_secs - 60.0).abs() < 0.01,
"PGC-level 60s must win, got {}",
title.duration_secs
);
}
/// parse_pgc with cell_playback_offset == 0 must produce NO cells (the
/// `cell_playback_offset > 0 && num_cells > 0` guard). Even with
/// nr_cells set, a zero offset means the table is absent.
#[test]
fn pgc_zero_cell_offset_no_cells() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x03] = 5; // claims 5 cells
// cell_playback_offset (0xE8) left 0.
let title = parse_pgc(&pgc, 0, 1).unwrap();
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
/// out-of-bounds slice index when first_cell > cell_durations.len().
///
/// Layout: 1 real cell, but the program map byte is 0xFF (255) — an
/// attacker-controlled value that exceeds the cell_durations Vec length.
/// Expected: parse_pgc returns Ok (the clamped sum is simply the full
/// cell duration) without panicking.
#[test]
fn pgc_program_map_oob_cell_index_no_panic() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x02] = 1; // nr_programs = 1
pgc[0x03] = 1; // nr_cells = 1
// program map offset at PGC+0xE6 (u16 BE) → right after the header
let pgm_off: u16 = 0xEA;
pgc[0xE6] = (pgm_off >> 8) as u8;
pgc[0xE7] = pgm_off as u8;
// cell playback offset at PGC+0xE8 → after the 1-byte program map
let cell_off: u16 = 0xEA + 1;
pgc[0xE8] = (cell_off >> 8) as u8;
pgc[0xE9] = cell_off as u8;
// Allocate space: 1 program-map byte + 1 cell × 24 bytes
pgc.resize(cell_off as usize + 24, 0);
// Craft: program 0's first_cell = 0xFF (255) — far past the 1 real cell
pgc[0xEA] = 0xFF;
// Cell 0 duration = 10s (BCD seconds byte at cell_base + 6)
pgc[cell_off as usize + 6] = 0x10; // BCD 0x10 = 10 seconds
// Must return Ok; must not panic.
let title = parse_pgc(&pgc, 0, 1).unwrap();
// With first_cell=255, end = min(254, 1) = 1, so chapter_times[0] = dur(cell0) = 10s.
assert_eq!(title.chapter_times.len(), 1);
assert!(
(title.chapter_times[0] - 10.0).abs() < 0.01,
"got {}",
title.chapter_times[0]
);
}
}