Compare commits

...
10 Commits
Author SHA1 Message Date
matthew 125a8e5bf0 aacs: full-ladder resolve_candidate + correct mk_from_dk
leak-guard / leak-guard (push) Successful in 24s
resolve_candidate(candidate, mkb, unit_key_ro, vid) -> ResolvedChain: one
composed, PURE-DERIVATION boil-down for a candidate key at any ladder rung
(KeyCandidate::{Uk,Vuk,Mk,Pk,Dk}, each carrying the module's newtype). Walks
DK/PK/MK/VUK -> terminal unit keys, parsing Unit_Key_RO.inf at the version
the disc's MKB declares, and returns every CPS unit key as (cps_unit, key)
(matching ResolvedKeys) plus the intermediate chain. No sampling/validation
(that's the caller's unit_key_validates) and no position recovery (Dk is a
positioned DeviceKey; recover_dk_position first). Adds ProcessingKey newtype.
Consumers stop re-composing the ladder; every client hardens one impl.

Correct mk_from_dk: real Subset-Difference walk (derive_media_key_and_pk_from_dk)
instead of the Media-Key-Variant path, which needed integrator KCD absent
in-tree and Err'd for every real disc -- dead for both consumers. Drops the
now-unused vid arg.
2026-07-01 22:34:52 -07:00
Matthew Jackson 741c1ea11d restore freemkv-unlock path dep for local dev (post-v1.2.1) 2026-07-01 19:08:56 -07:00
Matthew Jackson e713b26b87 v1.2.1: bump version (freemkv-unlock git-pinned for the tag) 2026-07-01 19:08:53 -07:00
Matthew Jackson fcbd667add changelog: describe the shipped re-base DTS fix + faithful-passthrough note 2026-07-01 19:02:04 -07:00
Matthew Jackson 88c03152e2 mux/dts: re-base PTS per PES instead of a running clock (fix long-title drift)
The first cut used a global running clock (max(next, own-PES PTS) + advance),
which fixed the same-PES collision but DRIFTED: once accumulated frame
durations exceeded the PES-timestamp spacing, it never re-based, so a
feature-long DVD DTS track ran minutes past its real length (2h44 for a 2h03
film) while AC-3 from the same source stayed exact.

Match the AC-3 path: re-base to each PES's own container timestamp, and advance
by one frame duration ONLY within a run of AUs sharing one PES. Fixes the DVD
multi-frame-per-PES collision without drift; the UHD DTS-HD MA per-PES
attribution (da85f56) is preserved (each AU still takes its own core PES's PTS).
Adds new_pes_rebases_to_its_own_pts_no_drift; full mux suite green (905).
2026-07-01 18:21:25 -07:00
Matthew Jackson c8e7ad5e56 v1.2.1: bump version + changelog (DVD DTS timestamp fix) 2026-07-01 17:30:25 -07:00
Matthew Jackson f122f08628 mux/dts: monotonic per-frame PTS + real frame duration (fixes DVD DTS)
DVD packs several DTS core frames into one PES; the parser stamped every
access unit with that single PES PTS and duration_ns=None, so consecutive
frames collided on one timestamp — ffmpeg rejected the output as 'non
monotonically increasing dts to muxer: X >= X' (deep-decode = corrupt,
e.g. The Punisher). The UHD DTS-HD MA path (one AU per PES, distinct PTS)
was unaffected, which is why this only surfaced on DVD.

Parse the DTS core header for samples ((NBLKS+1)*32) and sample rate
(SFREQ, 48kHz fallback) to derive each AU's duration, and stamp a running
monotonic PTS: max(next_clock, own-core-PES PTS), then advance by the
frame duration. A later PES whose PTS is ahead of the clock still wins
(preserves the UHD per-PES attribution from da85f56/c49a180); frames
sharing one PES advance frame-by-frame instead of colliding.

Tests: the 3 that encoded 'same PES -> same PTS' now assert monotonic
advance; new dvd_many_cores_one_pes_are_strictly_monotonic reproduces the
Punisher bug; duration/SFREQ-fallback unit tests added.
2026-07-01 17:17:35 -07:00
Matthew Jackson b2c3540989 restore freemkv-unlock path dep for local dev (post-v1.2.0) 2026-07-01 16:53:06 -07:00
Matthew Jackson 3a4307def6 v1.2.0: bump version (freemkv-unlock git-pinned for the tag) 2026-07-01 16:53:03 -07:00
Matthew Jackson 917026d566 changelog: DVD DTS/LPCM silent-audio fix + Pass-N recovery specialists + flat scheduler 2026-07-01 16:50:08 -07:00
6 changed files with 535 additions and 90 deletions
+44
View File
@@ -1,5 +1,25 @@
# Changelog # Changelog
## [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 ## [1.2.0] — 2026-07-01
### Breaking ### Breaking
@@ -32,6 +52,22 @@ consumers are the in-tree toolchain crates.
### Added ### Added
- **Pass-N marginal-sector recovery specialists.** The patch pass gained a
roster of parameterized recovery techniques — read speed (max/min), cache
bypass (FUA), and traversal (linear fwd/rev, bisect, cache-prime, oscillate,
per-sector speed-sweep) — each targeting a distinct physical failure mode of
marginal media. A per-rip **decayed (EWMA) scorecard** grades every technique
by its recent recovery rate and re-orders them best-first, so the engine
hardcodes no conclusion: a technique that fits *this* disc floats to the front
and one that doesn't self-deprioritises (but is never dropped). Every read is
wedge-safe and deadline-bounded; the existing fast/deep recovery behavior is
unchanged (the specialists are additive, tried only on the hardened residue).
- **Opt-in flat-pool recovery scheduler (`FREEMKV_PATCH_FLAT`).** Collapses the
breadth-first recovery tiers into one flat pool so every technique gets a shot
at each bad range immediately, scorecard-ordered — a data-driven bandit for a
hardened residual (e.g. a late resume) where the tiered ladder would spend a
long time on cheap techniques before reaching the specialists. Unset keeps the
proven tier ladder as the default.
- **`PassProgress` is the complete, mapfile-free progress contract.** Every - **`PassProgress` is the complete, mapfile-free progress contract.** Every
emission now carries the fully-rendered "where is the damage" drilldown emission now carries the fully-rendered "where is the damage" drilldown
(`located`): the bad ranges annotated with chapter + movie-time offset, the (`located`): the bad ranges annotated with chapter + movie-time offset, the
@@ -115,6 +151,14 @@ consumers are the in-tree toolchain crates.
### Fixed ### Fixed
- **DVD DTS/LPCM audio tracks no longer mux silent.** On DVD-Video the
`private_stream_1` sub-stream id's low nibble is the audio-stream *number*
(shared across codecs), not a per-codec ordinal. A DTS or LPCM track that
wasn't the disc's first audio stream got a sub-id one too low, so the demux
routing key (`0xBD00 | sub_id`) never matched and every packet was dropped —
the track appeared in the container but played silent (AC-3 at position 0
worked by coincidence). Audio sub-stream ids are now assigned by positional
stream number, so a DTS 5.0 track after an AC-3 5.1 track routes correctly.
- **ISO mux no longer drops real video at content-fragment tails.** A title's - **ISO mux no longer drops real video at content-fragment tails.** A title's
encrypted content can end mid-AACS-unit, with the disc zero-padding the rest encrypted content can end mid-AACS-unit, with the disc zero-padding the rest
of the 6144-byte aligned unit to the next fragment. The decrypt-verify of the 6144-byte aligned unit to the next fragment. The decrypt-verify
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "libfreemkv" name = "libfreemkv"
version = "1.2.0" version = "1.2.1"
edition = "2024" edition = "2024"
rust-version = "1.86" rust-version = "1.86"
license = "AGPL-3.0-only" license = "AGPL-3.0-only"
+236 -44
View File
@@ -20,14 +20,16 @@
//! uk_from_vuk(Vuk, enc_title_keys) → [UnitKey] (decrypt_unit_key each) //! 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: //! `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 //! both via the MKB's Subset-Difference cvalue tables: the device-key path
//! processing-key path walks the MKB's Subset-Difference cvalue tables. Neither //! recovers its Processing Key at the matching SD node and walks on to the MK;
//! needs a VID (the VID enters at `vuk_from_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::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. /// Volume ID (16 bytes) — read from the disc via the SCSI handshake / OEM path.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -42,6 +44,12 @@ pub struct MediaKey(pub [u8; 16]);
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Vuk(pub [u8; 16]); 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. /// One decrypted per-CPS-unit AACS title key.
/// ///
/// `idx` is the POSITIONAL index of the encrypted title key within the slice /// `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() .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 /// Wraps [`derive_media_key_and_pk_from_dk`] — the real SD walk the resolver
/// `resolve_keys_v21` path 1 passes: the placeholder Key Correction Data and the /// runs: each positioned device key is placed against the MKB's subset-diff /
/// disc Volume ID. Returns the FIRST tuple element `Km` (the Media Key) — the /// cvalue records, recovering its Processing Key at the matching node and
/// resolver treats `Km` as the media key and derives the VUK from it as /// continuing to the Media Key. Reachable for real discs whenever a device key
/// `Kvu = AES-G(Km, VID)`, which equals [`vuk_from_mk`]`(MediaKey(km), vid)`. The /// applies to the MKB. No VID is involved here — it enters at [`vuk_from_mk`].
/// variant fn's second element is that already-derived `Kvu`; returning `Km`
/// keeps this primitive at the "media key" level so the chain composes.
/// ///
/// Because the integrator KCD is unavailable in-tree (the placeholder is /// Returns [`Error::AacsMkUnavailable`] (E7018) when no supplied device key
/// rejected by the variant chain), this returns `Err` for every real disc today /// resolves the MKB — the same terminal error as [`mk_from_pk`]; no numeric
/// — byte-for-byte identical to `resolve_keys_v21` path 1, which the resolver /// distinction is load-bearing at this boundary.
/// also leaves unreachable in production. All variant-chain failures collapse to ///
/// [`Error::AacsMkUnavailable`] (E7018): no numeric distinction is load-bearing /// [`Error::AacsMkUnavailable`]: crate::error::Error::AacsMkUnavailable
/// at this boundary, and the variant error carries no English to preserve. pub fn mk_from_dk(device_keys: &[DeviceKey], mkb: &[u8]) -> Result<MediaKey, crate::error::Error> {
pub fn mk_from_dk( // Positioned device keys drive the real Subset-Difference MKB walk
device_keys: &[DeviceKey], // ([`derive_media_key_and_pk_from_dk`], the same walk the resolver runs). The
mkb: &[u8], // old Media-Key-Variant path needed integrator Key Correction Data absent
vid: Vid, // in-tree, so it Err'd for EVERY real disc (dead for both consumers —
) -> Result<MediaKey, crate::error::Error> { // freemkv-keysources' DK fallback and the kdb harvester). No VID is needed
let records = walk_mkb(mkb); // for the Media Key; it enters only at [`vuk_from_mk`].
match derive_media_key_variant( match derive_media_key_and_pk_from_dk(mkb, device_keys) {
&records, Some((km, _pk)) => Ok(MediaKey(km)),
device_keys, None => Err(crate::error::Error::AacsMkUnavailable),
&KEY_CORRECTION_DATA_PLACEHOLDER,
&vid.0,
) {
Ok((km, _kvu)) => Ok(MediaKey(km)),
Err(_) => 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -205,27 +335,28 @@ mod tests {
assert!(uk_from_vuk(Vuk([0u8; 16]), &[]).is_empty()); assert!(uk_from_vuk(Vuk([0u8; 16]), &[]).is_empty());
} }
/// `mk_from_dk` returns `Err(AacsMkUnavailable)` for the placeholder-KCD /// `mk_from_dk` returns `Err(AacsMkUnavailable)` when the MKB has no
/// path that production also leaves unreachable — never a wrong key, never a /// processable Subset-Difference tables (empty MKB, or one with no
/// panic — on both an empty MKB and a non-variant MKB. /// mk_dv/cvalues/subdiff records) — never a wrong key, never a panic.
#[test] #[test]
fn mk_from_dk_errors_without_integrator_kcd() { fn mk_from_dk_errors_on_unprocessable_mkb() {
let dk = DeviceKey { let dk = DeviceKey {
key: [0x11; 16], key: [0x11; 16],
node: 1, node: 1,
uv: 1, uv: 1,
u_mask_shift: 0, u_mask_shift: 0,
}; };
// Empty MKB → not a variant MKB → Err. // Empty MKB → no SD records to walk → Err.
let e = mk_from_dk(std::slice::from_ref(&dk), &[], Vid([0x09; 16])); let e = mk_from_dk(std::slice::from_ref(&dk), &[]);
assert!(matches!(e, Err(crate::error::Error::AacsMkUnavailable))); assert!(matches!(e, Err(crate::error::Error::AacsMkUnavailable)));
// A variant-looking MKB (0x82 record) still cannot complete without the // An MKB with no complete Subset-Difference tables (mk_dv / cvalues /
// integrator KCD, so it also errors — never silently yields a key. // subdiff) cannot yield a Media Key, so the real walk also errors —
// never silently yields a key.
let mut mkb: Vec<u8> = Vec::new(); 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]); 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))); assert!(matches!(e2, Err(crate::error::Error::AacsMkUnavailable)));
} }
@@ -305,4 +436,65 @@ mod tests {
Err(crate::error::Error::AacsMkUnavailable) 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");
}
} }
+14 -14
View File
@@ -77,6 +77,18 @@ pub fn decrypt_unit_key(vuk: &[u8; 16], encrypted_uk: &[u8; 16]) -> [u8; 16] {
aes_ecb_decrypt(vuk, encrypted_uk) 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 ───────────────────────────────────────────────── // ── Unit_Key_RO.inf parsing ─────────────────────────────────────────────────
/// Parsed Unit_Key_RO.inf file. /// Parsed Unit_Key_RO.inf file.
@@ -1203,13 +1215,7 @@ pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
.unwrap_or(false); .unwrap_or(false);
let has_vid = *ctx.volume_id != [0u8; 16]; let has_vid = *ctx.volume_id != [0u8; 16];
let derive_uks = |vuk: &[u8; 16]| -> Vec<(u32, [u8; 16])> { let derive_uks = |vuk: &[u8; 16]| derive_unit_keys(&uk_file, vuk);
uk_file
.encrypted_keys
.iter()
.map(|(num, enc_key)| (*num, decrypt_unit_key(vuk, enc_key)))
.collect()
};
let build = let build =
|vuk: Option<[u8; 16]>, unit_keys: Vec<(u32, [u8; 16])>, key_source: u8| -> ResolvedKeys { |vuk: Option<[u8; 16]>, unit_keys: Vec<(u32, [u8; 16])>, key_source: u8| -> ResolvedKeys {
@@ -1331,13 +1337,7 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
let has_vid = *ctx.volume_id != [0u8; 16]; let has_vid = *ctx.volume_id != [0u8; 16];
// Decrypt the disc's encrypted unit keys with a freshly-derived VUK. // Decrypt the disc's encrypted unit keys with a freshly-derived VUK.
let derive_uks = |vuk: &[u8; 16]| -> Vec<(u32, [u8; 16])> { let derive_uks = |vuk: &[u8; 16]| derive_unit_keys(&uk_file, vuk);
uk_file
.encrypted_keys
.iter()
.map(|(num, enc_key)| (*num, decrypt_unit_key(vuk, enc_key)))
.collect()
};
// Common result constructor — paths 1-4 supply Some(VUK) + derived // Common result constructor — paths 1-4 supply Some(VUK) + derived
// unit keys; path 5 supplies None + pre-decrypted unit keys from // unit keys; path 5 supplies None + pre-decrypted unit keys from
+4 -1
View File
@@ -36,7 +36,10 @@ pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer"; pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
// Boil-down derivation primitives (thin newtypes + wrappers over the crypto). // 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. // Structured, English-free resolution trace.
pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, UnlockStep}; pub use trace::{KeyNode, KeyOutcome, KeyStep, ResolutionTrace, UnlockOutcome, UnlockStep};
+236 -30
View File
@@ -38,6 +38,17 @@ pub struct DtsParser {
/// current `buf` start and rebased whenever bytes are drained from the /// current `buf` start and rebased whenever bytes are drained from the
/// front. /// front.
pts_marks: Vec<(usize, i64)>, 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 { impl Default for DtsParser {
@@ -52,9 +63,41 @@ impl DtsParser {
buf: Vec::with_capacity(32768), buf: Vec::with_capacity(32768),
pending_pts: 0, pending_pts: 0,
pts_marks: Vec::new(), 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 /// 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 /// 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). /// 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.buf.clear();
self.pts_marks.clear(); self.pts_marks.clear();
self.pending_pts = PTS_UNSET; 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() { if pes.data.is_empty() {
return Vec::new(); return Vec::new();
@@ -267,10 +314,13 @@ impl CodecParser for DtsParser {
}; };
let au: Vec<u8> = self.buf[..au_end].to_vec(); let au: Vec<u8> = self.buf[..au_end].to_vec();
// The AU takes the PTS of the PES covering its first byte — its own // The AU's own core PES PTS (the PES covering its first byte, even if
// core's PES, even if that PES preceded the one(s) carrying its // that PES preceded the one(s) carrying its extensions or the next
// extensions or the next core. // core), stamped monotonically: honored when it advances past the
let au_pts = self.front_pts(); // 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 { frames.push(Frame {
discontinuity: false, discontinuity: false,
coding: None, coding: None,
@@ -278,7 +328,7 @@ impl CodecParser for DtsParser {
pts_ns: au_pts, pts_ns: au_pts,
keyframe: true, keyframe: true,
data: au, data: au,
duration_ns: None, duration_ns: Some(dur_ns as u64),
}); });
self.drain_front(au_end); self.drain_front(au_end);
// After draining, the marker covering the new front (if any) carries // 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 // 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. // 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 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(); self.pts_marks.clear();
vec![Frame { vec![Frame {
discontinuity: false, discontinuity: false,
@@ -335,7 +385,7 @@ impl CodecParser for DtsParser {
pts_ns, pts_ns,
keyframe: true, keyframe: true,
data: au, 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 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -521,12 +614,22 @@ mod tests {
let fsize = size - 1; let fsize = size - 1;
let mut data = vec![0u8; size]; let mut data = vec![0u8; size];
data[0..4].copy_from_slice(&DTS_CORE_SYNC); 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[6] = ((fsize >> 4) & 0xFF) as u8;
data[7] = (data[7] & 0x0F) | (((fsize & 0x0F) << 4) 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 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 /// 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 /// optional false DTS core syncword embedded in its payload (decoding to a
/// plausible core size) — to prove precise sizing, not a payload scan, bounds /// plausible core size) — to prove precise sizing, not a payload scan, bounds
@@ -657,10 +760,11 @@ mod tests {
} }
#[test] #[test]
fn two_cores_back_to_back_emit_first_on_boundary() { fn two_cores_back_to_back_advance_within_one_pes() {
// The first complete unit is emitted as soon as the next core sync is // Both cores arrive in ONE PES — the DVD layout. AU1 keeps the PES PTS;
// seen; the second is held until flush. Both cores arrived in ONE PES, // AU2 must ADVANCE by one frame duration to stay monotonic. Reusing the
// so both legitimately carry that PES's PTS. // 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 parser = DtsParser::new();
let mut stream = make_dts_core(512); let mut stream = make_dts_core(512);
stream.extend_from_slice(&make_dts_core(640)); stream.extend_from_slice(&make_dts_core(640));
@@ -668,13 +772,18 @@ mod tests {
assert_eq!(f.len(), 1); assert_eq!(f.len(), 1);
assert_eq!(f[0].data.len(), 512); 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].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(); let tail = parser.flush();
assert_eq!(tail.len(), 1); assert_eq!(tail.len(), 1);
assert_eq!(tail[0].data.len(), 640); assert_eq!(tail[0].data.len(), 640);
assert_eq!( assert_eq!(
tail[0].pts_ns, tail[0].pts_ns,
pts_to_ns(90000), pts_to_ns(90000) + DTS_CORE_DUR_NS,
"AU2 came in the same PES → same PTS" "AU2 in the same PES advances one frame duration (monotonic)"
); );
} }
@@ -689,31 +798,33 @@ mod tests {
let mut parser = DtsParser::new(); let mut parser = DtsParser::new();
// PES A: just core1 (held — no following core yet). // 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"); 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); let mut pes_b = make_dts_core(600);
pes_b.extend_from_slice(&make_dts_core(640)); 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.len(), 2, "AU1 and AU2 both close in this call");
assert_eq!(f[0].data.len(), 512, "AU1 = core1"); assert_eq!(f[0].data.len(), 512, "AU1 = core1");
assert_eq!( assert_eq!(
f[0].pts_ns, f[0].pts_ns,
pts_to_ns(100), pts_to_ns(90000),
"AU1 keeps PES A's PTS, not the later PES B PTS" "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].data.len(), 600, "AU2 = core2");
assert_eq!( assert_eq!(
f[1].pts_ns, f[1].pts_ns,
pts_to_ns(200), pts_to_ns(190000),
"AU2's core arrived in PES B → PES B PTS" "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(); let tail = parser.flush();
assert_eq!(tail.len(), 1); 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] #[test]
@@ -728,23 +839,118 @@ mod tests {
// AU2 (core2) held awaiting a third core. // AU2 (core2) held awaiting a third core.
let mut pes_a = make_dts_core(512); let mut pes_a = make_dts_core(512);
pes_a.extend_from_slice(&make_dts_core(600)); 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.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. // PES B (realistically later): core3 — closes AU2 (core2). AU2's core
let f2 = parser.parse(&make_pes(make_dts_core(640), Some(200))); // 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.len(), 1);
assert_eq!(f2[0].data.len(), 600, "AU2 = core2"); assert_eq!(f2[0].data.len(), 600, "AU2 = core2");
assert_eq!( assert_eq!(
f2[0].pts_ns, f2[0].pts_ns,
pts_to_ns(100), pts_to_ns(90000) + DTS_CORE_DUR_NS,
"AU2's core arrived in PES A → must keep PES A's PTS, not PES B's" "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(); let tail = parser.flush();
assert_eq!(tail.len(), 1); 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 /// Build a minimal DTS-HD extension substream of `size` bytes (just the