decrypt: keep outer key range tail under nested ranges; ifo: fall back to 1:1 for out-of-table PTT_SRPT PGCN; revert angle-cell time exclusion

This commit is contained in:
Matthew Jackson
2026-09-29 19:18:38 -07:00
parent 0f0c906d1f
commit 8369d682f4
2 changed files with 91 additions and 74 deletions
+71 -26
View File
@@ -210,6 +210,8 @@ pub struct AacsKeyMap {
// (start_lba, end_lba, key_idx, phase). An LBA in NO range is passed through
// untouched — the map is a positive list of "this key here", nothing more.
ranges: Vec<(u32, u32, usize, Phase)>,
// Parallel to `ranges`: the original start each piece's unit parity is measured from.
anchors: Vec<u32>,
// Distinct, sorted key indices the map selects — derived from `ranges` once at
// construction so the per-batch decrypt bounds check does not re-allocate/sort
// it on every read. Kept in sync by building both in `from_ranges_phased`.
@@ -233,24 +235,38 @@ impl AacsKeyMap {
/// for base/CPS). Ranges are sorted; an LBA in no range is passed through.
pub(crate) fn from_ranges_phased(mut ranges: Vec<(u32, u32, usize, Phase)>) -> Self {
ranges.sort_by_key(|&(start, _, _, _)| start);
// Enforce disjointness (entry_for relies on it): a range starting inside its
// predecessor takes over from there, and the predecessor is cut at that start.
// Disjointness (entry_for relies on it): each boundary-to-boundary stretch goes to
// the covering range that starts last; `anchors` keeps the ORIGINAL start that
// FMTS unit parity is measured from.
let mut bounds: Vec<u32> = ranges.iter().flat_map(|r| [r.0, r.1]).collect();
bounds.sort_unstable();
bounds.dedup();
let mut disjoint: Vec<(u32, u32, usize, Phase)> = Vec::with_capacity(ranges.len());
for r in ranges {
if let Some(prev) = disjoint.last_mut()
&& prev.1 > r.0
let mut anchors: Vec<u32> = Vec::with_capacity(ranges.len());
let mut srcs: Vec<usize> = Vec::with_capacity(ranges.len());
let mut overlapped = false;
for w in bounds.windows(2) {
let (a, b) = (w[0], w[1]);
let mut cover = (0..ranges.len()).rev().filter(|&i| {
let r = ranges[i];
r.0 <= a && b <= r.1 && r.0 < r.1
});
let Some(win) = cover.next() else { continue };
overlapped |= cover.next().is_some();
if srcs.last() == Some(&win)
&& let Some(last) = disjoint.last_mut()
&& last.1 == a
{
tracing::warn!(
start = r.0,
prev_start = prev.0,
"overlapping AACS key ranges; the later range takes over"
);
prev.1 = r.0;
}
disjoint.retain(|&(s, e, _, _)| s < e);
if r.0 < r.1 {
disjoint.push(r);
last.1 = b;
continue;
}
let r = ranges[win];
disjoint.push((a, b, r.2, r.3));
anchors.push(r.0);
srcs.push(win);
}
if overlapped {
tracing::warn!("overlapping AACS key ranges; the later-starting range takes over");
}
let ranges = disjoint;
let mut key_indices: Vec<usize> = ranges.iter().map(|&(_, _, i, _)| i).collect();
@@ -258,6 +274,7 @@ impl AacsKeyMap {
key_indices.dedup();
Self {
ranges,
anchors,
key_indices,
}
}
@@ -267,20 +284,17 @@ impl AacsKeyMap {
/// the unit through untouched). O(log n). `range_start_lba` lets the mapped
/// decrypt compute a unit's parity WITHIN a forensic segment (`Even`/`Odd`).
pub fn entry_for(&self, lba: u32) -> Option<(usize, Phase, u32)> {
match self
let i = match self
.ranges
.binary_search_by(|&(start, _, _, _)| start.cmp(&lba))
{
Ok(i) => {
let (start, _, idx, ph) = self.ranges[i];
Some((idx, ph, start))
}
Err(0) => None,
Err(i) => {
let (start, end, idx, ph) = self.ranges[i - 1];
(lba >= start && lba < end).then_some((idx, ph, start))
}
}
Ok(i) => i,
Err(0) => return None,
Err(i) => i - 1,
};
let (start, end, idx, ph) = self.ranges[i];
(lba >= start && lba < end)
.then(|| (idx, ph, self.anchors.get(i).copied().unwrap_or(start)))
}
/// The unit-key index for the aligned unit at `lba`, or `None` when no range
@@ -1853,6 +1867,37 @@ mod tests {
}
// Overlapping ranges are made disjoint (later start wins) so entry_for stays sound.
#[test]
fn nested_key_range_keeps_outer_tail_and_phase_anchor() {
let map =
AacsKeyMap::from_ranges_phased(vec![(0, 100, 0, Phase::Even), (20, 60, 1, Phase::All)]);
assert_eq!(map.key_idx_for(80), Some(0));
assert_eq!(map.key_idx_for(40), Some(1));
assert_eq!(map.entry_for(80), Some((0, Phase::Even, 0)));
assert_eq!(map.entry_for(10), Some((0, Phase::Even, 0)));
}
/// Every sector of every input range resolves to some key.
#[test]
fn every_sector_of_every_input_range_gets_a_key() {
let input = vec![
(0, 100, 0),
(20, 30, 1),
(25, 60, 2),
(90, 120, 3),
(110, 115, 4),
];
let map = AacsKeyMap::from_ranges(input.clone());
for (s, e, _) in input {
for lba in s..e {
assert!(map.key_idx_for(lba).is_some(), "lba {lba} lost its key");
}
}
assert_eq!(map.key_idx_for(60), Some(0));
assert_eq!(map.key_idx_for(116), Some(3));
assert_eq!(map.key_idx_for(120), None);
}
#[test]
fn overlapping_key_ranges_are_made_disjoint() {
let map = AacsKeyMap::from_ranges(vec![(0, 100, 0), (20, 30, 1), (25, 60, 2), (60, 60, 3)]);
+20 -48
View File
@@ -812,8 +812,10 @@ pub(crate) fn parse_pgcit(
let mut skipped = 0usize;
for &(chapter_count, vts_title_num) in titles_info {
// TTN -> PGC goes through PTT_SRPT; without it assume 1:1 (TTN is 1-based).
// TTN -> PGC goes through PTT_SRPT; without it (or when its PGCN is past the
// table) assume 1:1 (TTN is 1-based).
let pgc_index = ptt_srpt_pgc_index(data, ptt_offset, vts_title_num)
.filter(|&i| i < num_pgcs as usize)
.unwrap_or(vts_title_num.saturating_sub(1) as usize);
if pgc_index >= num_pgcs as usize {
skipped += 1;
@@ -931,20 +933,9 @@ pub(crate) fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result
);
}
// Only one angle plays, so non-first angle cells after the leading (dropped, and
// shifted out by the scanner) cells add no time.
let lead = leading_secondary_cells(&cells);
let plays =
|i: usize, cat: u8| i < lead || !CellCategory::decode(cat).is_secondary_block_piece();
// Recalculate duration from cell times if PGC-level time is zero.
let duration_secs = if duration_secs == 0.0 && !cells.is_empty() {
cells
.iter()
.enumerate()
.filter(|&(i, c)| plays(i, c.category))
.map(|(_, c)| c.duration_secs)
.sum()
cells.iter().map(|c| c.duration_secs).sum()
} else {
duration_secs
};
@@ -969,10 +960,6 @@ pub(crate) fn parse_pgc(data: &[u8], pgc_offset: usize, chapters: u16) -> Result
cell_frames.push((0, 0.0));
continue;
}
if !plays(i, data[co]) {
cell_frames.push((0, 0.0));
continue;
}
let t = &data[co + 4..co + 8];
match bcd_to_frames(t) {
Some((f, r)) => {
@@ -2723,6 +2710,22 @@ mod tests {
assert_eq!(titles[0].duration_secs, 11.011);
}
/// A PTT_SRPT PGCN past the table falls back to the 1:1 mapping, not a dropped title.
#[test]
fn pgcit_ptt_pgcn_past_table_falls_back_to_one_to_one() {
let pgc0 = build_pgc(bcd_secs(11), &[(0x00, bcd_secs(11), 0, 9)], &[], None);
let pgc1 = build_pgc(bcd_secs(22), &[(0x00, bcd_secs(22), 50, 59)], &[], None);
let pgcit = build_pgcit(&[pgc0, pgc1]);
let pgcit_at = 512usize;
let mut data = vec![0u8; pgcit_at];
data.extend_from_slice(&pgcit);
let ptt_at = data.len();
data.extend_from_slice(&build_ptt_srpt(&[9, 9]));
let titles = parse_pgcit(&data, pgcit_at, ptt_at, &[(5, 1), (6, 2)]).unwrap();
let got: Vec<f64> = titles.iter().map(|t| t.duration_secs).collect();
assert_eq!(got, vec![11.011, 22.022]);
}
/// A title number past num_pgcs is dropped, even when an in-buffer 8-byte
/// "entry" happens to sit at that index.
#[test]
@@ -2738,37 +2741,6 @@ mod tests {
assert!(titles.is_empty(), "TTN 3 of a 2-PGC table must be omitted");
}
// Cell category bytes for an angle block: block_type=1, block_mode 1/2/3.
const ANGLE_FIRST: u8 = 0b0101_0000;
const ANGLE_MID: u8 = 0b1001_0000;
const ANGLE_LAST: u8 = 0b1101_0000;
/// Only one angle plays: the other angles' cells add no time to later chapters
/// or to the zero-PGC-time duration fallback.
#[test]
fn pgc_multi_angle_cells_count_once_in_chapter_times_and_duration() {
let cells = [
(0x00, bcd_secs(10), 0, 9),
(ANGLE_FIRST, bcd_secs(20), 10, 19),
(ANGLE_MID, bcd_secs(20), 20, 29),
(ANGLE_LAST, bcd_secs(20), 30, 39),
(0x00, bcd_secs(30), 40, 49),
];
let pgc = build_pgc(bcd_secs(59), &cells, &[1, 2, 5], None);
let title = parse_pgc(&pgc, 0, 3).unwrap();
let want = [0.0, 10.01, 30.03];
for (g, w) in title.chapter_times.iter().zip(want) {
assert!((g - w).abs() < 1e-9, "chapters {:?}", title.chapter_times);
}
let zero_time = build_pgc([0; 4], &cells, &[1, 2, 5], None);
let title = parse_pgc(&zero_time, 0, 3).unwrap();
assert!(
(title.duration_secs - 60.06).abs() < 1e-6,
"duration {}",
title.duration_secs
);
}
/// Cells with no rate flag fall back to literal seconds; in a mixed-rate PGC the
/// first rate seen wins for every frame count.
#[test]