disc: first-class FMTS + HD-DVD formats; CPI sample selection
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.
This commit is contained in:
+73
-254
@@ -6,6 +6,18 @@ use crate::mpls;
|
|||||||
use crate::sector::SectorSource;
|
use crate::sector::SectorSource;
|
||||||
use crate::udf;
|
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 {
|
impl Disc {
|
||||||
/// Scan Blu-ray titles from MPLS playlists.
|
/// Scan Blu-ray titles from MPLS playlists.
|
||||||
pub(super) fn scan_bluray_titles(
|
pub(super) fn scan_bluray_titles(
|
||||||
@@ -85,10 +97,19 @@ impl Disc {
|
|||||||
if first_ref {
|
if first_ref {
|
||||||
total_size += pkt_count as u64 * 192;
|
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.
|
// 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 {
|
for (lba, sectors) in file_exts {
|
||||||
if sectors > 0 && lba > 0 {
|
if sectors > 0 && lba > 0 {
|
||||||
extents.push(Extent {
|
extents.push(Extent {
|
||||||
@@ -314,256 +335,7 @@ impl Disc {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::sector::SectorSource;
|
use crate::udf::fixture::*;
|
||||||
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<u32, [u8; 2048]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
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<usize> {
|
|
||||||
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<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<FileSpec>,
|
|
||||||
subdirs: Vec<DirSpec>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<u8>, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) {
|
|
||||||
let start = buf.len();
|
|
||||||
let name_field: Vec<u8> = 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<u8>,
|
|
||||||
long_ad: bool,
|
|
||||||
) -> FileSpec {
|
|
||||||
FileSpec {
|
|
||||||
name: name.to_string(),
|
|
||||||
icb_lba,
|
|
||||||
data_lba,
|
|
||||||
size: contents.len() as u32,
|
|
||||||
long_ad,
|
|
||||||
contents,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// MPLS builder (BD-ROM PlayList spec). Mirrors the layout the
|
// MPLS builder (BD-ROM PlayList spec). Mirrors the layout the
|
||||||
// `mpls::parse` consumer reads (header@0, PlayList@playlist_start,
|
// `mpls::parse` consumer reads (header@0, PlayList@playlist_start,
|
||||||
@@ -797,13 +569,29 @@ mod tests {
|
|||||||
u32, /*packets*/
|
u32, /*packets*/
|
||||||
u32, /*data_lba*/
|
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 {
|
) -> udf::UdfFs {
|
||||||
// Layout LBAs: pick widely separated values to avoid collisions.
|
// Layout LBAs: pick widely separated values to avoid collisions.
|
||||||
let mut stream_files = Vec::new();
|
let mut stream_files = Vec::new();
|
||||||
let mut clipinf_files = Vec::new();
|
let mut clipinf_files = Vec::new();
|
||||||
let mut icb = 100u32;
|
let mut icb = 100u32;
|
||||||
for (name, sectors, packets, data_lba) in clips {
|
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).
|
// Size in bytes — file_extents derives sectors via div_ceil(2048).
|
||||||
let size = sectors * 2048;
|
let size = sectors * 2048;
|
||||||
stream_files.push(file(&m2ts, icb, *data_lba, size, true));
|
stream_files.push(file(&m2ts, icb, *data_lba, size, true));
|
||||||
@@ -881,6 +669,37 @@ mod tests {
|
|||||||
assert_eq!(t.clips[0].source_packets, 4000);
|
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
|
/// THE 0.31.0 DEDUP PATH. A playlist that references the SAME clip_id
|
||||||
/// from multiple PlayItems (seamless split / looped segment) must count
|
/// from multiple PlayItems (seamless split / looped segment) must count
|
||||||
/// the physical extents and packet bytes EXACTLY ONCE — mux reads
|
/// the physical extents and packet bytes EXACTLY ONCE — mux reads
|
||||||
|
|||||||
@@ -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<DiscTitle> {
|
||||||
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+157
-12
@@ -13,6 +13,7 @@ mod dvd;
|
|||||||
pub mod dvd_audio_probe;
|
pub mod dvd_audio_probe;
|
||||||
mod encrypt;
|
mod encrypt;
|
||||||
mod extract;
|
mod extract;
|
||||||
|
mod hddvd;
|
||||||
pub mod mapfile;
|
pub mod mapfile;
|
||||||
mod patch;
|
mod patch;
|
||||||
pub mod read_error;
|
pub mod read_error;
|
||||||
@@ -93,8 +94,16 @@ pub enum ContentFormat {
|
|||||||
pub enum DiscFormat {
|
pub enum DiscFormat {
|
||||||
/// 4K UHD Blu-ray (HEVC 2160p)
|
/// 4K UHD Blu-ray (HEVC 2160p)
|
||||||
Uhd,
|
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)
|
/// Standard Blu-ray (1080p/1080i)
|
||||||
BluRay,
|
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
|
||||||
Dvd,
|
Dvd,
|
||||||
/// Unknown
|
/// Unknown
|
||||||
@@ -1481,13 +1490,10 @@ impl Disc {
|
|||||||
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
|
let (capacity, mut buffered, udf_fs) = Self::read_udf(session)?;
|
||||||
|
|
||||||
let meta_title = Self::read_meta_title(&mut buffered, &udf_fs);
|
let meta_title = Self::read_meta_title(&mut buffered, &udf_fs);
|
||||||
let format = if udf_fs.find_dir("/BDMV").is_some() {
|
// Authoritative up front — same MKB-driven detector as the full scan
|
||||||
DiscFormat::BluRay // full scan distinguishes UHD vs BD
|
// (no titles needed: BD/UHD/FMTS come from the MKB generation). This
|
||||||
} else if udf_fs.find_dir("/VIDEO_TS").is_some() {
|
// no longer defaults to BluRay and defers UHD/FMTS to the full scan.
|
||||||
DiscFormat::Dvd
|
let format = Self::detect_disc_format(&mut buffered, &udf_fs, &[]);
|
||||||
} else {
|
|
||||||
DiscFormat::Unknown
|
|
||||||
};
|
|
||||||
let encrypted =
|
let encrypted =
|
||||||
udf_fs.find_dir("/AACS").is_some() || udf_fs.find_dir("/BDMV/AACS").is_some();
|
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 };
|
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() {
|
let (mut titles, content_format) = if udf_fs.find_dir("/BDMV").is_some() {
|
||||||
(
|
(
|
||||||
Self::scan_bluray_titles(reader, &udf_fs),
|
Self::scan_bluray_titles(reader, &udf_fs),
|
||||||
ContentFormat::BdTs,
|
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() {
|
} else if udf_fs.find_dir("/VIDEO_TS").is_some() {
|
||||||
(
|
(
|
||||||
Self::scan_dvd_titles(reader, &udf_fs),
|
Self::scan_dvd_titles(reader, &udf_fs),
|
||||||
@@ -1936,8 +1950,9 @@ impl Disc {
|
|||||||
crate::labels::apply(reader, &udf_fs, &mut titles);
|
crate::labels::apply(reader, &udf_fs, &mut titles);
|
||||||
crate::labels::fill_defaults(&mut titles);
|
crate::labels::fill_defaults(&mut titles);
|
||||||
|
|
||||||
// 5. Derive format, layers, region
|
// 5. Format (AACS MKB generation → BD/UHD/FMTS; tree → HD-DVD/DVD),
|
||||||
let format = Self::detect_format(&titles);
|
// layers, region.
|
||||||
|
let format = Self::detect_disc_format(reader, &udf_fs, &titles);
|
||||||
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
let layers = if capacity > 24_000_000 { 2 } else { 1 };
|
||||||
let region = DiscRegion::Free;
|
let region = DiscRegion::Free;
|
||||||
|
|
||||||
@@ -2087,6 +2102,48 @@ impl Disc {
|
|||||||
(lossless, max_ch, count)
|
(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 {
|
fn detect_format(titles: &[DiscTitle]) -> DiscFormat {
|
||||||
for title in titles.iter().take(3) {
|
for title in titles.iter().take(3) {
|
||||||
for stream in &title.streams {
|
for stream in &title.streams {
|
||||||
@@ -3977,8 +4034,10 @@ const MIN_BATCH_SECTORS: u16 = 3;
|
|||||||
|
|
||||||
pub(crate) fn ecc_sectors(format: DiscFormat) -> u16 {
|
pub(crate) fn ecc_sectors(format: DiscFormat) -> u16 {
|
||||||
match format {
|
match format {
|
||||||
DiscFormat::Uhd | DiscFormat::BluRay => 32,
|
// BD-family 64 KiB ECC block (32 × 2048). FMTS is a UHD BD disc.
|
||||||
DiscFormat::Dvd => 16,
|
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,
|
DiscFormat::Unknown => 32,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4474,6 +4533,92 @@ mod tests {
|
|||||||
assert_eq!(Disc::detect_format(&titles), DiscFormat::Unknown);
|
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<u8> {
|
||||||
|
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]
|
#[test]
|
||||||
fn content_format_default_bdts() {
|
fn content_format_default_bdts() {
|
||||||
let t = title_with_video(Codec::H264, Resolution::R1080p);
|
let t = title_with_video(Codec::H264, Resolution::R1080p);
|
||||||
|
|||||||
+107
-10
@@ -344,18 +344,24 @@ pub fn key_fetch(
|
|||||||
/// `start_lba`), which the library owns. A key source is *handed* these bytes
|
/// `start_lba`), which the library owns. A key source is *handed* these bytes
|
||||||
/// via `DiscInputs.samples`; it never reads the disc itself.
|
/// via `DiscInputs.samples`; it never reads the disc itself.
|
||||||
///
|
///
|
||||||
/// "Encrypted" is decided by [`crate::aacs::content::ts_sync_destroyed`] — the SAME
|
/// "Encrypted" is decided by [`crate::aacs::content::aacs_unit_encrypted`] — the
|
||||||
/// predicate the decrypt gate uses — so all sides agree. A clip opens with clear
|
/// AACS Copy Permission Indicator (CPI) in the top 2 bits of byte 0, the
|
||||||
/// navigation units (PAT/PMT, menus); only the feature body is scrambled, and a
|
/// spec-correct signal (libaacs' `buf[0] & 0xc0`). NOT the `ts_sync_destroyed`
|
||||||
/// clear unit proves nothing, so this collects only scrambled ones — probing
|
/// sync heuristic: destroyed TS syncs do not imply encryption (an FMTS variant
|
||||||
/// several points spread across EACH extent so a title whose encrypted body
|
/// frame or an odd clear unit can lack syncs yet be unencrypted), and a clear
|
||||||
/// starts late (or whose midpoint lands in clear nav) still yields samples.
|
/// 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(
|
pub fn read_encrypted_units(
|
||||||
reader: &mut dyn crate::sector::SectorSource,
|
reader: &mut dyn crate::sector::SectorSource,
|
||||||
title: &crate::disc::DiscTitle,
|
title: &crate::disc::DiscTitle,
|
||||||
n: usize,
|
n: usize,
|
||||||
) -> Vec<Vec<u8>> {
|
) -> Vec<Vec<u8>> {
|
||||||
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
|
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
|
// Probe several evenly-spaced points across EACH extent rather than only the
|
||||||
// midpoint-and-forward: a title whose encrypted feature starts late, or whose
|
// midpoint-and-forward: a title whose encrypted feature starts late, or whose
|
||||||
@@ -401,7 +407,7 @@ pub fn read_encrypted_units(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
let u = &buf[o..o + ALIGNED_UNIT_LEN];
|
let u = &buf[o..o + ALIGNED_UNIT_LEN];
|
||||||
if ts_sync_destroyed(u) {
|
if aacs_unit_encrypted(u) {
|
||||||
out.push(u.to_vec());
|
out.push(u.to_vec());
|
||||||
if out.len() >= n {
|
if out.len() >= n {
|
||||||
return out;
|
return out;
|
||||||
@@ -646,7 +652,7 @@ mod tests {
|
|||||||
/// finds the early scrambled band.
|
/// finds the early scrambled band.
|
||||||
#[test]
|
#[test]
|
||||||
fn read_encrypted_units_finds_scrambled_content_off_the_midpoint() {
|
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::error::Result;
|
||||||
use crate::sector::SectorSource;
|
use crate::sector::SectorSource;
|
||||||
|
|
||||||
@@ -716,7 +722,98 @@ mod tests {
|
|||||||
"the probe-spread must sample the early scrambled band the midpoint misses"
|
"the probe-spread must sample the early scrambled band the midpoint misses"
|
||||||
);
|
);
|
||||||
for s in &samples {
|
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<usize> {
|
||||||
|
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"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+221
@@ -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<u32, [u8; 2048]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<usize> {
|
||||||
|
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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<FileSpec>,
|
||||||
|
pub(crate) subdirs: Vec<DirSpec>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<u8>, name: &str, icb_lba: u32, is_dir: bool, is_parent: bool) {
|
||||||
|
let start = buf.len();
|
||||||
|
let name_field: Vec<u8> = 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<u8>,
|
||||||
|
long_ad: bool,
|
||||||
|
) -> FileSpec {
|
||||||
|
FileSpec {
|
||||||
|
name: name.to_string(),
|
||||||
|
icb_lba,
|
||||||
|
data_lba,
|
||||||
|
size: contents.len() as u32,
|
||||||
|
long_ad,
|
||||||
|
contents,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user