Files
libfreemkv/src/ifo.rs
T
Matthew Jackson b8f0af9ef5 1.3.1: relicense to MIT (clean-room CSS + drop copyleft-lib references)
Relicensed from AGPL-3.0 to MIT, effective 1.3.1 (<=1.3.0 remain AGPL). The CSS
content cipher and Stevenson title-key attack are attributed to their published
cryptanalysis (not libdvdcss); all libaacs/libbluray/libdvdread/libdvdnav name
references were dropped from comments while keeping the standard format/spec
descriptions. Also bumps to 1.3.1.
2026-07-10 12:31:19 -07:00

1783 lines
69 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 IFO layout).
/// Packs block_mode (bits 7-6), block_type (bits 5-4), seamless_play
/// (bit 3), interleaved (bit 2), stc_discontinuity (bit 1),
/// seamless_angle (bit 0). Carried so the extent builder can recognise
/// non-feature leading cells (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 IFO cell-playback layout. Byte-0 bitfields,
/// MSB-first: `block_mode`(7-6), `block_type`(5-4), `seamless_play`(3),
/// `interleaved`(2), `stc_discontinuity`(1), `seamless_angle`(0). (The real
/// `cell_type` is a karaoke-only field in byte 1, not used here.)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CellCategory {
/// bits 7-6: 0=not in block, 1=first cell of block, 2=in block, 3=last cell.
pub block_mode: u8,
/// bits 5-4: 0=not part of a block, 1=angle block.
pub block_type: u8,
/// bit 3: seamless playback (STC continuous).
pub seamless_play: bool,
/// bit 2: interleaved (multi-angle / seamless-branch interleave).
pub interleaved: bool,
/// bit 1: STC discontinuity at the start of this cell.
pub stc_discontinuity: bool,
/// bit 0: seamless angle change.
pub seamless_angle: bool,
}
impl CellCategory {
/// Decode the raw `cell_playback + 0` byte (DVD-Video IFO cell playback).
pub fn decode(raw: u8) -> Self {
CellCategory {
block_mode: (raw >> 6) & 0x03,
block_type: (raw >> 4) & 0x03,
seamless_play: (raw & 0x08) != 0,
interleaved: (raw & 0x04) != 0,
stc_discontinuity: (raw & 0x02) != 0,
seamless_angle: (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 (`block_mode` and
/// `block_type` both 0, 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.block_mode == 0 && self.block_type == 0
}
/// Marks a non-first piece of an angle block: an "in-block" or "last of
/// block" cell (`block_mode ∈ {2,3}`) of an angle block (`block_type==1`).
/// Concatenating these back-to-back with the first angle duplicates content
/// at the head of the feature. Conservative: the FIRST cell of a block
/// (`block_mode==1`) is NOT flagged — it is the angle we keep.
pub fn is_secondary_block_piece(&self) -> bool {
self.block_type == 1 && 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";
use crate::consts::SECTOR_BYTES;
// ── 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);
}
// The TT_SRPT pointer is at 0xC4 per the DVD-Video VMGI spec.
// (An informal '62-65' / 0x3E note seen elsewhere is wrong — do not use it.)
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_BYTES)
.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 (the VTSI management table). 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`.
//
// `vtstt_vobs` is a sector address **relative to the start of this VTS_xx_0.IFO
// file**, not an absolute disc LBA. The cell `first_sector`/`last_sector`
// values are in turn relative to `vtstt_vobs`. To turn them into the absolute
// disc LBAs the reader needs, add the IFO file's own on-disc location (from
// the UDF FS). Without this rebase every extent started `ifo_lba` sectors too
// early — for THESILENCEOFTHELAMBS the feature began at LBA 126 (the VMGI /
// VIDEO_TS.VOB main-menu region) instead of 132886 (VTS_03_1.VOB), so the
// first ~4.5 min of muxed video was the disc's main menu before the stream
// drifted into the movie.
let vtstt_vobs = be_u32(&vts_data, VTSTT_VOBS_OFFSET)?;
let ifo_lba = udf.file_start_lba(reader, &path)?;
let vob_start_sector = ifo_lba.saturating_add(vtstt_vobs);
// 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_BYTES)
.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 = (b1 >> 4) & 0x03; // sample_frequency: byte 1 bits 5-4 (DVD-Video audio attributes)
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. On DVD-Video the sub-id's **low nibble is the audio-stream
/// *number* (0-7), shared across all codecs** — the single stream index the
/// PGC `audio_control` table / navigation registers select — and the high
/// nibble is the codec base. So the sub-id is `codec_base | position`, where
/// `position` is the stream's index in the IFO audio-attribute table (NOT a
/// per-codec running count):
/// - AC-3 → `0x80 | i`
/// - DTS → `0x88 | i`
/// - LPCM → `0xA0 | i`
/// - MP1/MP2 and anything else → `None` (regular MPEG-audio PES, not a
/// private-stream-1 sub-id).
///
/// A per-codec ordinal was wrong: it only coincides with the wire id when a
/// codec's first stream is also the disc's audio stream #0. Any codec that is
/// not the first audio stream (e.g. a DTS track after an AC-3 track) then got
/// a sub-id one-too-low, so the demux routing key (`0xBD00 | sub_id`) never
/// matched and the track muxed silent. The positional index is the real wire
/// number, so distinct positions still give distinct sub-ids (no collision).
///
/// Position saturates at 7 so a malformed over-count never produces an
/// out-of-range sub-id.
fn assign_audio_sub_stream_ids(streams: &mut [DvdAudioAttr]) {
for (i, s) in streams.iter_mut().enumerate() {
let n = (i as u8).min(7);
s.sub_stream_id = match s.codec {
Codec::Ac3 => Some(0x80 | n),
Codec::Dts => Some(0x88 | n),
Codec::Lpcm => Some(0xA0 | n),
_ => 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-Video video attributes: 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: the sub-id low nibble is the
// POSITIONAL audio-stream number (shared across codecs), OR'd with the
// codec base. So idx 1 (DTS) → 0x89, idx 3 (AC-3) → 0x83 — the real
// wire ids the demux routes on. All distinct (positions are unique).
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 @ pos 0
assert_eq!(streams[1].sub_stream_id, Some(0x89)); // DTS @ pos 1
assert_eq!(streams[2].sub_stream_id, Some(0xA2)); // LPCM @ pos 2
assert_eq!(streams[3].sub_stream_id, Some(0x83)); // AC-3 @ pos 3
// 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");
}
/// Regression (The Punisher 2004): audio[0]=AC-3 5.1, audio[1]=DTS 5.0.
/// The DTS track sits at audio position 1, so its wire sub-id is 0x89
/// (0x88 | 1), NOT the per-codec 0x88. With the old per-codec ordinal it
/// got 0x88 → demux routing key 0xBD88 had no match → every DTS packet
/// (which carries 0x89) was dropped → the track muxed present-but-silent
/// while the AC-3 (at position 0, where ordinal and position coincide)
/// played fine. Positional numbering fixes it end-to-end.
#[test]
fn dts_after_ac3_uses_positional_substream_id() {
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: 5,
sample_rate: 48000,
language: "en".into(),
sub_stream_id: None,
},
];
assign_audio_sub_stream_ids(&mut streams);
assert_eq!(streams[0].sub_stream_id, Some(0x80));
assert_eq!(
streams[1].sub_stream_id,
Some(0x89),
"DTS at audio position 1 routes to 0x89 on the wire, not 0x88"
);
// The routing key the muxer actually uses must resolve for 0x89.
assert_eq!(crate::mux::ps::dvd_audio_pid(0x89), Some(0xBD89));
}
#[test]
fn audio_attr_dts() {
let mut data = vec![0u8; 16];
// DTS (coding=6), 96kHz (rate=1, byte1 bits 5-4), 2 channels (stored as 1)
// b0: bits 7-5=110(DTS) => 0b110_00000 = 0xC0
data[0] = 0xC0;
// b1: bits 5-4=01(96k), bits 2-0=001(channels-1=1) => 0b00_01_0_001 = 0x11
data[1] = 0x11;
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-Video IFO format; 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(0x81)); // AC3 @ pos 1
}
/// assign_audio_sub_stream_ids saturates the positional index 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 DVD-Video cell-category byte-0 bitfields: block_mode (7-6),
/// block_type (5-4), seamless_play (3), interleaved (2),
/// stc_discontinuity (1), seamless_angle (0).
#[test]
fn cell_category_decode_bits() {
// 0x00 → plain feature, nothing set.
let c = CellCategory::decode(0x00);
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());
// block_mode=1 (first cell of block), block_type=1 (angle block):
// 0b0101_0000 = 0x50. This is the angle we KEEP — not secondary.
let c = CellCategory::decode(0b0101_0000);
assert_eq!(c.block_mode, 1);
assert_eq!(c.block_type, 1);
assert!(!c.is_plain_feature());
assert!(!c.is_secondary_block_piece());
// block_mode=2 (in block) / 3 (last of block) of an angle block
// (block_type=1) → secondary.
assert!(CellCategory::decode(0b1001_0000).is_secondary_block_piece());
assert!(CellCategory::decode(0b1101_0000).is_secondary_block_piece());
// First cell of the block (block_mode=1) is NEVER secondary.
assert!(!CellCategory::decode(0b0101_0000).is_secondary_block_piece());
// The low flags (seamless_play bit3, interleaved bit2, stc bit1,
// seamless_angle bit0) on an otherwise-plain cell must NOT make it
// secondary — they don't mark non-feature content.
let c = CellCategory::decode(0b0000_1111);
assert!(c.seamless_play);
assert!(c.interleaved);
assert!(c.stc_discontinuity);
assert!(c.seamless_angle);
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, 0b1001_0000), // in-block cell of angle block → drop
cell(10, 19, 0b1101_0000), // last cell of angle 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, 0b1001_0000), cell(10, 19, 0b1101_0000)],
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 = 0x90 (in-block cell of angle block), 5s BCD.
pgc[0xEA] = 0x90;
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, 0x90);
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]
);
}
}