diff --git a/Cargo.toml b/Cargo.toml
index 85bdda6..ac31225 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -36,6 +36,12 @@ rand = "0.8"
cmac = "0.7"
zip = { version = "2", default-features = false, features = ["deflate"] }
base64 = "0.22.1"
+# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
+# bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL`
+# — untrusted disc bytes — into authoritative titles/clips/chapters. A real
+# parser, not a hand-rolled scanner: the XPL is genuine XML (comments, varied
+# attribute order, self-closing tags).
+roxmltree = "0.20"
# Trace-level instrumentation for Disc::copy + SgIoTransport::execute. Permitted
# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip)
# wire a tracing subscriber and pipe events into the JSONL debug log.
diff --git a/src/disc/hddvd.rs b/src/disc/hddvd.rs
index 250a583..9252dc3 100644
--- a/src/disc/hddvd.rs
+++ b/src/disc/hddvd.rs
@@ -9,26 +9,28 @@
//! rule: a genuinely different format is a new enumerator, not an extension
//! bolted into the BD path.
//!
-//! Title composition: the `HVA*.VTI` navigation file (the DVD-IFO analogue) names
-//! every clip in authored order. Standard Content splits the main feature across
-//! clips at the layer break (`FEATURE_1`/`FEATURE_2`, or `feature`/`feature_Divide`);
-//! the scanner parses the VTI clip table and concatenates those parts into ONE
-//! [`DiscTitle`] (so the largest-title pick is the whole movie, not just part 1),
-//! emitting every other clip as its own title. Container is
-//! [`ContentFormat::MpegPs`], so the existing PS mux path handles it. Per-clip
-//! streams ARE enumerated: the clip head is demuxed through the PS demuxer and one
-//! [`Stream`] is built per distinct elementary stream (video + DD+ audio
-//! sub-streams), with the codec sniffed from the ES bytes.
+//! Title composition — authoritative, from the Advanced-Content playlist. HD-DVD
+//! ships a real player playlist at `ADV_OBJ/VPLST000.XPL` (DVD-Forum
+//! `HDDVDVideo/Playlist` XML). The scanner parses it (with a real XML parser,
+//! `roxmltree`) into one [`DiscTitle`] per `
`: its ``
+//! clips in playback order (each an EVO, referenced via its `.MAP` sidecar), the
+//! `titleDuration`, the `displayName`, and the ``. A layer-break
+//! split (`FEATURE_1` + `FEATURE_2`, or `feature`/`feature_Divide`) is composed
+//! into ONE title with the two parts as clips, carrying each clip's title-time
+//! in/out points (45 kHz ticks) so a seamless join can be spliced onto one
+//! timeline. Container is [`ContentFormat::MpegPs`], so the existing PS mux path
+//! handles it. Per-clip streams are enumerated by demuxing the clip head and
+//! building one [`Stream`] per distinct elementary stream (video + DD+ audio),
+//! codec sniffed from the ES bytes.
//!
-//! What is NOT parsed yet — and is honestly stubbed, not faked:
-//! * full VTI program-chain parsing (chapters, non-feature title grouping) —
-//! the feature join uses the clip table + the `feature*` naming convention,
-//! not the authoritative PGC,
-//! * `.map` timemap → real durations,
-//! * subtitles (8-bit RLC on `0xBD` sub `0x20..=0x3F`).
+//! When no playlist is present (or it fails to parse), the scanner falls back to
+//! the older clip-name heuristic: parse the `HVA*.VTI` clip table, join the
+//! `feature*`-named clips into one title, and emit every other clip on its own.
//!
-//! Extents and size ARE real (the ripper needs those to image a clip); durations
-//! and chapters are left empty rather than guessed.
+//! Not parsed yet: subtitles (8-bit RLC on `0xBD` sub `0x20..=0x3F`) and per-track
+//! audio languages (the XPL carries `` but they are not yet
+//! wired onto the streams). Extents and size are real (the ripper images the
+//! clips).
use super::*;
use crate::mux::ps::{PsDemuxer, dvd_audio_pid};
@@ -316,6 +318,229 @@ fn collect_es(
}
}
+// ─────────────────────── Advanced-Content playlist (XPL) ──────────────────
+//
+// HD-DVD Advanced Content ships an authoritative playlist at
+// `ADV_OBJ/VPLST000.XPL` (DVD-Forum `HDDVDVideo/Playlist` XML). It is the real
+// player playlist: a `` of ``s, each naming its
+// `` clips (an EVO, via its `.MAP` sidecar) in playback
+// order with title-time in/out points, a `titleDuration`, a `displayName`, and a
+// ``. Parsing it gives authoritative title composition — clips,
+// duration, name, chapters — instead of the `feature*` clip-name heuristic, plus
+// the per-clip title-time offsets needed to splice a layer-break split
+// (`FEATURE_1` + `FEATURE_2`) onto one continuous timeline. Parsed with a real
+// XML parser (`roxmltree`), not a hand-rolled scanner — the XPL is genuine XML.
+
+/// One clip reference inside an XPL ``: the resolved `.evo` name (lower
+/// case) and the clip's placement on the title timeline, in seconds.
+struct XplClip {
+ evo: String,
+ begin_secs: f64,
+ end_secs: f64,
+}
+
+/// One `` from the XPL: number, display name, total duration, its clips
+/// in playback order, and chapter start times (seconds).
+struct XplTitle {
+ number: u16,
+ name: String,
+ duration_secs: f64,
+ clips: Vec,
+ chapters: Vec,
+}
+
+/// Parse an `HH:MM:SS:FF` (or `MM:SS:FF`) timecode at `tick_base` frames/sec into
+/// seconds. `None` on a malformed field.
+fn parse_timecode(s: &str, tick_base: u32) -> Option {
+ let n: Vec = s
+ .split(':')
+ .map(|p| p.trim().parse::())
+ .collect::, _>>()
+ .ok()?;
+ let tb = tick_base.max(1) as f64;
+ let (h, m, sec, f) = match n.as_slice() {
+ [h, m, s, f] => (*h, *m, *s, *f),
+ [m, s, f] => (0, *m, *s, *f),
+ _ => return None,
+ };
+ Some(h as f64 * 3600.0 + m as f64 * 60.0 + sec as f64 + f as f64 / tb)
+}
+
+/// `tickBase="60fps"` → 60. Defaults to 60 when absent/unparseable.
+fn parse_tick_base(s: &str) -> u32 {
+ let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
+ digits.parse().unwrap_or(60)
+}
+
+/// `` → `feature_1.evo`:
+/// take the basename, drop the extension, normalise to a lower-case `.evo` name
+/// (the playlist references the `.MAP` sidecar; the A/V is the same-stem `.EVO`).
+fn evo_from_src(src: &str) -> Option {
+ let base = src.rsplit(['/', '\\']).next().unwrap_or(src);
+ let stem = base.rsplit_once('.').map(|(s, _)| s).unwrap_or(base);
+ if stem.is_empty() {
+ return None;
+ }
+ Some(format!("{}.evo", stem.to_ascii_lowercase()))
+}
+
+/// Parse the Advanced-Content playlist into its titles. Elements are matched by
+/// LOCAL name (the document is in the `HDDVDVideo/Playlist` default namespace).
+/// Returns empty for a non-XML / non-playlist blob so the caller falls back to
+/// the clip-name heuristic.
+fn parse_xpl_titles(xpl: &[u8]) -> Vec {
+ let text = String::from_utf8_lossy(xpl);
+ let Ok(doc) = roxmltree::Document::parse(&text) else {
+ return Vec::new();
+ };
+ let local = |n: &roxmltree::Node, name: &str| n.tag_name().name() == name;
+
+ // tickBase lives on (default 60fps).
+ let tick_base = doc
+ .descendants()
+ .find(|n| local(n, "TitleSet"))
+ .and_then(|n| n.attribute("tickBase"))
+ .map(parse_tick_base)
+ .unwrap_or(60);
+
+ let mut titles = Vec::new();
+ for tnode in doc.descendants().filter(|n| local(n, "Title")) {
+ let number = tnode
+ .attribute("titleNumber")
+ .and_then(|s| s.trim().parse::().ok())
+ .unwrap_or(0);
+ let name = tnode
+ .attribute("displayName")
+ .or_else(|| tnode.attribute("id"))
+ .unwrap_or("")
+ .to_string();
+ let duration_secs = tnode
+ .attribute("titleDuration")
+ .and_then(|s| parse_timecode(s, tick_base))
+ .unwrap_or(0.0);
+
+ let mut clips = Vec::new();
+ for c in tnode
+ .descendants()
+ .filter(|n| local(n, "PrimaryAudioVideoClip"))
+ {
+ let Some(evo) = c.attribute("src").and_then(evo_from_src) else {
+ continue;
+ };
+ let begin_secs = c
+ .attribute("titleTimeBegin")
+ .and_then(|s| parse_timecode(s, tick_base))
+ .unwrap_or(0.0);
+ let end_secs = c
+ .attribute("titleTimeEnd")
+ .and_then(|s| parse_timecode(s, tick_base))
+ .unwrap_or(begin_secs);
+ clips.push(XplClip {
+ evo,
+ begin_secs,
+ end_secs,
+ });
+ }
+ if clips.is_empty() {
+ continue;
+ }
+
+ let chapters = tnode
+ .descendants()
+ .filter(|n| local(n, "Chapter"))
+ .filter_map(|ch| {
+ ch.attribute("titleTimeBegin")
+ .and_then(|s| parse_timecode(s, tick_base))
+ })
+ .collect();
+
+ titles.push(XplTitle {
+ number,
+ name,
+ duration_secs,
+ clips,
+ chapters,
+ });
+ }
+ titles
+}
+
+/// Read the Advanced-Content playlist `ADV_OBJ/VPLST*.XPL`, if present.
+fn read_adv_obj_xpl(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> Option> {
+ let dir = udf_fs.find_dir("/ADV_OBJ")?;
+ let name = dir.entries.iter().find_map(|e| {
+ let lower = e.name.to_ascii_lowercase();
+ (!e.is_dir && lower.starts_with("vplst") && lower.ends_with(".xpl")).then(|| e.name.clone())
+ })?;
+ udf_fs.read_file(reader, &format!("/ADV_OBJ/{name}")).ok()
+}
+
+/// Compose [`DiscTitle`]s from parsed XPL titles: resolve each title's clips to
+/// physical extents, concatenate them in playback order, carry the title-time
+/// in/out points onto each [`Clip`] (45 kHz ticks — the offset that splices a
+/// layer-break split onto one timeline), and attach the duration, name, and
+/// chapters. A title whose clips resolve to no on-disc extents is skipped.
+fn compose_xpl_titles(
+ reader: &mut dyn SectorSource,
+ xpl_titles: &[XplTitle],
+ clip_extents: &BTreeMap)>,
+) -> Vec {
+ let mut titles = Vec::new();
+ for t in xpl_titles {
+ let mut extents = Vec::new();
+ let mut size_bytes = 0u64;
+ let mut parts = Vec::new();
+ for c in &t.clips {
+ let Some((orig, size, exts)) = clip_extents.get(&c.evo) else {
+ continue;
+ };
+ extents.extend_from_slice(exts);
+ size_bytes += *size;
+ parts.push(Clip {
+ clip_id: orig
+ .rsplit_once('.')
+ .map(|(b, _)| b)
+ .unwrap_or(orig)
+ .to_string(),
+ in_time: (c.begin_secs * 45000.0).clamp(0.0, u32::MAX as f64) as u32,
+ out_time: (c.end_secs * 45000.0).clamp(0.0, u32::MAX as f64) as u32,
+ duration_secs: (c.end_secs - c.begin_secs).max(0.0),
+ source_packets: 0,
+ });
+ }
+ if parts.is_empty() {
+ continue;
+ }
+ let streams = probe_evo_streams(reader, &extents);
+ let chapters = t
+ .chapters
+ .iter()
+ .enumerate()
+ .map(|(i, &ts)| Chapter {
+ time_secs: ts.max(0.0),
+ name: super::chapter_name(i),
+ })
+ .collect();
+ titles.push(DiscTitle {
+ playlist: if t.name.is_empty() {
+ format!("Title {}", t.number)
+ } else {
+ t.name.clone()
+ },
+ playlist_id: t.number,
+ duration_secs: t.duration_secs,
+ size_bytes,
+ clips: parts,
+ streams,
+ chapters,
+ extents,
+ content_format: ContentFormat::MpegPs,
+ codec_privates: Vec::new(),
+ });
+ }
+ titles
+}
+
impl Disc {
/// Scan HD-DVD titles from the `HVDVD_TS/` `.evo` clips.
///
@@ -375,6 +600,18 @@ impl Disc {
}
}
+ // Authoritative composition from the Advanced-Content playlist
+ // (`ADV_OBJ/VPLST*.XPL`): real per-title clip lists, durations, names,
+ // chapters, and title-time offsets. This is the primary path; the
+ // clip-name heuristic below is the fallback when the playlist is absent
+ // or unparseable (or resolves to no on-disc clips).
+ if let Some(xpl) = read_adv_obj_xpl(reader, udf_fs) {
+ let composed = compose_xpl_titles(reader, &parse_xpl_titles(&xpl), &clip_extents);
+ if !composed.is_empty() {
+ return composed;
+ }
+ }
+
// Feature clips, in authored order, that actually resolved to extents.
let feature: Vec = order
.iter()
@@ -966,4 +1203,193 @@ mod tests {
"VC-1 routes to the extended-stream-id PID 0xFD55"
);
}
+
+ // ─────────────────────── Advanced-Content playlist (XPL) ──────────────
+
+ /// A minimal but faithful VPLST000.XPL: the DVD-Forum default namespace, an
+ /// XML comment, a ``, a MainMovie title whose main
+ /// feature is a two-clip layer-break split (FEATURE_1 + FEATURE_2, seamless)
+ /// with two chapters, and a separate deleted-scene title. Attribute order
+ /// varies (as it does across real discs).
+ const SYNTH_XPL: &str = r#"
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ "#;
+
+ #[test]
+ fn parse_timecode_hhmmssff_at_60fps() {
+ // 01:37:20:00 = 5840 s exactly.
+ assert!((parse_timecode("01:37:20:00", 60).unwrap() - 5840.0).abs() < 1e-6);
+ // 00:48:29:50 = 48m29s + 50/60 frames.
+ let want = 48.0 * 60.0 + 29.0 + 50.0 / 60.0;
+ assert!((parse_timecode("00:48:29:50", 60).unwrap() - want).abs() < 1e-6);
+ // MM:SS:FF short form (hours omitted).
+ assert!((parse_timecode("02:05:15", 60).unwrap() - (125.0 + 15.0 / 60.0)).abs() < 1e-6);
+ assert_eq!(parse_timecode("garbage", 60), None);
+ assert_eq!(parse_timecode("", 60), None);
+ }
+
+ #[test]
+ fn evo_from_src_maps_map_sidecar_to_evo() {
+ assert_eq!(
+ evo_from_src("file:///dvddisc/HVDVD_TS/FEATURE_1.MAP").as_deref(),
+ Some("feature_1.evo")
+ );
+ // Already an EVO, or lowercase feature — normalise to lower `.evo`.
+ assert_eq!(evo_from_src("feature.EVO").as_deref(), Some("feature.evo"));
+ assert_eq!(
+ evo_from_src("file:///x/feature_Divide.MAP").as_deref(),
+ Some("feature_divide.evo")
+ );
+ // Empty stem → None (defensive against a malformed src).
+ assert_eq!(evo_from_src("file:///x/").as_deref(), None);
+ }
+
+ #[test]
+ fn parse_xpl_titles_reads_titles_clips_chapters_durations() {
+ let titles = parse_xpl_titles(SYNTH_XPL.as_bytes());
+ assert_eq!(titles.len(), 2, "MainMovie + one deleted-scene title");
+
+ let mm = &titles[0];
+ assert_eq!(mm.number, 2);
+ assert_eq!(mm.name, "Main Movie");
+ assert!(
+ (mm.duration_secs - 5840.0).abs() < 1e-6,
+ "97:20 from titleDuration"
+ );
+ // The layer-break split is ONE title with TWO clips, contiguous timeline.
+ assert_eq!(mm.clips.len(), 2);
+ assert_eq!(mm.clips[0].evo, "feature_1.evo");
+ assert_eq!(mm.clips[1].evo, "feature_2.evo");
+ assert!(
+ (mm.clips[0].end_secs - mm.clips[1].begin_secs).abs() < 1e-6,
+ "FEATURE_2 begins exactly where FEATURE_1 ends (seamless join)"
+ );
+ assert!(
+ mm.clips[1].begin_secs > 0.0,
+ "second clip carries a title-time offset"
+ );
+ assert_eq!(mm.chapters.len(), 2);
+ assert!((mm.chapters[1] - (3.0 * 60.0 + 40.0 + 30.0 / 60.0)).abs() < 1e-6);
+
+ let del = &titles[1];
+ assert_eq!(del.name, "Deleted Scenes - Veronica Past");
+ assert_eq!(del.clips.len(), 1);
+ assert_eq!(del.clips[0].evo, "del5_veronicapast.evo");
+ }
+
+ #[test]
+ fn parse_xpl_titles_returns_empty_on_non_xml() {
+ assert!(parse_xpl_titles(b"not xml at all").is_empty());
+ assert!(parse_xpl_titles(&[0xFF, 0x00, 0x01, 0x02]).is_empty());
+ assert!(parse_xpl_titles(b" ").is_empty());
+ }
+
+ /// Build a UDF with `HVDVD_TS/` `.evo` clips plus an `ADV_OBJ/VPLST000.XPL`
+ /// carrying `xpl`, so `scan_hddvd_titles` takes the playlist path.
+ fn make_hddvd_fs_xpl(
+ disc: &mut MemDisc,
+ evos: &[(&str, u32, u32)],
+ xpl: &[u8],
+ ) -> crate::udf::UdfFs {
+ let mut hv_files = Vec::new();
+ let mut icb = 100u32;
+ for (name, sectors, data_lba) in evos {
+ hv_files.push(file(name, icb, *data_lba, sectors * 2048, true));
+ icb += 1;
+ }
+ let root = DirSpec {
+ name: String::new(),
+ icb_lba: 10,
+ dir_data_lba: 11,
+ files: Vec::new(),
+ subdirs: vec![
+ DirSpec {
+ name: "HVDVD_TS".to_string(),
+ icb_lba: 20,
+ dir_data_lba: 21,
+ files: hv_files,
+ subdirs: vec![],
+ },
+ DirSpec {
+ name: "ADV_OBJ".to_string(),
+ icb_lba: 30,
+ dir_data_lba: 31,
+ files: vec![file_with("VPLST000.XPL", 40, 4000, xpl.to_vec(), true)],
+ subdirs: vec![],
+ },
+ ],
+ };
+ build_udf_skeleton(disc, 10);
+ lay_dir(disc, &root);
+ crate::udf::read_filesystem(disc).expect("fs")
+ }
+
+ /// The authoritative path: when a VPLST000.XPL is present, titles come from
+ /// the playlist — the layer-break split (FEATURE_1 + FEATURE_2) is composed
+ /// into ONE title with the real duration, name, chapters, and per-clip
+ /// title-time offsets — not the clip-name heuristic.
+ #[test]
+ fn scan_hddvd_composes_titles_from_xpl_playlist() {
+ let mut disc = MemDisc::new();
+ let udf = make_hddvd_fs_xpl(
+ &mut disc,
+ &[
+ ("FEATURE_1.EVO", 2000, 5000),
+ ("FEATURE_2.EVO", 1800, 9000),
+ ("DEL5_VERONICAPAST.EVO", 100, 12000),
+ ],
+ SYNTH_XPL.as_bytes(),
+ );
+ let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
+ assert_eq!(
+ titles.len(),
+ 2,
+ "the two playlist titles that resolve to clips"
+ );
+
+ let mm = titles
+ .iter()
+ .find(|t| t.playlist == "Main Movie")
+ .expect("MainMovie composed from the playlist");
+ assert_eq!(mm.playlist_id, 2);
+ assert!(
+ (mm.duration_secs - 5840.0).abs() < 1.0,
+ "97:20 duration from titleDuration, not 0/unknown"
+ );
+ assert_eq!(
+ mm.clips.len(),
+ 2,
+ "layer-break split kept as ONE title, two clips"
+ );
+ // FEATURE_2's clip carries the 48:29 offset (45 kHz ticks) — the datum
+ // that splices it onto FEATURE_1's timeline instead of restarting at 0.
+ assert!(
+ mm.clips[1].in_time > 100_000_000,
+ "FEATURE_2 offset onto the title timeline (48:29 * 45000), got {}",
+ mm.clips[1].in_time
+ );
+ assert_eq!(mm.chapters.len(), 2);
+ assert_eq!(mm.chapters[0].name, "1", "bare ordinal chapter name");
+ // Both feature halves are in ONE title's extents.
+ assert!(!mm.extents.is_empty());
+ }
}