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::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<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,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
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<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]
|
||||
fn content_format_default_bdts() {
|
||||
let t = title_with_video(Codec::H264, Resolution::R1080p);
|
||||
|
||||
Reference in New Issue
Block a user