Memoise multi-CPS key-map sampling per extent, not per title

resolve_content_key_map calls resolve_mux_key_map once per title, and on a
multi-CPS disc that path issues 8 random single-unit reads per extent. A disc's
playlists overwhelmingly reference the same few clips — main feature, play-all,
per-chapter and seamless-branch variants — so the same physical extents were
re-sampled from the drive once per playlist. On a 60-playlist / 15-clip disc
that is ~2,400 non-sequential 6144-byte reads, roughly 8 minutes of pure seeking
at 200 ms per seek, before the mux starts. Now ~600 reads.

Keyed per EXTENT — (format, start_lba, sector_count) — rather than per title's
whole extent list, which is finer-grained than the forced-subtitle probe's cache
and strictly better here: a play-all playlist sharing 4 of 5 extents with the
main feature still hits on those 4.

Why a cached pool index is provably identical to a recomputed one, verified
rather than assumed:

  * `pick` iterates the pool IN ORDER and returns the FIRST index whose key
    decrypts a sample to clean.
  * The pool is APPEND-ONLY. Checked across the whole crate: only `push`, with no
    insert/remove/clear/retain/sort/dedup/truncate/drain/swap/reverse anywhere.
    So appended keys can only land AFTER a matched index, and the first match for
    the same samples cannot shift.
  * The samples are a pure function of the three values in the key, read from
    read-only optical media.

Two outcomes are deliberately NOT cached, which is what makes this safe rather
than merely faster:

  * the inherited index (`None if samples.is_empty() => last_idx`) is per-TITLE
    state, not a property of the extent — caching it would let one title's
    carry-in index leak into another title's clear extent, i.e. a WRONG key;
  * the fail-loud DecryptFailed verdict, so a retry after a key source banks the
    missing key re-samples instead of inheriting a stale answer.

Halt is still polled before the cache lookup, so cancellation is unchanged.
resolve_mux_key_map keeps its exact signature and delegates with a fresh cache,
so there is no public API change. ContentFormat gains Eq + Hash (additive).

Four tests, and the two mutants that matter both verified red: disabling the
cache short-circuit fails the hit and recompute-equivalence tests, and wrongly
caching the inherited index fails multi_cps_inherited_index_is_not_cached.
This commit is contained in:
Matthew Jackson
2026-07-29 19:49:55 -07:00
parent e3676e7cdf
commit 38aa895038
2 changed files with 447 additions and 3 deletions
+10 -2
View File
@@ -76,7 +76,7 @@ pub struct Disc {
}
/// Content format — determines how sectors are interpreted downstream.
#[derive(Debug, Clone, Copy, PartialEq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ContentFormat {
/// Blu-ray BD Transport Stream (192-byte packets)
BdTs,
@@ -2396,14 +2396,22 @@ impl Disc {
halt: Option<&crate::halt::Halt>,
) -> Result<crate::decrypt::AacsKeyMap> {
let mut ranges: Vec<(u32, u32, usize, crate::decrypt::Phase)> = Vec::new();
// One multi-CPS extent cache across every title. A disc's playlists
// overwhelmingly reference the same handful of clips (main feature,
// play-all, per-chapter and seamless-branch variants), so without this the
// same extents are re-sampled off the drive once per playlist — 8 random
// 6144-byte reads each, ~200 ms of seek apiece on a stock BD drive, all to
// recompute the same index from byte-identical input.
let mut cps_cache = crate::mux::resolve::CpsUnitCache::new();
for title in &self.titles {
let map = crate::mux::resolve_mux_key_map(
let map = crate::mux::resolve::resolve_mux_key_map_cached(
reader,
title,
keys,
fetch,
self.content_format,
halt,
&mut cps_cache,
)?;
ranges.extend_from_slice(map.ranges());
}