//! HD-DVD title scanning — `HVDVD_TS/` Enhanced-VOB (`.evo`) enumeration. //! //! HD-DVD is a **tree-level peer** of DVD and Blu-ray (not a stream variant like //! FMTS): its content lives in `HVDVD_TS/` as `.evo` clips — Enhanced VOB, an //! MPEG **program** stream — each with a small `.map` timemap sidecar, navigated //! by `.xpl`/`.ifo` playlists in `HVDVD_TS/` and `ADV_OBJ/`. Because it is a //! different tree with a different playlist format, it gets its OWN scanner //! (this file), a peer to [`Disc::scan_bluray_titles`] — the two-format design //! 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. //! //! 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`). //! //! Extents and size ARE real (the ripper needs those to image a clip); durations //! and chapters are left empty rather than guessed. use super::*; use crate::mux::ps::{PsDemuxer, dvd_audio_pid}; use crate::sector::SectorSource; use crate::udf; use std::collections::BTreeMap; /// Clip stream-file extension in the HD-DVD `HVDVD_TS/` tree. HD-DVD is a /// separate tree from BD, so this is a separate constant — deliberately NOT an /// entry in [`super::bluray`]'s BD-tree `CLIP_STREAM_EXTS`. const HDDVD_CLIP_EXT: &str = ".evo"; /// Sectors of an `.evo` clip head to demux when probing its elementary streams /// (~16 MiB). Enough to see the opening video access unit (SPS) plus every /// interleaved audio sub-stream, without imaging the whole multi-GiB clip. const EVO_PROBE_SECTORS: u32 = 8192; /// Cap on the elementary-stream sample retained per stream while probing — a /// video SPS / audio syncword lands well inside the first few KiB, so 128 KiB /// is generous while bounding probe memory. const EVO_ES_SAMPLE_CAP: usize = 128 * 1024; /// HD-DVD Standard Content navigation file magic (`HVDVD_TS/HVA*.VTI`). The VTI /// is the DVD-IFO analogue: it holds a fixed-stride clip table naming every /// `.evo` in authored order. const HDDVD_VTI_MAGIC: &[u8] = b"ADVANCED-VTS"; /// Byte stride between clip-table entries in the VTI. Each entry holds a /// NUL-terminated `.EVO` at a constant sub-offset, so every clip name /// shares one residue modulo this stride — the signal used to isolate the table. const VTI_CLIP_ENTRY_STRIDE: usize = 0x140; /// Cap on clip-name hits collected from a VTI. A real clip table holds a few /// dozen entries; this bounds the scan so a crafted VTI packed with millions of /// `.EVO` tokens (up to the 64 MiB UDF read cap) can't burn CPU/memory. const MAX_VTI_HITS: usize = 8192; /// Parse the clip-name table from an `ADVANCED-VTS` VTI, returning clip /// filenames in authored (table) order. /// /// The table is a run of `VTI_CLIP_ENTRY_STRIDE`-spaced records, each carrying a /// NUL-terminated `.EVO`. Rather than trust the (imprecise) header pointer, /// this collects every NUL-terminated `*.EVO` name and keeps the largest group /// sharing one residue modulo the stride — the clip table — in offset order. /// Returns empty for a non-VTI blob or one with no recognizable table. fn parse_vti_clip_order(vti: &[u8]) -> Vec { if !vti.starts_with(HDDVD_VTI_MAGIC) { return Vec::new(); } let is_name_byte = |b: u8| b.is_ascii_graphic(); // Bucket hits by residue-mod-stride in a SINGLE pass — the clip table shares // one residue, so the largest bucket is it (avoids an O(stride*hits) rescan). let mut buckets: std::collections::HashMap> = std::collections::HashMap::new(); let mut count = 0usize; let mut i = 0usize; while i < vti.len() && count < MAX_VTI_HITS { if !is_name_byte(vti[i]) { i += 1; continue; } let start = i; while i < vti.len() && is_name_byte(vti[i]) { i += 1; } let name = &vti[start..i]; let nul_terminated = i < vti.len() && vti[i] == 0; if nul_terminated && name.len() >= 5 && name[name.len() - 4..].eq_ignore_ascii_case(b".EVO") { buckets .entry(start % VTI_CLIP_ENTRY_STRIDE) .or_default() .push((start, String::from_utf8_lossy(name).into_owned())); count += 1; } } // Pick the largest residue bucket (the clip table). On a size tie, break // deterministically by the bucket's smallest offset — `HashMap` iteration // order is randomized, so `max_by_key` alone could pick a different bucket // run-to-run on identical bytes. let Some(mut best) = buckets .into_values() .max_by_key(|g| (g.len(), std::cmp::Reverse(g.iter().map(|(o, _)| *o).min()))) else { return Vec::new(); }; best.sort_by_key(|(o, _)| *o); best.into_iter().map(|(_, n)| n).collect() } /// Whether a clip belongs to the main feature. HD-DVD Standard Content authors /// the feature as one or more clips whose name begins `feature` (case-insensitive) /// — `FEATURE_1`/`FEATURE_2` (a layer-break split) or `feature`/`feature_Divide`. /// The feature is imaged as ONE title by concatenating these in authored order. fn is_feature_clip(name: &str) -> bool { let base = name.rsplit_once('.').map(|(b, _)| b).unwrap_or(name); base.to_ascii_lowercase().starts_with("feature") } /// Sniff a video codec from a program-stream video elementary-stream sample by /// its MPEG / Annex-B start codes: /// * `00 00 01 B3` → MPEG-2 (sequence_header) /// * `00 00 01 0F` → VC-1 (BD/HD-DVD sequence-header BDU) /// * `00 00 01 [x7]` H.264 SPS NAL (type 7, forbidden_zero_bit clear) → H.264 /// /// Returns `None` when no recognizable start code is present. The scan prefers /// the unambiguous MPEG-2 / VC-1 sequence headers; H.264 is inferred from an SPS /// NAL so a stray slice/picture code can't be mistaken for a different codec. fn sniff_video_codec(es: &[u8]) -> Option { let mut saw_h264_sps = false; let mut i = 0usize; while i + 4 <= es.len() { if es[i] == 0x00 && es[i + 1] == 0x00 && es[i + 2] == 0x01 { let code = es[i + 3]; match code { 0xB3 => return Some(Codec::Mpeg2), 0x0F => return Some(Codec::Vc1), // H.264 SPS: mask off nal_ref_idc (bits 6-5); keep the // forbidden_zero_bit (must be 0) + nal_unit_type (low 5 bits). // 0x07/0x27/0x47/0x67 all decode to a type-7 SPS. _ if (code & 0x9F) == 0x07 => saw_h264_sps = true, _ => {} } i += 3; } else { i += 1; } } saw_h264_sps.then_some(Codec::H264) } /// Sniff an audio codec from a `private_stream_1` sub-stream sample. Today only /// Dolby Digital Plus (E-AC-3) is recognized — its `0x0B77` syncword — which is /// what ANCHORMAN / SHAUN carry on sub-ids `0xC0..=0xC7`. Returns `None` for an /// unrecognized sample so the caller drops the stream rather than mislabeling it. fn sniff_audio_codec(es: &[u8]) -> Option { let has_sync = es.windows(2).any(|w| w[0] == 0x0B && w[1] == 0x77); has_sync.then_some(Codec::Ac3Plus) } /// Demux the head of an `.evo` clip (through the disc's [`SectorSource`]) and /// build one [`Stream`] per distinct elementary stream found: the video track /// (mapped to the canonical [`DVD_VIDEO_PID`]) and every DD+ audio sub-stream /// (mapped via [`dvd_audio_pid`]). Codec is sniffed from the demuxed ES bytes. /// /// Mirrors the stream construction in `Disc::scan_dvd_titles`; resolution / /// language / channels use sane HD-DVD defaults (the muxer reads the true pixel /// dimensions from the H.264 SPS, and E-AC-3 channel counts are not decoded /// here). Returns an empty vec when the clip cannot be read or carries no /// recognizable stream (e.g. an AACS-encrypted clip probed as ciphertext). fn probe_evo_streams(reader: &mut dyn SectorSource, extents: &[Extent]) -> Vec { let mut demux = PsDemuxer::new(); let mut video: Vec = Vec::new(); // Routing PID of the video track, captured from the first video PES seen: // `DVD_VIDEO_PID` for a plain 0xE0-0xEF stream (Anchorman's H.264 on 0xE2), // or `0xFD00 | stream_id_extension` for an HD-DVD extended-stream-id video // (Shaun's VC-1 on 0xFD ext 0x55). Kept in lockstep with `PsPacket::dvd_pid` // so the emitted `Stream` PID matches what the demuxer routes at mux time. let mut video_pid: Option = None; // sub_id -> ES sample, ordered so audio tracks surface in sub-id order. let mut audio: BTreeMap> = BTreeMap::new(); let mut remaining = EVO_PROBE_SECTORS; 'outer: for ext in extents { let mut lba = ext.start_lba; let mut left = ext.sector_count; while left > 0 && remaining > 0 { // 1 MiB read chunks (512 sectors) keep buffers small. let n = left.min(remaining).min(512) as u16; let mut buf = vec![0u8; n as usize * crate::consts::SECTOR_BYTES]; if reader.read_sectors(lba, n, &mut buf, false).is_err() { break 'outer; } for pkt in demux.feed(&buf) { collect_es(&pkt, &mut video, &mut video_pid, &mut audio); } lba += n as u32; left -= n as u32; remaining -= n as u32; } } for pkt in demux.flush() { collect_es(&pkt, &mut video, &mut video_pid, &mut audio); } let mut streams = Vec::new(); // Emit the video stream only when the codec was actually identified from the // sampled head. Guessing (e.g. defaulting to H.264) would tag a VC-1 — or a // still-encrypted — clip with the wrong codec, so the mux applies the wrong // parser and produces a corrupt track; dropping it is the honest outcome // (matches the audio path below), and a real clear clip always carries its // sequence header / SPS at the head, so this never fires on a normal disc. if let (Some(pid), Some(codec)) = (video_pid, sniff_video_codec(&video)) { streams.push(Stream::Video(VideoStream { pid, codec, // HD-DVD is HD (1080). The muxer reads the true coded dimensions // from the H.264/VC-1 bitstream; this is a coarse default only. resolution: Resolution::R1080p, frame_rate: FrameRate::F23_976, hdr: HdrFormat::Sdr, color_space: ColorSpace::Bt709, display_aspect: None, secondary: false, label: String::new(), measured_cicp: None, })); } for (sub, sample) in &audio { let Some(codec) = sniff_audio_codec(sample) else { continue; }; let Some(pid) = dvd_audio_pid(*sub) else { continue; }; streams.push(Stream::Audio(AudioStream { pid, codec, // DD+ main tracks are 5.1; E-AC-3 channel counts are not decoded at // scan time, so this is a default (a 2.0 track is over-stated as // 5.1 in the header — the compressed audio itself is unaffected). channels: AudioChannels::Surround51, language: String::new(), sample_rate: SampleRate::S48, secondary: false, purpose: crate::disc::LabelPurpose::Normal, label: String::new(), })); } streams } /// Accumulate a demuxed PES packet's elementary-stream bytes into the video / /// per-audio-sub-id sample buffers (bounded by [`EVO_ES_SAMPLE_CAP`]). fn collect_es( pkt: &crate::mux::ps::PsPacket, video: &mut Vec, video_pid: &mut Option, audio: &mut BTreeMap>, ) { use crate::consts::pes_stream_id::{PRIVATE_STREAM_1, VIDEO, VIDEO_MAX}; const EXTENDED_STREAM_ID: u8 = 0xFD; /// VC-1 video rides extended-stream-id `0xFD` with `stream_id_extension` /// `0x55`. HD audio (MLP/TrueHD) can also use `0xFD` with other extensions — /// routing those to their own audio tracks is deferred (see the HD-DVD /// program-chain follow-up); until then only the VC-1 extension is treated as /// video, so an audio `0xFD` sub-stream can never mis-stamp the video PID. const VC1_STREAM_ID_EXT: u8 = 0x55; // Whether this packet is the VC-1 video sub-stream of the 0xFD extended id. let is_vc1_ext = pkt.stream_id == EXTENDED_STREAM_ID && pkt.sub_stream_id == Some(VC1_STREAM_ID_EXT); match pkt.stream_id { // Plain MPEG video (0xE0-0xEF), or the VC-1 sub-stream of the HD-DVD // extended-stream-id (0xFD). Both feed the single video ES sample; the // routing PID comes from `PsPacket::dvd_pid` so it matches the demuxer. VIDEO..=VIDEO_MAX => { if video_pid.is_none() { *video_pid = pkt.dvd_pid(); } if video.len() < EVO_ES_SAMPLE_CAP { video.extend_from_slice(&pkt.data); } } EXTENDED_STREAM_ID if is_vc1_ext => { if video_pid.is_none() { *video_pid = pkt.dvd_pid(); } if video.len() < EVO_ES_SAMPLE_CAP { video.extend_from_slice(&pkt.data); } } PRIVATE_STREAM_1 => { if let Some(sub) = pkt.sub_stream_id { if (0xC0..=0xC7).contains(&sub) { let slot = audio.entry(sub).or_default(); if slot.len() < EVO_ES_SAMPLE_CAP { slot.extend_from_slice(&pkt.data); } } } } _ => {} } } impl Disc { /// Scan HD-DVD titles from the `HVDVD_TS/` `.evo` clips. /// /// The main feature is authored as one or more `.evo` clips (a layer-break /// split — `FEATURE_1`/`FEATURE_2` or `feature`/`feature_Divide`). The /// `HVA*.VTI` navigation file names every clip in authored order; this parses /// it to concatenate the feature clips into ONE title (so the largest-title /// pick gets the whole movie, not just part 1), emitting every other clip as /// its own title. Falls back to one title per clip when the VTI is absent or /// unparseable, so a disc with no readable navigation still enumerates. /// `chapters`/duration are left empty pending deeper VTI parsing. pub(super) fn scan_hddvd_titles( reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs, ) -> Vec { let Some(ts_dir) = udf_fs.find_dir("/HVDVD_TS") else { return Vec::new(); }; // Snapshot clips (name, size) and the VTI navigation file. The `ts_dir` // borrow must end before the `udf_fs` reads below re-borrow it. let mut clips: Vec<(String, u64)> = Vec::new(); let mut vti_name: Option = None; for e in &ts_dir.entries { if e.is_dir { continue; } let lower = e.name.to_ascii_lowercase(); if lower.ends_with(HDDVD_CLIP_EXT) { clips.push((e.name.clone(), e.size)); } else if lower.ends_with(".vti") && vti_name.is_none() { vti_name = Some(e.name.clone()); } } // Authored clip order from the VTI clip table (empty if no VTI). let order: Vec = vti_name .and_then(|n| udf_fs.read_file(reader, &format!("/HVDVD_TS/{n}")).ok()) .map(|b| parse_vti_clip_order(&b)) .unwrap_or_default(); // Resolve each clip's physical extents once, keyed by lower-case name. let mut clip_extents: BTreeMap)> = BTreeMap::new(); for (name, size) in &clips { let mut extents = Vec::new(); if let Ok(file_exts) = udf_fs.file_extents(reader, &format!("/HVDVD_TS/{name}")) { for (lba, sectors) in file_exts { if sectors > 0 && lba > 0 { extents.push(Extent { start_lba: lba, sector_count: sectors, }); } } } if !extents.is_empty() { clip_extents.insert(name.to_ascii_lowercase(), (name.clone(), *size, extents)); } } // Feature clips, in authored order, that actually resolved to extents. let feature: Vec = order .iter() .filter(|n| is_feature_clip(n)) .filter(|n| clip_extents.contains_key(&n.to_ascii_lowercase())) .cloned() .collect(); let feature_set: std::collections::HashSet = feature.iter().map(|n| n.to_ascii_lowercase()).collect(); let mut titles = Vec::new(); let mut next_id = 0u16; // The composed feature title: concatenate its parts' extents in authored // order. Streams are probed from the head (the first part). One `Clip` per // part records the composition. if !feature.is_empty() { let mut extents = Vec::new(); let mut size_bytes = 0u64; let mut parts = Vec::new(); for n in &feature { if let Some((orig, size, exts)) = clip_extents.get(&n.to_ascii_lowercase()) { 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: 0, out_time: 0, duration_secs: 0.0, source_packets: 0, }); } } let streams = probe_evo_streams(reader, &extents); titles.push(DiscTitle { playlist: "FEATURE".to_string(), playlist_id: next_id, duration_secs: 0.0, size_bytes, clips: parts, streams, chapters: Vec::new(), extents, content_format: ContentFormat::MpegPs, codec_privates: Vec::new(), }); next_id += 1; } // Every remaining clip is its own title (unchanged behaviour). Iterated in // directory order; when there is no VTI/feature this emits ALL clips. for (name, _size) in &clips { let key = name.to_ascii_lowercase(); if feature_set.contains(&key) { continue; } let Some((orig, size, extents)) = clip_extents.get(&key) else { continue; }; // Probe the clip head for its elementary streams so the mux path // builds a non-empty `pid_to_track` and actually routes packets. let streams = probe_evo_streams(reader, extents); let clip_id = orig .rsplit_once('.') .map(|(base, _)| base.to_string()) .unwrap_or_else(|| orig.clone()); titles.push(DiscTitle { playlist: orig.clone(), playlist_id: next_id, duration_secs: 0.0, size_bytes: *size, clips: vec![Clip { clip_id, in_time: 0, out_time: 0, duration_secs: 0.0, source_packets: 0, }], streams, chapters: Vec::new(), extents: extents.clone(), content_format: ContentFormat::MpegPs, codec_privates: Vec::new(), }); next_id += 1; } titles } } #[cfg(test)] mod tests { use super::*; use crate::udf::fixture::*; /// Build a UDF with an `HVDVD_TS/` tree holding the listed `.evo` clips /// (name, sector count, data LBA). fn make_hddvd_fs(disc: &mut MemDisc, evos: &[(&str, u32, u32)]) -> crate::udf::UdfFs { let mut files = Vec::new(); let mut icb = 100u32; for (name, sectors, data_lba) in evos { 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, subdirs: vec![], }], }; build_udf_skeleton(disc, 10); lay_dir(disc, &root); crate::udf::read_filesystem(disc).expect("fs") } /// HD-DVD's own enumerator yields one title per `.evo`, MpegPs container, /// with real physical extents (mirrors the BD `.m2ts` extent path). #[test] fn scan_hddvd_titles_enumerates_evo_extents() { let mut disc = MemDisc::new(); let udf = make_hddvd_fs( &mut disc, &[("FEATURE.EVO", 2000, 5000), ("BLOOP.EVO", 300, 9000)], ); let titles = Disc::scan_hddvd_titles(&mut disc, &udf); assert_eq!(titles.len(), 2, "one title per .evo clip"); for t in &titles { assert_eq!( t.content_format, ContentFormat::MpegPs, "EVO is a program stream" ); assert_eq!(t.extents.len(), 1); } let feature = titles.iter().find(|t| t.playlist == "FEATURE.EVO").unwrap(); assert_eq!(feature.extents[0].start_lba, PART_START + 5000); assert_eq!(feature.extents[0].sector_count, 2000); assert_eq!( feature.clips[0].clip_id, "FEATURE", "clip_id drops the extension" ); } // ── VTI playlist parsing + feature composition ──────────────────────── /// Build a synthetic `ADVANCED-VTS` VTI whose clip table lists `clips` in /// order — one fixed-stride entry each, NUL-terminated name at `entry+0x42`. fn synthetic_vti(clips: &[&str]) -> Vec { let table_start = 0x200usize; let mut v = vec![0u8; table_start + clips.len() * VTI_CLIP_ENTRY_STRIDE]; v[..HDDVD_VTI_MAGIC.len()].copy_from_slice(HDDVD_VTI_MAGIC); for (i, name) in clips.iter().enumerate() { let off = table_start + i * VTI_CLIP_ENTRY_STRIDE + 0x42; v[off..off + name.len()].copy_from_slice(name.as_bytes()); // The byte after the name stays 0 (NUL terminator). } v } #[test] fn parse_vti_clip_order_reads_table_in_authored_order() { let vti = synthetic_vti(&[ "DELOGO.EVO", "FEATURE_1.EVO", "FEATURE_2.EVO", "TRAILER.EVO", ]); let order = parse_vti_clip_order(&vti); assert_eq!( order, vec![ "DELOGO.EVO".to_string(), "FEATURE_1.EVO".to_string(), "FEATURE_2.EVO".to_string(), "TRAILER.EVO".to_string(), ] ); // A non-VTI blob yields nothing. assert!(parse_vti_clip_order(b"not a vti").is_empty()); } #[test] fn parse_vti_clip_order_caps_hits_on_a_crafted_vti() { // A crafted VTI packed with far more than MAX_VTI_HITS `.EVO` tokens must // not scan/collect them all (a CPU/memory amplification on a routine // scan). The result is capped, and parsing stays fast. let mut vti = Vec::with_capacity(1_000_000); vti.extend_from_slice(HDDVD_VTI_MAGIC); // ~160k tokens of the form "X.EVO\0" — well over the 8192 cap. for _ in 0..(MAX_VTI_HITS * 20) { vti.extend_from_slice(b"X.EVO\0"); } let out = parse_vti_clip_order(&vti); assert!( out.len() <= MAX_VTI_HITS, "collected hits capped at MAX_VTI_HITS, got {}", out.len() ); } #[test] fn parse_vti_clip_order_is_deterministic_on_a_bucket_size_tie() { // Two residue buckets of EQUAL size must resolve to the SAME winner every // call — `HashMap` iteration is randomized, so a `max_by_key` without a // deterministic tie-break could pick a different bucket run-to-run on // identical bytes. Build a VTI whose stray `.EVO` names tie the real // table's bucket count, then assert the result is stable across calls. let mut vti = vec![0u8; 0x600]; vti[..HDDVD_VTI_MAGIC.len()].copy_from_slice(HDDVD_VTI_MAGIC); let put = |v: &mut Vec, off: usize, name: &str| { v[off..off + name.len()].copy_from_slice(name.as_bytes()); }; // Bucket A (residue 0x42): two names at stride 0x140. put(&mut vti, 0x142, "A1.EVO"); put(&mut vti, 0x282, "A2.EVO"); // Bucket B (residue 0x50): two names — same count, different residue. put(&mut vti, 0x150, "B1.EVO"); put(&mut vti, 0x290, "B2.EVO"); let first = parse_vti_clip_order(&vti); for _ in 0..20 { assert_eq!( parse_vti_clip_order(&vti), first, "tie-break must be deterministic across repeated calls" ); } assert!(!first.is_empty()); } #[test] fn is_feature_clip_matches_the_feature_naming_variants() { // Layer-break split (Shaun / Anchorman) and the divide form (Harry Potter). assert!(is_feature_clip("FEATURE_1.EVO")); assert!(is_feature_clip("FEATURE_2.EVO")); assert!(is_feature_clip("feature.EVO")); assert!(is_feature_clip("feature_Divide.EVO")); // Extras are not the feature. assert!(!is_feature_clip("TRAILER.EVO")); assert!(!is_feature_clip("DLS_01.EVO")); assert!(!is_feature_clip("EPK.EVO")); } #[test] fn scan_hddvd_composes_split_feature_into_one_title() { // A disc whose feature is FEATURE_1 + FEATURE_2 (a layer-break split), plus // a TRAILER extra. The VTI names them in authored order; the scan must // JOIN the two feature parts into one title (so the largest-title pick is // the whole movie) and keep the trailer as its own title. let mut disc = MemDisc::new(); let vti = synthetic_vti(&["FEATURE_1.EVO", "FEATURE_2.EVO", "TRAILER.EVO"]); let files = vec![ file("FEATURE_1.EVO", 100, 5000, 10 * 2048, true), file("FEATURE_2.EVO", 101, 8000, 6 * 2048, true), file("TRAILER.EVO", 102, 12000, 2 * 2048, true), file_with("HVA00001.VTI", 103, 15000, vti, true), ]; 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, subdirs: vec![], }], }; build_udf_skeleton(&mut disc, 10); lay_dir(&mut disc, &root); let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); let titles = Disc::scan_hddvd_titles(&mut disc, &udf); // One composed FEATURE title + the trailer. assert_eq!(titles.len(), 2, "feature parts merged, trailer separate"); let feat = titles .iter() .find(|t| t.playlist == "FEATURE") .expect("composed feature title"); assert_eq!(feat.clips.len(), 2, "both feature parts recorded"); assert_eq!(feat.size_bytes, (10 + 6) * 2048, "part sizes summed"); // Extents concatenated in authored order: FEATURE_1 (lba 5000) then // FEATURE_2 (lba 8000) — the movie plays through in order. assert_eq!(feat.extents.len(), 2); assert_eq!(feat.extents[0].start_lba, PART_START + 5000); assert_eq!(feat.extents[0].sector_count, 10); assert_eq!(feat.extents[1].start_lba, PART_START + 8000); assert_eq!(feat.extents[1].sector_count, 6); // The largest title is the whole feature, not just part 1. let largest = titles.iter().max_by_key(|t| t.size_bytes).unwrap(); assert_eq!(largest.playlist, "FEATURE"); assert!(titles.iter().any(|t| t.playlist == "TRAILER.EVO")); } // ── codec sniffing ──────────────────────────────────────────────────── #[test] fn sniff_video_codec_recognizes_h264_vc1_mpeg2() { // H.264 SPS NAL (type 7). 0x67/0x27/0x47 all decode to type 7. assert_eq!( sniff_video_codec(&[0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1E]), Some(Codec::H264) ); assert_eq!( sniff_video_codec(&[0x11, 0x00, 0x00, 0x01, 0x27, 0x64]), Some(Codec::H264) ); // VC-1 sequence-header BDU (0x0F). assert_eq!( sniff_video_codec(&[0x00, 0x00, 0x01, 0x0F, 0xC0]), Some(Codec::Vc1) ); // MPEG-2 sequence_header (0xB3). assert_eq!( sniff_video_codec(&[0x00, 0x00, 0x01, 0xB3, 0x2D]), Some(Codec::Mpeg2) ); // A slice/picture-only sample (no SPS/sequence) is indeterminate. assert_eq!(sniff_video_codec(&[0x00, 0x00, 0x01, 0x61, 0x9A]), None); assert_eq!(sniff_video_codec(&[0xDE, 0xAD, 0xBE, 0xEF]), None); } #[test] fn sniff_audio_codec_recognizes_eac3_syncword() { assert_eq!( sniff_audio_codec(&[0x00, 0x0B, 0x77, 0x12, 0x34]), Some(Codec::Ac3Plus) ); assert_eq!(sniff_audio_codec(&[0x00, 0x01, 0x02, 0x03]), None); } // ── EVO head probe → streams ────────────────────────────────────────── /// A minimal bounded PES: `00 00 01 [id] [len:2] 80 00 00 [payload]`. fn pes(stream_id: u8, payload: &[u8]) -> Vec { let mut v = vec![0x00, 0x00, 0x01, stream_id]; let len = (3 + payload.len()) as u16; // flags1+flags2+hdl + payload v.extend_from_slice(&len.to_be_bytes()); v.extend_from_slice(&[0x80, 0x00, 0x00]); v.extend_from_slice(payload); v } /// Synthetic EVO program-stream: pack header, a video PES (H.264 SPS+IDR on /// stream_id 0xE2, exactly as ANCHORMAN carries it), two DD+ audio PES /// (sub-ids 0xC0/0xC1, each with the 4-byte sub-header + E-AC-3 syncword), /// then program-end. fn synthetic_evo() -> Vec { let mut d = Vec::new(); // MPEG-2 pack header (14 bytes, stuffing 0). d.extend_from_slice(&[ 0x00, 0x00, 0x01, 0xBA, 0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3, 0xF8, ]); // Video PES on stream_id 0xE2 (Anchorman's H.264 sub-id in the 0xE0-0xEF // range): SPS (type 7) + IDR (type 5) Annex-B. let video_es = [ 0x00, 0x00, 0x01, 0x67, 0x42, 0x00, 0x1E, 0xAB, 0xCD, // SPS 0x00, 0x00, 0x01, 0x65, 0x88, 0x00, // IDR slice ]; d.extend_from_slice(&pes(0xE2, &video_es)); // DD+ audio PES: sub-id + 4-byte sub-header (num_frames + ptr) folded in // — the demuxer strips 4 bytes, leaving the E-AC-3 syncword. for sub in [0xC0u8, 0xC1] { let audio_payload = [ sub, 0x01, 0x00, 0x00, // sub-id + num_frames(1) + ptr(2) 0x0B, 0x77, 0xDE, 0xAD, // E-AC-3 syncword + body ]; d.extend_from_slice(&pes(0xBD, &audio_payload)); } d.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]); // program end d } /// Build a UDF whose `HVDVD_TS/FEATURE.EVO` holds the given raw bytes. fn make_hddvd_fs_with_evo(disc: &mut MemDisc, evo: &[u8]) -> crate::udf::UdfFs { 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: vec![file_with("FEATURE.EVO", 100, 5000, evo.to_vec(), true)], subdirs: vec![], }], }; build_udf_skeleton(disc, 10); lay_dir(disc, &root); crate::udf::read_filesystem(disc).expect("fs") } /// End-to-end: scanning an `.evo` whose head carries an H.264 video PES and /// two DD+ audio PES yields a title with the video track (canonical /// DVD_VIDEO_PID) and both DD+ tracks (0xBDC0 / 0xBDC1) — the non-empty /// `streams` the mux path needs to route packets (the historical blocker). #[test] fn scan_hddvd_titles_probes_streams_from_evo_head() { let mut disc = MemDisc::new(); let udf = make_hddvd_fs_with_evo(&mut disc, &synthetic_evo()); let titles = Disc::scan_hddvd_titles(&mut disc, &udf); assert_eq!(titles.len(), 1); let t = &titles[0]; let video: Vec<_> = t .streams .iter() .filter_map(|s| match s { Stream::Video(v) => Some(v), _ => None, }) .collect(); assert_eq!(video.len(), 1, "one video track probed"); assert_eq!(video[0].codec, Codec::H264, "SPS sniffed as H.264"); assert_eq!( video[0].pid, crate::mux::ps::DVD_VIDEO_PID, "video routes to canonical PID" ); let audio: Vec<_> = t .streams .iter() .filter_map(|s| match s { Stream::Audio(a) => Some(a), _ => None, }) .collect(); assert_eq!(audio.len(), 2, "both DD+ sub-streams probed"); assert!(audio.iter().all(|a| a.codec == Codec::Ac3Plus)); let pids: Vec = audio.iter().map(|a| a.pid).collect(); assert_eq!(pids, vec![0xBDC0, 0xBDC1], "DD+ PIDs 0xBDC0/0xBDC1"); } /// A clip whose head carries no recognizable stream (unreadable / /// ciphertext) leaves `streams` empty rather than fabricating one — the /// title still enumerates (extents are real). #[test] fn scan_hddvd_titles_empty_streams_when_head_unrecognized() { let mut disc = MemDisc::new(); // 4 KiB of junk with no PS start codes. let junk = vec![0x55u8; 4096]; let udf = make_hddvd_fs_with_evo(&mut disc, &junk); let titles = Disc::scan_hddvd_titles(&mut disc, &udf); assert_eq!(titles.len(), 1); assert!( titles[0].streams.is_empty(), "no recognizable stream → empty, not fabricated" ); } /// A PES on the HD-DVD extended-stream-id (0xFD) carrying the given /// `stream_id_extension` in a minimal PES extension: flags1=0x80, flags2=0x01 /// (PES_extension only), header_data_length=3, optional bytes /// `[ext_flags=0x01][field_len=0x81][ext]` — exactly the shape SHAUN's VC-1 /// video PES uses (ext 0x55). fn pes_extended(stream_id_extension: u8, payload: &[u8]) -> Vec { let mut v = vec![0x00, 0x00, 0x01, 0xFD]; let opt = [0x01u8, 0x81, stream_id_extension]; let len = (3 + opt.len() + payload.len()) as u16; // flags1+flags2+hdl + opt + payload v.extend_from_slice(&len.to_be_bytes()); v.extend_from_slice(&[0x80, 0x01, opt.len() as u8]); v.extend_from_slice(&opt); v.extend_from_slice(payload); v } /// Synthetic EVO carrying VC-1 video on the extended-stream-id 0xFD (ext /// 0x55), as SHAUN OF THE DEAD does, plus one DD+ audio PES. fn synthetic_evo_vc1() -> Vec { let mut d = Vec::new(); d.extend_from_slice(&[ 0x00, 0x00, 0x01, 0xBA, 0x44, 0x00, 0x04, 0x00, 0x04, 0x01, 0x01, 0x89, 0xC3, 0xF8, ]); // VC-1 sequence header (00 00 01 0F) + a frame BDU (00 00 01 0D). let video_es = [ 0x00, 0x00, 0x01, 0x0F, 0xC5, 0x00, 0x00, // sequence header BDU 0x00, 0x00, 0x01, 0x0D, 0x12, 0x34, // frame BDU ]; d.extend_from_slice(&pes_extended(0x55, &video_es)); let audio_payload = [0xC0u8, 0x01, 0x00, 0x00, 0x0B, 0x77, 0xDE, 0xAD]; d.extend_from_slice(&pes(0xBD, &audio_payload)); d.extend_from_slice(&[0x00, 0x00, 0x01, 0xB9]); d } /// Build a bare PsPacket for the collect_es routing test. fn ps_pkt(stream_id: u8, sub: Option, data: Vec) -> crate::mux::ps::PsPacket { crate::mux::ps::PsPacket { stream_id, sub_stream_id: sub, pts: None, dts: None, data, source: None, } } #[test] fn collect_es_routes_only_vc1_0xfd_to_video() { use crate::mux::ps::hddvd_extended_pid; // The 0xFD guard: only the VC-1 extension (0x55) is video. An HD-audio // 0xFD sub-stream (e.g. 0x72) that arrives FIRST must NOT stamp video_pid // with its PID or pollute the video sample — else the real video track is // lost. (Routing 0xFD audio to its own track is deferred.) let mut video = Vec::new(); let mut video_pid: Option = None; let mut audio = BTreeMap::new(); // Audio-on-0xFD (ext 0x72) first — must be ignored by the video path. collect_es( &ps_pkt(0xFD, Some(0x72), vec![0xAA; 32]), &mut video, &mut video_pid, &mut audio, ); assert!( video.is_empty(), "0xFD audio sub-stream not routed to video" ); assert_eq!(video_pid, None, "0xFD audio did not stamp the video PID"); // Then the real VC-1 video (ext 0x55). collect_es( &ps_pkt(0xFD, Some(0x55), vec![0xBB; 32]), &mut video, &mut video_pid, &mut audio, ); assert_eq!( video_pid, Some(hddvd_extended_pid(0x55)), "video PID stamped from the VC-1 0xFD sub-stream (0xFD55)" ); assert_eq!(video.len(), 32, "VC-1 0xFD payload accumulated as video"); } /// End-to-end: an `.evo` whose video rides the extended-stream-id 0xFD yields /// a VC-1 video track routed to `0xFD00 | ext` (0xFD55) — the PID the demuxer /// derives from the same stream_id_extension, so mux-time routing lines up. #[test] fn scan_hddvd_titles_probes_vc1_on_extended_stream_id() { let mut disc = MemDisc::new(); let udf = make_hddvd_fs_with_evo(&mut disc, &synthetic_evo_vc1()); let titles = Disc::scan_hddvd_titles(&mut disc, &udf); assert_eq!(titles.len(), 1); let video: Vec<_> = titles[0] .streams .iter() .filter_map(|s| match s { Stream::Video(v) => Some(v), _ => None, }) .collect(); assert_eq!(video.len(), 1, "one video track probed"); assert_eq!(video[0].codec, Codec::Vc1, "VC-1 sequence header sniffed"); assert_eq!( video[0].pid, crate::mux::ps::hddvd_extended_pid(0x55), "VC-1 routes to the extended-stream-id PID 0xFD55" ); } }