hddvd: read AACS-wrapped playlists, judge encryption from the packs, join PEVOB_n features

- A genuine disc's VPLST*.XPL is an AACS-encapsulated ARF ([HD] §4.4.2,
  Table 4-13: "AACS", type 12h, Nfs at bytes 7..11, a 272-byte file name field,
  then the XML). The wrapper is stripped before parsing; rips ship it bare.
- An AACS directory no longer marks an HD DVD encrypted on its own: rips keep it
  renamed (ANY!, AAC!) over clear EVOs. Identify and scan sample the largest
  EVOs' packs and keep the AACS verdict only when a pack is scrambled.
- The clip-derived feature also joins PEVOB_1/PEVOB_2 splits.
- Comments: the VTI is the Advanced VTS information file; 0xFD ext 0x56 is the
  VC-1 sub video, and TrueHD rides private_stream_1.
This commit is contained in:
Matthew Jackson
2026-10-04 12:02:43 -07:00
parent b3e1a93e9e
commit 4d5987b21e
3 changed files with 172 additions and 12 deletions
+130 -8
View File
@@ -26,9 +26,9 @@ const EVO_PROBE_SECTORS: u32 = 8192;
/// is generous while bounding probe memory.
const EVO_ES_SAMPLE_CAP: usize = 128 * 1024;
/// HD-DVD Standard Content navigation file magic (`HVDVD_TS/HVA*.VTI`). The VTI
/// is the DVD-IFO analogue: it holds a fixed-stride clip table naming every
/// `.evo` in authored order.
/// HD-DVD Advanced VTS information file magic (`HVDVD_TS/HVA00001.VTI`). The VTI
/// is the Advanced Content's DVD-IFO analogue: it holds a fixed-stride clip table
/// naming every `.evo` in authored order.
const HDDVD_VTI_MAGIC: &[u8] = b"ADVANCED-VTS";
/// Byte stride between clip-table entries in the VTI. Each entry holds a
@@ -41,6 +41,67 @@ const VTI_CLIP_ENTRY_STRIDE: usize = 0x140;
/// `.EVO` tokens (up to the 64 MiB UDF read cap) can't burn CPU/memory.
const MAX_VTI_HITS: usize = 8192;
// Windows sampled per EVO, sectors per window, and EVOs sampled when judging whether an
// HD DVD's content is scrambled.
const SCRAMBLE_SAMPLE_WINDOWS: u64 = 2;
const SCRAMBLE_SAMPLE_SECTORS: u32 = 32;
const SCRAMBLE_SAMPLE_EVOS: usize = 3;
/// Whether an HD DVD's EVOs are AACS-scrambled, judged per pack (`[HD]` §4.3.2:
/// `PES_scrambling_control` is `01` on an encrypted pack). A rip keeps a renamed AACS
/// directory (`ANY!`, `AAC!`) over clear EVOs, so the directory alone proves nothing.
/// Samples windows of the largest EVOs; `None` when no window could be read.
pub(crate) fn hddvd_content_scrambled(
reader: &mut dyn SectorSource,
udf_fs: &udf::UdfFs,
) -> Option<bool> {
let dir = udf_fs.find_dir("/HVDVD_TS")?;
let mut evos: Vec<(&str, u64)> = dir
.entries
.iter()
.filter(|e| !e.is_dir && e.name.to_ascii_lowercase().ends_with(HDDVD_CLIP_EXT))
.map(|e| (e.name.as_str(), e.size))
.collect();
evos.sort_by_key(|e| std::cmp::Reverse(e.1));
let mut read_any = false;
for (name, _) in evos.into_iter().take(SCRAMBLE_SAMPLE_EVOS) {
let Ok(exts) = udf_fs.file_extents(reader, &format!("/HVDVD_TS/{name}")) else {
continue;
};
let total: u64 = exts.iter().map(|e| u64::from(e.1)).sum();
for w in 1..=SCRAMBLE_SAMPLE_WINDOWS {
let mut off = total * w / (SCRAMBLE_SAMPLE_WINDOWS + 1);
let Some(&(lba, left)) = exts.iter().find(|e| {
let hit = off < u64::from(e.1);
if !hit {
off -= u64::from(e.1);
}
hit
}) else {
continue;
};
let n = (u64::from(left) - off).min(u64::from(SCRAMBLE_SAMPLE_SECTORS)) as u32;
let mut buf = vec![0u8; n as usize * crate::consts::SECTOR_BYTES];
let Ok(got) = reader.read_sectors(lba + off as u32, n as u16, &mut buf, false) else {
continue;
};
read_any = true;
let pack_scrambled = |p: &[u8; crate::consts::SECTOR_BYTES]| {
crate::aacs::hddvd::classify(p) == crate::aacs::hddvd::PackKind::Scrambled
};
if buf[..got.min(buf.len())]
.as_chunks::<{ crate::consts::SECTOR_BYTES }>()
.0
.iter()
.any(pack_scrambled)
{
return Some(true);
}
}
}
read_any.then_some(false)
}
// Parses the ADVANCED-VTS VTI clip-name table (authored order): collects every NUL-terminated
// `*.EVO` name and keeps the largest group sharing one residue mod the stride (the clip table).
fn parse_vti_clip_order(vti: &[u8]) -> Vec<String> {
@@ -87,11 +148,15 @@ fn parse_vti_clip_order(vti: &[u8]) -> Vec<String> {
best.into_iter().map(|(_, n)| n).collect()
}
// Whether a clip name begins `feature` (case-insensitive): `FEATURE_1`/`_2`
// (layer-break split) or `feature`/`feature_Divide`, imaged as ONE title.
// Whether a clip is a part of the split feature (case-insensitive): a name beginning `feature`
// (`FEATURE_1`/`_2`, `feature`/`feature_Divide`) or a primary-EVOB part `PEVOB_<n>`.
fn is_feature_clip(name: &str) -> bool {
let base = name.rsplit_once('.').map(|(b, _)| b).unwrap_or(name);
base.to_ascii_lowercase().starts_with("feature")
let lower = base.to_ascii_lowercase();
lower.starts_with("feature")
|| lower
.strip_prefix("pevob_")
.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit()))
}
// Sniffs a video codec from an MPEG-PS video ES sample by start code: MPEG-2 (`B3`), VC-1
@@ -244,8 +309,8 @@ fn collect_es(
) {
use crate::consts::pes_stream_id::{PRIVATE_STREAM_1, VIDEO, VIDEO_MAX};
const EXTENDED_STREAM_ID: u8 = 0xFD;
// VC-1 rides extended-stream-id `0xFD` ext `0x55`; other `0xFD` exts are HD audio
// (MLP/TrueHD, routing deferred), so only this ext is video.
// Main-video VC-1 rides extended-stream-id `0xFD` ext `0x55`. Ext `0x56` is the VC-1 sub
// video (picture-in-picture), not routed; TrueHD rides private_stream_1, never `0xFD`.
const VC1_STREAM_ID_EXT: u8 = 0x55;
// Whether this packet is the VC-1 video sub-stream of the 0xFD extended id.
let is_vc1_ext =
@@ -472,10 +537,36 @@ fn xpl_depth_within_limit(text: &str) -> bool {
true
}
// `[HD]` §4.4.2: the 283-byte header of an AACS-encapsulated Advanced Resource File, and its
// FILE_ID. Bytes 7..11 hold the Resource Data size Nfs (Tables 4-12 to 4-14).
const ARF_HEADER_LEN: usize = 283;
const ARF_FILE_ID: &[u8; 4] = b"AACS";
// The Resource Data of an AACS-encapsulated ARF (a genuine disc's playlist), or the bytes
// themselves when bare (a rip strips the wrapper). Hash (`12h`), MAC (`02h`) and Non-Protected
// (`21h`) formats carry it in the clear after the header; `None` for the encrypted ones.
fn arf_resource(bytes: &[u8]) -> Option<&[u8]> {
if bytes.len() < ARF_HEADER_LEN || !bytes.starts_with(ARF_FILE_ID) {
return Some(bytes);
}
match bytes[4] {
0x02 | 0x12 | 0x21 => {
let nfs = u32::from_be_bytes([bytes[7], bytes[8], bytes[9], bytes[10]]) as usize;
let end = ARF_HEADER_LEN.saturating_add(nfs).min(bytes.len());
Some(&bytes[ARF_HEADER_LEN..end])
}
_ => None,
}
}
// Parses the Advanced-Content playlist into its titles, matching elements by
// LOCAL name (default `HDDVDVideo/Playlist` namespace). Empty for a
// non-XML/non-playlist blob or one over MAX_XPL_DEPTH (falls back to clip-name).
fn parse_xpl_titles(xpl: &[u8]) -> Vec<XplTitle> {
let Some(xpl) = arf_resource(xpl) else {
tracing::warn!(target: "freemkv::disc", "playlist is an encrypted AACS ARF: not parsed");
return Vec::new();
};
let text = String::from_utf8_lossy(xpl);
if !xpl_depth_within_limit(&text) {
return Vec::new();
@@ -1112,6 +1203,10 @@ mod tests {
assert!(is_feature_clip("feature.EVO"));
assert!(is_feature_clip("feature_Divide.EVO"));
// Extras are not the feature.
assert!(is_feature_clip("PEVOB_1.EVO"));
assert!(is_feature_clip("pevob_2.evo"));
assert!(!is_feature_clip("PEVOB_.EVO"));
assert!(!is_feature_clip("PEVOB_MENU.EVO"));
assert!(!is_feature_clip("TRAILER.EVO"));
assert!(!is_feature_clip("DLS_01.EVO"));
assert!(!is_feature_clip("EPK.EVO"));
@@ -1931,6 +2026,33 @@ mod tests {
assert_eq!(del.clips[0].evo, "del5_veronicapast.evo");
}
// `[HD]` §4.4.2 Table 4-13 (Encapsulation Format for Hash): "AACS", type 12h, reserved,
// Nfs at bytes 7..11, the 272-byte Resource File Name field, the data, a Hash Pointer.
fn arf(kind: u8, data: &[u8]) -> Vec<u8> {
let mut v = b"AACS".to_vec();
v.extend_from_slice(&[kind, 0, 0]);
v.extend_from_slice(&(data.len() as u32).to_be_bytes());
let mut name = b"VPLST000.XPL.AACS".to_vec();
name.resize(272, 0);
v.extend_from_slice(&name);
v.extend_from_slice(data);
v.extend_from_slice(&1u32.to_be_bytes());
v
}
#[test]
fn parse_xpl_titles_reads_a_playlist_wrapped_as_an_aacs_arf() {
let bare = parse_xpl_titles(SYNTH_XPL.as_bytes());
assert!(!bare.is_empty());
for kind in [0x12, 0x02, 0x21] {
let wrapped = parse_xpl_titles(&arf(kind, SYNTH_XPL.as_bytes()));
assert_eq!(wrapped.len(), bare.len(), "type {kind:#04x}");
assert_eq!(wrapped[0].duration_secs, bare[0].duration_secs);
}
// Encrypted ARFs (01h, 11h) cannot be read without the Title Key.
assert!(parse_xpl_titles(&arf(0x11, SYNTH_XPL.as_bytes())).is_empty());
}
#[test]
fn parse_xpl_titles_returns_empty_on_non_xml() {
assert!(parse_xpl_titles(b"not xml at all").is_empty());
+13 -3
View File
@@ -1779,7 +1779,9 @@ impl Disc {
// (no titles needed: BD/UHD/FMTS come from the MKB generation). It no
// longer defaults to BluRay or defers UHD/FMTS to the full scan.
let format = Self::detect_disc_format(&mut buffered, &udf_fs, &[]);
let encrypted = aacs_dir_present(&udf_fs);
let encrypted = aacs_dir_present(&udf_fs)
&& (format != DiscFormat::HdDvd
|| hddvd::hddvd_content_scrambled(&mut buffered, &udf_fs) != Some(false));
let layers = Self::layers_for(format, capacity);
Ok(DiscId {
@@ -2447,10 +2449,10 @@ impl Disc {
) -> Result<Self> {
let scan_with_t0 = std::time::Instant::now();
tracing::info!(target: "freemkv::scan", phase = "scan_with", "begin");
let encrypted = aacs.is_some();
let mut encrypted = aacs.is_some();
// Lookup-free: the state carries the disc's AACS inputs but no key; the caller
// resolves one into a `KeyRing`.
let (aacs, aacs_error) = match aacs {
let (mut aacs, mut aacs_error) = match aacs {
Some((cap, bus)) => encrypt::resolve_aacs(cap, &bus),
None => (None, None),
};
@@ -2466,6 +2468,14 @@ impl Disc {
ContentFormat::BdTs,
)
} else if udf_fs.find_dir("/HVDVD_TS").is_some() {
// An AACS directory over EVOs whose packs are all unscrambled is a decrypted rip.
if encrypted && hddvd::hddvd_content_scrambled(reader, &udf_fs) == Some(false) {
tracing::info!(
target: "freemkv::scan",
"HD DVD carries an AACS directory but its EVO packs are clear: not encrypted"
);
(encrypted, aacs, aacs_error) = (false, None, None);
}
(
Self::scan_hddvd_titles(reader, &udf_fs, halt)?,
ContentFormat::MpegPs,
+29 -1
View File
@@ -673,7 +673,24 @@ fn pre_ake_read_refusal_is_a_plain_scan_error() {
assert_eq!(rig.count(|c| matches!(c[0], 0xA3 | 0xA4)), 0);
}
// 64 video packs, each flagged scrambled (`[HD]` §4.3.2) or each clear (a decrypted rip).
fn evo(scrambled: bool) -> Vec<u8> {
(0..64u8)
.flat_map(|i| {
let mut p = crate::aacs::hddvd::tests::video_pack(i);
if scrambled {
p[20] |= 0x10;
}
p
})
.collect()
}
fn hddvd_disc() -> MemDisc {
hddvd_disc_with(true)
}
fn hddvd_disc_with(scrambled: bool) -> MemDisc {
let root = DirSpec {
name: String::new(),
icb_lba: 10,
@@ -684,7 +701,7 @@ fn hddvd_disc() -> MemDisc {
name: "HVDVD_TS".into(),
icb_lba: 20,
dir_data_lba: 21,
files: vec![file("MAIN.EVO", 100, 5_000, 3_000_000, true)],
files: vec![file_with("MAIN.EVO", 100, 5_000, evo(scrambled), true)],
subdirs: vec![],
},
DirSpec {
@@ -738,6 +755,17 @@ fn hddvd_image_aacs_dir_is_captured() {
assert!(d.encrypted && d.aacs.is_some() && d.aacs_error.is_none());
}
// A rip keeps a renamed AACS dir (`AACS!` here) over clear EVOs: encryption follows the packs'
// scrambling flags, not the directory, for the fast identify and the full scan alike.
#[test]
fn hddvd_rip_with_an_aacs_dir_over_clear_packs_is_not_encrypted() {
let mut rig = Rig::new(hddvd_disc_with(false), |_| {});
assert!(!Disc::identify(&mut rig.drive).expect("identify").encrypted);
let mut mem = hddvd_disc_with(false);
let d = Disc::scan_image(&mut mem, 9_999, &ScanOptions::default()).expect("scan");
assert!(!d.encrypted && d.aacs.is_none() && !d.titles.is_empty());
}
#[test]
fn halted_during_capture_propagates() {
for lba in [CERT_LBA, MKB_LBA] {