From 85347597cc4eb4c2b03ec741837aa6a83ee9ee6c Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:16:04 -0700 Subject: [PATCH] disc: first-class FMTS + HD-DVD formats; CPI sample selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add DiscFormat::Fmts (AACS 2.1) and DiscFormat::HdDvd as first-class peers. Format derives from the AACS MKB generation (mkb_type().generation(): V10=BD, V20=UHD, V21=FMTS), reusing existing AACS code, and from the on-disc tree for HD-DVD/DVD. One detector (detect_disc_format) shared by the coarse DiscId probe and the full scan — no more 'default BluRay, defer to full scan'. FMTS is a BD-tree stream variant: parse_playlist resolves the clip stream via CLIP_STREAM_EXTS (.m2ts -> .fmts -> .ssif), so the .fmts main feature yields real extents (previously silently empty). HD-DVD is a tree-level peer with its own enumerator (disc/hddvd.rs): HVDVD_TS/*.evo -> MpegPs titles with real extents (playlist/stream parsing honestly stubbed). Sample selection for key resolution now uses the authoritative AACS CPI flag (aacs_unit_encrypted, byte-0 & 0xC0) not the ts_sync_destroyed heuristic — container-agnostic (M2TS/FMTS/EVO; TS-sync is meaningless on HD-DVD program streams) and stops the decode-server '0 encrypted units' rejection. Tests live with each format (bluray/hddvd/mod); generic UDF fixture builders extracted to a shared udf::fixture module. --- src/disc/bluray.rs | 327 ++++++++++----------------------------------- src/disc/hddvd.rs | 157 ++++++++++++++++++++++ src/disc/mod.rs | 169 +++++++++++++++++++++-- src/keysource.rs | 117 ++++++++++++++-- src/udf.rs | 221 ++++++++++++++++++++++++++++++ 5 files changed, 715 insertions(+), 276 deletions(-) create mode 100644 src/disc/hddvd.rs diff --git a/src/disc/bluray.rs b/src/disc/bluray.rs index 8c11af7..9788e6b 100644 --- a/src/disc/bluray.rs +++ b/src/disc/bluray.rs @@ -6,6 +6,18 @@ use crate::mpls; use crate::sector::SectorSource; use crate::udf; +/// Stream-file extensions probed for a BD-family playlist clip, in priority +/// order. A clip is normally `.m2ts`; AACS 2.1 (FMTS) discs name the main feature +/// `.fmts` (an M2TS transport stream plus forensic variant segments) and 3D discs +/// use `.ssif`. `.m2ts` is tried first, so a normal clip is unaffected — the +/// fallback only runs when `.m2ts` is absent (exactly when `file_extents` errors). +/// +/// Scope: these are all variants that live in `BDMV/STREAM/` and are reached +/// through an MPLS playlist. HD-DVD's `.evo` does NOT belong here — HD-DVD is a +/// different tree (`HVDVD_TS/`) with `.XPL` playlists and needs its own +/// enumerator (a peer to `parse_playlist`), not another extension in this list. +const CLIP_STREAM_EXTS: [&str; 3] = ["m2ts", "fmts", "ssif"]; + impl Disc { /// Scan Blu-ray titles from MPLS playlists. pub(super) fn scan_bluray_titles( @@ -85,10 +97,19 @@ impl Disc { if first_ref { total_size += pkt_count as u64 * 192; - // Get m2ts file extents from UDF allocation descriptors. + // Get stream file extents from UDF allocation descriptors. // Dual-layer discs split files across layers — UDF knows the real layout. - let m2ts_path = format!("/BDMV/STREAM/{}.m2ts", play_item.clip_id); - if let Ok(file_exts) = udf_fs.file_extents(reader, &m2ts_path) { + // + // The clip's stream file is normally `.m2ts`, but AACS 2.1 + // (FMTS) discs name the main feature `.fmts` and 3D discs + // use `.ssif` (see [`CLIP_STREAM_EXTS`]). A normal `.m2ts` + // clip is unchanged — the fallback only runs when `.m2ts` + // is absent, which is exactly when `file_extents` errors. + let file_exts = CLIP_STREAM_EXTS.iter().find_map(|ext| { + let path = format!("/BDMV/STREAM/{}.{}", play_item.clip_id, ext); + udf_fs.file_extents(reader, &path).ok() + }); + if let Some(file_exts) = file_exts { for (lba, sectors) in file_exts { if sectors > 0 && lba > 0 { extents.push(Extent { @@ -314,256 +335,7 @@ impl Disc { #[cfg(test)] mod tests { use super::*; - use crate::sector::SectorSource; - use std::collections::HashMap; - - // --------------------------------------------------------------- - // In-memory disc backing store - // --------------------------------------------------------------- - - /// In-memory SectorSource backed by an absolute-LBA → 2048-byte - /// sector map. Unmapped sectors read as zeroes (matches a freshly - /// formatted region). Mirrors the `MapReader` used in `udf.rs` - /// tests so fixtures are byte-for-byte interoperable. - struct MemDisc { - sectors: HashMap, - } - - impl MemDisc { - fn new() -> Self { - Self { - sectors: HashMap::new(), - } - } - fn put(&mut self, lba: u32, data: [u8; 2048]) { - self.sectors.insert(lba, data); - } - /// Write arbitrary-length bytes starting at `lba`, splitting across - /// consecutive 2048-byte sectors (zero-padded last sector). - fn put_bytes(&mut self, lba: u32, bytes: &[u8]) { - for (i, chunk) in bytes.chunks(2048).enumerate() { - let mut s = [0u8; 2048]; - s[..chunk.len()].copy_from_slice(chunk); - self.put(lba + i as u32, s); - } - } - } - - impl SectorSource for MemDisc { - fn read_sectors( - &mut self, - lba: u32, - count: u16, - buf: &mut [u8], - _recovery: bool, - ) -> crate::error::Result { - let need = count as usize * 2048; - for i in 0..count as u32 { - let off = i as usize * 2048; - let s = self.sectors.get(&(lba + i)).copied().unwrap_or([0u8; 2048]); - buf[off..off + 2048].copy_from_slice(&s); - } - Ok(need) - } - } - - // --------------------------------------------------------------- - // UDF image builder — produces a disc image `udf::read_filesystem` - // can navigate. All field offsets are cited from ECMA-167 / the - // exact bytes `udf.rs::read_filesystem` reads. - // --------------------------------------------------------------- - - /// Fixed layout. PART_START == META_START so file LBAs (physical - /// partition relative) and ICB/dir LBAs (metadata relative) share - /// one address space — both resolve to abs = PART_START + lba. This - /// keeps fixtures small; `read_filesystem` takes the single-partition - /// path (num_partition_maps == 1) so no metadata-partition file is - /// needed. - const PART_START: u32 = 2000; - - /// One file's on-disc placement: metadata LBA of its ICB, the LBA of - /// its (single contiguous) data extent, byte length, and whether the - /// ICB encodes its allocation descriptor as a Long AD (16-byte, the - /// real BD-ROM .m2ts layout) vs Short AD (8-byte). - struct FileSpec { - name: String, - icb_lba: u32, - data_lba: u32, - size: u32, - long_ad: bool, - /// Optional explicit file contents written at `data_lba`. - contents: Vec, - } - - /// A directory node for the builder: its ICB LBA, the LBA where its - /// FID list lives, child files, and child subdirectories. - struct DirSpec { - name: String, - icb_lba: u32, - dir_data_lba: u32, - files: Vec, - subdirs: Vec, - } - - /// Build an Extended File Entry ICB (tag 266) with one allocation - /// descriptor. Offsets per `udf.rs`: tag@0, ICB-tag flags@34, - /// info_length(u64)@56, l_ea@208, l_ad@212, ADs@216. - fn build_file_icb(size: u32, data_lba: u32, long_ad: bool) -> [u8; 2048] { - let mut s = [0u8; 2048]; - s[0..2].copy_from_slice(&266u16.to_le_bytes()); // Extended File Entry - if long_ad { - // ICB Tag flags low 3 bits = 1 → Long AD (16-byte stride). - s[34..36].copy_from_slice(&1u16.to_le_bytes()); - } - s[56..64].copy_from_slice(&(size as u64).to_le_bytes()); // info_length - s[208..212].copy_from_slice(&0u32.to_le_bytes()); // l_ea - let ad_size: u32 = if long_ad { 16 } else { 8 }; - s[212..216].copy_from_slice(&ad_size.to_le_bytes()); // l_ad - // Short/Long AD share length(4)@216 | lba(4)@220. extent_type 0 - // (recorded) is top 2 bits = 0, so raw == len. - s[216..220].copy_from_slice(&(size & 0x3FFF_FFFF).to_le_bytes()); - s[220..224].copy_from_slice(&data_lba.to_le_bytes()); - // Long AD's part_ref(2)@224 + impl_use(6)@226 stay zero. - s - } - - /// Build a directory ICB (tag 266) whose single short AD points at the - /// directory's FID data. - fn build_dir_icb(dir_data_lba: u32, dir_data_len: u32) -> [u8; 2048] { - build_file_icb(dir_data_len, dir_data_lba, false) - } - - /// Append one File Identifier Descriptor (tag 257) to `buf`. - /// Layout per `read_directory`: tag@0, file_chars@18, l_fi@19, - /// ICB long_ad extent_location(LBA)@24, l_iu(u16)@36, name@(38+l_iu). - /// Name uses UDF compression-id 8 (8-bit ASCII), so the on-disc name - /// field is `[0x08, ascii_bytes...]` and l_fi = 1 + ascii.len(). - fn push_fid(buf: &mut Vec, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) { - let start = buf.len(); - let name_field: Vec = if is_parent { - Vec::new() - } else { - let mut v = vec![0x08u8]; - v.extend_from_slice(name.as_bytes()); - v - }; - let l_fi = name_field.len(); - let mut fid = vec![0u8; 38]; - fid[0..2].copy_from_slice(&257u16.to_le_bytes()); // FID tag - let mut file_chars = 0u8; - if is_dir { - file_chars |= 0x02; - } - if is_parent { - file_chars |= 0x08; - } - fid[18] = file_chars; - fid[19] = l_fi as u8; - // ICB long_ad: extent_location LBA at offset 24. - fid[24..28].copy_from_slice(&icb_lba.to_le_bytes()); - // l_iu (u16) at offset 36 = 0. - fid[36..38].copy_from_slice(&0u16.to_le_bytes()); - buf.extend_from_slice(&fid); - buf.extend_from_slice(&name_field); - // Pad to 4-byte alignment (FID stride = (38 + l_iu + l_fi + 3) & !3). - let used = buf.len() - start; - let pad = (used + 3) & !3; - buf.resize(start + pad, 0); - } - - /// Recursively lay a DirSpec (and children) into the MemDisc, writing - /// directory ICBs, FID lists, file ICBs, and file data. - fn lay_dir(disc: &mut MemDisc, dir: &DirSpec) { - let mut fids = Vec::new(); - // Parent entry first (file_chars bit 0x08) — skipped by the parser - // but present on real discs. - push_fid(&mut fids, "", dir.icb_lba, true, true); - for f in &dir.files { - push_fid(&mut fids, &f.name, f.icb_lba, false, false); - disc.put( - PART_START + f.icb_lba, - build_file_icb(f.size, f.data_lba, f.long_ad), - ); - if !f.contents.is_empty() { - disc.put_bytes(PART_START + f.data_lba, &f.contents); - } - } - for sub in &dir.subdirs { - push_fid(&mut fids, &sub.name, sub.icb_lba, true, false); - } - disc.put( - PART_START + dir.icb_lba, - build_dir_icb(dir.dir_data_lba, fids.len() as u32), - ); - disc.put_bytes(PART_START + dir.dir_data_lba, &fids); - for sub in &dir.subdirs { - lay_dir(disc, sub); - } - } - - /// Build the static UDF anchor/VDS/FSD structure so `read_filesystem` - /// reaches `root_icb_lba`. Single partition map → metadata_start == - /// partition_start == PART_START. - fn build_udf_skeleton(disc: &mut MemDisc, root_icb_lba: u32) { - // AVDP at sector 256, tag 2 (ECMA-167 §10.2). - let mut avdp = [0u8; 2048]; - avdp[0..2].copy_from_slice(&2u16.to_le_bytes()); - disc.put(256, avdp); - - // Partition Descriptor (tag 5) at sector 32: partition_start@188. - let mut pd = [0u8; 2048]; - pd[0..2].copy_from_slice(&5u16.to_le_bytes()); - pd[188..192].copy_from_slice(&PART_START.to_le_bytes()); - disc.put(32, pd); - - // Logical Volume Descriptor (tag 6) at sector 33: - // num_partition_maps(u32)@268 = 1 (single map → no metadata part). - let mut lvd = [0u8; 2048]; - lvd[0..2].copy_from_slice(&6u16.to_le_bytes()); - lvd[268..272].copy_from_slice(&1u32.to_le_bytes()); - disc.put(33, lvd); - - // Terminating Descriptor (tag 8) at sector 34 → ends VDS scan. - let mut td = [0u8; 2048]; - td[0..2].copy_from_slice(&8u16.to_le_bytes()); - disc.put(34, td); - - // File Set Descriptor (tag 256) at metadata_start (== PART_START): - // root-dir ICB LBA at offset 404 (long_ad extent_location). - let mut fsd = [0u8; 2048]; - fsd[0..2].copy_from_slice(&256u16.to_le_bytes()); - fsd[404..408].copy_from_slice(&root_icb_lba.to_le_bytes()); - disc.put(PART_START, fsd); - } - - fn file(name: &str, icb_lba: u32, data_lba: u32, size: u32, long_ad: bool) -> FileSpec { - FileSpec { - name: name.to_string(), - icb_lba, - data_lba, - size, - long_ad, - contents: Vec::new(), - } - } - - fn file_with( - name: &str, - icb_lba: u32, - data_lba: u32, - contents: Vec, - long_ad: bool, - ) -> FileSpec { - FileSpec { - name: name.to_string(), - icb_lba, - data_lba, - size: contents.len() as u32, - long_ad, - contents, - } - } - + use crate::udf::fixture::*; // --------------------------------------------------------------- // MPLS builder (BD-ROM PlayList spec). Mirrors the layout the // `mpls::parse` consumer reads (header@0, PlayList@playlist_start, @@ -797,13 +569,29 @@ mod tests { u32, /*packets*/ u32, /*data_lba*/ )], + ) -> udf::UdfFs { + make_bdmv_fs_ext(disc, clips, "m2ts") + } + + /// As [`make_bdmv_fs`] but the STREAM file carries `stream_ext` instead of + /// `.m2ts` (e.g. "fmts" for an AACS 2.1 feature clip, "ssif" for 3D) — drives + /// the [`CLIP_STREAM_EXTS`] fallback in `parse_playlist`. + fn make_bdmv_fs_ext( + disc: &mut MemDisc, + clips: &[( + &str, + u32, /*sectors*/ + u32, /*packets*/ + u32, /*data_lba*/ + )], + stream_ext: &str, ) -> udf::UdfFs { // Layout LBAs: pick widely separated values to avoid collisions. let mut stream_files = Vec::new(); let mut clipinf_files = Vec::new(); let mut icb = 100u32; for (name, sectors, packets, data_lba) in clips { - let m2ts = format!("{name}.m2ts"); + let m2ts = format!("{name}.{stream_ext}"); // Size in bytes — file_extents derives sectors via div_ceil(2048). let size = sectors * 2048; stream_files.push(file(&m2ts, icb, *data_lba, size, true)); @@ -881,6 +669,37 @@ mod tests { assert_eq!(t.clips[0].source_packets, 4000); } + /// AACS 2.1: the feature clip is `00001.fmts`, NOT `.m2ts`. The + /// [`CLIP_STREAM_EXTS`] fallback in `parse_playlist` must still resolve the + /// physical extent — before the fix the hard-coded `.m2ts` path errored, + /// yielding empty extents (a silent empty rip and 0 encrypted samples for key + /// resolution). Size still comes from the `.clpi`, which parses regardless. + #[test] + fn parse_playlist_fmts_clip_resolves_extent() { + let mut disc = MemDisc::new(); + // Only a .fmts stream exists for clip 00001 (no .m2ts on disc). + let udf = make_bdmv_fs_ext(&mut disc, &[("00001", 1000, 4000, 5000)], "fmts"); + let mpls = build_mpls( + &[PiSpec { + clip_id: *b"00001", + in_time: 0, + out_time: 60 * 45000, + }], + (0, 0, 0, 0, 0, 0, 0, 0), + &[], + &[], + ); + let t = Disc::parse_playlist(&mut disc, &udf, "00001.mpls", &mpls).expect("title"); + assert_eq!(t.size_bytes, 4000 * 192, "size from .clpi source packets"); + assert_eq!( + t.extents.len(), + 1, + "the .fmts extent must be resolved via fallback" + ); + assert_eq!(t.extents[0].start_lba, PART_START + 5000); + assert_eq!(t.extents[0].sector_count, 1000); + } + /// THE 0.31.0 DEDUP PATH. A playlist that references the SAME clip_id /// from multiple PlayItems (seamless split / looped segment) must count /// the physical extents and packet bytes EXACTLY ONCE — mux reads diff --git a/src/disc/hddvd.rs b/src/disc/hddvd.rs new file mode 100644 index 0000000..fd2fa48 --- /dev/null +++ b/src/disc/hddvd.rs @@ -0,0 +1,157 @@ +//! 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. +//! +//! Scope today: enumerate the `.evo` clips and yield one [`DiscTitle`] per clip +//! (container [`ContentFormat::MpegPs`], so the existing PS mux path handles it). +//! What is NOT parsed yet — and is honestly stubbed, not faked: +//! * `.xpl` playlist ordering (title composition / chapters), +//! * per-clip stream enumeration (would demux the EVO program stream), +//! * `.map` timemap → real durations. +//! +//! Extents and size ARE real (the ripper needs those to image a clip); the rest +//! is left empty rather than guessed. + +use super::*; +use crate::sector::SectorSource; +use crate::udf; + +/// 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"; + +impl Disc { + /// Scan HD-DVD titles from the `HVDVD_TS/` `.evo` clips. + /// + /// One [`DiscTitle`] per `.evo` with real physical extents (from the UDF + /// allocation descriptors) and declared size; `streams`/`chapters`/duration + /// are left empty pending `.xpl`/EVO parsing (see module docs). Returns an + /// empty vec when `HVDVD_TS/` is absent or holds no readable `.evo`. + pub(super) fn scan_hddvd_titles( + reader: &mut dyn SectorSource, + udf_fs: &udf::UdfFs, + ) -> Vec { + let mut titles = Vec::new(); + let Some(ts_dir) = udf_fs.find_dir("/HVDVD_TS") else { + return titles; + }; + // Snapshot clip (name, size) first: the `ts_dir` borrow must end before + // the `udf_fs.file_extents` calls below re-borrow `udf_fs`. + let clips: Vec<(String, u64)> = ts_dir + .entries + .iter() + .filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(HDDVD_CLIP_EXT)) + .map(|e| (e.name.clone(), e.size)) + .collect(); + + for (idx, (name, size)) in clips.iter().enumerate() { + let path = format!("/HVDVD_TS/{name}"); + let mut extents = Vec::new(); + if let Ok(file_exts) = udf_fs.file_extents(reader, &path) { + for (lba, sectors) in file_exts { + if sectors > 0 && lba > 0 { + extents.push(Extent { + start_lba: lba, + sector_count: sectors, + }); + } + } + } + if extents.is_empty() { + continue; + } + let clip_id = name + .rsplit_once('.') + .map(|(base, _)| base.to_string()) + .unwrap_or_else(|| name.clone()); + titles.push(DiscTitle { + playlist: name.clone(), + playlist_id: idx as u16, + 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: Vec::new(), + chapters: Vec::new(), + extents, + content_format: ContentFormat::MpegPs, + codec_privates: Vec::new(), + }); + } + 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" + ); + } +} diff --git a/src/disc/mod.rs b/src/disc/mod.rs index cc8c4f9..fc61159 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -13,6 +13,7 @@ mod dvd; pub mod dvd_audio_probe; mod encrypt; mod extract; +mod hddvd; pub mod mapfile; mod patch; pub mod read_error; @@ -93,8 +94,16 @@ pub enum ContentFormat { pub enum DiscFormat { /// 4K UHD Blu-ray (HEVC 2160p) Uhd, + /// UHD Blu-ray with AACS 2.1 FMTS content — the main feature is a `.fmts` + /// clip (M2TS transport stream plus interleaved forensic variant segments). + /// A BD-tree disc (enumerated by [`Disc::scan_bluray_titles`]); distinct + /// from [`DiscFormat::Uhd`] only in the container + AACS generation. + Fmts, /// Standard Blu-ray (1080p/1080i) BluRay, + /// HD-DVD — `HVDVD_TS/` tree with `.evo` (Enhanced VOB, MPEG program stream) + /// clips. A tree-level peer of DVD/BD, enumerated by its own scanner. + HdDvd, /// DVD Dvd, /// Unknown @@ -1481,13 +1490,10 @@ impl Disc { let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?; let meta_title = Self::read_meta_title(&mut buffered, &udf_fs); - let format = if udf_fs.find_dir("/BDMV").is_some() { - DiscFormat::BluRay // full scan distinguishes UHD vs BD - } else if udf_fs.find_dir("/VIDEO_TS").is_some() { - DiscFormat::Dvd - } else { - DiscFormat::Unknown - }; + // Authoritative up front — same MKB-driven detector as the full scan + // (no titles needed: BD/UHD/FMTS come from the MKB generation). This + // no longer defaults to BluRay and defers UHD/FMTS to the full scan. + let format = Self::detect_disc_format(&mut buffered, &udf_fs, &[]); let encrypted = udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some(); let layers = if capacity > 24_000_000 { 2 } else { 1 }; @@ -1907,12 +1913,20 @@ impl Disc { } }; - // 3. Titles — BD (MPLS playlists) or DVD (IFO title sets) + // 3. Titles + container — dispatched by on-disc tree. HD-DVD and DVD are + // tree-level peers, each with its own enumerator; FMTS shares the BD + // tree (a `.fmts` stream variant). Disc FORMAT is a separate axis + // derived below from the AACS MKB generation, not the tree. let (mut titles, content_format) = if udf_fs.find_dir("/BDMV").is_some() { ( Self::scan_bluray_titles(reader, &udf_fs), ContentFormat::BdTs, ) + } else if udf_fs.find_dir("/HVDVD_TS").is_some() { + ( + Self::scan_hddvd_titles(reader, &udf_fs), + ContentFormat::MpegPs, + ) } else if udf_fs.find_dir("/VIDEO_TS").is_some() { ( Self::scan_dvd_titles(reader, &udf_fs), @@ -1936,8 +1950,9 @@ impl Disc { crate::labels::apply(reader, &udf_fs, &mut titles); crate::labels::fill_defaults(&mut titles); - // 5. Derive format, layers, region - let format = Self::detect_format(&titles); + // 5. Format (AACS MKB generation → BD/UHD/FMTS; tree → HD-DVD/DVD), + // layers, region. + let format = Self::detect_disc_format(reader, &udf_fs, &titles); let layers = if capacity > 24_000_000 { 2 } else { 1 }; let region = DiscRegion::Free; @@ -2087,6 +2102,48 @@ impl Disc { (lossless, max_ch, count) } + /// The disc format, from the two on-disc axes: + /// * **tree** → HD-DVD (`HVDVD_TS/`) and DVD (`VIDEO_TS/`) are tree-level + /// peers with their own enumerators; + /// * **AACS MKB generation** → within the BD tree (`BDMV/`), the MKB Type + /// record decides BD (1.0) / UHD (2.0) / FMTS (2.1). This is the + /// authoritative, cheap signal (the Type record is the first bytes of + /// `MKB_RO.inf`) — reusing [`crate::aacs::mkb::mkb_type`] / + /// [`crate::aacs::mkb::MkbType::generation`], not a filesystem heuristic. + /// + /// An unencrypted / MKB-less BD tree falls back to video resolution (still a + /// BD-tree disc, so never below [`DiscFormat::BluRay`]). + fn detect_disc_format( + reader: &mut dyn SectorSource, + udf_fs: &crate::udf::UdfFs, + titles: &[DiscTitle], + ) -> DiscFormat { + use crate::aacs::mkb::{AacsVersion, mkb_type}; + if udf_fs.find_dir("/HVDVD_TS").is_some() { + return DiscFormat::HdDvd; + } + if udf_fs.find_dir("/VIDEO_TS").is_some() { + return DiscFormat::Dvd; + } + if udf_fs.find_dir("/BDMV").is_some() { + // Only the Type-and-Version record (first record) is needed. + if let Ok(mkb) = udf_fs.read_file_prefix(reader, "/AACS/MKB_RO.inf", 64) { + match mkb_type(&mkb).map(|t| t.generation()) { + Some(AacsVersion::V21) => return DiscFormat::Fmts, + Some(AacsVersion::V20) => return DiscFormat::Uhd, + Some(AacsVersion::V10) => return DiscFormat::BluRay, + None => {} + } + } + // Unencrypted / unreadable MKB: refine by resolution, default BD. + return match Self::detect_format(titles) { + DiscFormat::Unknown => DiscFormat::BluRay, + other => other, + }; + } + DiscFormat::Unknown + } + fn detect_format(titles: &[DiscTitle]) -> DiscFormat { for title in titles.iter().take(3) { for stream in &title.streams { @@ -3977,8 +4034,10 @@ const MIN_BATCH_SECTORS: u16 = 3; pub(crate) fn ecc_sectors(format: DiscFormat) -> u16 { match format { - DiscFormat::Uhd | DiscFormat::BluRay => 32, - DiscFormat::Dvd => 16, + // BD-family 64 KiB ECC block (32 × 2048). FMTS is a UHD BD disc. + DiscFormat::Uhd | DiscFormat::Fmts | DiscFormat::BluRay => 32, + // 32 KiB ECC block (16 × 2048) — DVD and HD-DVD. + DiscFormat::Dvd | DiscFormat::HdDvd => 16, DiscFormat::Unknown => 32, } } @@ -4474,6 +4533,92 @@ mod tests { assert_eq!(Disc::detect_format(&titles), DiscFormat::Unknown); } + /// An AACS MKB Type-and-Version record (0x10) carrying `raw_type` — the only + /// record [`Disc::detect_disc_format`] reads to decide BD/UHD/FMTS. + fn mkb_type_record(raw_type: u32) -> Vec { + let mut v = vec![0x10, 0x00, 0x00, 0x0c]; // record type 0x10, rec_len 12 + v.extend_from_slice(&raw_type.to_be_bytes()); // MKBType @ body offset 0 + v.extend_from_slice(&0u32.to_be_bytes()); // version @ body offset 4 + v + } + + /// FORMAT derives from the AACS MKB generation, not the tree or filesystem: + /// 2.1 → FMTS, 2.0 → UHD, 1.0 → BD — all from the MKB Type record. + #[test] + fn detect_format_from_mkb_generation() { + use crate::udf::fixture::*; + for (raw, expected) in [ + (0x4815_1003u32, DiscFormat::Fmts), + (0x4814_1003u32, DiscFormat::Uhd), + (0x0004_1003u32, DiscFormat::BluRay), + ] { + let mut disc = MemDisc::new(); + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![ + DirSpec { + name: "BDMV".into(), + icb_lba: 12, + dir_data_lba: 13, + files: Vec::new(), + subdirs: vec![], + }, + DirSpec { + name: "AACS".into(), + icb_lba: 14, + dir_data_lba: 15, + files: vec![file_with( + "MKB_RO.inf", + 16, + 5000, + mkb_type_record(raw), + true, + )], + subdirs: vec![], + }, + ], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + assert_eq!( + Disc::detect_disc_format(&mut disc, &udf, &[]), + expected, + "MKB type {raw:#010x}" + ); + } + } + + /// HD-DVD is a tree-level format — recognized from `HVDVD_TS/`, no MKB. + #[test] + fn detect_format_hddvd_from_tree() { + use crate::udf::fixture::*; + let mut disc = MemDisc::new(); + let root = DirSpec { + name: String::new(), + icb_lba: 10, + dir_data_lba: 11, + files: Vec::new(), + subdirs: vec![DirSpec { + name: "HVDVD_TS".into(), + icb_lba: 20, + dir_data_lba: 21, + files: Vec::new(), + subdirs: vec![], + }], + }; + build_udf_skeleton(&mut disc, 10); + lay_dir(&mut disc, &root); + let udf = crate::udf::read_filesystem(&mut disc).expect("fs"); + assert_eq!( + Disc::detect_disc_format(&mut disc, &udf, &[]), + DiscFormat::HdDvd + ); + } + #[test] fn content_format_default_bdts() { let t = title_with_video(Codec::H264, Resolution::R1080p); diff --git a/src/keysource.rs b/src/keysource.rs index 62cf233..ab8f244 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -344,18 +344,24 @@ pub fn key_fetch( /// `start_lba`), which the library owns. A key source is *handed* these bytes /// via `DiscInputs.samples`; it never reads the disc itself. /// -/// "Encrypted" is decided by [`crate::aacs::content::ts_sync_destroyed`] — the SAME -/// predicate the decrypt gate uses — so all sides agree. A clip opens with clear -/// navigation units (PAT/PMT, menus); only the feature body is scrambled, and a -/// clear unit proves nothing, so this collects only scrambled ones — probing -/// several points spread across EACH extent so a title whose encrypted body -/// starts late (or whose midpoint lands in clear nav) still yields samples. +/// "Encrypted" is decided by [`crate::aacs::content::aacs_unit_encrypted`] — the +/// AACS Copy Permission Indicator (CPI) in the top 2 bits of byte 0, the +/// spec-correct signal (libaacs' `buf[0] & 0xc0`). NOT the `ts_sync_destroyed` +/// sync heuristic: destroyed TS syncs do not imply encryption (an FMTS variant +/// frame or an odd clear unit can lack syncs yet be unencrypted), and a clear +/// unit sent to a key server yields nothing to validate against — the "0 +/// encrypted units" rejection. A clip opens with clear navigation units (PAT/PMT, +/// menus) whose CPI is clear; only CPI-flagged content units are collected — +/// probing several points spread across EACH extent so a title whose encrypted +/// body starts late (or whose midpoint lands in clear nav) still yields samples. +/// CPI is read at each extent's `start_lba` (clip-file-anchored), so byte 0 is a +/// real unit start and the flag is meaningful. pub fn read_encrypted_units( reader: &mut dyn crate::sector::SectorSource, title: &crate::disc::DiscTitle, n: usize, ) -> Vec> { - use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed}; + use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted}; const CHUNK_UNITS: u32 = 15; // 45 sectors/read — under the drive transfer cap // Probe several evenly-spaced points across EACH extent rather than only the // midpoint-and-forward: a title whose encrypted feature starts late, or whose @@ -401,7 +407,7 @@ pub fn read_encrypted_units( break; } let u = &buf[o..o + ALIGNED_UNIT_LEN]; - if ts_sync_destroyed(u) { + if aacs_unit_encrypted(u) { out.push(u.to_vec()); if out.len() >= n { return out; @@ -646,7 +652,7 @@ mod tests { /// finds the early scrambled band. #[test] fn read_encrypted_units_finds_scrambled_content_off_the_midpoint() { - use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, ts_sync_destroyed}; + use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted}; use crate::error::Result; use crate::sector::SectorSource; @@ -716,7 +722,98 @@ mod tests { "the probe-spread must sample the early scrambled band the midpoint misses" ); for s in &samples { - assert!(ts_sync_destroyed(s), "every sample is a scrambled unit"); + assert!( + aacs_unit_encrypted(s), + "every sample is a CPI-flagged encrypted unit (byte0 & 0xC0 != 0)" + ); + } + } + + /// DISCRIMINATING: selection is by the AACS CPI (byte 0), NOT the + /// `ts_sync_destroyed` heuristic. Half the units are sync-destroyed but + /// CPI-CLEAR (`byte0 & 0xC0 == 0`) — genuinely UNencrypted units that merely + /// lack TS syncs; the old sampler collected these and the key server rejected + /// the POST as "0 encrypted units". `read_encrypted_units` must skip them and + /// return ONLY CPI-flagged units. A regression to `ts_sync_destroyed` would + /// collect the CPI-clear units too and fail the `& 0xC0` assertion. + #[test] + fn read_encrypted_units_selects_by_cpi_not_ts_sync() { + use crate::aacs::content::{ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted}; + use crate::error::Result; + use crate::sector::SectorSource; + + // Even units: CPI-clear (byte0 & 0xC0 == 0) AND sync-destroyed (no 0x47). + // Odd units: CPI-set (byte0 = 0xC0) with a scrambled body. + // `ts_sync_destroyed` is TRUE for BOTH; `aacs_unit_encrypted` only odd. + struct MixSource { + ext_start: u32, + total_units: u32, + } + impl SectorSource for MixSource { + fn capacity_sectors(&self) -> u32 { + self.ext_start + self.total_units * ALIGNED_UNIT_SECTORS + 64 + } + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + _r: bool, + ) -> Result { + let bytes = count as usize * 2048; + for (i, chunk) in buf[..bytes].chunks_mut(ALIGNED_UNIT_LEN).enumerate() { + if chunk.len() < ALIGNED_UNIT_LEN { + break; + } + let abs = (lba - self.ext_start) / ALIGNED_UNIT_SECTORS + i as u32; + if abs % 2 == 0 { + chunk.fill(0x11); // CPI-clear (0x11 & 0xC0 == 0), no TS sync + } else { + chunk.fill(0xAB); // scrambled body (no TS sync) + chunk[0] = 0xC0; // CPI set -> encrypted + } + } + Ok(bytes) + } + } + + let total_units = 400u32; + let ext_start = 500u32; + let mut src = MixSource { + ext_start, + total_units, + }; + let title = crate::disc::DiscTitle { + playlist: String::new(), + playlist_id: 0, + duration_secs: 0.0, + size_bytes: 0, + clips: Vec::new(), + streams: Vec::new(), + chapters: Vec::new(), + extents: vec![crate::disc::Extent { + start_lba: ext_start, + sector_count: total_units * ALIGNED_UNIT_SECTORS, + }], + content_format: crate::disc::ContentFormat::BdTs, + codec_privates: Vec::new(), + }; + + let samples = read_encrypted_units(&mut src, &title, 8); + assert!( + !samples.is_empty(), + "the CPI-flagged (odd) units must still be collected" + ); + for s in &samples { + assert!( + aacs_unit_encrypted(s), + "only CPI-flagged units are selected" + ); + assert_eq!( + s[0] & 0xC0, + 0xC0, + "a CPI-clear sync-destroyed unit must never be sampled" + ); } } diff --git a/src/udf.rs b/src/udf.rs index c8a68e1..63526c1 100644 --- a/src/udf.rs +++ b/src/udf.rs @@ -2391,3 +2391,224 @@ mod tests { ); } } + +/// Shared UDF image fixtures for tests across the `disc::*` format scanners. +/// +/// Builds an in-memory disc image that [`read_filesystem`] can navigate — a +/// [`MemDisc`] SectorSource plus a [`DirSpec`] tree laid out via [`lay_dir`] and +/// [`build_udf_skeleton`]. Format-agnostic: BD (`bluray.rs`), HD-DVD +/// (`hddvd.rs`), and the format detector (`disc/mod.rs`) all build their own +/// trees (`BDMV/`, `HVDVD_TS/`, `AACS/…`) on top of these primitives, so each +/// format's tests live in that format's file, not piled into one. +#[cfg(test)] +pub(crate) mod fixture { + use crate::sector::SectorSource; + use std::collections::HashMap; + + /// PART_START == META_START: file LBAs (partition-relative) and ICB/dir LBAs + /// (metadata-relative) share one address space (abs = PART_START + lba), so + /// `read_filesystem` takes the single-partition path. + pub(crate) const PART_START: u32 = 2000; + + /// In-memory `SectorSource` (absolute-LBA → 2048-byte sector map); unmapped + /// sectors read as zeroes. + pub(crate) struct MemDisc { + sectors: HashMap, + } + + impl MemDisc { + pub(crate) fn new() -> Self { + Self { + sectors: HashMap::new(), + } + } + fn put(&mut self, lba: u32, data: [u8; 2048]) { + self.sectors.insert(lba, data); + } + /// Write arbitrary-length bytes at `lba`, split across 2048-byte sectors. + pub(crate) fn put_bytes(&mut self, lba: u32, bytes: &[u8]) { + for (i, chunk) in bytes.chunks(2048).enumerate() { + let mut s = [0u8; 2048]; + s[..chunk.len()].copy_from_slice(chunk); + self.put(lba + i as u32, s); + } + } + } + + impl SectorSource for MemDisc { + fn read_sectors( + &mut self, + lba: u32, + count: u16, + buf: &mut [u8], + _recovery: bool, + ) -> crate::error::Result { + let need = count as usize * 2048; + for i in 0..count as u32 { + let off = i as usize * 2048; + let s = self.sectors.get(&(lba + i)).copied().unwrap_or([0u8; 2048]); + buf[off..off + 2048].copy_from_slice(&s); + } + Ok(need) + } + } + + /// One file's placement: ICB metadata LBA, data-extent LBA, byte length, + /// Long-AD (16-byte, real BD-ROM layout) vs Short-AD, optional contents. + pub(crate) struct FileSpec { + pub(crate) name: String, + pub(crate) icb_lba: u32, + pub(crate) data_lba: u32, + pub(crate) size: u32, + pub(crate) long_ad: bool, + pub(crate) contents: Vec, + } + + /// A directory node: ICB LBA, FID-list LBA, child files and subdirectories. + pub(crate) struct DirSpec { + pub(crate) name: String, + pub(crate) icb_lba: u32, + pub(crate) dir_data_lba: u32, + pub(crate) files: Vec, + pub(crate) subdirs: Vec, + } + + /// Build an Extended File Entry ICB (tag 266) with one allocation descriptor. + pub(crate) fn build_file_icb(size: u32, data_lba: u32, long_ad: bool) -> [u8; 2048] { + let mut s = [0u8; 2048]; + s[0..2].copy_from_slice(&266u16.to_le_bytes()); // Extended File Entry + if long_ad { + s[34..36].copy_from_slice(&1u16.to_le_bytes()); // ICB flags → Long AD + } + s[56..64].copy_from_slice(&(size as u64).to_le_bytes()); // info_length + s[208..212].copy_from_slice(&0u32.to_le_bytes()); // l_ea + let ad_size: u32 = if long_ad { 16 } else { 8 }; + s[212..216].copy_from_slice(&ad_size.to_le_bytes()); // l_ad + s[216..220].copy_from_slice(&(size & 0x3FFF_FFFF).to_le_bytes()); + s[220..224].copy_from_slice(&data_lba.to_le_bytes()); + s + } + + fn build_dir_icb(dir_data_lba: u32, dir_data_len: u32) -> [u8; 2048] { + build_file_icb(dir_data_len, dir_data_lba, false) + } + + /// Append one File Identifier Descriptor (tag 257) to `buf`. + fn push_fid(buf: &mut Vec, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) { + let start = buf.len(); + let name_field: Vec = if is_parent { + Vec::new() + } else { + let mut v = vec![0x08u8]; + v.extend_from_slice(name.as_bytes()); + v + }; + let l_fi = name_field.len(); + let mut fid = vec![0u8; 38]; + fid[0..2].copy_from_slice(&257u16.to_le_bytes()); // FID tag + let mut file_chars = 0u8; + if is_dir { + file_chars |= 0x02; + } + if is_parent { + file_chars |= 0x08; + } + fid[18] = file_chars; + fid[19] = l_fi as u8; + fid[24..28].copy_from_slice(&icb_lba.to_le_bytes()); // ICB long_ad LBA @24 + fid[36..38].copy_from_slice(&0u16.to_le_bytes()); // l_iu @36 + buf.extend_from_slice(&fid); + buf.extend_from_slice(&name_field); + let used = buf.len() - start; + let pad = (used + 3) & !3; + buf.resize(start + pad, 0); + } + + /// Recursively lay a [`DirSpec`] into the [`MemDisc`]. + pub(crate) fn lay_dir(disc: &mut MemDisc, dir: &DirSpec) { + let mut fids = Vec::new(); + push_fid(&mut fids, "", dir.icb_lba, true, true); + for f in &dir.files { + push_fid(&mut fids, &f.name, f.icb_lba, false, false); + disc.put( + PART_START + f.icb_lba, + build_file_icb(f.size, f.data_lba, f.long_ad), + ); + if !f.contents.is_empty() { + disc.put_bytes(PART_START + f.data_lba, &f.contents); + } + } + for sub in &dir.subdirs { + push_fid(&mut fids, &sub.name, sub.icb_lba, true, false); + } + disc.put( + PART_START + dir.icb_lba, + build_dir_icb(dir.dir_data_lba, fids.len() as u32), + ); + disc.put_bytes(PART_START + dir.dir_data_lba, &fids); + for sub in &dir.subdirs { + lay_dir(disc, sub); + } + } + + /// Build the static UDF anchor/VDS/FSD so `read_filesystem` reaches + /// `root_icb_lba` (single partition map → metadata_start == PART_START). + pub(crate) fn build_udf_skeleton(disc: &mut MemDisc, root_icb_lba: u32) { + let mut avdp = [0u8; 2048]; + avdp[0..2].copy_from_slice(&2u16.to_le_bytes()); + disc.put(256, avdp); + + let mut pd = [0u8; 2048]; + pd[0..2].copy_from_slice(&5u16.to_le_bytes()); + pd[188..192].copy_from_slice(&PART_START.to_le_bytes()); + disc.put(32, pd); + + let mut lvd = [0u8; 2048]; + lvd[0..2].copy_from_slice(&6u16.to_le_bytes()); + lvd[268..272].copy_from_slice(&1u32.to_le_bytes()); + disc.put(33, lvd); + + let mut td = [0u8; 2048]; + td[0..2].copy_from_slice(&8u16.to_le_bytes()); + disc.put(34, td); + + let mut fsd = [0u8; 2048]; + fsd[0..2].copy_from_slice(&256u16.to_le_bytes()); + fsd[404..408].copy_from_slice(&root_icb_lba.to_le_bytes()); + disc.put(PART_START, fsd); + } + + pub(crate) fn file( + name: &str, + icb_lba: u32, + data_lba: u32, + size: u32, + long_ad: bool, + ) -> FileSpec { + FileSpec { + name: name.to_string(), + icb_lba, + data_lba, + size, + long_ad, + contents: Vec::new(), + } + } + + pub(crate) fn file_with( + name: &str, + icb_lba: u32, + data_lba: u32, + contents: Vec, + long_ad: bool, + ) -> FileSpec { + FileSpec { + name: name.to_string(), + icb_lba, + data_lba, + size: contents.len() as u32, + long_ad, + contents, + } + } +}