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:
Matthew Jackson
2026-07-07 18:16:04 -07:00
parent bc04ee7bd2
commit 85347597cc
5 changed files with 715 additions and 276 deletions
+73 -254
View File
@@ -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