use crate::disc::{Codec, Resolution};
use crate::error::{Error, Result};
use crate::sector::SectorSource;
use crate::udf::UdfFs;
#[derive(Debug)]
pub struct DvdInfo {
pub title_sets: Vec<DvdTitleSet>,
}
#[derive(Debug)]
pub struct DvdTitleSet {
pub vts_number: u8,
pub vob_start_sector: u32,
pub video: DvdVideoAttr,
pub audio_streams: Vec<DvdAudioAttr>,
pub subtitle_streams: Vec<DvdSubtitleAttr>,
pub titles: Vec<DvdTitle>,
}
#[derive(Debug)]
#[allow(dead_code)]
pub struct DvdTitle {
pub chapters: u16,
pub duration_secs: f64,
pub cells: Vec<DvdCell>,
pub chapter_times: Vec<f64>,
pub palette: Option<Vec<[u8; 4]>>,
}
#[derive(Debug, Clone)]
pub struct DvdCell {
pub first_sector: u32,
pub last_sector: u32,
pub category: u8,
pub duration_secs: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CellCategory {
pub block_mode: u8,
pub block_type: u8,
pub seamless_play: bool,
pub interleaved: bool,
pub stc_discontinuity: bool,
pub seamless_angle: bool,
}
impl CellCategory {
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,
}
}
pub fn is_plain_feature(&self) -> bool {
self.block_mode == 0 && self.block_type == 0
}
pub fn is_secondary_block_piece(&self) -> bool {
self.block_type == 1 && matches!(self.block_mode, 2 | 3)
}
}
impl DvdTitle {
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);
if !cat.is_secondary_block_piece() {
break;
}
idx += 1;
}
if idx >= n { 0 } else { idx }
}
pub fn feature_cells(&self) -> &[DvdCell] {
&self.cells[self.feature_start_cell()..]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TvSystem {
Ntsc,
Pal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DvdAspect {
R4x3,
R16x9,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct DvdVideoAttr {
pub codec: Codec,
pub resolution: Resolution,
pub aspect: DvdAspect,
pub standard: TvSystem,
}
#[derive(Debug, Clone)]
pub struct DvdAudioAttr {
pub codec: Codec,
pub channels: u8,
pub sample_rate: u32,
pub language: String,
pub sub_stream_id: Option<u8>,
}
#[derive(Debug, Clone)]
pub struct DvdSubtitleAttr {
pub language: String,
}
const VMG_MAGIC: &[u8; 12] = b"DVDVIDEO-VMG";
const VTS_MAGIC: &[u8; 12] = b"DVDVIDEO-VTS";
use crate::consts::SECTOR_BYTES;
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]]))
}
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],
]))
}
fn byte_at(data: &[u8], offset: usize) -> Result<u8> {
data.get(offset).copied().ok_or(Error::IfoParse)
}
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])
}
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, };
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
}
}
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
}
pub fn parse_vmg(reader: &mut dyn SectorSource, udf: &UdfFs) -> Result<DvdInfo> {
let vmg_data = udf.read_file(reader, "/VIDEO_TS/VIDEO_TS.IFO")?;
if vmg_data.len() < 12 || &vmg_data[0..12] != VMG_MAGIC {
return Err(Error::IfoParse);
}
if vmg_data.len() < 0xC8 {
return Err(Error::IfoParse);
}
let tt_srpt_sector = be_u32(&vmg_data, 0xC4)?;
let tt_srpt_offset = (tt_srpt_sector as usize)
.checked_mul(SECTOR_BYTES)
.ok_or(Error::IfoParse)?;
if tt_srpt_offset + 8 > vmg_data.len() {
return Err(Error::IfoParse);
}
let num_titles = be_u16(&vmg_data, tt_srpt_offset)?;
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; }
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; }
title_set_map
.entry(vts_number)
.or_default()
.push((num_chapters, vts_title_num));
}
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(_) => {
continue;
}
}
}
Ok(DvdInfo { title_sets })
}
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)?;
if vts_data.len() < 12 || &vts_data[0..12] != VTS_MAGIC {
return Err(Error::IfoParse);
}
if vts_data.len() < 0x204 {
return Err(Error::IfoParse);
}
const VTSTT_VOBS_OFFSET: usize = 0xC4; const VTS_PGCIT_OFFSET: usize = 0xCC;
let pgcit_sector = be_u32(&vts_data, VTS_PGCIT_OFFSET)?;
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);
let video = parse_video_attr(&vts_data)?;
let num_audio = be_u16(&vts_data, 0x200 + 2)?;
let num_audio = std::cmp::min(num_audio, 8) as usize; 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_audio_sub_stream_ids(&mut audio_streams);
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; 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)?);
}
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,
})
}
const V_ATR_VIDEO_FORMAT_SHIFT: u8 = 4;
const V_ATR_ASPECT_SHIFT: u8 = 2;
const V_ATR_FIELD_MASK: u8 = 0x03;
pub(crate) const VIDEO_FORMAT_NTSC: u8 = 0;
pub(crate) const VIDEO_FORMAT_PAL: u8 = 1;
pub(crate) const ASPECT_4X3: u8 = 0;
pub(crate) const ASPECT_16X9: u8 = 3;
#[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)
}
fn parse_video_attr(data: &[u8]) -> Result<DvdVideoAttr> {
let b0 = byte_at(data, 0x200)?;
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,
};
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,
})
}
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; let sample_rate = match sample_rate_flag {
0 => 48000,
1 => 96000,
_ => 48000,
};
let channels = (b1 & 0x07) + 1;
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(DvdAudioAttr {
codec,
channels,
sample_rate,
language,
sub_stream_id: None,
})
}
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,
};
}
}
fn parse_subtitle_attr(data: &[u8], offset: usize) -> Result<DvdSubtitleAttr> {
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 })
}
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)?;
let entries_start = pgcit_offset + 8;
let mut titles = Vec::new();
for &(chapter_count, vts_title_num) in titles_info {
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;
}
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),
Err(_) => continue,
}
}
Ok(titles)
}
fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result<DvdTitle> {
if pgc_offset + 0xEA > data.len() {
return Err(Error::IfoParse);
}
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);
let cell_playback_offset = be_u16(data, pgc_offset + 0xE8)? as usize;
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,
});
}
}
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() {
let co = cell_base + i * 24;
if co + 8 <= data.len() {
total += bcd_to_secs(&data[co + 4..co + 8]);
}
}
total
} else {
duration_secs
};
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;
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);
}
}
for p in 0..nr_of_programs {
if pgm_base + p >= data.len() {
break;
}
let first_cell = data[pgm_base + p] as usize;
let end = first_cell.saturating_sub(1).min(cell_durations.len());
let time: f64 = cell_durations[..end].iter().sum();
times.push(time);
}
}
times
};
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]]);
}
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,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bcd_to_secs_basic() {
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() {
let bcd = [0x00, 0x01, 0x30, 0b11_010101];
let secs = bcd_to_secs(&bcd);
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() {
let bcd = [0xFF, 0x01, 0x02, 0b01_000000];
let secs = bcd_to_secs(&bcd);
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()); assert!(be_u32(&data, 0).is_err()); }
#[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() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x02] = 1; pgc[0x03] = 2; pgc[0x04] = 0x01; pgc[0x05] = 0x59; pgc[0x06] = 0x30; pgc[0x07] = 0b11_000000; let cell_offset: u16 = 0xEA; pgc[0xE8] = (cell_offset >> 8) as u8;
pgc[0xE9] = cell_offset as u8;
pgc.resize(pgc.len() + 48, 0);
let co = 0xEA;
pgc[co + 8] = 0;
pgc[co + 9] = 0;
pgc[co + 10] = 0;
pgc[co + 11] = 100; pgc[co + 20] = 0;
pgc[co + 21] = 0;
pgc[co + 22] = 0;
pgc[co + 23] = 200; let co = 0xEA + 24;
pgc[co + 8] = 0;
pgc[co + 9] = 0;
pgc[co + 10] = 1;
pgc[co + 11] = 44; pgc[co + 20] = 0;
pgc[co + 21] = 0;
pgc[co + 22] = 1;
pgc[co + 23] = 144;
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);
}
#[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);
}
#[test]
fn video_attr_absolute_bytes_pin_real_layout() {
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),
(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");
}
let mut df = vec![0u8; 0x204];
df[0x200] = 0x03; 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];
data[0] = 0x00;
data[1] = 0x05;
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() {
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)); assert_eq!(streams[1].sub_stream_id, Some(0x88)); assert_eq!(streams[2].sub_stream_id, Some(0xA0)); assert_eq!(streams[3].sub_stream_id, Some(0x81)); 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];
data[0] = 0xC0;
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");
}
#[test]
fn bcd_25fps_frame_contribution() {
let bcd = [0x00, 0x00, 0x00, 0b01_010010]; let secs = bcd_to_secs(&bcd);
assert!((secs - 12.0 / 25.0).abs() < 0.001, "got {secs}");
}
#[test]
fn bcd_unknown_rate_ignores_frames() {
let bcd = [0x00, 0x01, 0x00, 0b00_011001]; let secs = bcd_to_secs(&bcd);
assert!((secs - 60.0).abs() < 0.001, "got {secs}");
let bcd2 = [0x00, 0x01, 0x00, 0b10_011001];
assert!((bcd_to_secs(&bcd2) - 60.0).abs() < 0.001);
}
#[test]
fn bcd_frame_count_masks_rate_bits() {
let bcd = [0x00, 0x00, 0x00, 0b11_100101]; let secs = bcd_to_secs(&bcd);
assert!((secs - 25.0 / 29.97).abs() < 0.001, "got {secs}");
}
#[test]
fn bcd_double_digit_hours() {
let bcd = [0x10, 0x00, 0x00, 0x00]; let secs = bcd_to_secs(&bcd);
assert!((secs - 10.0 * 3600.0).abs() < 0.01, "got {secs}");
}
#[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()); }
#[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());
}
#[test]
fn video_attr_reserved_standard_defaults_ntsc() {
let mut data = vec![0u8; 0x204];
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);
}
#[test]
fn video_attr_reserved_aspect_defaults_4_3() {
let mut data = vec![0u8; 0x204];
data[0x200] = v_atr_byte(VIDEO_FORMAT_NTSC, ASPECT_4X3 + 1);
let attr = parse_video_attr(&data).unwrap();
assert_eq!(attr.aspect, DvdAspect::R4x3);
}
#[test]
fn audio_attr_lpcm_and_unknown_coding() {
let mut data = vec![0u8; 8];
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);
let mut data2 = vec![0u8; 8];
data2[0] = 0b001_00000; let attr2 = parse_audio_attr(&data2, 0).unwrap();
assert_eq!(attr2.codec, Codec::Unknown(1));
}
#[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, "");
}
#[test]
fn audio_attr_reserved_rate_defaults_48k() {
let mut data = vec![0u8; 8];
data[0] = 0b0001_0000; let attr = parse_audio_attr(&data, 0).unwrap();
assert_eq!(attr.sample_rate, 48000);
}
#[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, "");
}
#[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); assert_eq!(streams[1].sub_stream_id, Some(0x80)); }
#[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}"
);
}
assert_eq!(streams[7].sub_stream_id, Some(0x87));
assert_eq!(streams[8].sub_stream_id, Some(0x87));
}
#[test]
fn pgc_too_short_errs() {
let pgc = vec![0u8; 0xE9]; assert!(parse_pgc(&pgc, 0, 1).is_err());
}
#[test]
fn pgc_truncated_cell_table_stops() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x02] = 1;
pgc[0x03] = 3; pgc[0xE8] = 0x00;
pgc[0xE9] = 0xEA;
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();
assert_eq!(title.cells.len(), 2);
assert_eq!(title.cells[0].first_sector, 10);
assert_eq!(title.cells[1].first_sector, 20);
}
#[test]
fn pgc_palette_present_and_empty() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x03] = 0; 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]);
let mut pgc2 = vec![0u8; 0xEA];
pgc2[0x03] = 0;
let title2 = parse_pgc(&pgc2, 0, 1).unwrap();
assert!(title2.palette.is_none());
}
#[test]
fn pgc_palette_padding_only_is_empty() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x03] = 0;
pgc[0xA4] = 0xFF;
let title = parse_pgc(&pgc, 0, 1).unwrap();
assert!(
title.palette.is_none(),
"padding-only palette must be treated as empty"
);
}
#[test]
fn pgc_chapter_times_from_program_map() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x02] = 2; pgc[0x03] = 3; let pgm_off: u16 = 0xEA;
pgc[0xE6] = (pgm_off >> 8) as u8;
pgc[0xE7] = pgm_off as u8;
let cell_off: u16 = 0xEA + 2; pgc[0xE8] = (cell_off >> 8) as u8;
pgc[0xE9] = cell_off as u8;
pgc.resize(cell_off as usize + 3 * 24, 0);
pgc[0xEA] = 1;
pgc[0xEB] = 3;
let cb = cell_off as usize;
pgc[cb + 6] = 0x05; pgc[cb + 24 + 6] = 0x07; pgc[cb + 48 + 6] = 0x09;
let title = parse_pgc(&pgc, 0, 2).unwrap();
assert_eq!(title.chapter_times.len(), 2);
assert!((title.chapter_times[0] - 0.0).abs() < 0.01);
assert!(
(title.chapter_times[1] - 12.0).abs() < 0.01,
"got {}",
title.chapter_times[1]
);
}
#[test]
fn pgc_nonzero_duration_not_recomputed() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x02] = 1;
pgc[0x03] = 1;
pgc[0x05] = 0x01; pgc[0x07] = 0b01_000000; pgc[0xE8] = 0x00;
pgc[0xE9] = 0xEA;
pgc.resize(0xEA + 24, 0);
pgc[0xEA + 6] = 0x59; 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
);
}
#[test]
fn pgc_zero_cell_offset_no_cells() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x03] = 5; let title = parse_pgc(&pgc, 0, 1).unwrap();
assert!(title.cells.is_empty());
}
fn cell(first: u32, last: u32, category: u8) -> DvdCell {
DvdCell {
first_sector: first,
last_sector: last,
category,
duration_secs: 0.0,
}
}
#[test]
fn cell_category_decode_bits() {
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());
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());
assert!(CellCategory::decode(0b1001_0000).is_secondary_block_piece());
assert!(CellCategory::decode(0b1101_0000).is_secondary_block_piece());
assert!(!CellCategory::decode(0b0101_0000).is_secondary_block_piece());
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());
}
#[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);
}
#[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), cell(10, 19, 0b1101_0000), cell(20, 119, 0x00), 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);
}
#[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);
}
#[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());
}
#[test]
fn pgc_reads_cell_category_and_duration() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x02] = 1;
pgc[0x03] = 2; pgc[0xE8] = 0x00;
pgc[0xE9] = 0xEA;
pgc.resize(0xEA + 48, 0);
pgc[0xEA] = 0x90;
pgc[0xEA + 6] = 0x05;
pgc[0xEA + 8..0xEA + 12].copy_from_slice(&10u32.to_be_bytes());
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);
assert_eq!(title.feature_start_cell(), 1);
}
#[test]
fn pgc_program_map_oob_cell_index_no_panic() {
let mut pgc = vec![0u8; 0xEA];
pgc[0x02] = 1; pgc[0x03] = 1;
let pgm_off: u16 = 0xEA;
pgc[0xE6] = (pgm_off >> 8) as u8;
pgc[0xE7] = pgm_off as u8;
let cell_off: u16 = 0xEA + 1;
pgc[0xE8] = (cell_off >> 8) as u8;
pgc[0xE9] = cell_off as u8;
pgc.resize(cell_off as usize + 24, 0);
pgc[0xEA] = 0xFF;
pgc[cell_off as usize + 6] = 0x10;
let title = parse_pgc(&pgc, 0, 1).unwrap();
assert_eq!(title.chapter_times.len(), 1);
assert!(
(title.chapter_times[0] - 10.0).abs() < 0.01,
"got {}",
title.chapter_times[0]
);
}
}