From 68dfffda946f8df4621cf5ab34c82861973cbe4d Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:09:44 -0700 Subject: [PATCH] dvd: play a title the way its navigation packs say A cell in an interleaved block shares its sectors with another program's units (a second language version), and each VOB runs its own clock. The mux read every sector of a cell and inferred joins from timestamp jumps, so a title mixed in the other version's video, froze at the jumps, and lost about half a second of audio at each VOB join. Each VOBU's navigation pack names its cell and its start/end time: keep only the VOBUs of the cell each extent opens with, and shift each new VOB's clock to start where the last kept VOBU ended, on every track at once. --- src/mux/pipelined_stream.rs | 21 +++- src/mux/ps.rs | 222 ++++++++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+), 1 deletion(-) diff --git a/src/mux/pipelined_stream.rs b/src/mux/pipelined_stream.rs index 39d3beeb..94084d1a 100644 --- a/src/mux/pipelined_stream.rs +++ b/src/mux/pipelined_stream.rs @@ -43,6 +43,8 @@ pub struct PipelinedPesStream { /// are expected on every disc; instead of a per-packet WARN they're tallied /// and summarised once at EOF. dropped_nav_packets: u64, + /// DVD: keeps each cell's own VOBUs and joins the VOBs' clocks into one timeline. + vobu_nav: Option, /// Packets of MPEG-2 audio extension streams (`0xD0|n`) with no declared extension track, /// reported once at EOF (see `ps::warn_undeclared_extensions`). mpeg_extension_packets: [u64; 8], @@ -120,6 +122,9 @@ impl PipelinedPesStream { .then(super::au_assembly::SecondFieldMerge::default) }) .collect(); + let vobu_nav = (title.content_format == crate::disc::ContentFormat::MpegPs + && !title.extents.is_empty()) + .then(|| super::ps::VobuNav::new(&title.extents)); Self { title, parsers, @@ -131,6 +136,7 @@ impl PipelinedPesStream { failed: None, skip_parse: false, dropped_nav_packets: 0, + vobu_nav, mpeg_extension_packets: [0; 8], dropped_ps: Default::default(), resync, @@ -274,7 +280,12 @@ impl PipelinedPesStream { } fn consume_ps(&mut self, packets: Vec) { - for ps in packets { + for mut ps in packets { + if let Some(nav) = self.vobu_nav.as_mut() + && !nav.admit(&mut ps) + { + continue; + } if let Some(id) = self.video_stream_id && (0xE0..=0xEF).contains(&ps.stream_id) && ps.stream_id != id @@ -382,6 +393,14 @@ impl PipelinedPesStream { self.dropped_nav_packets ); } + if let Some(n) = self + .vobu_nav + .as_ref() + .map(|g| g.dropped_vobus) + .filter(|&n| n > 0) + { + tracing::info!(target: "mux", vobus = n, "left out VOBUs interleaved from another program"); + } super::ps::warn_undeclared_extensions(&self.mpeg_extension_packets); self.dropped_ps.report(); // Drain any AU a parser buffered past the last PES (DTS-HD tail, MPEG-2 diff --git a/src/mux/ps.rs b/src/mux/ps.rs index a30ba1e2..e6dabce1 100644 --- a/src/mux/ps.rs +++ b/src/mux/ps.rs @@ -144,6 +144,122 @@ impl DroppedPs { } } +/// Reads a DVD title the way its navigation packs say to play it. +/// +/// A DVD cell is a sector range, and in an interleaved block that range also holds the units of +/// another program (a second language version, say). Every VOBU opens with a navigation pack +/// naming its VOB and cell; a cell's first sector is its own first VOBU, so the first one read +/// in each extent names the cell, and any later VOBU naming another belongs to the other program. +/// +/// The same pack gives the VOBU's start and end time. Each VOB runs its own clock, so where a +/// kept VOBU does not start where the last one ended, every later packet is shifted to close +/// the jump: the title plays as one timeline, on every track at once. +#[derive(Debug, Default)] +pub(crate) struct VobuNav { + // Feed byte offset at which each extent ends, in read order. + ends: Vec, + extent: usize, + cell: Option<(u16, u8)>, + keep: bool, + // The current VOBU's start/end time from its PCI packet, until its DSI decides it is kept. + pci: Option<(i64, i64)>, + // Where the last kept VOBU ended, in shifted time, and the shift in force (90 kHz). + end: Option, + shift: i64, + pub(crate) dropped_vobus: u64, + pub(crate) joins: u64, +} + +// A jump smaller than this (1 ms) is rounding, not a new clock. +const JOIN_SLACK_TICKS: i64 = 90; + +impl VobuNav { + /// Navigation for a title read as `extents`, back to back. + pub(crate) fn new(extents: &[crate::disc::Extent]) -> Self { + let mut end = 0u64; + let ends = extents + .iter() + .map(|e| { + end += e.sector_count as u64 * 2048; + end + }) + .collect(); + VobuNav { + ends, + keep: true, + ..Default::default() + } + } + + /// Whether `ps` belongs to the cell being read; a kept packet is moved onto the title's + /// timeline. Packets without a source position pass untouched. + pub(crate) fn admit(&mut self, ps: &mut PsPacket) -> bool { + let Some(at) = ps.source.map(|s| s.byte) else { + return true; + }; + while self.ends.get(self.extent).is_some_and(|&end| at >= end) { + self.extent += 1; + self.cell = None; + self.keep = true; + } + if ps.is_nav() { + match ps.data.first() { + Some(0x00) => self.pci = pci_times(&ps.data), + Some(0x01) => self.on_dsi(&ps.data), + _ => {} + } + return self.keep; + } + if self.keep && self.shift != 0 { + let moved = |t: u64| (t as i64).saturating_add(self.shift).max(0) as u64; + ps.pts = ps.pts.map(moved); + ps.dts = ps.dts.map(moved); + } + self.keep + } + + fn on_dsi(&mut self, dsi: &[u8]) { + let Some(id) = dsi_cell(dsi) else { + return; + }; + let cell = *self.cell.get_or_insert(id); + self.keep = id == cell; + if !self.keep { + self.dropped_vobus += 1; + return; + } + let Some((start, end)) = self.pci.take() else { + return; + }; + if let Some(prev) = self.end + && (start + self.shift - prev).abs() > JOIN_SLACK_TICKS + { + self.shift = prev - start; + self.joins += 1; + } + self.end = Some(end + self.shift); + } +} + +// The VOBU start and end presentation times a PCI packet gives (90 kHz). +fn pci_times(payload: &[u8]) -> Option<(i64, i64)> { + // Substream 0x00, then PCI_GI: LBN (4), category (2), reserved (2), UOP control (4), + // start time (4), end time (4). + let word = |o: usize| Some(u32::from_be_bytes(payload.get(o..o + 4)?.try_into().ok()?) as i64); + let (start, end) = (word(13)?, word(17)?); + (end > start).then_some((start, end)) +} + +// The (VOB id, cell id) a DSI packet's general information names. +fn dsi_cell(payload: &[u8]) -> Option<(u16, u8)> { + // Substream 0x01, then DSI_GI: SCR, LBN, VOBU end and three reference ends (4 bytes each), + // VOB id (2), a reserved byte and the cell id. + if payload.first() != Some(&0x01) || payload.len() < 29 { + return None; + } + Some((u16::from_be_bytes([payload[25], payload[26]]), payload[28])) +} + /// Reports, once at end of stream, MPEG-2 audio extension packets (`0xD0|n`, index `n`) that /// had no declared extension track (the IFO did not say coding mode 3) and were left out. pub(crate) fn warn_undeclared_extensions(packets: &[u64; 8]) { @@ -1238,6 +1354,112 @@ mod tests { } } + // A video packet at feed byte `at` with presentation time `pts` (90 kHz). + fn video(at: u64, pts: u64) -> PsPacket { + let mut p = mk(0xE0, None); + p.pts = Some(pts); + p.source = Some(crate::pes::SourcePos::at_byte(at)); + p + } + + // The PCI and DSI packets of a navigation pack at `at`, for a VOBU of `cell` timed + // `start..end`. + fn nav_pack(at: u64, cell: (u16, u8), start: u32, end: u32) -> [PsPacket; 2] { + let mut pci = mk(PRIVATE_STREAM_2, None); + pci.data = vec![0u8; 21]; + pci.data[13..17].copy_from_slice(&start.to_be_bytes()); + pci.data[17..21].copy_from_slice(&end.to_be_bytes()); + let mut dsi = mk(PRIVATE_STREAM_2, None); + dsi.data = vec![0u8; 29]; + dsi.data[0] = 0x01; + dsi.data[25..27].copy_from_slice(&cell.0.to_be_bytes()); + dsi.data[28] = cell.1; + for p in [&mut pci, &mut dsi] { + p.source = Some(crate::pes::SourcePos::at_byte(at)); + } + [pci, dsi] + } + + // Feed `packets` through `nav`, returning the PTS of each kept non-navigation packet. + fn kept(nav: &mut VobuNav, packets: Vec) -> Vec { + let mut out = Vec::new(); + for mut p in packets { + if nav.admit(&mut p) && !p.is_nav() { + out.extend(p.pts); + } + } + out + } + + const SECTOR: u64 = 2048; + + #[test] + fn vobu_nav_keeps_only_the_cell_each_extent_opens_with() { + let extents = [ + crate::disc::Extent { + start_lba: 0, + sector_count: 10, + }, + crate::disc::Extent { + start_lba: 50, + sector_count: 10, + }, + ]; + let mut nav = VobuNav::new(&extents); + let s = |n: u64| n * SECTOR; + let mut packets = Vec::new(); + // Extent 1 is cell (7, 2), with a unit of (8, 1) woven in. + packets.extend(nav_pack(s(0), (7, 2), 0, 900)); + packets.push(video(s(1), 100)); + packets.extend(nav_pack(s(3), (8, 1), 50_000, 50_900)); + packets.push(video(s(4), 50_100)); + packets.extend(nav_pack(s(6), (7, 2), 900, 1_800)); + packets.push(video(s(7), 1_000)); + // Extent 2 opens with (8, 1): that is now the cell being read. + packets.extend(nav_pack(s(10), (8, 1), 1_800, 2_700)); + packets.push(video(s(11), 1_900)); + packets.extend(nav_pack(s(12), (7, 2), 2_700, 3_600)); + packets.push(video(s(13), 2_800)); + assert_eq!(kept(&mut nav, packets), vec![100, 1_000, 1_900]); + assert_eq!(nav.dropped_vobus, 2); + assert_eq!(nav.joins, 0); + // A packet the demuxer could not place passes untouched. + let mut loose = mk(0xE0, None); + assert!(nav.admit(&mut loose)); + } + + #[test] + fn vobu_nav_joins_each_vob_clock_onto_the_last() { + // One cell per extent, as a title is read: VOB 4's cell, then VOB 5's. + let cells = [ + crate::disc::Extent { + start_lba: 0, + sector_count: 2, + }, + crate::disc::Extent { + start_lba: 2, + sector_count: 10, + }, + ]; + let mut nav = VobuNav::new(&cells); + let s = |n: u64| n * SECTOR; + let mut packets = Vec::new(); + // VOB 4 runs 0.1 s .. 126.6 s; VOB 5 restarts its clock at 0.07 s. + packets.extend(nav_pack(s(0), (4, 1), 9_000, 11_394_000)); + packets.push(video(s(1), 11_390_000)); + packets.extend(nav_pack(s(2), (5, 1), 6_300, 60_300)); + packets.push(video(s(3), 6_300)); + packets.push(video(s(4), 1_000)); // audio that leads its video in the new VOB + packets.extend(nav_pack(s(5), (5, 1), 60_300, 114_300)); + packets.push(video(s(6), 60_300)); + let shift = 11_394_000 - 6_300; + assert_eq!( + kept(&mut nav, packets), + vec![11_390_000, 6_300 + shift, 1_000 + shift, 60_300 + shift] + ); + assert_eq!(nav.joins, 1); + } + #[test] fn parse_extended_stream_id_extracts_stream_id_extension() { // SHAUN's VC-1 video PES: stream_id 0xFD, flags2=0x01 (PES_extension only),