From 3b0c1f2c6c34c2a33f430c1ab45b15caec98ae0f Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:06:40 -0700 Subject: [PATCH] dvd: the unit walk stops on Stop; duration and chapter names follow the chapter marks The interleaved-unit walk polls halt before each read, passes a drive's Halted up instead of taking it for an unreadable chain, gives the cell back whole on a short read rather than reparsing the last pack, and cannot overflow on a cell spanning every sector. A title's duration no longer subtracts the dropped leading angle pieces: the PGC time never counted them, and the cell-sum fallback now counts an angle block once, as the chapter marks do. Text-data chapter names are placed by the part-of-title table on the program each chapter starts at; names that cannot be placed one per program are dropped. A text-data name is looked for within its longest accepted length, not to the end of the table. --- src/disc/dvd.rs | 335 ++++++++++++++++++++++++++++++++++++++++++------ src/ifo.rs | 124 +++++++++++++----- 2 files changed, 392 insertions(+), 67 deletions(-) diff --git a/src/disc/dvd.rs b/src/disc/dvd.rs index 5a8f4493..de999a6d 100644 --- a/src/disc/dvd.rs +++ b/src/disc/dvd.rs @@ -24,43 +24,80 @@ const DSI_NEXT_ILVU_SA: usize = DSI_DATA + 32 + 6; // SML_PBI's "no next unit": 0xFFFFFFFF by the book, and discs also write 0x7FFFFFFF. const NO_NEXT_ILVU: u32 = 0x7FFF_FFFF; -// The sector ranges an interleaved cell plays: its own interleaved units, found by following -// each unit's navigation pack. The chain runs per VOB, past the cell, so it is clipped to the -// cell. `None` when a pack does not read or parse, which leaves the cell's range to the caller. +// An interleaved cell's own units, following each unit's nav pack, clipped to the cell (at most +// one read per cell sector, `halt` polled before each). `Ok(None)` when a pack does not read +// whole or parse, which leaves the caller the cell's range; a Stop is `Err(Halted)`. fn interleaved_units( reader: &mut dyn SectorSource, vob_start: u32, cell: &ifo::DvdCell, -) -> Option> { - let mut buf = vec![0u8; 2048]; + halt: Option<&crate::halt::Halt>, +) -> Result>> { + let mut buf = vec![0u8; crate::consts::SECTOR_BYTES]; let mut at = cell.first_sector; let mut units = Vec::new(); while at <= cell.last_sector { - reader - .read_sectors(vob_start.checked_add(at)?, 1, &mut buf, false) - .ok()?; + if halt.is_some_and(|h| h.is_cancelled()) { + return Err(Error::Halted); + } + let Some(lba) = vob_start.checked_add(at) else { + return Ok(None); + }; + match reader.read_sectors(lba, 1, &mut buf, false) { + Ok(n) if n >= buf.len() => {} + Err(Error::Halted) => return Err(Error::Halted), + Ok(_) | Err(_) => return Ok(None), + } let is_nav = buf[..4] == [0, 0, 1, 0xBA] && buf[0x400..0x404] == [0, 0, 1, 0xBF] && buf[0x406] == 0x01; if !is_nav { - return None; + return Ok(None); } let word = |o: usize| u32::from_be_bytes([buf[o], buf[o + 1], buf[o + 2], buf[o + 3]]); let (end_rel, next_rel) = (word(DSI_ILVU_EA), word(DSI_NEXT_ILVU_SA)); - if end_rel == 0 { - return None; - } - let end = at.checked_add(end_rel)?.min(cell.last_sector); + let end = at.saturating_add(end_rel).min(cell.last_sector); + let Some(sector_count) = (end - at).checked_add(1).filter(|_| end_rel != 0) else { + return Ok(None); + }; units.push(Extent { - start_lba: vob_start.checked_add(at)?, - sector_count: end - at + 1, + start_lba: lba, + sector_count, }); if next_rel >= NO_NEXT_ILVU || next_rel == 0 { break; } - at = at.checked_add(next_rel)?; + let Some(next) = at.checked_add(next_rel) else { + break; + }; + at = next; } - (!units.is_empty()).then_some(units) + Ok((!units.is_empty()).then_some(units)) +} + +// Chapter (part of title) names moved onto the `programs` program marks: each part names the +// program it starts at. `None` when that is not one name per program at most (a part in +// another PGC, two parts on one program, a part past the marks). +fn program_names( + names: &[Option], + title: &ifo::DvdTitle, + programs: usize, +) -> Option>> { + let Some(parts) = title.ptt_programs.as_deref() else { + // No part table: parts are taken as programs 1, 2, ... + return Some(names.to_vec()); + }; + if parts.len() != names.len() { + return None; + } + let mut out: Vec>> = vec![None; programs]; + for (&pgn, name) in parts.iter().zip(names) { + let slot = out.get_mut(usize::from(pgn).checked_sub(1)?)?; + if slot.replace(name.clone()).is_some() { + return None; + } + } + Some(out.into_iter().map(Option::flatten).collect()) } impl Disc { @@ -209,10 +246,7 @@ impl Disc { extents.push(whole); continue; } - if halt.is_some_and(|h| h.is_cancelled()) { - return Err(Error::Halted); - } - match interleaved_units(reader, ts.vob_start_sector, cell) { + match interleaved_units(reader, ts.vob_start_sector, cell, halt)? { Some(units) => extents.extend(units), None => { tracing::warn!( @@ -295,10 +329,12 @@ impl Disc { .collect(); let in_head = shifted.iter().filter(|&&t| t <= 0.0).count(); let skipped = in_head.saturating_sub(1); - // Disc text names index the chapter before the head was dropped. + // Disc text names each chapter (part of title); a mark is a program, indexed + // from before the head was dropped. let names = dvd_info .chapter_names - .get(&(ts.vts_number, dvd_title.vts_title_num)); + .get(&(ts.vts_number, dvd_title.vts_title_num)) + .and_then(|n| program_names(n, dvd_title, shifted.len())); let chapters: Vec = shifted .into_iter() .skip(skipped) @@ -306,6 +342,7 @@ impl Disc { .map(|(i, time_secs)| Chapter { time_secs, name: names + .as_ref() .and_then(|n| n.get(skipped + i).cloned().flatten()) .unwrap_or_else(|| chapter_name(i)), }) @@ -314,7 +351,8 @@ impl Disc { titles.push(DiscTitle { playlist: format!("VTS_{:02}_{}.VOB", ts.vts_number, title_number), playlist_id: title_number, - duration_secs: (dvd_title.duration_secs - dropped_secs).max(0.0), + // The dropped head is angle pieces, which the title time already leaves out. + duration_secs: dvd_title.duration_secs, size_bytes, clips: Vec::new(), streams, @@ -2101,7 +2139,9 @@ mod tests { nav_pack(&mut disc, vob + 100, 9, 30); // unit 100..=109, next at 130 nav_pack(&mut disc, vob + 130, 4, 20); // unit 130..=134, next at 150 nav_pack(&mut disc, vob + 150, 9, end_marker); // unit 150..=159, last - let units = super::interleaved_units(&mut disc, vob, &icell(100, 159)).unwrap(); + let units = super::interleaved_units(&mut disc, vob, &icell(100, 159), None) + .unwrap() + .unwrap(); let spans: Vec<(u32, u32)> = units .iter() .map(|e| (e.start_lba, e.sector_count)) @@ -2116,9 +2156,102 @@ mod tests { fn an_interleaved_cell_unit_walk_stays_inside_the_cell() { let mut disc = MemDisc::new(); nav_pack(&mut disc, 100, 50, 0x7FFF_FFFF); - let units = super::interleaved_units(&mut disc, 0, &icell(100, 120)).unwrap(); + let units = super::interleaved_units(&mut disc, 0, &icell(100, 120), None) + .unwrap() + .unwrap(); assert_eq!((units[0].start_lba, units[0].sector_count), (100, 21)); - assert!(super::interleaved_units(&mut disc, 0, &icell(500, 600)).is_none()); + assert!( + super::interleaved_units(&mut disc, 0, &icell(500, 600), None) + .unwrap() + .is_none() + ); + } + + // A source that reads `inner`, cancels `halt` after `cancel_after` reads, and answers a + // read at or past `short_from` with Ok(0) and the buffer untouched. + struct WalkReader<'a> { + inner: &'a mut MemDisc, + halt: crate::halt::Halt, + cancel_after: u32, + short_from: u32, + reads: u32, + } + impl SectorSource for WalkReader<'_> { + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + recovery: bool, + ) -> crate::error::Result { + self.reads += 1; + if self.reads >= self.cancel_after { + self.halt.cancel(); + } + if lba >= self.short_from { + return Ok(0); + } + self.inner.read_sectors(lba, count, buf, recovery) + } + } + + fn three_unit_chain() -> MemDisc { + let mut disc = MemDisc::new(); + nav_pack(&mut disc, 100, 9, 30); + nav_pack(&mut disc, 130, 4, 20); + nav_pack(&mut disc, 150, 9, 0xFFFF_FFFF); + disc + } + + // A Stop during the unit walk ends it as Halted, whether raised between reads or by the + // drive's own read; neither is mistaken for an unreadable chain. + #[test] + fn an_interleaved_unit_walk_stops_on_halt() { + let mut disc = three_unit_chain(); + let halt = crate::halt::Halt::new(); + let mut reader = WalkReader { + inner: &mut disc, + halt: halt.clone(), + cancel_after: 1, + short_from: u32::MAX, + reads: 0, + }; + let walk = super::interleaved_units(&mut reader, 0, &icell(100, 159), Some(&halt)); + assert!(matches!(walk, Err(Error::Halted)), "{walk:?}"); + assert_eq!(reader.reads, 1, "no read after the Stop"); + + let mut disc = three_unit_chain(); + let mut drive = HaltingReader { + inner: &mut disc, + halt_at: 130, + }; + let walk = super::interleaved_units(&mut drive, 0, &icell(100, 159), None); + assert!(matches!(walk, Err(Error::Halted)), "{walk:?}"); + } + + // A read that returns short leaves the last pack in the buffer: the walk gives the cell + // back whole rather than reparse it as the next unit. + #[test] + fn a_short_read_in_the_unit_walk_is_unreadable() { + let mut disc = three_unit_chain(); + let mut reader = WalkReader { + inner: &mut disc, + halt: crate::halt::Halt::new(), + cancel_after: u32::MAX, + short_from: 130, + reads: 0, + }; + let walk = super::interleaved_units(&mut reader, 0, &icell(100, 159), None); + assert!(matches!(walk, Ok(None)), "{walk:?}"); + } + + // A cell spanning the whole sector space whose first unit claims all of it: no overflow. + #[test] + fn an_interleaved_unit_spanning_every_sector_does_not_overflow() { + let mut disc = MemDisc::new(); + nav_pack(&mut disc, 0, u32::MAX, 0xFFFF_FFFF); + let walk = super::interleaved_units(&mut disc, 0, &icell(0, u32::MAX), None); + assert!(matches!(walk, Ok(None)), "{walk:?}"); } /// A chapter named in the VMG text data reaches Chapter.name in place of @@ -2193,7 +2326,7 @@ mod tests { } // A leading interleaved-angle sub-block cell (category 0x90) must be dropped from muxed - // extents; chapters shift earlier by its duration. + // extents; chapter marks never counted its time. #[test] fn scan_dvd_titles_drops_leading_scene_index_cell() { let mut disc = MemDisc::new(); @@ -2236,18 +2369,17 @@ mod tests { // First extent starts at the feature cell (vob 1000 + 100), not at 1000+0. assert_eq!(t.extents[0].start_lba, 9000 + 1000 + 100); // ifo_lba + vtstt + first assert_eq!(t.extents[1].start_lba, 9000 + 1000 + 300); - // Chapter times shift earlier by the dropped 5s. Program 0 was at the - // dropped head (clamped to 0); program 1 was at cell 3 = - // dur(cell0)+dur(cell1) = 5 + 59 = 64s, now 59s after the 5s shift. + // The 0x90 head cell is an angle piece, which chapter timing gives no time: program 0 + // starts at 0 and program 1 at cell 3 = dur(cell1) = 59s. assert_eq!(t.chapters.len(), 2); assert!( (t.chapters[0].time_secs - 0.0).abs() < 0.01, - "ch0 clamped to 0, got {}", + "ch0 at 0, got {}", t.chapters[0].time_secs ); assert!( (t.chapters[1].time_secs - 59.0).abs() < 0.01, - "ch1 shifted by dropped 5s → 59s, got {}", + "ch1 at 59s, got {}", t.chapters[1].time_secs ); } @@ -2335,9 +2467,22 @@ mod tests { } fn scan_cells(cells: &[(u32, u32, u8, u8)], programs: &[u8], nchap: u16) -> DiscTitle { + scan_vts(build_vts_cells(1000, 0x00, cells, programs), nchap) + } + + fn scan_vts(vts: Vec, nchap: u16) -> DiscTitle { + scan_vts_named(vts, nchap, &[]) + } + + // `scan_vts` with VMG text data naming the title's chapters `names` (none: no text data). + fn scan_vts_named(vts: Vec, nchap: u16, names: &[&[u8]]) -> DiscTitle { let mut disc = MemDisc::new(); - let vmg = build_vmg(&[(nchap, 1, 1)]); - let vts = build_vts_cells(1000, 0x00, cells, programs); + let mut vmg = build_vmg(&[(nchap, 1, 1)]); + if !names.is_empty() { + let mut items: Vec<(u8, &[u8])> = vec![(0x02, b"feature")]; + items.extend(names.iter().map(|n| (0x04, *n))); + vmg = ifo::with_txtdt(vmg, &ifo::build_txtdt_mg(0x11, &items)); + } let udf = build_video_ts_fs( &mut disc, &[ @@ -2361,7 +2506,124 @@ mod tests { .remove(0) } - // Dropped head time comes off the duration, and head chapters collapse to one mark at 0. + // Adds a VTS_PTT_SRPT to a `build_vts_cells` IFO: title 1's chapters, (PGCN, PGN) each. + fn with_ptt(mut vts: Vec, parts: &[(u16, u16)]) -> Vec { + let at = 3 * 2048; + vts[0xC8..0xCC].copy_from_slice(&3u32.to_be_bytes()); + vts[at..at + 2].copy_from_slice(&1u16.to_be_bytes()); + let last = (12 + parts.len() * 4 - 1) as u32; + vts[at + 4..at + 8].copy_from_slice(&last.to_be_bytes()); + vts[at + 8..at + 12].copy_from_slice(&12u32.to_be_bytes()); + for (i, (pgcn, pgn)) in parts.iter().enumerate() { + let e = at + 12 + i * 4; + vts[e..e + 2].copy_from_slice(&pgcn.to_be_bytes()); + vts[e + 2..e + 4].copy_from_slice(&pgn.to_be_bytes()); + } + vts + } + + fn names(t: &DiscTitle) -> Vec<&str> { + t.chapters.iter().map(|c| c.name.as_str()).collect() + } + + const FOUR_CELLS: [(u32, u32, u8, u8); 4] = [ + (0, 99, 0x00, 0x10), + (100, 199, 0x00, 0x10), + (200, 299, 0x00, 0x10), + (300, 399, 0x00, 0x10), + ]; + + // Chapter names follow the part-of-title table: two named chapters starting at programs + // 1 and 3 name those marks, and the marks between keep their ordinals. + #[test] + fn scan_dvd_titles_names_the_program_each_chapter_starts_at() { + let vts = with_ptt( + build_vts_cells(1000, 0x00, &FOUR_CELLS, &[1, 2, 3, 4]), + &[(1, 1), (1, 3)], + ); + let t = scan_vts_named(vts, 2, &[b"Opening", b"Finale"]); + assert_eq!(names(&t), ["Opening", "2", "Finale", "4"]); + } + + // A chapter in another PGC, or two on one program, cannot be placed: no names. + #[test] + fn scan_dvd_titles_drops_names_it_cannot_place() { + for parts in [[(1, 1), (2, 1)], [(1, 2), (1, 2)]] { + let vts = with_ptt( + build_vts_cells(1000, 0x00, &FOUR_CELLS, &[1, 2, 3, 4]), + &parts, + ); + let t = scan_vts_named(vts, 2, &[b"Opening", b"Finale"]); + assert_eq!(names(&t), ["1", "2", "3", "4"], "{parts:?}"); + } + } + + // Names index the marks from before the head collapsed: the first kept mark is program 3. + #[test] + fn scan_dvd_titles_names_skip_the_collapsed_head() { + let cells = [ + (0, 9, 0x90, 0x05), + (10, 19, 0x90, 0x05), + (100, 199, 0x00, 0x20), + (300, 399, 0x00, 0x20), + ]; + let parts = [(1, 1), (1, 2), (1, 3), (1, 4)]; + let vts = with_ptt(build_vts_cells(1000, 0x00, &cells, &[1, 2, 3, 4]), &parts); + let t = scan_vts_named(vts, 4, &[b"Logo", b"Index", b"Main", b"End"]); + assert_eq!(names(&t), ["Main", "End"]); + } + + // Sets the PGC playback time (BCD seconds) of a `build_vts_cells` IFO. + fn with_pgc_secs(mut vts: Vec, bcd_secs: u8) -> Vec { + vts[2 * 2048 + 0x100 + 6] = bcd_secs; + vts + } + + // The PGC's own playback time counts an angle block once: the dropped leading angle + // pieces are not in it, so nothing comes off it, and the last chapter mark stays inside. + #[test] + fn scan_dvd_titles_pgc_time_is_not_cut_by_the_dropped_head() { + let cells = [ + (0, 9, 0x90, 0x05), + (10, 19, 0x90, 0x05), + (100, 199, 0x00, 0x20), + (300, 399, 0x00, 0x20), + ]; + let vts = with_pgc_secs(build_vts_cells(1000, 0x00, &cells, &[3, 4]), 0x40); + let t = scan_vts(vts, 2); + assert!( + (t.duration_secs - 40.0).abs() < 0.01, + "got {}", + t.duration_secs + ); + assert!((t.chapters[1].time_secs - 20.0).abs() < 0.01); + } + + // With no PGC time, the duration summed from cells leaves out an angle block's other + // angles wherever the block sits, as the chapter marks do. + #[test] + fn scan_dvd_titles_cell_sum_counts_an_angle_block_once() { + let t = scan_cells( + &[ + (0, 99, 0x00, 0x20), + (100, 199, 0x50, 0x10), + (200, 299, 0x90, 0x10), + (300, 399, 0xD0, 0x10), + (400, 499, 0x00, 0x20), + ], + &[1, 5], + 2, + ); + assert!( + (t.duration_secs - 50.0).abs() < 0.01, + "got {}", + t.duration_secs + ); + assert!((t.chapters[1].time_secs - 30.0).abs() < 0.01); + assert_eq!(t.extents.len(), 3, "one angle of the block is read"); + } + + // The dropped head adds no time to the duration, and head chapters collapse to one mark at 0. #[test] fn scan_dvd_titles_dropped_head_adjusts_duration_and_chapters() { let t = scan_cells( @@ -2434,6 +2696,7 @@ mod tests { spst_ctl: [0; 32], vts_title_num, pgcs, + ptt_programs: None, } } diff --git a/src/ifo.rs b/src/ifo.rs index 2706c537..a94f400e 100644 --- a/src/ifo.rs +++ b/src/ifo.rs @@ -70,6 +70,9 @@ pub struct DvdTitle { /// Distinct PGCs this title's part-of-title entries point at. Only the first is read, /// so a title with more is missing the rest. pub pgcs: usize, + /// The program (PGN) each chapter (part of title) starts at, from VTS_PTT_SRPT. `None` + /// when the table does not hold the title; empty when a chapter lies in another PGC. + pub ptt_programs: Option>, } /// A cell — contiguous sector range within a VOB. @@ -651,7 +654,9 @@ fn txtdt_titles(vmg: &[u8]) -> Option>>> { // One tab- or NUL-terminated name. Unknown charsets pass only plain ASCII; empty, // overlong or control-bearing names are `None` so the caller keeps its fallback. fn txtdt_name(data: &[u8], at: usize, charset: u8) -> Option { + // Never look past the longest name: thousands of items can share one unterminated run. let raw = data.get(at..)?; + let raw = &raw[..raw.len().min(TXTDT_MAX_NAME + 1)]; let raw = &raw[..raw .iter() .position(|&b| b == b'\t' || b == 0) @@ -1019,38 +1024,46 @@ fn ptt_srpt_pgc_index(data: &[u8], ptt_offset: usize, ttn: u8) -> Option pgcn.checked_sub(1) } +// Title `ttn`'s part-of-title entries, (PGCN, PGN) each, in chapter order; `None` when the +// table is absent or does not hold the title. +fn ptt_srpt_parts(data: &[u8], ptt_offset: usize, ttn: u8) -> Option> { + if ptt_offset == 0 || ttn == 0 { + return None; + } + let count = be_u16(data, ptt_offset).ok()? as usize; + let last_byte = be_u32(data, ptt_offset + 4).ok()? as usize; + if ttn as usize > count { + return None; + } + let at = |t: usize| { + be_u32(data, ptt_offset + 8 + (t - 1) * 4) + .ok() + .map(|r| r as usize) + }; + let start = at(ttn as usize)?; + let end = if (ttn as usize) < count { + at(ttn as usize + 1)? + } else { + last_byte.checked_add(1)? + }; + let mut parts = Vec::new(); + let mut rel = start; + while rel.checked_add(4)? <= end { + let entry = ptt_offset.checked_add(rel)?; + parts.push((be_u16(data, entry).ok()?, be_u16(data, entry + 2).ok()?)); + rel += 4; + } + Some(parts) +} + // Distinct PGCs title `ttn`'s part-of-title entries point at. A title split across // PGCs (one per chapter) is read from its first PGC only; the caller warns on > 1. fn ptt_srpt_pgc_count(data: &[u8], ptt_offset: usize, ttn: u8) -> usize { - let entries = || -> Option> { - let count = be_u16(data, ptt_offset).ok()? as usize; - let last_byte = be_u32(data, ptt_offset + 4).ok()? as usize; - if ttn as usize > count { - return None; - } - let at = |t: usize| { - be_u32(data, ptt_offset + 8 + (t - 1) * 4) - .ok() - .map(|r| r as usize) - }; - let start = at(ttn as usize)?; - let end = if (ttn as usize) < count { - at(ttn as usize + 1)? - } else { - last_byte.checked_add(1)? - }; - let mut pgcns = Vec::new(); - let mut rel = start; - while rel.checked_add(4)? <= end { - pgcns.push(be_u16(data, ptt_offset.checked_add(rel)?).ok()?); - rel += 4; - } - Some(pgcns) - }; - if ptt_offset == 0 || ttn == 0 { - return 0; - } - let mut pgcns = entries().unwrap_or_default(); + let mut pgcns: Vec = ptt_srpt_parts(data, ptt_offset, ttn) + .unwrap_or_default() + .into_iter() + .map(|(pgcn, _)| pgcn) + .collect(); pgcns.sort_unstable(); pgcns.dedup(); pgcns.len() @@ -1098,6 +1111,14 @@ pub(crate) fn parse_pgcit( // Warned once per disc by the DVD scan, which sees every title set. let pgcs = ptt_srpt_pgc_count(data, ptt_offset, vts_title_num); + // The program each chapter starts at, when every chapter lies in this PGC. + let ptt_programs = ptt_srpt_parts(data, ptt_offset, vts_title_num).map(|parts| match parts + .iter() + .all(|&(pgcn, _)| usize::from(pgcn) == pgc_index + 1) + { + true => parts.into_iter().map(|(_, pgn)| pgn).collect(), + false => Vec::new(), + }); let entry_offset = entries_start + pgc_index * 8; if entry_offset + 8 > data.len() { @@ -1124,6 +1145,7 @@ pub(crate) fn parse_pgcit( // dropped sibling PGC (position in `titles` is not vts_title_num). title.vts_title_num = vts_title_num; title.pgcs = pgcs; + title.ptt_programs = ptt_programs; titles.push(title); } Err(e) => { @@ -1205,9 +1227,15 @@ pub(crate) fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result ); } - // Recalculate duration from cell times if PGC-level time is zero. + // Recalculate duration from cell times if PGC-level time is zero. Like the PGC time and + // the chapter marks, it counts an angle block once: its other angles' cells add nothing + // (unless every cell is one, which `leading_secondary_cells` also keeps whole). let duration_secs = if duration_secs == 0.0 && !cells.is_empty() { - cells.iter().map(|c| c.duration_secs).sum() + let primary = |c: &&DvdCell| !CellCategory::decode(c.category).is_secondary_block_piece(); + match cells.iter().any(|c| primary(&c)) { + true => cells.iter().filter(primary).map(|c| c.duration_secs).sum(), + false => cells.iter().map(|c| c.duration_secs).sum(), + } } else { duration_secs }; @@ -1310,6 +1338,7 @@ pub(crate) fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result // Set by the caller (parse_pgcit) which knows the TT_SRPT title number. vts_title_num: 0, pgcs: 0, + ptt_programs: None, }) } @@ -2307,6 +2336,7 @@ mod tests { spst_ctl: [0; 32], vts_title_num: 0, pgcs: 1, + ptt_programs: None, }; assert_eq!(t.feature_start_cell(), 0); assert_eq!(t.feature_cells().len(), 3); @@ -2332,6 +2362,7 @@ mod tests { spst_ctl: [0; 32], vts_title_num: 0, pgcs: 1, + ptt_programs: None, }; assert_eq!(t.feature_start_cell(), 2); let fc = t.feature_cells(); @@ -2353,6 +2384,7 @@ mod tests { spst_ctl: [0; 32], vts_title_num: 0, pgcs: 1, + ptt_programs: None, }; assert_eq!(t.feature_start_cell(), 0); assert_eq!(t.feature_cells().len(), 2); @@ -2371,6 +2403,7 @@ mod tests { spst_ctl: [0; 32], vts_title_num: 0, pgcs: 1, + ptt_programs: None, }; assert_eq!(t.feature_start_cell(), 0); assert!(t.feature_cells().is_empty()); @@ -2979,6 +3012,35 @@ mod tests { assert_eq!(map[&(1, 1)], names(&[None, Some("ascii")])); } + /// Every item of a full table pointing into one long unterminated run (the item table + /// itself, crafted free of TAB and NUL) costs one bounded look, not a scan to the end. + #[test] + fn txtdt_unterminated_names_are_read_bounded() { + let items = usize::from(u16::MAX / 2); + let base = TXTDT_MG_HDR + 8 + TXTDT_LU_ITEMS; + let mut d = vec![0xFFu8; base + 4 + items * 8 + 1024 * 1024]; + d[..TXTDT_MG_HDR + 8].fill(0x01); + d[TXTDT_MG_HDR + 4..TXTDT_MG_HDR + 8] + .copy_from_slice(&((TXTDT_MG_HDR + 8) as u32).to_be_bytes()); + let last = (d.len() - 1) as u32; + d[0x10..0x14].copy_from_slice(&last.to_be_bytes()); + for i in 0..items { + let item = base + 4 + i * 8; + d[item] = if i == 0 { TX_TITLE } else { TX_CHAPTER }; + d[item + 6..item + 8].copy_from_slice(&0x0404u16.to_be_bytes()); + } + let vmg = with_txtdt(vmg_with_tt_srpt(1, &[(2, 1, 1)]), &d); + let started = std::time::Instant::now(); + let titles = txtdt_titles(&vmg).expect("table"); + assert_eq!(titles[0].len(), items - 1); + assert!( + titles[0].iter().all(Option::is_none), + "overlong names are no names" + ); + let took = started.elapsed(); + assert!(took < std::time::Duration::from_secs(5), "took {took:?}"); + } + /// No text data, bad pointers, bad lengths and truncation yield no names and /// never read out of bounds or panic. #[test]