mirror of
https://github.com/freemkv/libfreemkv.git
synced 2026-10-07 18:13:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b878dcc06 | ||
|
|
7511b0b808 | ||
|
|
707989bd68 | ||
|
|
57b2f0c4f9 | ||
|
|
47406a7e32 | ||
|
|
03cbda741a | ||
|
|
ec59ffe105 | ||
|
|
02132f52b5 | ||
|
|
ca8d046f2a | ||
|
|
f33a9f6c22 | ||
|
|
a9aee0d5a4 | ||
|
|
e16f207efa |
@@ -1,5 +1,77 @@
|
||||
# Changelog
|
||||
|
||||
## [1.2.2] — 2026-07-04
|
||||
|
||||
### Added
|
||||
|
||||
- **AACS 2.1 Media Key Variant support.** The Media Key Variant scheme is now
|
||||
detected and parsed from the real MKB record types found on variant discs —
|
||||
`0x2d` (Encrypted Media Key Variant Data), `0x2f` (Variant Key Data table,
|
||||
65,535 × 16), and `0x0c` (variant cvalues, one per subset-difference slot) —
|
||||
replacing the earlier placeholder `0x82`/`0x83` types, which were a guess and
|
||||
appear on no real MKB. The V2.0→V2.1 upgrade detection and fixtures are updated
|
||||
accordingly, so a genuine AACS 2.1 variant disc now resolves.
|
||||
- **`resolve_candidate`** — one composed, pure-derivation boil-down for a
|
||||
candidate key at any ladder rung (DK/PK/MK/VUK → terminal unit keys), parsing
|
||||
`Unit_Key_RO.inf` at the disc's declared AACS version and returning every CPS
|
||||
unit key. Consumers stop re-composing the ladder; every client hardens a single
|
||||
implementation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`mk_from_dk` does the real Subset-Difference walk again.** It previously ran
|
||||
the Media-Key-Variant path, which needed an integrator KCD absent in-tree and
|
||||
errored for every real disc — effectively dead for both consumers. It now
|
||||
performs the genuine device-key SD walk; the Volume ID enters at the VUK step
|
||||
(where it belongs), not the MK step. This revives the DK→MK fallback across the
|
||||
toolchain (`freemkv-keysources` adopts the corrected two-argument call).
|
||||
- **autorip: a down online key service is no longer reported as a missing key.**
|
||||
When the online key source resolves no key for an encrypted disc, autorip now
|
||||
runs one bounded reachability probe (SSRF-pinned, ~8 s, no redirects) and
|
||||
distinguishes a transient outage (transport error / 502·503·504 → down; 429 →
|
||||
rate-limited) from a genuine no-key (any real HTTP answer → up). A transient
|
||||
verdict triggers a bounded key-resolution retry (3 attempts, 8/16/32 s backoff)
|
||||
and, if the service stays down, parks the disc in a distinct retryable state
|
||||
("Key service unavailable — temporary outage, not a missing key; will retry.")
|
||||
instead of the permanent "no keys found". Never hammers the drive or service.
|
||||
|
||||
### Performance
|
||||
|
||||
- **Processing-Key resolution is ~15× faster on UHD.** A Processing Key is the
|
||||
key at its subset-difference node (one AES-G from the Media Key), so it is now
|
||||
tried directly against the MKB cvalue tables (matching libaacs `_calc_mk_pks`)
|
||||
instead of BFS-walking the SD tree at unknown depth — which was both wrong for
|
||||
terminal PKs and slow on a large UHD MKB (~181k cvalues). PK derivation on UHD
|
||||
drops from ~37 s to ~2.4 s; the SD tree walk now lives solely in the device-key
|
||||
path.
|
||||
|
||||
### autorip
|
||||
|
||||
- **Clear stuck move errors from the System tab.** Each move-queue error now has
|
||||
a ✕ to dismiss it, plus Clear all and Refresh — so a resolved or stale error
|
||||
can be cleared without restarting the container (the mover re-records any that
|
||||
are still genuinely failing on its next tick).
|
||||
|
||||
## [1.2.1] — 2026-07-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- **DVD DTS audio no longer muxes with non-monotonic timestamps.** A DVD
|
||||
Program Stream packs several DTS core frames into one PES packet; the parser
|
||||
stamped every access unit with that single PES timestamp and no per-frame
|
||||
duration, so consecutive frames collided on one PTS and a strict decode/remux
|
||||
(ffmpeg) rejected the track — `non monotonically increasing dts to muxer`.
|
||||
The DTS parser now derives each core frame's duration from its header
|
||||
(`(NBLKS+1)*32` samples ÷ the `SFREQ` sample rate) and re-bases to each PES's
|
||||
own container timestamp, advancing by a frame duration only *within* a single
|
||||
PES — so the track stays monotonic and does not drift past its real length on
|
||||
a feature-long title. The UHD DTS-HD MA path (one access unit per PES) is
|
||||
unaffected: each unit keeps its own PES timestamp, preserving the 1.2.0 per-PES
|
||||
attribution. Completes the DVD DTS fix begun in 1.2.0 (which corrected the
|
||||
silent-track routing, exposing this timing bug). Note: genuinely corrupt
|
||||
source DTS frames — valid framing, bad audio blocks — are passed through
|
||||
faithfully; freemkv never fabricates or drops audio it can't prove is bad.
|
||||
|
||||
## [1.2.0] — 2026-07-01
|
||||
|
||||
### Breaking
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "libfreemkv"
|
||||
version = "1.2.0"
|
||||
version = "1.2.2"
|
||||
edition = "2024"
|
||||
rust-version = "1.86"
|
||||
license = "AGPL-3.0-only"
|
||||
@@ -28,7 +28,7 @@ cbc = "0.1"
|
||||
# Interim path dep for local cross-repo dev; the release script re-pins this to
|
||||
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
|
||||
# the released tag resolves freemkv-unlock from git, not a sibling path).
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.2.0" }
|
||||
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.2.2" }
|
||||
num-bigint = "0.4"
|
||||
num-traits = "0.2"
|
||||
num-integer = "0.1"
|
||||
|
||||
+236
-44
@@ -20,14 +20,16 @@
|
||||
//! uk_from_vuk(Vuk, enc_title_keys) → [UnitKey] (decrypt_unit_key each)
|
||||
//! ```
|
||||
//!
|
||||
//! `mk_from_dk` and `mk_from_pk` are two entry points to the SAME Media Key:
|
||||
//! the device-key path walks the MKB's Media-Key-Variant chain, the
|
||||
//! processing-key path walks the MKB's Subset-Difference cvalue tables. Neither
|
||||
//! needs a VID (the VID enters at `vuk_from_mk`).
|
||||
//! `mk_from_dk` and `mk_from_pk` are two entry points to the SAME Media Key,
|
||||
//! both via the MKB's Subset-Difference cvalue tables: the device-key path
|
||||
//! recovers its Processing Key at the matching SD node and walks on to the MK;
|
||||
//! the processing-key path starts from a precomputed PK. Neither needs a VID
|
||||
//! (the VID enters at `vuk_from_mk`).
|
||||
|
||||
use super::keys::{decrypt_unit_key, derive_media_key_from_pk, derive_vuk};
|
||||
use super::keys::{
|
||||
decrypt_unit_key, derive_media_key_and_pk_from_dk, derive_media_key_from_pk, derive_vuk,
|
||||
};
|
||||
use super::types::DeviceKey;
|
||||
use super::variants::{KEY_CORRECTION_DATA_PLACEHOLDER, derive_media_key_variant, walk_mkb};
|
||||
|
||||
/// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -42,6 +44,12 @@ pub struct MediaKey(pub [u8; 16]);
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Vuk(pub [u8; 16]);
|
||||
|
||||
/// Processing Key (Kp, 16 bytes) — an MKB Subset-Difference key that yields the
|
||||
/// Media Key. A leaked/precomputed PK in the keydb, or the intermediate PK a
|
||||
/// device-key walk derives at its matching SD node.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ProcessingKey(pub [u8; 16]);
|
||||
|
||||
/// One decrypted per-CPS-unit AACS title key.
|
||||
///
|
||||
/// `idx` is the POSITIONAL index of the encrypted title key within the slice
|
||||
@@ -84,36 +92,30 @@ pub fn uk_from_vuk(vuk: Vuk, enc_title_keys: &[[u8; 16]]) -> Vec<UnitKey> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Derive the Media Key (Km) from device keys via the Media Key Variant chain.
|
||||
/// Derive the Media Key (Km) from positioned device keys via the MKB's
|
||||
/// Subset-Difference tables.
|
||||
///
|
||||
/// Wraps [`walk_mkb`] + [`derive_media_key_variant`] with exactly the arguments
|
||||
/// `resolve_keys_v21` path 1 passes: the placeholder Key Correction Data and the
|
||||
/// disc Volume ID. Returns the FIRST tuple element `Km` (the Media Key) — the
|
||||
/// resolver treats `Km` as the media key and derives the VUK from it as
|
||||
/// `Kvu = AES-G(Km, VID)`, which equals [`vuk_from_mk`]`(MediaKey(km), vid)`. The
|
||||
/// variant fn's second element is that already-derived `Kvu`; returning `Km`
|
||||
/// keeps this primitive at the "media key" level so the chain composes.
|
||||
/// Wraps [`derive_media_key_and_pk_from_dk`] — the real SD walk the resolver
|
||||
/// runs: each positioned device key is placed against the MKB's subset-diff /
|
||||
/// cvalue records, recovering its Processing Key at the matching node and
|
||||
/// continuing to the Media Key. Reachable for real discs whenever a device key
|
||||
/// applies to the MKB. No VID is involved here — it enters at [`vuk_from_mk`].
|
||||
///
|
||||
/// Because the integrator KCD is unavailable in-tree (the placeholder is
|
||||
/// rejected by the variant chain), this returns `Err` for every real disc today
|
||||
/// — byte-for-byte identical to `resolve_keys_v21` path 1, which the resolver
|
||||
/// also leaves unreachable in production. All variant-chain failures collapse to
|
||||
/// [`Error::AacsMkUnavailable`] (E7018): no numeric distinction is load-bearing
|
||||
/// at this boundary, and the variant error carries no English to preserve.
|
||||
pub fn mk_from_dk(
|
||||
device_keys: &[DeviceKey],
|
||||
mkb: &[u8],
|
||||
vid: Vid,
|
||||
) -> Result<MediaKey, crate::error::Error> {
|
||||
let records = walk_mkb(mkb);
|
||||
match derive_media_key_variant(
|
||||
&records,
|
||||
device_keys,
|
||||
&KEY_CORRECTION_DATA_PLACEHOLDER,
|
||||
&vid.0,
|
||||
) {
|
||||
Ok((km, _kvu)) => Ok(MediaKey(km)),
|
||||
Err(_) => Err(crate::error::Error::AacsMkUnavailable),
|
||||
/// Returns [`Error::AacsMkUnavailable`] (E7018) when no supplied device key
|
||||
/// resolves the MKB — the same terminal error as [`mk_from_pk`]; no numeric
|
||||
/// distinction is load-bearing at this boundary.
|
||||
///
|
||||
/// [`Error::AacsMkUnavailable`]: crate::error::Error::AacsMkUnavailable
|
||||
pub fn mk_from_dk(device_keys: &[DeviceKey], mkb: &[u8]) -> Result<MediaKey, crate::error::Error> {
|
||||
// Positioned device keys drive the real Subset-Difference MKB walk
|
||||
// ([`derive_media_key_and_pk_from_dk`], the same walk the resolver runs). The
|
||||
// old Media-Key-Variant path needed integrator Key Correction Data absent
|
||||
// in-tree, so it Err'd for EVERY real disc (dead for both consumers —
|
||||
// freemkv-keysources' DK fallback and the kdb harvester). No VID is needed
|
||||
// for the Media Key; it enters only at [`vuk_from_mk`].
|
||||
match derive_media_key_and_pk_from_dk(mkb, device_keys) {
|
||||
Some((km, _pk)) => Ok(MediaKey(km)),
|
||||
None => Err(crate::error::Error::AacsMkUnavailable),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +144,134 @@ pub fn mk_from_pk(
|
||||
}
|
||||
}
|
||||
|
||||
/// A candidate key at any rung of the AACS ladder, handed to [`resolve_candidate`].
|
||||
///
|
||||
/// Each variant carries the module's existing newtype for that rung (a `Dk` is a
|
||||
/// POSITIONED [`DeviceKey`] — recover an unpositioned one with
|
||||
/// [`super::keys::recover_dk_position`] first).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum KeyCandidate {
|
||||
Uk(UnitKey),
|
||||
Vuk(Vuk),
|
||||
Mk(MediaKey),
|
||||
Pk(ProcessingKey),
|
||||
Dk(DeviceKey),
|
||||
}
|
||||
|
||||
/// The AACS key chain derived from a candidate, from [`resolve_candidate`].
|
||||
///
|
||||
/// PURE DERIVATION — no unit sampling, no validation. `unit_keys` holds every
|
||||
/// CPS-unit key the disc's `Unit_Key_RO.inf` yields from the VUK (positional
|
||||
/// order); the caller runs [`super::decrypt::unit_key_validates`] to find which
|
||||
/// one actually opens the disc. Rungs above the candidate are `None` (a `Vuk`
|
||||
/// candidate has no `mk`/`pk`/`dk`; a `Uk` candidate has only `unit_keys`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedChain {
|
||||
/// Every unit key derived from the VUK, as `(cps_unit_number, key)` — the
|
||||
/// CPS-unit numbers come from `Unit_Key_RO.inf` (via `parse_unit_key_ro`), so
|
||||
/// a consumer maps `UK → CPS unit` directly. Same shape as
|
||||
/// [`super::keys::ResolvedKeys::unit_keys`]. A `Uk` candidate yields exactly
|
||||
/// itself, keyed by its own `idx`.
|
||||
pub unit_keys: Vec<(u32, [u8; 16])>,
|
||||
pub vuk: Option<Vuk>,
|
||||
pub mk: Option<MediaKey>,
|
||||
pub pk: Option<ProcessingKey>,
|
||||
/// The positioned device key (for a `Dk` candidate).
|
||||
pub dk: Option<DeviceKey>,
|
||||
}
|
||||
|
||||
/// Derive the full AACS key chain from a candidate key of ANY ladder rung.
|
||||
///
|
||||
/// Runs the deterministic derivation DOWNWARD to the disc's terminal unit keys:
|
||||
/// `DK → MK → VUK → UKs`, `PK → MK → VUK → UKs`, `MK → VUK → UKs`,
|
||||
/// `VUK → UKs`, or `UK → itself`. Composes the module's own boil steps
|
||||
/// ([`mk_from_pk`], [`vuk_from_mk`], [`uk_from_vuk`]) and parses
|
||||
/// `Unit_Key_RO.inf` at the version the disc's MKB declares (48-byte stride for
|
||||
/// AACS-1.0, 64 for AACS-2.x), so a multi-CPS disc yields all its unit keys from
|
||||
/// the one candidate.
|
||||
///
|
||||
/// PURE DERIVATION: no sampling, no validation, no position recovery. Every step
|
||||
/// is deterministic AES, so the returned keys are only as sound as the input
|
||||
/// candidate — validate `unit_keys` against a real encrypted unit with
|
||||
/// [`super::decrypt::unit_key_validates`] to prove the candidate opens the disc.
|
||||
///
|
||||
/// Returns `None` only when derivation itself cannot proceed: a PK its MKB
|
||||
/// rejects, a `Dk` the MKB can't process, a missing VID on a path that needs
|
||||
/// one, or an unparseable/empty `Unit_Key_RO.inf`.
|
||||
pub fn resolve_candidate(
|
||||
candidate: &KeyCandidate,
|
||||
mkb: &[u8],
|
||||
unit_key_ro: &[u8],
|
||||
vid: Option<Vid>,
|
||||
) -> Option<ResolvedChain> {
|
||||
use super::keys::{AacsVersion, derive_media_key_and_pk_from_dk, mkb_type, parse_unit_key_ro};
|
||||
|
||||
// Boil a VUK → all unit keys, each paired with its declared CPS-unit number.
|
||||
// `.inf` parsing lives here: derive the stride version from the disc's own
|
||||
// MKB, then defer the VUK→unit-keys step to the shared `derive_unit_keys`
|
||||
// (the one place both resolvers and this path decrypt the title keys).
|
||||
let boil = |vuk: Vuk| -> Option<Vec<(u32, [u8; 16])>> {
|
||||
let version = mkb_type(mkb)
|
||||
.map(|t| t.generation())
|
||||
.unwrap_or(AacsVersion::V10);
|
||||
let ukf = parse_unit_key_ro(unit_key_ro, version)?;
|
||||
if ukf.encrypted_keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(super::keys::derive_unit_keys(&ukf, &vuk.0))
|
||||
};
|
||||
|
||||
match candidate {
|
||||
KeyCandidate::Uk(uk) => Some(ResolvedChain {
|
||||
unit_keys: vec![(uk.idx, uk.key)],
|
||||
vuk: None,
|
||||
mk: None,
|
||||
pk: None,
|
||||
dk: None,
|
||||
}),
|
||||
KeyCandidate::Vuk(v) => Some(ResolvedChain {
|
||||
unit_keys: boil(*v)?,
|
||||
vuk: Some(*v),
|
||||
mk: None,
|
||||
pk: None,
|
||||
dk: None,
|
||||
}),
|
||||
KeyCandidate::Mk(mk) => {
|
||||
let vuk = vuk_from_mk(*mk, vid?);
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(*mk),
|
||||
pk: None,
|
||||
dk: None,
|
||||
})
|
||||
}
|
||||
KeyCandidate::Pk(pk) => {
|
||||
let mk = mk_from_pk(std::slice::from_ref(&pk.0), mkb).ok()?;
|
||||
let vuk = vuk_from_mk(mk, vid?);
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(mk),
|
||||
pk: Some(*pk),
|
||||
dk: None,
|
||||
})
|
||||
}
|
||||
KeyCandidate::Dk(dk) => {
|
||||
let (km, pk) = derive_media_key_and_pk_from_dk(mkb, std::slice::from_ref(dk))?;
|
||||
let mk = MediaKey(km);
|
||||
let vuk = vuk_from_mk(mk, vid?);
|
||||
Some(ResolvedChain {
|
||||
unit_keys: boil(vuk)?,
|
||||
vuk: Some(vuk),
|
||||
mk: Some(mk),
|
||||
pk: Some(ProcessingKey(pk)),
|
||||
dk: Some(dk.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -205,27 +335,28 @@ mod tests {
|
||||
assert!(uk_from_vuk(Vuk([0u8; 16]), &[]).is_empty());
|
||||
}
|
||||
|
||||
/// `mk_from_dk` returns `Err(AacsMkUnavailable)` for the placeholder-KCD
|
||||
/// path that production also leaves unreachable — never a wrong key, never a
|
||||
/// panic — on both an empty MKB and a non-variant MKB.
|
||||
/// `mk_from_dk` returns `Err(AacsMkUnavailable)` when the MKB has no
|
||||
/// processable Subset-Difference tables (empty MKB, or one with no
|
||||
/// mk_dv/cvalues/subdiff records) — never a wrong key, never a panic.
|
||||
#[test]
|
||||
fn mk_from_dk_errors_without_integrator_kcd() {
|
||||
fn mk_from_dk_errors_on_unprocessable_mkb() {
|
||||
let dk = DeviceKey {
|
||||
key: [0x11; 16],
|
||||
node: 1,
|
||||
uv: 1,
|
||||
u_mask_shift: 0,
|
||||
};
|
||||
// Empty MKB → not a variant MKB → Err.
|
||||
let e = mk_from_dk(std::slice::from_ref(&dk), &[], Vid([0x09; 16]));
|
||||
// Empty MKB → no SD records to walk → Err.
|
||||
let e = mk_from_dk(std::slice::from_ref(&dk), &[]);
|
||||
assert!(matches!(e, Err(crate::error::Error::AacsMkUnavailable)));
|
||||
|
||||
// A variant-looking MKB (0x82 record) still cannot complete without the
|
||||
// integrator KCD, so it also errors — never silently yields a key.
|
||||
// An MKB with no complete Subset-Difference tables (mk_dv / cvalues /
|
||||
// subdiff) cannot yield a Media Key, so the real walk also errors —
|
||||
// never silently yields a key.
|
||||
let mut mkb: Vec<u8> = Vec::new();
|
||||
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]); // variant data record
|
||||
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]); // stray data record only
|
||||
mkb.extend_from_slice(&[0xAB; 16]);
|
||||
let e2 = mk_from_dk(&[dk], &mkb, Vid([0x09; 16]));
|
||||
let e2 = mk_from_dk(&[dk], &mkb);
|
||||
assert!(matches!(e2, Err(crate::error::Error::AacsMkUnavailable)));
|
||||
}
|
||||
|
||||
@@ -305,4 +436,65 @@ mod tests {
|
||||
Err(crate::error::Error::AacsMkUnavailable)
|
||||
));
|
||||
}
|
||||
|
||||
/// Minimal AACS-1.0 (48-byte stride) `Unit_Key_RO.inf` with `n` encrypted
|
||||
/// unit keys — `parse_unit_key_ro` numbers CPS units 1..=n.
|
||||
fn synth_inf(encs: &[[u8; 16]]) -> Vec<u8> {
|
||||
let uk_pos = 32usize;
|
||||
let stride = 48usize;
|
||||
let n = encs.len();
|
||||
let total = uk_pos + 48 + n.saturating_sub(1) * stride + 16;
|
||||
let mut inf = vec![0u8; total.max(20)];
|
||||
inf[..4].copy_from_slice(&(uk_pos as u32).to_be_bytes());
|
||||
inf[uk_pos..uk_pos + 2].copy_from_slice(&(n as u16).to_be_bytes());
|
||||
for (i, k) in encs.iter().enumerate() {
|
||||
let o = uk_pos + 48 + i * stride;
|
||||
inf[o..o + 16].copy_from_slice(k);
|
||||
}
|
||||
inf
|
||||
}
|
||||
|
||||
/// A VUK candidate boils to ALL the disc's unit keys, each paired with its
|
||||
/// declared CPS-unit number, and each key equals the VUK-decrypt of its slot.
|
||||
#[test]
|
||||
fn resolve_candidate_vuk_returns_all_cps_units() {
|
||||
let vuk = Vuk([0x33u8; 16]);
|
||||
let encs = [[0x11u8; 16], [0x22u8; 16], [0x44u8; 16]];
|
||||
let inf = synth_inf(&encs);
|
||||
let r = resolve_candidate(&KeyCandidate::Vuk(vuk), &[], &inf, None).expect("vuk derives");
|
||||
let cps: Vec<u32> = r.unit_keys.iter().map(|(c, _)| *c).collect();
|
||||
assert_eq!(
|
||||
cps,
|
||||
vec![1, 2, 3],
|
||||
"every CPS unit surfaced, numbered from the inf"
|
||||
);
|
||||
for ((_, key), enc) in r.unit_keys.iter().zip(encs.iter()) {
|
||||
assert_eq!(
|
||||
*key,
|
||||
decrypt_unit_key(&vuk.0, enc),
|
||||
"key = VUK-decrypt of its slot"
|
||||
);
|
||||
}
|
||||
assert_eq!(r.vuk, Some(vuk));
|
||||
assert!(r.mk.is_none() && r.pk.is_none() && r.dk.is_none());
|
||||
}
|
||||
|
||||
/// 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 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());
|
||||
}
|
||||
|
||||
/// MK/PK/DK paths derive the VUK from a VID; without one, derivation stops.
|
||||
#[test]
|
||||
fn resolve_candidate_mk_requires_vid() {
|
||||
let r = resolve_candidate(&KeyCandidate::Mk(MediaKey([1u8; 16])), &[], &[], None);
|
||||
assert!(r.is_none(), "MK path returns None without a VID");
|
||||
}
|
||||
}
|
||||
|
||||
+43
-114
@@ -9,8 +9,9 @@ use super::types::DeviceKey;
|
||||
///
|
||||
/// The content cert byte distinguishes V10 (`0x00`) from V20 (`0x01`). V21
|
||||
/// cannot be detected from the cert alone — a V21 disc carries a V20 cert
|
||||
/// and is upgraded to `V21` only after the MKB walk turns up record types
|
||||
/// `0x82` / `0x83` (Media Key Variant Data and Variant Number).
|
||||
/// and is upgraded to `V21` only after the MKB walk turns up the real Variant
|
||||
/// records `0x2d` / `0x2f` (Encrypted Media Key Variant Data and the Variant
|
||||
/// Key Data table).
|
||||
///
|
||||
/// Key-storage stride in `Unit_Key_RO.inf` is 48 bytes for V10 and 64
|
||||
/// bytes for V20 / V21.
|
||||
@@ -77,6 +78,18 @@ pub fn decrypt_unit_key(vuk: &[u8; 16], encrypted_uk: &[u8; 16]) -> [u8; 16] {
|
||||
aes_ecb_decrypt(vuk, encrypted_uk)
|
||||
}
|
||||
|
||||
/// Decrypt every encrypted unit key in a parsed `Unit_Key_RO.inf` with a VUK,
|
||||
/// paired with its declared CPS-unit number. THE single VUK→unit-keys step:
|
||||
/// both classical/v21 resolvers and `boil::resolve_candidate` call this, so the
|
||||
/// map cannot drift between the player and harvest paths.
|
||||
pub(crate) fn derive_unit_keys(uk_file: &UnitKeyFile, vuk: &[u8; 16]) -> Vec<(u32, [u8; 16])> {
|
||||
uk_file
|
||||
.encrypted_keys
|
||||
.iter()
|
||||
.map(|(num, enc_key)| (*num, decrypt_unit_key(vuk, enc_key)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── Unit_Key_RO.inf parsing ─────────────────────────────────────────────────
|
||||
|
||||
/// Parsed Unit_Key_RO.inf file.
|
||||
@@ -240,8 +253,17 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
|
||||
|
||||
/// Derive Media Key from MKB data using processing keys.
|
||||
///
|
||||
/// Processing keys are pre-computed keys that work for specific MKB versions.
|
||||
/// This is the fast path — no subset-difference tree traversal needed.
|
||||
/// A Processing Key is **terminal**: it is the key at its Subset-Difference
|
||||
/// node, one `AES-G` from the Media Key. So this is the fast path — each PK is
|
||||
/// tried *directly* against the MKB cvalue tables (no tree descent), matching
|
||||
/// libaacs `_calc_mk_pks` (iterate PKs × cvalues). On a large AACS 2.x UHD MKB
|
||||
/// (~181k cvalues) this is ~15x faster than treating a PK as a device-node
|
||||
/// label and walking the tree.
|
||||
///
|
||||
/// If you hold a **device-node label** at unknown tree depth (not a terminal
|
||||
/// PK), derive its Media Key through the device-key path
|
||||
/// ([`derive_media_key_from_dk`]) — that path owns the Subset-Difference tree
|
||||
/// walk; the PK path never descends.
|
||||
///
|
||||
/// MKB format:
|
||||
/// Record type 0x10 = Type and Version Record (has MKB version)
|
||||
@@ -251,67 +273,28 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
|
||||
/// Record type 0x05 = Media Key Data Record (cvalues, 1:1 with 0x04)
|
||||
/// Record type 0x07 = Explicit Subset-Difference Record (NOT cvalues)
|
||||
pub fn derive_media_key_from_pk(mkb: &[u8], processing_keys: &[[u8; 16]]) -> Option<[u8; 16]> {
|
||||
derive_media_key_from_pk_walked(mkb, processing_keys, PK_WALK_MAX_DEPTH)
|
||||
}
|
||||
|
||||
/// SD-tree walk depth applied to every entry in `processing_keys`.
|
||||
///
|
||||
/// Each entry is treated as a node-key (label) at unknown depth. The
|
||||
/// resolver applies `AES-G3(K, 1)` to derive the PK at this node, then
|
||||
/// descends via `AES-G3(K, 0)` (left child) and `AES-G3(K, 2)` (right
|
||||
/// child) up to this many additional levels — try-everything since we
|
||||
/// have no path bits per entry.
|
||||
///
|
||||
/// Each level doubles the candidate count. Cost per entry per MKB
|
||||
/// cvalue ≈ `2 × (2^(D+1) - 1)` AES decrypts. For a ~100-cvalue MKB
|
||||
/// (typical UHD) at depth 2: ~14 × 100 = 1400 ops per entry; for 1.5k
|
||||
/// entries that's ~2 M validate calls, sub-second with AES-NI.
|
||||
///
|
||||
/// Set to 0 to disable walking (entries tried only as terminal PKs).
|
||||
const PK_WALK_MAX_DEPTH: u8 = 3;
|
||||
|
||||
/// Hard ceiling on the requested walk depth. The BFS frontier holds `2^depth`
|
||||
/// 16-byte node keys, so an uncapped `max_depth` (e.g. 26+) would exhaust
|
||||
/// memory; the walk silently clamps to this. 5 (32-wide frontier) covers every
|
||||
/// realistic leaked-label case with margin.
|
||||
const PK_WALK_MAX_DEPTH_CAP: u8 = 5;
|
||||
|
||||
/// Same as [`derive_media_key_from_pk`] but with explicit walk depth.
|
||||
/// Each entry is tried as a terminal PK at depth 0, then as a node-key
|
||||
/// whose PK and children are derived via `AES-G3(K, 0|1|2)` for up to
|
||||
/// `max_depth` additional levels.
|
||||
///
|
||||
/// The BFS frontier grows as `2^max_depth`; `max_depth` is clamped to
|
||||
/// [`PK_WALK_MAX_DEPTH_CAP`] so a large value cannot exhaust memory.
|
||||
pub(crate) fn derive_media_key_from_pk_walked(
|
||||
mkb: &[u8],
|
||||
processing_keys: &[[u8; 16]],
|
||||
max_depth: u8,
|
||||
) -> Option<[u8; 16]> {
|
||||
let mk_dv = mkb_find_mk_dv(mkb)?;
|
||||
let uvs = mkb_find_subdiff_records(mkb)?;
|
||||
let cvalues = mkb_find_cvalues(mkb)?;
|
||||
walk_pk_against_tables_impl(processing_keys, &uvs, &cvalues, &mk_dv, max_depth)
|
||||
try_pk_against_tables(processing_keys, &uvs, &cvalues, &mk_dv)
|
||||
}
|
||||
|
||||
/// Core Subset-Difference PK walk over explicit record bodies. The single PK→MK
|
||||
/// walk engine, reached in production via [`derive_media_key_from_pk`].
|
||||
fn walk_pk_against_tables_impl(
|
||||
/// Core terminal-PK table scan over explicit record bodies. Each processing
|
||||
/// key is tried **directly** against every `(uv, cvalue)` pair — no tree
|
||||
/// descent. Reached in production via [`derive_media_key_from_pk`]; factored
|
||||
/// out so reproduction harnesses can drive it with explicit tables.
|
||||
fn try_pk_against_tables(
|
||||
processing_keys: &[[u8; 16]],
|
||||
uvs: &[u8],
|
||||
cvalues: &[u8],
|
||||
mk_dv: &[u8; 16],
|
||||
max_depth: u8,
|
||||
) -> Option<[u8; 16]> {
|
||||
// Clamp the frontier depth (2^depth node keys) so a caller-supplied value
|
||||
// cannot OOM the process.
|
||||
let max_depth = max_depth.min(PK_WALK_MAX_DEPTH_CAP);
|
||||
let num_uvs = uvs
|
||||
.chunks(5)
|
||||
.take_while(|c| c.len() == 5 && (c[0] & 0xC0) == 0)
|
||||
.count();
|
||||
|
||||
let try_against_mkb = |pk: &[u8; 16]| -> Option<[u8; 16]> {
|
||||
for pk in processing_keys {
|
||||
for i in 0..num_uvs {
|
||||
if (i + 1) * 16 > cvalues.len() {
|
||||
continue;
|
||||
@@ -326,48 +309,6 @@ fn walk_pk_against_tables_impl(
|
||||
return Some(mk);
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
// Two interpretations per entry:
|
||||
// (a) entry IS already a terminal PK → validate directly
|
||||
// (b) entry is a node key (label) → derive PK via aesg3(K, 1) and validate
|
||||
// Then descend to children's node keys via aesg3(K, 0) / aesg3(K, 2) and
|
||||
// repeat up to max_depth levels deep.
|
||||
for entry in processing_keys {
|
||||
// Depth-0 attempts on the raw entry
|
||||
if let Some(mk) = try_against_mkb(entry) {
|
||||
return Some(mk);
|
||||
}
|
||||
let pk_at_node = aesg3(entry, 1);
|
||||
if let Some(mk) = try_against_mkb(&pk_at_node) {
|
||||
return Some(mk);
|
||||
}
|
||||
if max_depth == 0 {
|
||||
continue;
|
||||
}
|
||||
// Walk: BFS through child node keys
|
||||
let mut frontier: Vec<[u8; 16]> = vec![aesg3(entry, 0), aesg3(entry, 2)];
|
||||
for depth in 1..=max_depth {
|
||||
let mut next = Vec::with_capacity(frontier.len() * 2);
|
||||
for nk in &frontier {
|
||||
// Try this node's PK (label → PK at this level)
|
||||
let pk_here = aesg3(nk, 1);
|
||||
if let Some(mk) = try_against_mkb(&pk_here) {
|
||||
return Some(mk);
|
||||
}
|
||||
// Some leaked materials are themselves PKs at this depth, so
|
||||
// also try the node-key bytes directly.
|
||||
if let Some(mk) = try_against_mkb(nk) {
|
||||
return Some(mk);
|
||||
}
|
||||
if depth < max_depth {
|
||||
next.push(aesg3(nk, 0));
|
||||
next.push(aesg3(nk, 2));
|
||||
}
|
||||
}
|
||||
frontier = next;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -1163,7 +1104,7 @@ pub fn resolve_keys_v1(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||
|
||||
/// AACS 2.0 key resolution. Parses `Unit_Key_RO.inf` with 64-byte
|
||||
/// stride. Tries paths 1 → 4 in order. When paths 3/4 succeed against
|
||||
/// an MKB carrying Variant records (`0x82` / `0x83`), the result's
|
||||
/// an MKB carrying Variant records (`0x2d` / `0x2f`), the result's
|
||||
/// `version` is upgraded to [`AacsVersion::V21`] — derivation still
|
||||
/// runs through the classical V2 path; the V21-specific Variant chain
|
||||
/// is wired separately via [`resolve_keys_v21`].
|
||||
@@ -1203,13 +1144,7 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
|
||||
.unwrap_or(false);
|
||||
let has_vid = *ctx.volume_id != [0u8; 16];
|
||||
|
||||
let derive_uks = |vuk: &[u8; 16]| -> Vec<(u32, [u8; 16])> {
|
||||
uk_file
|
||||
.encrypted_keys
|
||||
.iter()
|
||||
.map(|(num, enc_key)| (*num, decrypt_unit_key(vuk, enc_key)))
|
||||
.collect()
|
||||
};
|
||||
let derive_uks = |vuk: &[u8; 16]| derive_unit_keys(&uk_file, vuk);
|
||||
|
||||
let build =
|
||||
|vuk: Option<[u8; 16]>, unit_keys: Vec<(u32, [u8; 16])>, key_source: u8| -> ResolvedKeys {
|
||||
@@ -1331,13 +1266,7 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
|
||||
let has_vid = *ctx.volume_id != [0u8; 16];
|
||||
|
||||
// Decrypt the disc's encrypted unit keys with a freshly-derived VUK.
|
||||
let derive_uks = |vuk: &[u8; 16]| -> Vec<(u32, [u8; 16])> {
|
||||
uk_file
|
||||
.encrypted_keys
|
||||
.iter()
|
||||
.map(|(num, enc_key)| (*num, decrypt_unit_key(vuk, enc_key)))
|
||||
.collect()
|
||||
};
|
||||
let derive_uks = |vuk: &[u8; 16]| derive_unit_keys(&uk_file, vuk);
|
||||
|
||||
// Common result constructor — paths 1-4 supply Some(VUK) + derived
|
||||
// unit keys; path 5 supplies None + pre-decrypted unit keys from
|
||||
@@ -1785,11 +1714,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_walk_pk_against_tables_accepts_planted_pk_rejects_corrupt() {
|
||||
// Lock in the shared SD walk (`walk_pk_against_tables_impl`) used by the
|
||||
fn probe_try_pk_against_tables_accepts_planted_pk_rejects_corrupt() {
|
||||
// Lock in the terminal PK scan (`try_pk_against_tables`) used by the
|
||||
// production PK path (`derive_media_key_from_pk`). Plant a terminal PK
|
||||
// whose derived Media Key satisfies a synthetic verify record; confirm
|
||||
// the walk ACCEPTS it against caller-supplied SD/cvalue tables and
|
||||
// the scan ACCEPTS it against caller-supplied SD/cvalue tables and
|
||||
// REJECTS a 1-byte corruption.
|
||||
use super::super::decrypt::aes_ecb_encrypt as enc;
|
||||
|
||||
@@ -1817,14 +1746,14 @@ mod tests {
|
||||
subdiff.extend_from_slice(&uv);
|
||||
|
||||
assert_eq!(
|
||||
walk_pk_against_tables_impl(std::slice::from_ref(&pk), &subdiff, &cv, &mk_dv, 1),
|
||||
try_pk_against_tables(std::slice::from_ref(&pk), &subdiff, &cv, &mk_dv),
|
||||
Some(mk),
|
||||
"planted terminal PK must verify"
|
||||
);
|
||||
let mut bad = pk;
|
||||
bad[0] ^= 0xFF;
|
||||
assert_eq!(
|
||||
walk_pk_against_tables_impl(std::slice::from_ref(&bad), &subdiff, &cv, &mk_dv, 1),
|
||||
try_pk_against_tables(std::slice::from_ref(&bad), &subdiff, &cv, &mk_dv),
|
||||
None,
|
||||
"corrupted PK must be rejected"
|
||||
);
|
||||
@@ -2667,9 +2596,9 @@ mod tests {
|
||||
}),
|
||||
};
|
||||
|
||||
// MKB with a 0x83 variant record makes is_variant_mkb true.
|
||||
// MKB with a 0x2f variant record makes is_variant_mkb true.
|
||||
let mut mkb = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 1];
|
||||
mkb.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0x2f, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0x55; 16]);
|
||||
|
||||
let providers: &[&dyn super::super::KeyProvider] = &[&keydb];
|
||||
|
||||
+4
-1
@@ -36,7 +36,10 @@ pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
|
||||
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
|
||||
|
||||
// Boil-down derivation primitives (thin newtypes + wrappers over the crypto).
|
||||
pub use boil::{MediaKey, UnitKey, Vid, Vuk, mk_from_dk, mk_from_pk, uk_from_vuk, vuk_from_mk};
|
||||
pub use boil::{
|
||||
KeyCandidate, MediaKey, ProcessingKey, ResolvedChain, UnitKey, Vid, Vuk, mk_from_dk,
|
||||
mk_from_pk, resolve_candidate, uk_from_vuk, vuk_from_mk,
|
||||
};
|
||||
// Structured, English-free resolution trace.
|
||||
pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, UnlockStep};
|
||||
|
||||
|
||||
+102
-74
@@ -9,23 +9,26 @@
|
||||
//!
|
||||
//! This module is wiring only — `resolve_keys` is not aware of it. The
|
||||
//! entry point is [`derive_media_key_variant`]. The Variant scheme is
|
||||
//! detected via the new MKB record types `0x82` (Encrypted Media Key
|
||||
//! Variant Data + Variant Key Data) and `0x83` (Variant Number). When
|
||||
//! a disc carries neither, callers should fall back to the classical
|
||||
//! single-stage derivation in [`super::keys`].
|
||||
//! detected via the real AACS 2.1 MKB records `0x2d` (Encrypted Media
|
||||
//! Key Variant Data / C), `0x2f` (Variant Key Data table, 65,535×16),
|
||||
//! and `0x0c` (variant cvalues, one per `0x04` subset-difference slot).
|
||||
//! When a disc carries none, callers fall back to the classical
|
||||
//! single-stage derivation in [`super::keys`]. (The earlier `0x82`/`0x83`
|
||||
//! record types were a speculative guess that never appeared in any real
|
||||
//! MKB; they were replaced with the real records once a live variant MKB
|
||||
//! was obtained.)
|
||||
//!
|
||||
//! **Status: the chain cannot yet produce a key on a real disc.** Two
|
||||
//! sub-fields are unfinished:
|
||||
//! - [`variants_for_uv`] (the `VARIANTS[uv]` lookup in the `0x83`
|
||||
//! record) is a stub that always returns `None`, so the chain
|
||||
//! short-circuits with [`MediaKeyVariantError::VariantsTableUnavailable`].
|
||||
//! - The Encrypted Media Key Variant Data (C) and the Variant Key
|
||||
//! Data (VKD) table are *distinct* sub-fields of the `0x82` record
|
||||
//! per AACS 2.1, but [`variant_data_record`] (C) and
|
||||
//! [`variant_key_data`] (VKD) both currently return the *whole*
|
||||
//! first `0x82` body — so on a single-`0x82` disc they alias. The
|
||||
//! `0x82` sub-field offsets must be fixed against a real Variant
|
||||
//! disc before this chain is wired into `resolve_keys`.
|
||||
//! **Status: the chain cannot yet produce a key on a real disc — for two
|
||||
//! reasons, one external, one internal:**
|
||||
//! - EXTERNAL: no device key in our pool walks any real variant MKB,
|
||||
//! so `Kp` (and thus the whole chain) can't be produced live. This is
|
||||
//! a key-acquisition gap, not a code gap.
|
||||
//! - INTERNAL: the exact `VARIANTS[uv]` lookup ([`variants_for_uv`])
|
||||
//! and the Nonce / C sub-field offsets can't be pinned without a real
|
||||
//! disc + covering key to run the chain end-to-end against the `0x86`
|
||||
//! verify. Until then [`variants_for_uv`] returns `None` and the
|
||||
//! chain halts at [`MediaKeyVariantError::VariantsTableUnavailable`],
|
||||
//! so a wrong best-effort offset is never silently trusted.
|
||||
//!
|
||||
//! The chain follows the published spec:
|
||||
//!
|
||||
@@ -128,48 +131,61 @@ pub(crate) fn mkb_records(mkb: &[u8]) -> impl Iterator<Item = (usize, u8, usize)
|
||||
})
|
||||
}
|
||||
|
||||
/// True iff `records` contains at least one Media Key Variant record
|
||||
/// (type `0x82` or `0x83`).
|
||||
/// True iff `records` contains at least one Media Key Variant record.
|
||||
///
|
||||
/// The real AACS 2.1 Variant markers — confirmed against a live variant MKB —
|
||||
/// are `0x2d` (Encrypted Media Key Variant Data / C) and `0x2f` (Variant Key
|
||||
/// Data table, 65,535×16). Both are absent from non-variant 1.0/2.0 MKBs (which
|
||||
/// instead carry `0x05` host-revocation-signature and no `0x0c`/`0x2d`/`0x2f`).
|
||||
/// The earlier `0x82`/`0x83` guess was speculative and never appeared in any
|
||||
/// real MKB.
|
||||
pub fn is_variant_mkb(records: &[MkbRecord]) -> bool {
|
||||
records.iter().any(|r| matches!(r.rec_type, 0x82 | 0x83))
|
||||
records.iter().any(|r| matches!(r.rec_type, 0x2d | 0x2f))
|
||||
}
|
||||
|
||||
/// Body of the Encrypted Media Key Variant Data record (type `0x82`).
|
||||
/// Body of the Encrypted Media Key Variant Data record (type `0x2d`).
|
||||
///
|
||||
/// Returns the whole first `0x82` body; the internal C / VKD sub-field
|
||||
/// split is not yet decoded, so this aliases [`variant_key_data`] on a
|
||||
/// single-`0x82` disc. `pub(crate)` until the sub-field offsets are fixed
|
||||
/// against a real variant disc — it is not part of the public surface
|
||||
/// because it knowingly returns an undecoded composite.
|
||||
/// Confirmed against a live variant MKB as the `0x2d` record (92,220 bytes on
|
||||
/// the reference disc — not a clean multiple of 16, so it is a structured /
|
||||
/// count-prefixed record, not a flat C-block table). The exact per-uv C
|
||||
/// selection is the one sub-field still unconfirmed without a real disc+key to
|
||||
/// test against; the chain currently consumes the leading 16-byte block.
|
||||
/// `pub(crate)` until that offset is pinned.
|
||||
pub(crate) fn variant_data_record(records: &[MkbRecord]) -> Option<&[u8]> {
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.rec_type == 0x82)
|
||||
.find(|r| r.rec_type == 0x2d)
|
||||
.map(|r| r.body.as_slice())
|
||||
}
|
||||
|
||||
/// 16-byte Nonce from the Variant Number record (type `0x83`). Returns
|
||||
/// the first 16 bytes of the body.
|
||||
/// 16-byte Nonce for `Kvn = AES-G(Kp, Nonce)`.
|
||||
///
|
||||
/// **UNCONFIRMED source.** The speculative `0x83` "Variant Number" record does
|
||||
/// not exist on a real variant MKB. The `0x2d` Encrypted-Media-Key-Variant-Data
|
||||
/// record is the most likely home for a per-disc nonce, so this best-effort
|
||||
/// reads the trailing 16 bytes of `0x2d`. Confirming this (vs. a field inside
|
||||
/// `0x21`, or a fixed slice of `0x2d`) needs a real disc+key to test the whole
|
||||
/// chain against the `0x86` verify — until then the chain halts earlier at
|
||||
/// [`variants_for_uv`], so a wrong nonce here is never silently trusted.
|
||||
pub fn variant_nonce(records: &[MkbRecord]) -> Option<[u8; 16]> {
|
||||
let r = records.iter().find(|r| r.rec_type == 0x83)?;
|
||||
let r = records.iter().find(|r| r.rec_type == 0x2d)?;
|
||||
if r.body.len() < 16 {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 16];
|
||||
out.copy_from_slice(&r.body[..16]);
|
||||
out.copy_from_slice(&r.body[r.body.len() - 16..]);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Body of the Variant Key Data record. Returns the first `0x82` body
|
||||
/// that is a non-empty multiple of 16 bytes.
|
||||
/// The Variant Key Data (VKD) table — record type `0x2f`.
|
||||
///
|
||||
/// Like [`variant_data_record`], this returns the whole `0x82` body and
|
||||
/// aliases it on a single-`0x82` disc; the C / VKD sub-field split is
|
||||
/// undecoded. `pub(crate)` until fixed against a real variant disc.
|
||||
/// Confirmed against a live variant MKB: exactly 65,535 × 16 = 1,048,560 bytes,
|
||||
/// indexed by the resolved `VKDidx`. This is disc-public data (it is why the
|
||||
/// VKD alone buys nothing without the Media Key chain above it).
|
||||
pub(crate) fn variant_key_data(records: &[MkbRecord]) -> Option<&[u8]> {
|
||||
records
|
||||
.iter()
|
||||
.find(|r| r.rec_type == 0x82 && !r.body.is_empty() && r.body.len() % 16 == 0)
|
||||
.find(|r| r.rec_type == 0x2f && !r.body.is_empty() && r.body.len() % 16 == 0)
|
||||
.map(|r| r.body.as_slice())
|
||||
}
|
||||
|
||||
@@ -258,7 +274,13 @@ pub fn walk_processing_key(
|
||||
) -> Option<ProcessingKeyMatch> {
|
||||
let mk_dv = mkb_find_mk_dv(records)?;
|
||||
let uvs = mkb_find_body(records, 0x04)?;
|
||||
let cvalues = mkb_find_body(records, 0x07).or_else(|| mkb_find_body(records, 0x05))?;
|
||||
// Variant cvalue source: a real variant MKB carries its per-uv cvalue table
|
||||
// in record `0x0c` (confirmed 46,101×16, one per `0x04` subset-difference
|
||||
// slot). Fall back to `0x07`/`0x05` for the synthetic fixtures and any MKB
|
||||
// shape that keeps its cvalues there.
|
||||
let cvalues = mkb_find_body(records, 0x0c)
|
||||
.or_else(|| mkb_find_body(records, 0x07))
|
||||
.or_else(|| mkb_find_body(records, 0x05))?;
|
||||
|
||||
let num_uvs = uvs
|
||||
.chunks(5)
|
||||
@@ -560,12 +582,14 @@ mod tests {
|
||||
|
||||
fn synthetic_mkb_with_variant() -> Vec<u8> {
|
||||
let mut mkb = synthetic_mkb_classical();
|
||||
// 0x82 — 16-byte body (Variant data / VKD slot).
|
||||
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]);
|
||||
// 0x2d — Encrypted Media Key Variant Data: C (head 16) then the
|
||||
// trailing 16-byte Nonce, 32-byte body.
|
||||
mkb.extend_from_slice(&[0x2d, 0x00, 0x00, 0x24]);
|
||||
mkb.extend_from_slice(&[0xEE; 16]);
|
||||
// 0x83 — 16-byte body (Variant Nonce).
|
||||
mkb.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0x55; 16]);
|
||||
// 0x2f — Variant Key Data table: one 16-byte VKD entry.
|
||||
mkb.extend_from_slice(&[0x2f, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xCC; 16]);
|
||||
mkb
|
||||
}
|
||||
|
||||
@@ -594,9 +618,12 @@ mod tests {
|
||||
fn variant_detection_positive_on_variant() {
|
||||
let recs = walk_mkb(&synthetic_mkb_with_variant());
|
||||
assert!(is_variant_mkb(&recs));
|
||||
// Nonce = trailing 16 of 0x2d; VKD = the 0x2f entry; C = the whole 0x2d.
|
||||
assert_eq!(variant_nonce(&recs), Some([0x55; 16]));
|
||||
assert_eq!(variant_key_data(&recs), Some(&[0xEE; 16][..]));
|
||||
assert_eq!(variant_data_record(&recs), Some(&[0xEE; 16][..]));
|
||||
assert_eq!(variant_key_data(&recs), Some(&[0xCC; 16][..]));
|
||||
let c = variant_data_record(&recs).unwrap();
|
||||
assert_eq!(&c[..16], &[0xEE; 16][..]);
|
||||
assert_eq!(&c[16..], &[0x55; 16][..]);
|
||||
}
|
||||
|
||||
// ── Chain entry-point classification ──
|
||||
@@ -742,18 +769,16 @@ mod tests {
|
||||
mkb.extend_from_slice(&[0x86, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xCD; 16]);
|
||||
|
||||
// 0x82 record: holds C (Encrypted Media Key Variant Data) AND
|
||||
// doubles as the VKD table (single 16-byte entry → VKDidx must
|
||||
// resolve to 0 for `chain_surfaces_variants_table_gap` test —
|
||||
// but the test never reaches the VKD lookup since the
|
||||
// VARIANTS[uv] helper is not yet wired).
|
||||
mkb.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]);
|
||||
// 0x2d record: Encrypted Media Key Variant Data — C (head 16) then a
|
||||
// trailing 16-byte Nonce (variant_nonce reads the tail), 32-byte body.
|
||||
mkb.extend_from_slice(&[0x2d, 0x00, 0x00, 0x24]);
|
||||
mkb.extend_from_slice(&c_block);
|
||||
|
||||
// 0x83 record: 16-byte Nonce.
|
||||
mkb.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0x77; 16]);
|
||||
|
||||
// 0x2f record: Variant Key Data table — one 16-byte entry.
|
||||
mkb.extend_from_slice(&[0x2f, 0x00, 0x00, 0x14]);
|
||||
mkb.extend_from_slice(&[0xBB; 16]);
|
||||
|
||||
let recs = walk_mkb(&mkb);
|
||||
|
||||
let dk = DeviceKey {
|
||||
@@ -860,29 +885,29 @@ mod tests {
|
||||
// ── Record selectors ───────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn is_variant_mkb_true_for_0x82_alone_and_0x83_alone() {
|
||||
// Either record type alone flags the MKB as variant.
|
||||
let only82 = walk_mkb(&{
|
||||
fn is_variant_mkb_true_for_0x2d_alone_and_0x2f_alone() {
|
||||
// Either variant record type alone flags the MKB as variant.
|
||||
let only2d = walk_mkb(&{
|
||||
let mut m = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 0];
|
||||
m.extend_from_slice(&[0x82, 0x00, 0x00, 0x14]);
|
||||
m.extend_from_slice(&[0x2d, 0x00, 0x00, 0x14]);
|
||||
m.extend_from_slice(&[0xEE; 16]);
|
||||
m
|
||||
});
|
||||
assert!(is_variant_mkb(&only82));
|
||||
let only83 = walk_mkb(&{
|
||||
assert!(is_variant_mkb(&only2d));
|
||||
let only2f = walk_mkb(&{
|
||||
let mut m = vec![0x10, 0x00, 0x00, 0x08, 0, 0, 0, 0];
|
||||
m.extend_from_slice(&[0x83, 0x00, 0x00, 0x14]);
|
||||
m.extend_from_slice(&[0x2f, 0x00, 0x00, 0x14]);
|
||||
m.extend_from_slice(&[0x55; 16]);
|
||||
m
|
||||
});
|
||||
assert!(is_variant_mkb(&only83));
|
||||
assert!(is_variant_mkb(&only2f));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variant_nonce_requires_16_byte_body() {
|
||||
// A 0x83 record with < 16-byte body → None (no panic on the copy).
|
||||
// A 0x2d record with < 16-byte body → None (no panic on the tail copy).
|
||||
let recs = walk_mkb(&{
|
||||
let mut m = vec![0x83, 0x00, 0x00, 0x0C]; // 8-byte body
|
||||
let mut m = vec![0x2d, 0x00, 0x00, 0x0C]; // 8-byte body
|
||||
m.extend_from_slice(&[0x11; 8]);
|
||||
m
|
||||
});
|
||||
@@ -891,16 +916,21 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn variant_key_data_requires_nonempty_multiple_of_16() {
|
||||
// A 0x82 body that is NOT a multiple of 16 is rejected by
|
||||
// A 0x2f VKD body that is NOT a multiple of 16 is rejected by
|
||||
// variant_key_data (it needs whole 16-byte VKD slots).
|
||||
let recs = walk_mkb(&{
|
||||
let mut m = vec![0x82, 0x00, 0x00, 0x0E]; // 10-byte body (not %16)
|
||||
let mut m = vec![0x2f, 0x00, 0x00, 0x0E]; // 10-byte body (not %16)
|
||||
m.extend_from_slice(&[0x22; 10]);
|
||||
m
|
||||
});
|
||||
assert_eq!(variant_key_data(&recs), None);
|
||||
// variant_data_record returns the body regardless of length.
|
||||
assert_eq!(variant_data_record(&recs), Some(&[0x22u8; 10][..]));
|
||||
// variant_data_record reads 0x2d and returns its body regardless of length.
|
||||
let recs2 = walk_mkb(&{
|
||||
let mut m = vec![0x2d, 0x00, 0x00, 0x0E];
|
||||
m.extend_from_slice(&[0x33; 10]);
|
||||
m
|
||||
});
|
||||
assert_eq!(variant_data_record(&recs2), Some(&[0x33u8; 10][..]));
|
||||
}
|
||||
|
||||
// ── derive_media_key_variant: missing-record classification ────────────
|
||||
@@ -917,16 +947,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn chain_reports_mkb_incomplete_when_nonce_missing() {
|
||||
// Build a variant MKB (has 0x82 so is_variant true, and a DK can walk
|
||||
// it) but WITHOUT a 0x83 nonce record → MkbIncomplete at the
|
||||
// variant_nonce `?`.
|
||||
// Start from the full setup, then rebuild the byte stream dropping
|
||||
// the 0x83 record.
|
||||
// Build a variant MKB (still variant via 0x2f, and a DK can walk it)
|
||||
// but WITHOUT the 0x2d record that carries C + the trailing Nonce →
|
||||
// MkbIncomplete at the variant_nonce `?`.
|
||||
let (recs, dk, _, _) = synthetic_variant_setup(0x00);
|
||||
// Reconstruct bytes without the 0x83 record.
|
||||
// Reconstruct bytes without the 0x2d record.
|
||||
let mut mkb = Vec::new();
|
||||
for r in &recs {
|
||||
if r.rec_type == 0x83 {
|
||||
if r.rec_type == 0x2d {
|
||||
continue;
|
||||
}
|
||||
mkb.push(r.rec_type);
|
||||
@@ -936,7 +964,7 @@ mod tests {
|
||||
mkb.extend_from_slice(&r.body);
|
||||
}
|
||||
let recs2 = walk_mkb(&mkb);
|
||||
assert!(is_variant_mkb(&recs2), "still variant via 0x82");
|
||||
assert!(is_variant_mkb(&recs2), "still variant via 0x2f");
|
||||
let err = derive_media_key_variant(&recs2, &[dk], &[0xAA; 16], &[0u8; 16])
|
||||
.expect_err("missing nonce → MkbIncomplete");
|
||||
assert_eq!(err, MediaKeyVariantError::MkbIncomplete);
|
||||
|
||||
+236
-30
@@ -38,6 +38,17 @@ pub struct DtsParser {
|
||||
/// current `buf` start and rebased whenever bytes are drained from the
|
||||
/// front.
|
||||
pts_marks: Vec<(usize, i64)>,
|
||||
/// The `front_pts` of the PREVIOUS emitted access unit. When the current
|
||||
/// AU's `front_pts` differs, it began a new PES → re-base to it. When it is
|
||||
/// unchanged, this AU shares the previous AU's PES → advance one frame
|
||||
/// duration. This per-PES re-base (rather than a global running clock) is
|
||||
/// what keeps a feature-long DVD DTS track from drifting past its real
|
||||
/// length. `PTS_UNSET` = no AU emitted yet.
|
||||
last_front_pts: i64,
|
||||
/// The PTS for the NEXT AU *if it shares the current PES* (the within-PES
|
||||
/// running cursor: previous emit + its duration). Only consulted when
|
||||
/// `front_pts` is unchanged from `last_front_pts`. `PTS_UNSET` = no base yet.
|
||||
next_pts_ns: i64,
|
||||
}
|
||||
|
||||
impl Default for DtsParser {
|
||||
@@ -52,9 +63,41 @@ impl DtsParser {
|
||||
buf: Vec::with_capacity(32768),
|
||||
pending_pts: 0,
|
||||
pts_marks: Vec::new(),
|
||||
last_front_pts: PTS_UNSET,
|
||||
next_pts_ns: PTS_UNSET,
|
||||
}
|
||||
}
|
||||
|
||||
/// Stamp an access unit's PTS. `front` is the AU's own core-PES PTS (from
|
||||
/// [`front_pts`]); `dur_ns` its decoded duration.
|
||||
///
|
||||
/// The model matches the (correct) AC-3 path: **re-base to each PES's own
|
||||
/// container timestamp, and advance by one frame duration ONLY within a run
|
||||
/// of AUs that share the same PES.** A new PES (its `front` differs from the
|
||||
/// previous AU's) trusts its own timestamp — so the emitted timeline tracks
|
||||
/// the container and never drifts. Advancing within a PES is what fixes the
|
||||
/// DVD case (several DTS core frames packed in one PES, which otherwise all
|
||||
/// collided on that single PES timestamp → "non monotonically increasing
|
||||
/// dts"). A global running clock was WRONG here: once accumulated frame
|
||||
/// durations exceeded the PES spacing it never re-based, drifting the track
|
||||
/// minutes past the real length over a feature-long title.
|
||||
fn stamp_pts(&mut self, front: i64, dur_ns: i64) -> i64 {
|
||||
let base = if front != PTS_UNSET && front != self.last_front_pts {
|
||||
// New PES (or the first AU): trust its own timestamp — no drift.
|
||||
front
|
||||
} else if self.next_pts_ns != PTS_UNSET {
|
||||
// Same PES as the previous AU (front unchanged) → advance one frame.
|
||||
self.next_pts_ns
|
||||
} else if front != PTS_UNSET {
|
||||
front
|
||||
} else {
|
||||
0
|
||||
};
|
||||
self.last_front_pts = front;
|
||||
self.next_pts_ns = base + dur_ns;
|
||||
base
|
||||
}
|
||||
|
||||
/// Drop `n` bytes from the front of `buf` and rebase the PTS markers so
|
||||
/// their offsets stay relative to the new buffer start. A marker that now
|
||||
/// sits at or before offset 0 is clamped to 0 (it still covers the front).
|
||||
@@ -137,6 +180,10 @@ impl CodecParser for DtsParser {
|
||||
self.buf.clear();
|
||||
self.pts_marks.clear();
|
||||
self.pending_pts = PTS_UNSET;
|
||||
// A concealed gap is a timeline discontinuity: let the post-gap AU
|
||||
// re-base to its own PES PTS rather than the pre-gap cursor.
|
||||
self.next_pts_ns = PTS_UNSET;
|
||||
self.last_front_pts = PTS_UNSET;
|
||||
}
|
||||
if pes.data.is_empty() {
|
||||
return Vec::new();
|
||||
@@ -267,10 +314,13 @@ impl CodecParser for DtsParser {
|
||||
};
|
||||
|
||||
let au: Vec<u8> = self.buf[..au_end].to_vec();
|
||||
// The AU takes the PTS of the PES covering its first byte — its own
|
||||
// core's PES, even if that PES preceded the one(s) carrying its
|
||||
// extensions or the next core.
|
||||
let au_pts = self.front_pts();
|
||||
// The AU's own core PES PTS (the PES covering its first byte, even if
|
||||
// that PES preceded the one(s) carrying its extensions or the next
|
||||
// core), stamped monotonically: honored when it advances past the
|
||||
// running clock (UHD one-AU-per-PES), but never allowed to collide
|
||||
// with the previous AU when several cores share ONE PES (DVD).
|
||||
let dur_ns = dts_core_duration_ns(&au) as i64;
|
||||
let au_pts = self.stamp_pts(self.front_pts(), dur_ns);
|
||||
frames.push(Frame {
|
||||
discontinuity: false,
|
||||
coding: None,
|
||||
@@ -278,7 +328,7 @@ impl CodecParser for DtsParser {
|
||||
pts_ns: au_pts,
|
||||
keyframe: true,
|
||||
data: au,
|
||||
duration_ns: None,
|
||||
duration_ns: Some(dur_ns as u64),
|
||||
});
|
||||
self.drain_front(au_end);
|
||||
// After draining, the marker covering the new front (if any) carries
|
||||
@@ -324,9 +374,9 @@ impl CodecParser for DtsParser {
|
||||
}
|
||||
// The final AU's PTS is the PES covering the buffer front (its core's
|
||||
// PES). Fall back to pending_pts, clamping the sentinel to 0.
|
||||
let front = self.front_pts();
|
||||
let pts_ns = if front == PTS_UNSET { 0 } else { front };
|
||||
let au = std::mem::take(&mut self.buf);
|
||||
let dur_ns = dts_core_duration_ns(&au) as i64;
|
||||
let pts_ns = self.stamp_pts(self.front_pts(), dur_ns);
|
||||
self.pts_marks.clear();
|
||||
vec![Frame {
|
||||
discontinuity: false,
|
||||
@@ -335,7 +385,7 @@ impl CodecParser for DtsParser {
|
||||
pts_ns,
|
||||
keyframe: true,
|
||||
data: au,
|
||||
duration_ns: None,
|
||||
duration_ns: Some(dur_ns as u64),
|
||||
}]
|
||||
}
|
||||
|
||||
@@ -501,6 +551,49 @@ fn dts_core_frame_size(data: &[u8]) -> usize {
|
||||
fsize + 1
|
||||
}
|
||||
|
||||
/// DTS core `SFREQ` → sample rate (Hz). 4-bit index; reserved/invalid entries
|
||||
/// fall back to 48 kHz (the DVD/UHD norm) so a bogus value never yields a zero
|
||||
/// rate (division) or a wildly wrong frame duration.
|
||||
const DTS_CORE_SAMPLE_RATES: [u32; 16] = [
|
||||
48_000, // 0: invalid → fallback
|
||||
8_000, 16_000, 32_000, 48_000, // 4: invalid → fallback
|
||||
48_000, // 5: invalid → fallback
|
||||
11_025, 22_050, 44_100, 48_000, // 9: invalid → fallback
|
||||
48_000, // 10: invalid → fallback
|
||||
12_000, 24_000, 48_000, 96_000, 192_000,
|
||||
];
|
||||
|
||||
/// Samples in one DTS core frame: `(NBLKS + 1) * 32`. `NBLKS` (7 bits) is the
|
||||
/// core-header PCM-sample-block count — the same field ffmpeg's `dca` decoder
|
||||
/// uses to timestamp frames. Bit layout after the 32-bit sync: FTYPE(1) SHORT(5)
|
||||
/// CPF(1) **NBLKS(7)** FSIZE(14) …, so NBLKS = byte4 bit0 + byte5 bits7-2.
|
||||
fn dts_core_samples(data: &[u8]) -> u32 {
|
||||
if data.len() < CORE_HEADER_MIN_BYTES {
|
||||
return 512; // typical; only reached on a truncated header
|
||||
}
|
||||
let nblks = ((data[4] as u32 & 0x01) << 6) | (data[5] as u32 >> 2);
|
||||
(nblks + 1) * 32
|
||||
}
|
||||
|
||||
/// DTS core sample rate (Hz) from `SFREQ` (4 bits: byte8 bits5-2), with a 48 kHz
|
||||
/// fallback for reserved indices.
|
||||
fn dts_core_sample_rate(data: &[u8]) -> u32 {
|
||||
if data.len() < CORE_HEADER_MIN_BYTES {
|
||||
return 48_000;
|
||||
}
|
||||
let sfreq = (data[8] as usize >> 2) & 0x0F;
|
||||
DTS_CORE_SAMPLE_RATES[sfreq]
|
||||
}
|
||||
|
||||
/// Duration of one DTS core access unit in nanoseconds: `samples / rate`,
|
||||
/// rounded to nearest. This is what lets consecutive core frames packed in a
|
||||
/// single DVD PES advance monotonically instead of colliding on one PES PTS.
|
||||
fn dts_core_duration_ns(data: &[u8]) -> u64 {
|
||||
let samples = dts_core_samples(data) as u64;
|
||||
let rate = dts_core_sample_rate(data) as u64;
|
||||
(samples * 1_000_000_000 + rate / 2) / rate
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -521,12 +614,22 @@ mod tests {
|
||||
let fsize = size - 1;
|
||||
let mut data = vec![0u8; size];
|
||||
data[0..4].copy_from_slice(&DTS_CORE_SYNC);
|
||||
data[5] = (data[5] & 0xFC) | ((fsize >> 12) & 0x03) as u8;
|
||||
// NBLKS = 15 → (15+1)*32 = 512 samples/frame (the DVD/UHD DTS-core norm).
|
||||
// NBLKS is byte4 bit0 + byte5 bits7-2; here byte4 bit0 = 0, byte5 = 15<<2.
|
||||
data[5] = (15u8 << 2) | ((fsize >> 12) & 0x03) as u8;
|
||||
data[6] = ((fsize >> 4) & 0xFF) as u8;
|
||||
data[7] = (data[7] & 0x0F) | (((fsize & 0x0F) << 4) as u8);
|
||||
// SFREQ = 13 → 48 kHz (byte8 bits5-2). Only when the header byte exists.
|
||||
if size > 8 {
|
||||
data[8] = 13u8 << 2;
|
||||
}
|
||||
data
|
||||
}
|
||||
|
||||
// 512 samples @ 48 kHz, rounded to nearest ns — the duration make_dts_core
|
||||
// frames advance by. (512 * 1e9 + 24000) / 48000 = 10_666_667 ns.
|
||||
const DTS_CORE_DUR_NS: i64 = (512 * 1_000_000_000 + 48_000 / 2) / 48_000;
|
||||
|
||||
/// A real DTS-HD EXSS substream of `total` bytes (short header form), with an
|
||||
/// optional false DTS core syncword embedded in its payload (decoding to a
|
||||
/// plausible core size) — to prove precise sizing, not a payload scan, bounds
|
||||
@@ -657,10 +760,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_cores_back_to_back_emit_first_on_boundary() {
|
||||
// The first complete unit is emitted as soon as the next core sync is
|
||||
// seen; the second is held until flush. Both cores arrived in ONE PES,
|
||||
// so both legitimately carry that PES's PTS.
|
||||
fn two_cores_back_to_back_advance_within_one_pes() {
|
||||
// Both cores arrive in ONE PES — the DVD layout. AU1 keeps the PES PTS;
|
||||
// AU2 must ADVANCE by one frame duration to stay monotonic. Reusing the
|
||||
// single PES PTS for both was the "non monotonically increasing dts to
|
||||
// muxer" bug (fixed for 1.2.1).
|
||||
let mut parser = DtsParser::new();
|
||||
let mut stream = make_dts_core(512);
|
||||
stream.extend_from_slice(&make_dts_core(640));
|
||||
@@ -668,13 +772,18 @@ mod tests {
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].data.len(), 512);
|
||||
assert_eq!(f[0].pts_ns, pts_to_ns(90000), "AU1 keeps its PES PTS");
|
||||
assert_eq!(
|
||||
f[0].duration_ns,
|
||||
Some(DTS_CORE_DUR_NS as u64),
|
||||
"AU1 carries a real frame duration (was None)"
|
||||
);
|
||||
let tail = parser.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].data.len(), 640);
|
||||
assert_eq!(
|
||||
tail[0].pts_ns,
|
||||
pts_to_ns(90000),
|
||||
"AU2 came in the same PES → same PTS"
|
||||
pts_to_ns(90000) + DTS_CORE_DUR_NS,
|
||||
"AU2 in the same PES advances one frame duration (monotonic)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -689,31 +798,33 @@ mod tests {
|
||||
let mut parser = DtsParser::new();
|
||||
|
||||
// PES A: just core1 (held — no following core yet).
|
||||
let f0 = parser.parse(&make_pes(make_dts_core(512), Some(100)));
|
||||
let f0 = parser.parse(&make_pes(make_dts_core(512), Some(90000)));
|
||||
assert!(f0.is_empty(), "core1 held awaiting next core");
|
||||
|
||||
// PES B: core2 + core3. Closes AU1 (core1) and AU2 (core2).
|
||||
// PES B (realistically LATER — far past one frame): core2 + core3.
|
||||
// Closes AU1 (core1) and AU2 (core2).
|
||||
let mut pes_b = make_dts_core(600);
|
||||
pes_b.extend_from_slice(&make_dts_core(640));
|
||||
let f = parser.parse(&make_pes(pes_b, Some(200)));
|
||||
let f = parser.parse(&make_pes(pes_b, Some(190000)));
|
||||
assert_eq!(f.len(), 2, "AU1 and AU2 both close in this call");
|
||||
assert_eq!(f[0].data.len(), 512, "AU1 = core1");
|
||||
assert_eq!(
|
||||
f[0].pts_ns,
|
||||
pts_to_ns(100),
|
||||
pts_to_ns(90000),
|
||||
"AU1 keeps PES A's PTS, not the later PES B PTS"
|
||||
);
|
||||
assert_eq!(f[1].data.len(), 600, "AU2 = core2");
|
||||
assert_eq!(
|
||||
f[1].pts_ns,
|
||||
pts_to_ns(200),
|
||||
"AU2's core arrived in PES B → PES B PTS"
|
||||
pts_to_ns(190000),
|
||||
"AU2's core is in PES B → attributes to PES B PTS (ahead of the clock, so it wins)"
|
||||
);
|
||||
|
||||
// AU3 (core3) drains on flush, also PES B PTS.
|
||||
// AU3 (core3) drains on flush — 2nd core in PES B, so it advances one
|
||||
// frame duration from AU2 to stay monotonic.
|
||||
let tail = parser.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].pts_ns, pts_to_ns(200));
|
||||
assert_eq!(tail[0].pts_ns, pts_to_ns(190000) + DTS_CORE_DUR_NS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -728,23 +839,118 @@ mod tests {
|
||||
// AU2 (core2) held awaiting a third core.
|
||||
let mut pes_a = make_dts_core(512);
|
||||
pes_a.extend_from_slice(&make_dts_core(600));
|
||||
let f = parser.parse(&make_pes(pes_a, Some(100)));
|
||||
let f = parser.parse(&make_pes(pes_a, Some(90000)));
|
||||
assert_eq!(f.len(), 1);
|
||||
assert_eq!(f[0].pts_ns, pts_to_ns(100), "AU1 PES A PTS");
|
||||
assert_eq!(f[0].pts_ns, pts_to_ns(90000), "AU1 PES A PTS");
|
||||
|
||||
// PES B: core3 — closes AU2 (core2). AU2's core was in PES A.
|
||||
let f2 = parser.parse(&make_pes(make_dts_core(640), Some(200)));
|
||||
// PES B (realistically later): core3 — closes AU2 (core2). AU2's core
|
||||
// was in PES A, so it is PES A's 2nd frame: it advances one frame
|
||||
// duration from AU1 (still on PES A's timeline, and monotonic) — it does
|
||||
// NOT inherit the closing PES B PTS.
|
||||
let f2 = parser.parse(&make_pes(make_dts_core(640), Some(190000)));
|
||||
assert_eq!(f2.len(), 1);
|
||||
assert_eq!(f2[0].data.len(), 600, "AU2 = core2");
|
||||
assert_eq!(
|
||||
f2[0].pts_ns,
|
||||
pts_to_ns(100),
|
||||
"AU2's core arrived in PES A → must keep PES A's PTS, not PES B's"
|
||||
pts_to_ns(90000) + DTS_CORE_DUR_NS,
|
||||
"AU2 = 2nd frame of PES A → PES A base + one frame, not the closing PES B PTS"
|
||||
);
|
||||
|
||||
// AU3 = core3, whose own core is in PES B → jumps to PES B's PTS.
|
||||
let tail = parser.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(tail[0].pts_ns, pts_to_ns(200), "AU3 = core3, PES B PTS");
|
||||
assert_eq!(
|
||||
tail[0].pts_ns,
|
||||
pts_to_ns(190000),
|
||||
"AU3 = core3 in PES B → PES B PTS"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dvd_many_cores_one_pes_are_strictly_monotonic() {
|
||||
// Punisher-DVD reproduction: a single PES carrying SEVERAL DTS core
|
||||
// frames (the DVD packing) must emit STRICTLY-increasing PTSs. The old
|
||||
// code stamped every AU with the one PES PTS, which ffmpeg rejected as
|
||||
// "non monotonically increasing dts to muxer: X >= X".
|
||||
let mut parser = DtsParser::new();
|
||||
let mut stream = Vec::new();
|
||||
for _ in 0..6 {
|
||||
stream.extend_from_slice(&make_dts_core(512));
|
||||
}
|
||||
let mut frames = parser.parse(&make_pes(stream, Some(90000)));
|
||||
frames.extend(parser.flush());
|
||||
assert_eq!(frames.len(), 6, "all six cores emitted");
|
||||
for w in frames.windows(2) {
|
||||
assert!(
|
||||
w[1].pts_ns > w[0].pts_ns,
|
||||
"consecutive DTS AUs must STRICTLY increase: {} !> {}",
|
||||
w[1].pts_ns,
|
||||
w[0].pts_ns
|
||||
);
|
||||
}
|
||||
// Each advances by exactly one frame duration, and carries that duration.
|
||||
assert_eq!(frames[0].pts_ns, pts_to_ns(90000));
|
||||
assert_eq!(frames[1].pts_ns, pts_to_ns(90000) + DTS_CORE_DUR_NS);
|
||||
assert_eq!(frames[5].pts_ns, pts_to_ns(90000) + 5 * DTS_CORE_DUR_NS);
|
||||
for f in &frames {
|
||||
assert_eq!(f.duration_ns, Some(DTS_CORE_DUR_NS as u64));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dts_core_duration_512_samples_48khz() {
|
||||
// NBLKS=15 → (15+1)*32 = 512 samples; SFREQ=13 → 48 kHz.
|
||||
let core = make_dts_core(512);
|
||||
assert_eq!(dts_core_samples(&core), 512);
|
||||
assert_eq!(dts_core_sample_rate(&core), 48_000);
|
||||
assert_eq!(dts_core_duration_ns(&core), DTS_CORE_DUR_NS as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dts_core_sfreq_reserved_falls_back_to_48k() {
|
||||
// A bogus SFREQ index must never yield a zero rate (division) — fall
|
||||
// back to 48 kHz.
|
||||
let mut core = make_dts_core(512);
|
||||
core[8] = 0; // SFREQ = 0 (reserved)
|
||||
assert_eq!(dts_core_sample_rate(&core), 48_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_pes_rebases_to_its_own_pts_no_drift() {
|
||||
// Regression for the drift bug: a global running clock overshot a
|
||||
// feature-long DTS track by minutes (2h44 for a 2h03 film). When a NEW
|
||||
// PES arrives whose PTS is BEHIND where accumulated frame durations
|
||||
// would put a running clock, the AU must re-base to that PES's OWN
|
||||
// timestamp — tracking the container, not drifting ahead of it.
|
||||
let mut parser = DtsParser::new();
|
||||
// PES A: core1 + core2 (2 frames), pts 90000.
|
||||
let mut pes_a = make_dts_core(512);
|
||||
pes_a.extend_from_slice(&make_dts_core(600));
|
||||
let f = parser.parse(&make_pes(pes_a, Some(90000)));
|
||||
assert_eq!(f.len(), 1, "AU1 (core1) emits on the core2 boundary");
|
||||
assert_eq!(f[0].pts_ns, pts_to_ns(90000), "AU1 = PES A base");
|
||||
// PES B: core3, pts only 500 ticks after PES A — LESS than one frame
|
||||
// (960 ticks @ 48 kHz). AU2 (core2, still PES A) advances within PES A;
|
||||
// AU3 (core3, PES B) must RE-BASE to PES B's own PTS.
|
||||
let f2 = parser.parse(&make_pes(make_dts_core(640), Some(90500)));
|
||||
assert_eq!(f2.len(), 1, "AU2 (core2) closes on core3");
|
||||
assert_eq!(
|
||||
f2[0].pts_ns,
|
||||
pts_to_ns(90000) + DTS_CORE_DUR_NS,
|
||||
"AU2 = 2nd frame of PES A → advances one frame within PES A"
|
||||
);
|
||||
let tail = parser.flush();
|
||||
assert_eq!(tail.len(), 1);
|
||||
assert_eq!(
|
||||
tail[0].pts_ns,
|
||||
pts_to_ns(90500),
|
||||
"AU3 = core3 in PES B → re-bases to PES B's PTS"
|
||||
);
|
||||
assert_ne!(
|
||||
tail[0].pts_ns,
|
||||
pts_to_ns(90000) + 2 * DTS_CORE_DUR_NS,
|
||||
"must NOT carry the accumulated running clock across a PES (drift)"
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a minimal DTS-HD extension substream of `size` bytes (just the
|
||||
|
||||
Reference in New Issue
Block a user