mux: FMTS reads only our-phase units (AacsKeyMap::read_plan)
An FMTS forensic segment interleaves our device group's variant with a foreign group's at the aligned-unit level. The mux was decrypting only our phase but leaving the alternate (foreign) units in the buffer as ciphertext, trusting the demux to 'drop untouched ciphertext cleanly'. It doesn't: random 0x47 bytes at the 192-byte stride hit tracked PIDs, mis-parse, and trip the demux's concealed-gap keyframe-resync — which drops GOOD frames of ours around every segment (349 resyncs / ~6391 packets on Stand by Me, visible as playback flaws). The map already knows which unit each LBA is and, for a forensic segment, which phase is ours. AacsKeyMap::read_plan turns that into the title's read plan: every default/CPS unit, plus inside a segment ONLY our-phase units. The alternate units are never fetched, decrypted, or handed to the demux — the demux sees one gapless our-variant stream. - read_plan is general (single-CPS, multi-CPS, FMTS): a map with no Even/Odd range returns the extents unchanged, so DVD/CSS, single-CPS UHD and multi-CPS Blu-ray read byte-for-byte as before. - Wired into build_iso_pipeline (the file-backed highway that muxes resumed ISOs). Producer re-anchors unit_base per extent, so per-unit segment reads stay unit-aligned and decrypt correctly. - Extent gains PartialEq/Eq for the read_plan tests. Tests: read_plan non-forensic unchanged; forensic omits exactly the alternate units, kept units match the decrypt gate unit-for-unit. All 2314 lib tests pass; precommit green on 1.86. Pending: end-to-end ISO re-mux validation (concealed gaps 349 -> ~0).
This commit is contained in:
+175
@@ -301,6 +301,89 @@ impl AacsKeyMap {
|
|||||||
v.dedup();
|
v.dedup();
|
||||||
v
|
v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build the FMTS **read plan**: the title's aligned units filtered down to
|
||||||
|
/// only the units this rip must actually read — every default / CPS unit,
|
||||||
|
/// plus, inside each forensic segment, ONLY our-phase ([`Phase::Even`] /
|
||||||
|
/// [`Phase::Odd`]) units. The alternate-phase units are a different device
|
||||||
|
/// group's variant: a licensed player never reads them, and neither do we.
|
||||||
|
/// They are omitted from the plan entirely, so they are never fetched,
|
||||||
|
/// decrypted, or handed to the demux — the demux therefore sees one gapless
|
||||||
|
/// our-variant stream, with no ciphertext to trip a concealed-gap resync (the
|
||||||
|
/// old behaviour that dropped good frames around every segment).
|
||||||
|
///
|
||||||
|
/// `extents` are the title's clip extents (unit-aligned in the interior;
|
||||||
|
/// a shorter tail is ordinary content and always kept). `unit_sectors` is the
|
||||||
|
/// AACS aligned-unit size in sectors (3). Contiguous kept units coalesce into
|
||||||
|
/// as few extents as possible so the producer still issues large sequential
|
||||||
|
/// reads across default content; only inside a ~480 KB forensic segment do
|
||||||
|
/// reads become unit-granular (every other unit). A map with no forensic
|
||||||
|
/// (Even/Odd) range returns `extents` unchanged — the common disc is not
|
||||||
|
/// touched.
|
||||||
|
///
|
||||||
|
/// The parity test is byte-identical to the decrypt hot loop
|
||||||
|
/// (`(unit_lba - range_start) / unit_sectors`), so a unit kept here is exactly
|
||||||
|
/// a unit [`decrypt_sectors_mapped`] would open, and vice-versa.
|
||||||
|
pub fn read_plan(
|
||||||
|
&self,
|
||||||
|
extents: &[crate::disc::Extent],
|
||||||
|
unit_sectors: u32,
|
||||||
|
) -> Vec<crate::disc::Extent> {
|
||||||
|
// No forensic segment → read everything, unchanged (byte-for-byte).
|
||||||
|
if !self
|
||||||
|
.ranges
|
||||||
|
.iter()
|
||||||
|
.any(|&(_, _, _, p)| matches!(p, Phase::Even | Phase::Odd))
|
||||||
|
{
|
||||||
|
return extents.to_vec();
|
||||||
|
}
|
||||||
|
let us = unit_sectors.max(1);
|
||||||
|
let mut plan: Vec<crate::disc::Extent> = Vec::new();
|
||||||
|
// Append `sectors` at `lba`, coalescing with the previous extent when they
|
||||||
|
// are physically contiguous so default runs stay one big sequential read.
|
||||||
|
let mut push = |lba: u32, sectors: u32| {
|
||||||
|
if sectors == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(last) = plan.last_mut() {
|
||||||
|
if last.start_lba.saturating_add(last.sector_count) == lba {
|
||||||
|
last.sector_count += sectors;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plan.push(crate::disc::Extent {
|
||||||
|
start_lba: lba,
|
||||||
|
sector_count: sectors,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
for e in extents {
|
||||||
|
let mut off = 0u32;
|
||||||
|
while off < e.sector_count {
|
||||||
|
let lba = e.start_lba.saturating_add(off);
|
||||||
|
let remaining = e.sector_count - off;
|
||||||
|
if remaining < us {
|
||||||
|
// Extent tail shorter than a whole unit: ordinary content
|
||||||
|
// (nothing follows to desync), always read.
|
||||||
|
push(lba, remaining);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let (_, phase, range_start) = self.entry_for(lba);
|
||||||
|
let keep = match phase {
|
||||||
|
Phase::All => true,
|
||||||
|
Phase::Even | Phase::Odd => {
|
||||||
|
let unit_ix = (lba - range_start) / us;
|
||||||
|
let is_odd = unit_ix % 2 == 1;
|
||||||
|
is_odd == matches!(phase, Phase::Odd)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if keep {
|
||||||
|
push(lba, us);
|
||||||
|
}
|
||||||
|
off += us;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
plan
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypt a buffer of sectors in-place using a resolved [`AacsKeyMap`] — the
|
/// Decrypt a buffer of sectors in-place using a resolved [`AacsKeyMap`] — the
|
||||||
@@ -1556,6 +1639,98 @@ mod tests {
|
|||||||
assert_eq!(phased.key_idx_for(150), 3);
|
assert_eq!(phased.key_idx_for(150), 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A map with no forensic (Even/Odd) range is the common disc: `read_plan`
|
||||||
|
/// returns the extents unchanged, so nothing but FMTS is affected.
|
||||||
|
#[test]
|
||||||
|
fn read_plan_non_forensic_is_unchanged() {
|
||||||
|
use crate::disc::Extent;
|
||||||
|
let us = (aacs::content::ALIGNED_UNIT_LEN / 2048) as u32; // 3
|
||||||
|
let ext = vec![
|
||||||
|
Extent {
|
||||||
|
start_lba: 1000,
|
||||||
|
sector_count: 300,
|
||||||
|
},
|
||||||
|
Extent {
|
||||||
|
start_lba: 5000,
|
||||||
|
sector_count: 60,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// Single-CPS and multi-CPS (All) maps both leave the plan untouched.
|
||||||
|
assert_eq!(AacsKeyMap::single(0).read_plan(&ext, us), ext);
|
||||||
|
let multi = AacsKeyMap::from_ranges(vec![(1000, 1150, 2)], 0);
|
||||||
|
assert_eq!(multi.read_plan(&ext, us), ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FMTS: a forensic Even segment drops exactly its alternate (odd) units from
|
||||||
|
/// the read plan — they are never fetched — while default content on either
|
||||||
|
/// side stays one coalesced sequential run. The kept units are byte-identical
|
||||||
|
/// to the ones the decrypt hot loop opens.
|
||||||
|
#[test]
|
||||||
|
fn read_plan_forensic_reads_only_our_phase_units() {
|
||||||
|
use crate::disc::Extent;
|
||||||
|
let us = (aacs::content::ALIGNED_UNIT_LEN / 2048) as u32; // 3
|
||||||
|
// One extent, 100 units [1000, 1300). A 10-unit Even forensic segment at
|
||||||
|
// LBA [1030, 1060): kept even units are ix 0,2,4,6,8 → LBA 1030,1036,1042,
|
||||||
|
// 1048,1054; dropped odd units → 1033,1039,1045,1051,1057.
|
||||||
|
let ext = vec![Extent {
|
||||||
|
start_lba: 1000,
|
||||||
|
sector_count: 300,
|
||||||
|
}];
|
||||||
|
let map = AacsKeyMap::from_ranges_phased(vec![(1030, 1060, 5, Phase::Even)], 0);
|
||||||
|
let plan = map.read_plan(&ext, us);
|
||||||
|
let expected = vec![
|
||||||
|
Extent {
|
||||||
|
start_lba: 1000,
|
||||||
|
sector_count: 33,
|
||||||
|
}, // 1000..1030 default + the ix-0 even unit at 1030
|
||||||
|
Extent {
|
||||||
|
start_lba: 1036,
|
||||||
|
sector_count: 3,
|
||||||
|
},
|
||||||
|
Extent {
|
||||||
|
start_lba: 1042,
|
||||||
|
sector_count: 3,
|
||||||
|
},
|
||||||
|
Extent {
|
||||||
|
start_lba: 1048,
|
||||||
|
sector_count: 3,
|
||||||
|
},
|
||||||
|
Extent {
|
||||||
|
start_lba: 1054,
|
||||||
|
sector_count: 3,
|
||||||
|
},
|
||||||
|
Extent {
|
||||||
|
start_lba: 1060,
|
||||||
|
sector_count: 240,
|
||||||
|
}, // default resumes, coalesced to the extent end
|
||||||
|
];
|
||||||
|
assert_eq!(plan, expected);
|
||||||
|
// Exactly the 5 odd units (15 sectors) are omitted; nothing else.
|
||||||
|
let kept: u32 = plan.iter().map(|e| e.sector_count).sum();
|
||||||
|
assert_eq!(
|
||||||
|
kept,
|
||||||
|
300 - 5 * us,
|
||||||
|
"only the alternate-phase units are dropped"
|
||||||
|
);
|
||||||
|
// Every kept LBA is one the decrypt loop would decrypt (All or our parity),
|
||||||
|
// and no dropped LBA is: the plan and the decrypt gate agree unit-for-unit.
|
||||||
|
for e in &plan {
|
||||||
|
let mut off = 0;
|
||||||
|
while off < e.sector_count {
|
||||||
|
let lba = e.start_lba + off;
|
||||||
|
let (_, phase, rs) = map.entry_for(lba);
|
||||||
|
if let Phase::Even | Phase::Odd = phase {
|
||||||
|
let is_odd = ((lba - rs) / us) % 2 == 1;
|
||||||
|
assert!(
|
||||||
|
is_odd == matches!(phase, Phase::Odd),
|
||||||
|
"plan kept an alternate-phase unit at LBA {lba}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
off += us;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Phase::Even → only even-index units in the range are decrypted; the odd
|
/// Phase::Even → only even-index units in the range are decrypted; the odd
|
||||||
/// (alternate variant) half is left BYTE-FOR-BYTE as ciphertext for the muxer.
|
/// (alternate variant) half is left BYTE-FOR-BYTE as ciphertext for the muxer.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+1
-1
@@ -457,7 +457,7 @@ pub(crate) fn chapter_name(i: usize) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A contiguous range of sectors on disc.
|
/// A contiguous range of sectors on disc.
|
||||||
#[derive(Debug, Clone, Copy)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct Extent {
|
pub struct Extent {
|
||||||
pub start_lba: u32,
|
pub start_lba: u32,
|
||||||
pub sector_count: u32,
|
pub sector_count: u32,
|
||||||
|
|||||||
@@ -1038,6 +1038,18 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
|
|||||||
)),
|
)),
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
// The map IS the title's read plan: it says which CPS unit / forensic segment
|
||||||
|
// each LBA belongs to. Walk ONLY the units it marks as ours — every default /
|
||||||
|
// CPS unit, and inside an FMTS forensic segment only our-phase units. The
|
||||||
|
// alternate-phase units are a different device group's variant; a licensed
|
||||||
|
// player never reads them, and neither do we — they are never fetched,
|
||||||
|
// decrypted, or handed to the demux, so the demux sees one gapless our-variant
|
||||||
|
// stream (no ciphertext to trip a concealed-gap resync). A non-forensic map
|
||||||
|
// returns the extents unchanged, so the common disc reads exactly as before.
|
||||||
|
let extents = match &key_map {
|
||||||
|
Some(map) => map.read_plan(&extents, unit_align as u32),
|
||||||
|
None => extents,
|
||||||
|
};
|
||||||
let mut decrypting =
|
let mut decrypting =
|
||||||
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
|
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
|
||||||
if let Some(map) = key_map {
|
if let Some(map) = key_map {
|
||||||
|
|||||||
Reference in New Issue
Block a user