Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
125a8e5bf0 | ||
|
|
741c1ea11d | ||
|
|
e713b26b87 | ||
|
|
fcbd667add | ||
|
|
88c03152e2 | ||
|
|
c8e7ad5e56 | ||
|
|
f122f08628 | ||
|
|
b2c3540989 | ||
|
|
3a4307def6 | ||
|
|
917026d566 |
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user