From 43cbfa07f5402026aec69f360a052cae8e51e5ad Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:00:28 -0700 Subject: [PATCH] 1.3.2: AACS 2.1 FMTS variant-decode foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add UnitKey.variant_number (0 = ordinary, 1..32 = forensic variant) with new/variant constructors, and aacs::variant_select — resolve a disc's single variant and classify each aligned unit (default / variant / drop foreign / conceal keyless). Correct IndividualSegment.tbl: the per-record field is the variant (cycles 1..32 on a retail disc), not a segment number — Segment.number -> Segment.variant. --- CHANGELOG.md | 20 +++++ Cargo.toml | 2 +- src/aacs/derive.rs | 5 +- src/aacs/mod.rs | 6 +- src/aacs/segment.rs | 58 +++++++++--- src/aacs/types.rs | 36 ++++++++ src/aacs/variant_select.rs | 178 +++++++++++++++++++++++++++++++++++++ src/keysource.rs | 10 +-- 8 files changed, 284 insertions(+), 31 deletions(-) create mode 100644 src/aacs/variant_select.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index c90adc3..eb6cc57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## [1.3.2] — 2026-07-10 + +### Added + +- **AACS 2.1 (FMTS) variant-decode foundation.** `UnitKey` gains a + `variant_number` field (`0` = ordinary content, `1..=32` = a forensic + variant) with `UnitKey::new` / `UnitKey::variant` constructors, and a new + `aacs::variant_select` module resolves a disc's single forensic variant and + classifies each aligned unit — decrypt with the default key, decrypt with the + variant key, drop a foreign variant, or conceal a keyless forensic unit. This + is the groundwork for selecting one variant's segments and dropping the other + 31; the decrypt-pipeline wiring lands with the variant key source. + +### Fixed + +- **`IndividualSegment.tbl`: the per-record field is the variant, not a segment + number.** `Segment.number` → `Segment.variant`. Verified against a retail 2.1 + disc, the field cycles `1..=32` across the table (a per-variant tag) rather + than counting up, so variant selection routes on the correct value. + ## [1.3.1] — 2026-07-10 ### Licensing diff --git a/Cargo.toml b/Cargo.toml index 7cac3cc..5ec9b8b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libfreemkv" -version = "1.3.1" +version = "1.3.2" edition = "2024" rust-version = "1.86" license = "MIT" diff --git a/src/aacs/derive.rs b/src/aacs/derive.rs index e68690b..6e01d12 100644 --- a/src/aacs/derive.rs +++ b/src/aacs/derive.rs @@ -612,10 +612,7 @@ mod resolve_candidate_tests { /// A bare UK candidate is terminal — it returns itself keyed by its own idx. #[test] fn resolve_candidate_uk_is_itself() { - let uk = UnitKey { - idx: 2, - key: [0x9u8; 16], - }; + let uk = UnitKey::new(2, [0x9u8; 16]); let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &[], None).expect("uk is terminal"); assert_eq!(r.unit_keys, vec![(2, uk.key)]); assert!(r.vuk.is_none() && r.mk.is_none()); diff --git a/src/aacs/mod.rs b/src/aacs/mod.rs index 459f7eb..bb6402b 100644 --- a/src/aacs/mod.rs +++ b/src/aacs/mod.rs @@ -38,6 +38,7 @@ pub mod segment_key; pub mod trace; pub mod types; pub mod variant; +pub mod variant_select; /// On-disc UDF paths to the AACS key-input files (with their fallbacks). /// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`, @@ -103,10 +104,7 @@ mod tests { let _ = is_variant_mkb(&walk_mkb(&[])); let _ = disc_hash_hex(&disc_hash(b"x")); let _ = super::derive::resolve_candidate( - &super::derive::KeyCandidate::Uk(super::types::UnitKey { - idx: 0, - key: [0u8; 16], - }), + &super::derive::KeyCandidate::Uk(super::types::UnitKey::new(0, [0u8; 16])), &[], &[], None, diff --git a/src/aacs/segment.rs b/src/aacs/segment.rs index f0bdc3b..59b2621 100644 --- a/src/aacs/segment.rs +++ b/src/aacs/segment.rs @@ -17,12 +17,15 @@ //! ```text //! header (8 bytes): u32 type | u16 count | u16 record_size (= 16) //! record[count] (16 bytes each): -//! u32 marker (= 0x01000000) | u16 segment_number | u16 flag (= 1) +//! u32 marker (= 0x01000000) | u16 variant | u16 flag (= 1) //! u32 start_spn | u32 end_spn (source-packet numbers, inclusive) //! ``` -//! Source-packet numbers are the 192-byte BDAV packet index: byte offset = -//! `spn * 192`. Observed on one disc: 792 segments, each ~2560 packets -//! (~480 KB), spread across the entire 54 GB feature (one roughly every 67 MB). +//! `variant` is the 1..32 forensic-variant tag, NOT a sequential segment id: +//! measured on a retail 2.1 disc (Zombieland) it cycles 1,2,…,32,1,2,… across +//! records in file order — 24 full cycles of 32 plus a final partial cycle of +//! 24 = 792 records. Source-packet numbers are the 192-byte BDAV packet index: +//! byte offset = `spn * 192`. Each segment is ~2560 packets (~480 KB), spread +//! across the entire 54 GB feature (one roughly every 67 MB). /// Fixed size of one `IndividualSegment.tbl` record. pub const SEGMENT_RECORD_LEN: usize = 16; @@ -48,8 +51,11 @@ pub const BYPASS_FMTS_KEY: bool = true; /// in the FMTS clip. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct Segment { - /// 1-based segment number (table order). - pub number: u16, + /// Forensic variant tag, 1..=32 (field@4 of the record). Cycles across the + /// table rather than counting up — it selects WHICH variant this range is, + /// which is what a variant-keyed decode routes on. (`0` is not used here; + /// the default/non-forensic content carries no segment record at all.) + pub variant: u16, /// First source packet of the segment (inclusive). pub start_spn: u32, /// Last source packet of the segment (inclusive). @@ -141,12 +147,12 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option> { let mut segments = Vec::with_capacity(count); for i in 0..count { let o = 8 + i * record_size; - // o+4..o+8 = segment_number (u16) + flag (u16); o+8..o+16 = start/end SPN. - let number = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]); + // o+4..o+8 = variant (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN. + let variant = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]); let start_spn = u32::from_be_bytes([tbl[o + 8], tbl[o + 9], tbl[o + 10], tbl[o + 11]]); let end_spn = u32::from_be_bytes([tbl[o + 12], tbl[o + 13], tbl[o + 14], tbl[o + 15]]); segments.push(Segment { - number, + variant, start_spn, end_spn, }); @@ -159,7 +165,7 @@ mod tests { use super::*; /// Build a table with the real on-disc layout: 8-byte header + N 16-byte - /// records. `recs` are `(segment_number, start_spn, end_spn)`. + /// records. `recs` are `(variant, start_spn, end_spn)`. fn build_tbl(recs: &[(u16, u32, u32)]) -> Vec { let mut v = Vec::new(); v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type @@ -177,7 +183,9 @@ mod tests { #[test] fn parses_real_disc_layout() { - // First three records observed on a retail 2.1 disc: 2560-packet segments. + // First three records observed on retail 2.1 (Zombieland): the variant + // field counts 1,2,3,… (it wraps at 32 further into the table — see + // `variant_field_cycles_one_to_thirty_two`), segments are 2560 packets. let tbl = build_tbl(&[ (1, 343680, 346239), (2, 695616, 698175), @@ -185,7 +193,9 @@ mod tests { ]); let segs = parse_individual_segments(&tbl).expect("parse"); assert_eq!(segs.len(), 3); - assert_eq!(segs[0].number, 1); + assert_eq!(segs[0].variant, 1); + assert_eq!(segs[1].variant, 2); + assert_eq!(segs[2].variant, 3); assert_eq!(segs[0].start_spn, 343680); assert_eq!(segs[0].end_spn, 346239); assert_eq!(segs[0].packet_count(), 2560); @@ -230,7 +240,27 @@ mod tests { // A unit sitting squarely inside: start at packet 344000 → byte 344000*192. let off = 344000u64 * SOURCE_PACKET_LEN; let hit = variant_segment_for_unit(&segs, off).expect("inside the segment"); - assert_eq!(hit.number, 1); + assert_eq!(hit.variant, 1); + } + + #[test] + fn variant_field_cycles_one_to_thirty_two() { + // Reality on Zombieland: field@4 is the variant, cycling 1..=32 in file + // order (NOT a sequential segment id). Reproduce one-and-a-bit cycles. + let mut recs = Vec::new(); + let mut spn = 1000u32; + for row in 0..2 { + for v in 1..=32u16 { + recs.push((v, spn, spn + 2559)); + spn += 50_000; // ~one segment every ~67 MB + } + let _ = row; + } + let segs = parse_individual_segments(&build_tbl(&recs)).unwrap(); + assert_eq!(segs.len(), 64); + assert_eq!(segs[31].variant, 32); // end of first cycle + assert_eq!(segs[32].variant, 1); // wraps, does not become 33 + assert!(segs.iter().all(|s| (1..=32).contains(&s.variant))); } #[test] @@ -250,7 +280,7 @@ mod tests { // Unit covering packets [80, 111]: overlaps [100,200] at the tail. let off = 80u64 * SOURCE_PACKET_LEN; let hit = variant_segment_for_unit(&segs, off).expect("straddles the start edge"); - assert_eq!(hit.number, 7); + assert_eq!(hit.variant, 7); // A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap. let before = 68u64 * SOURCE_PACKET_LEN; // [68, 99] assert!(variant_segment_for_unit(&segs, before).is_none()); diff --git a/src/aacs/types.rs b/src/aacs/types.rs index 9445871..90cb875 100644 --- a/src/aacs/types.rs +++ b/src/aacs/types.rs @@ -57,6 +57,42 @@ pub struct ProcessingKey(pub [u8; 16]); pub struct UnitKey { pub idx: u32, pub key: [u8; 16], + /// AACS 2.1 (FMTS) forensic-variant tag. + /// + /// `0` = ordinary (non-forensic) content — the value for every 1.0 / 2.0 + /// key and for the bulk of a 2.1 title. `1..=32` = a variant key that + /// decrypts the forensic segments tagged with that same variant in + /// `IndividualSegment.tbl`. A disc resolves to exactly one variant, so at + /// most one non-zero value is ever in play for a given rip; the decode + /// selects the segments matching it and drops the other variants. + pub variant_number: u8, +} + +impl UnitKey { + /// An ordinary (non-forensic) unit key: `variant_number == 0`. The value + /// for every AACS 1.0 / 2.0 key and the bulk of a 2.1 title. + pub const fn new(idx: u32, key: [u8; 16]) -> Self { + Self { + idx, + key, + variant_number: 0, + } + } + + /// A forensic-variant key: `variant_number` in `1..=32`, decrypting the + /// `IndividualSegment.tbl` segments tagged with that variant. + pub const fn variant(idx: u32, key: [u8; 16], variant_number: u8) -> Self { + Self { + idx, + key, + variant_number, + } + } + + /// Whether this key decrypts ordinary (non-forensic) content. + pub const fn is_default_variant(&self) -> bool { + self.variant_number == 0 + } } /// A per-disc entry from the key database. diff --git a/src/aacs/variant_select.rs b/src/aacs/variant_select.rs new file mode 100644 index 0000000..3ea512d --- /dev/null +++ b/src/aacs/variant_select.rs @@ -0,0 +1,178 @@ +//! FMTS variant selection — the pure decode-time decision for a 2.1 disc. +//! +//! A 2.1 disc resolves to exactly one forensic variant (1..=32) for a given +//! rip. `IndividualSegment.tbl` tags each forensic segment with a variant (see +//! [`super::segment`]); the decode keeps the segments matching our variant, +//! drops the other 31, and treats everything outside a segment as ordinary +//! (variant-0) content. This module owns that classification and nothing else — +//! no I/O, no keys, no cipher — so it is fully testable in isolation. The +//! decrypt pipeline consumes the [`UnitDisposition`] it returns. +//! +//! Where the resolved variant comes from is a separate concern +//! ([`resolve_disc_variant`]): today it is read off the variant keys the key +//! source handed us; when Processing Keys are available it will come from the +//! VK derivation instead. Either way the disposition logic below is identical. + +use super::segment::{Segment, variant_segment_for_unit}; +use super::types::UnitKey; + +/// What the decode should do with one AACS aligned unit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnitDisposition { + /// Outside every forensic segment: ordinary content, decrypt with the + /// default (variant-0) unit key. + Default, + /// Inside a forensic segment tagged with OUR resolved variant: decrypt with + /// that variant's key. + Variant(u8), + /// Inside a forensic segment tagged with a DIFFERENT variant: not our + /// watermark, so it is not part of our output — drop it. + DropForeignVariant(u8), + /// Inside a forensic segment but no variant key is held (the disc's variant + /// was never resolved): the segment cannot be decoded, so it is concealed + /// as loss. Carries the segment's variant for diagnostics. + ForensicNoKey(u8), +} + +/// Resolve the disc's single forensic variant from the keys we hold. +/// +/// Scans for a variant key (`variant_number` in `1..=32`) and returns its +/// variant. `None` when only default (variant-0) keys are held — i.e. no +/// variant source answered, so forensic segments are not decodable. A disc has +/// exactly one variant, so the first non-zero key decides; if several distinct +/// variant keys were somehow supplied the lowest wins (deterministic), which is +/// only a defensive tiebreak — the probe/derivation yields one. +pub fn resolve_disc_variant(unit_keys: &[UnitKey]) -> Option { + unit_keys + .iter() + .map(|k| k.variant_number) + .filter(|&v| v != 0) + .min() +} + +/// Classify the AACS aligned unit at `unit_offset` (clip-relative bytes) given +/// the forensic segment map and the disc's resolved variant (`None` if no +/// variant key is held). +pub fn unit_disposition( + unit_offset: u64, + segments: &[Segment], + disc_variant: Option, +) -> UnitDisposition { + match variant_segment_for_unit(segments, unit_offset) { + // Not in any forensic segment → ordinary content. + None => UnitDisposition::Default, + // In a forensic segment → decide by whether it is our variant. + Some(seg) => { + let seg_variant = seg.variant as u8; + match disc_variant { + Some(v) if v == seg_variant => UnitDisposition::Variant(v), + Some(_) => UnitDisposition::DropForeignVariant(seg_variant), + None => UnitDisposition::ForensicNoKey(seg_variant), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::aacs::content::ALIGNED_UNIT_LEN; + use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments}; + + /// Build a one-record segment table (variant, start_spn, end_spn). + fn tbl(recs: &[(u16, u32, u32)]) -> Vec { + let mut v = Vec::new(); + v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); + v.extend_from_slice(&(recs.len() as u16).to_be_bytes()); + v.extend_from_slice(&16u16.to_be_bytes()); + for &(n, s, e) in recs { + v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); + v.extend_from_slice(&n.to_be_bytes()); + v.extend_from_slice(&1u16.to_be_bytes()); + v.extend_from_slice(&s.to_be_bytes()); + v.extend_from_slice(&e.to_be_bytes()); + } + parse_individual_segments(&v).expect("parse") + } + + fn uk(idx: u32, variant: u8) -> UnitKey { + if variant == 0 { + UnitKey::new(idx, [0u8; 16]) + } else { + UnitKey::variant(idx, [variant; 16], variant) + } + } + + #[test] + fn resolve_picks_the_single_variant_key() { + // Default keys only → no variant resolved. + assert_eq!(resolve_disc_variant(&[uk(0, 0)]), None); + assert_eq!(resolve_disc_variant(&[]), None); + // One variant key among defaults → that variant. + assert_eq!(resolve_disc_variant(&[uk(0, 0), uk(1, 7)]), Some(7)); + // Defensive: lowest of several distinct variants (deterministic). + assert_eq!(resolve_disc_variant(&[uk(0, 9), uk(1, 3)]), Some(3)); + } + + #[test] + fn unit_outside_segments_is_default() { + let segs = tbl(&[(1, 343680, 346239)]); + let off = 1000u64 * SOURCE_PACKET_LEN; // well before the segment + assert_eq!( + unit_disposition(off, &segs, Some(1)), + UnitDisposition::Default + ); + // With no segments at all (1.0 / 2.0), everything is Default. + assert_eq!( + unit_disposition(off, &[], Some(1)), + UnitDisposition::Default + ); + } + + #[test] + fn unit_in_our_variant_decrypts() { + let segs = tbl(&[(7, 100, 200)]); + let off = 120u64 * SOURCE_PACKET_LEN; + assert_eq!( + unit_disposition(off, &segs, Some(7)), + UnitDisposition::Variant(7) + ); + } + + #[test] + fn unit_in_foreign_variant_drops() { + // Segment tagged variant 7, but our disc variant is 3 → drop it. + let segs = tbl(&[(7, 100, 200)]); + let off = 120u64 * SOURCE_PACKET_LEN; + assert_eq!( + unit_disposition(off, &segs, Some(3)), + UnitDisposition::DropForeignVariant(7) + ); + } + + #[test] + fn forensic_unit_with_no_key_is_concealed() { + // A forensic segment but we never resolved a variant → conceal as loss. + let segs = tbl(&[(7, 100, 200)]); + let off = 120u64 * SOURCE_PACKET_LEN; + assert_eq!( + unit_disposition(off, &segs, None), + UnitDisposition::ForensicNoKey(7) + ); + } + + #[test] + fn straddling_unit_still_classified_as_its_segment() { + // A unit whose 32-packet span only tails into the segment still routes + // to the segment (matches variant_segment_for_unit's span test). + let segs = tbl(&[(5, 100, 200)]); + let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32 + // Start so the unit covers [80, 80+31] = [80, 111]: overlaps at 100. + let off = 80u64 * SOURCE_PACKET_LEN; + assert!(80 + unit_packets - 1 >= 100, "sanity: unit tails into seg"); + assert_eq!( + unit_disposition(off, &segs, Some(5)), + UnitDisposition::Variant(5) + ); + } +} diff --git a/src/keysource.rs b/src/keysource.rs index 21f2e56..faef8ca 100644 --- a/src/keysource.rs +++ b/src/keysource.rs @@ -563,10 +563,7 @@ mod tests { struct HasKey([u8; 16]); impl KeySource for HasKey { fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result, Error> { - Ok(vec![UnitKey { - idx: 0, - key: self.0, - }]) + Ok(vec![UnitKey::new(0, self.0)]) } } @@ -612,10 +609,7 @@ mod tests { if let Ok(s) = ctx.samples(8) { self.seen.lock().unwrap().extend(s); } - Ok(vec![UnitKey { - idx: 0, - key: self.key, - }]) + Ok(vec![UnitKey::new(0, self.key)]) } }