wip: rc6 VFR/DVD/CSS base (held for bulletproofing + split)

This commit is contained in:
Matthew Jackson
2026-06-25 18:17:08 -07:00
parent 9b6a48e9d9
commit 8e0797eab0
9 changed files with 653 additions and 305 deletions
+31 -16
View File
@@ -262,7 +262,20 @@ pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
/// Inner body of [`crack_title_key`] — the actual AttackPattern search. Split /// Inner body of [`crack_title_key`] — the actual AttackPattern search. Split
/// out so the public entry point can wall-clock the whole attempt for the /// out so the public entry point can wall-clock the whole attempt for the
/// runaway guard without threading a timer through every return path. /// runaway guard without threading a timer through every return path.
fn crack_title_key_inner(sector: &[u8]) -> Option<[u8; 5]> { /// AttackPattern crib: the predicted 10-byte plaintext at byte 0x80.
///
/// Scans the clear header `sec[0x00..0x80]` (never scrambled) for the longest
/// run that repeats with a cycle length in 2..0x2F. If the run is long enough
/// (`plen > 3` and at least two full cycles), the plaintext at 0x80 is taken to
/// be that periodic run continuing forward. Returns `None` for an unscrambled
/// sector or one with no usable run — such a sector can be neither cracked nor
/// key-validated, only descrambled with an externally-cached key.
///
/// The header is untouched by `descramble_sector`, so the crib is identical
/// before and after descramble: the decrypt path uses it as a per-sector
/// "did the cached key descramble correctly?" oracle (the predicted plaintext
/// must reappear at 0x80), and the cracker uses it as its known plaintext.
pub(crate) fn attack_crib(sector: &[u8]) -> Option<[u8; 10]> {
if sector.len() < SECTOR_SIZE || sector[FLAG_BYTE] & 0x30 == 0 { if sector.len() < SECTOR_SIZE || sector[FLAG_BYTE] & 0x30 == 0 {
return None; return None;
} }
@@ -285,14 +298,6 @@ fn crack_title_key_inner(sector: &[u8]) -> Option<[u8; 5]> {
// Need at least a few repeated bytes and at least one full cycle. // Need at least a few repeated bytes and at least one full cycle.
if best_plen > 3 && best_p > 0 && best_plen / best_p >= 2 { if best_plen > 3 && best_p > 0 && best_plen / best_p >= 2 {
let seed: [u8; 5] = [
sector[SEED_OFFSET],
sector[SEED_OFFSET + 1],
sector[SEED_OFFSET + 2],
sector[SEED_OFFSET + 3],
sector[SEED_OFFSET + 4],
];
// The known plaintext is the periodic run continuing past 0x80. The // The known plaintext is the periodic run continuing past 0x80. The
// crib starts at `0x80 - (best_plen/best_p)*best_p` and continues // crib starts at `0x80 - (best_plen/best_p)*best_p` and continues
// through the encrypted region; the bytes at and after 0x80 are the // through the encrypted region; the bytes at and after 0x80 are the
@@ -300,30 +305,40 @@ fn crack_title_key_inner(sector: &[u8]) -> Option<[u8; 5]> {
let cycles = best_plen / best_p; let cycles = best_plen / best_p;
let plain_start = 0x80 - cycles * best_p; let plain_start = 0x80 - cycles * best_p;
// The cipher is the 10 bytes at 0x80; the crib is their predicted // Each predicted byte is the run sample one or more periods back:
// plaintext. The periodic run (period `best_p`) is known to continue // `sec[plain_start + (i % best_p)]`. For in-run offsets
// through 0x80, so each predicted byte is the run sample one or more
// periods back: `sec[plain_start + (i % best_p)]`. For in-run offsets
// (`plain_start + i < 0x80`) the run is exactly periodic, so this // (`plain_start + i < 0x80`) the run is exactly periodic, so this
// equals `sec[plain_start + i]`; for offsets at/after 0x80 the raw // equals `sec[plain_start + i]`; for offsets at/after 0x80 the raw
// byte is ciphertext, so we MUST wrap within the period rather than // byte is ciphertext, so we MUST wrap within the period rather than
// read it. (Reading `&sec[plain_start..+10]` directly — as before — // read it. (Reading `&sec[plain_start..+10]` directly — as before —
// pulled ciphertext into the crib whenever the run covered fewer than // pulled ciphertext into the crib whenever the run covered fewer than
// 10 bytes before 0x80, producing false-negative key recovery.) // 10 bytes before 0x80, producing false-negative key recovery.)
let crypted = &sector[0x80..0x80 + 10];
let mut plain = [0u8; 10]; let mut plain = [0u8; 10];
for (i, p) in plain.iter_mut().enumerate() { for (i, p) in plain.iter_mut().enumerate() {
*p = sector[plain_start + (i % best_p)]; *p = sector[plain_start + (i % best_p)];
} }
Some(plain)
} else {
None
}
}
fn crack_title_key_inner(sector: &[u8]) -> Option<[u8; 5]> {
let plain = attack_crib(sector)?;
let seed: [u8; 5] = [
sector[SEED_OFFSET],
sector[SEED_OFFSET + 1],
sector[SEED_OFFSET + 2],
sector[SEED_OFFSET + 3],
sector[SEED_OFFSET + 4],
];
let crypted = &sector[0x80..0x80 + 10];
if let Some(key) = recover_title_key_from_plain(crypted, &plain, &seed) { if let Some(key) = recover_title_key_from_plain(crypted, &plain, &seed) {
// Verify against the same predicted plaintext. // Verify against the same predicted plaintext.
if descramble_matches(sector, &key, &plain) { if descramble_matches(sector, &key, &plain) {
return Some(key); return Some(key);
} }
} }
}
None None
} }
+156 -35
View File
@@ -183,7 +183,7 @@ impl DecryptKeys {
/// scrambled unit decrypted. /// scrambled unit decrypted.
pub fn decrypt_sectors( pub fn decrypt_sectors(
buf: &mut [u8], buf: &mut [u8],
keys: &DecryptKeys, keys: &mut DecryptKeys,
unit_key_idx: usize, unit_key_idx: usize,
) -> Result<usize, crate::error::Error> { ) -> Result<usize, crate::error::Error> {
let dropped: usize = match keys { let dropped: usize = match keys {
@@ -351,8 +351,41 @@ pub fn decrypt_sectors(
dropped_bytes.into_inner() dropped_bytes.into_inner()
} }
DecryptKeys::Css { title_key } => { DecryptKeys::Css { title_key } => {
// CSS has no supplied key list: the ONLY source of a title key is
// cracking the data, and the key changes per VTS/VOB region. So
// `title_key` is a CACHE of the last crack, not a fixed disc key —
// applying it blindly across a region boundary descrambles with the
// wrong key (valid headers, garbage payload). Validate it on every
// scrambled sector and re-crack on a miss (libdvdcss's on-demand
// per-region rekey; the same validate-then-rekey shape the AACS arm
// above uses, but re-cracking instead of picking from a list).
//
// The clear header (<0x80) is never scrambled, so its periodic crib
// predicts the plaintext at 0x80. Descramble with the cached key; if
// the crib fails to reappear the key region changed (or the primed
// key was wrong) — restore the ciphertext, re-crack from this very
// sector, and descramble again. A crib-less sector (no periodic run)
// can be neither validated nor cracked, so it rides the cached key —
// correct, because it lives in the same region as the nearby crib
// sector that set the cache.
for chunk in buf.chunks_mut(2048) { for chunk in buf.chunks_mut(2048) {
if chunk.len() < 2048 || !css::is_scrambled(chunk) {
continue;
}
let crib = css::stevenson::attack_crib(chunk);
let original: Option<Vec<u8>> = crib.as_ref().map(|_| chunk.to_vec());
css::lfsr::descramble_sector(title_key, chunk); css::lfsr::descramble_sector(title_key, chunk);
if let (Some(crib), Some(original)) = (crib, original) {
if chunk[0x80..0x80 + 10] != crib[..] {
// Cached key is stale for this region — restore the
// ciphertext and crack this sector's own key.
chunk.copy_from_slice(&original);
if let Some(fresh) = css::stevenson::crack_title_key(chunk) {
*title_key = fresh;
}
css::lfsr::descramble_sector(title_key, chunk);
}
}
} }
0 0
} }
@@ -381,11 +414,11 @@ mod tests {
} }
let snapshot = unit.clone(); let snapshot = unit.clone();
let keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
}; };
decrypt_sectors(&mut unit, &keys, 0).unwrap(); decrypt_sectors(&mut unit, &mut keys, 0).unwrap();
assert_eq!( assert_eq!(
unit, snapshot, unit, snapshot,
"non-m2ts unit must be restored after failed decrypt" "non-m2ts unit must be restored after failed decrypt"
@@ -424,7 +457,7 @@ mod tests {
/// bytes byte-for-byte unchanged — no regression on real discs. /// bytes byte-for-byte unchanged — no regression on real discs.
#[test] #[test]
fn aacs_clear_trailing_partial_is_tolerated_unchanged() { fn aacs_clear_trailing_partial_is_tolerated_unchanged() {
let keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
}; };
@@ -434,7 +467,7 @@ mod tests {
let mut buf = unit; let mut buf = unit;
buf.extend_from_slice(&tail); buf.extend_from_slice(&tail);
decrypt_sectors(&mut buf, &keys, 0).expect("clear trailing partial is Ok"); decrypt_sectors(&mut buf, &mut keys, 0).expect("clear trailing partial is Ok");
assert_eq!( assert_eq!(
&buf[aacs::ALIGNED_UNIT_LEN..], &buf[aacs::ALIGNED_UNIT_LEN..],
@@ -449,7 +482,7 @@ mod tests {
/// corruption, so we must fail loud with `DecryptFailed`. /// corruption, so we must fail loud with `DecryptFailed`.
#[test] #[test]
fn aacs_scrambled_trailing_partial_is_rejected() { fn aacs_scrambled_trailing_partial_is_rejected() {
let keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
}; };
@@ -459,7 +492,7 @@ mod tests {
let mut buf = unit; let mut buf = unit;
buf.extend_from_slice(&tail); buf.extend_from_slice(&tail);
let err = decrypt_sectors(&mut buf, &keys, 0) let err = decrypt_sectors(&mut buf, &mut keys, 0)
.expect_err("scrambled trailing partial must be rejected"); .expect_err("scrambled trailing partial must be rejected");
assert_eq!( assert_eq!(
err.code(), err.code(),
@@ -471,12 +504,12 @@ mod tests {
/// An empty buffer is a valid no-op (zero units), not an error. /// An empty buffer is a valid no-op (zero units), not an error.
#[test] #[test]
fn aacs_empty_buffer_is_ok() { fn aacs_empty_buffer_is_ok() {
let keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
}; };
let mut buf: Vec<u8> = Vec::new(); let mut buf: Vec<u8> = Vec::new();
assert!(decrypt_sectors(&mut buf, &keys, 0).is_ok()); assert!(decrypt_sectors(&mut buf, &mut keys, 0).is_ok());
} }
/// An exact multiple of the unit length has no trailing partial: behavior /// An exact multiple of the unit length has no trailing partial: behavior
@@ -484,14 +517,14 @@ mod tests {
/// attempted. Two clear units must round-trip untouched and return `Ok`. /// attempted. Two clear units must round-trip untouched and return `Ok`.
#[test] #[test]
fn aacs_exact_multiple_unchanged() { fn aacs_exact_multiple_unchanged() {
let keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
}; };
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN * 2); let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN * 2);
let snapshot = buf.clone(); let snapshot = buf.clone();
decrypt_sectors(&mut buf, &keys, 0).expect("exact-multiple buffer is Ok"); decrypt_sectors(&mut buf, &mut keys, 0).expect("exact-multiple buffer is Ok");
assert_eq!( assert_eq!(
buf, snapshot, buf, snapshot,
@@ -512,7 +545,7 @@ mod tests {
fn none_keys_is_noop() { fn none_keys_is_noop() {
let mut buf: Vec<u8> = (0..4096u32).map(|i| (i % 256) as u8).collect(); let mut buf: Vec<u8> = (0..4096u32).map(|i| (i % 256) as u8).collect();
let snapshot = buf.clone(); let snapshot = buf.clone();
decrypt_sectors(&mut buf, &DecryptKeys::None, 0).expect("None is always Ok"); decrypt_sectors(&mut buf, &mut DecryptKeys::None, 0).expect("None is always Ok");
assert_eq!(buf, snapshot, "None must not touch the buffer"); assert_eq!(buf, snapshot, "None must not touch the buffer");
} }
@@ -564,8 +597,8 @@ mod tests {
let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42]; let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
let (mut sector, plaintext) = make_css_sector(&title_key, &seed, 0xA5); let (mut sector, plaintext) = make_css_sector(&title_key, &seed, 0xA5);
let keys = DecryptKeys::Css { title_key }; let mut keys = DecryptKeys::Css { title_key };
decrypt_sectors(&mut sector, &keys, 0).expect("CSS decrypt is Ok"); decrypt_sectors(&mut sector, &mut keys, 0).expect("CSS decrypt is Ok");
assert_eq!( assert_eq!(
&sector[0x80..2048], &sector[0x80..2048],
&plaintext[0x80..2048], &plaintext[0x80..2048],
@@ -594,8 +627,8 @@ mod tests {
let (s1, p1) = make_css_sector(&title_key, &[0x66, 0x77, 0x88, 0x99, 0xAA], 0xC3); let (s1, p1) = make_css_sector(&title_key, &[0x66, 0x77, 0x88, 0x99, 0xAA], 0xC3);
let mut buf = s0; let mut buf = s0;
buf.extend_from_slice(&s1); buf.extend_from_slice(&s1);
let keys = DecryptKeys::Css { title_key }; let mut keys = DecryptKeys::Css { title_key };
decrypt_sectors(&mut buf, &keys, 0).expect("CSS multi-sector decrypt is Ok"); decrypt_sectors(&mut buf, &mut keys, 0).expect("CSS multi-sector decrypt is Ok");
assert_eq!( assert_eq!(
&buf[0x80..2048], &buf[0x80..2048],
&p0[0x80..2048], &p0[0x80..2048],
@@ -608,6 +641,94 @@ mod tests {
); );
} }
/// Build a CSS sector whose clear header ends in a periodic run that
/// continues into the encrypted region — the crackable shape `attack_crib`/
/// `crack_title_key` recover a key from (a constant body fill gives a
/// degenerate crib the cracker can't pin a unique key on). Returns
/// (scrambled_sector, plaintext_body).
fn make_crackable_css_sector(
title_key: &[u8; 5],
seed: &[u8; 5],
period: usize,
) -> (Vec<u8>, Vec<u8>) {
let mut plaintext = vec![0u8; 2048];
plaintext[0x14] = 0x10; // scramble flag
// Periodic run from 0x59 (just above the seed) through 0x80 and on into
// the encrypted region; phase anchored to offset 0 so it is continuous
// across the 0x80 boundary.
let pat: Vec<u8> = (0..period)
.map(|k| (0xA0u8.wrapping_add(k as u8)) ^ 0x5A)
.collect();
for (i, b) in plaintext.iter_mut().enumerate().skip(0x59) {
*b = pat[i % period];
}
plaintext[0x54..0x59].copy_from_slice(seed); // seed sits below the run
let body = plaintext.clone();
css::lfsr::scramble_sector(title_key, &mut plaintext);
(plaintext, body)
}
/// CSS title keys are per-VTS/VOB region: a real disc holds DIFFERENT keys
/// for different regions and the only way to get each is to crack it. The
/// decrypt path must re-crack when the cached key stops descrambling (its
/// crib no longer reappears at 0x80) instead of blindly applying one key
/// across a region boundary — the bug that pixelated every freemkv DVD rip.
///
/// Two sectors scrambled under DIFFERENT keys, cache primed to ONLY the
/// first (exactly what the one-shot scan crack leaves). Sector 0 validates +
/// descrambles with the cached key; sector 1's cached-key descramble fails
/// the crib, so the path re-cracks sector 1's own key and recovers its
/// plaintext. Before the fix (blind single-key apply) sector 1 was garbage.
///
/// Grounding: the CSS arm's `attack_crib` → `chunk[0x80..] != crib` →
/// `crack_title_key` → `*title_key = fresh` rekey.
/// Mutation: drop the rekey branch (apply the cached key always) → sector 1's
/// body no longer matches its plaintext; this fails.
#[test]
fn css_rekeys_when_title_key_region_changes() {
let key_a = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let key_b = [0x07, 0x5A, 0xC3, 0x10, 0x88]; // a DIFFERENT region's key
let (s0, p0) = make_crackable_css_sector(&key_a, &[0x11, 0x22, 0x33, 0x44, 0x55], 4);
let (s1, p1) = make_crackable_css_sector(&key_b, &[0x66, 0x77, 0x88, 0x99, 0xAA], 4);
// Precondition: each sector must be crackable on its own (the rekey
// depends on it). If this fails the fixture, not the path, is at fault.
assert_eq!(
crate::css::stevenson::crack_title_key(&s0),
Some(key_a),
"fixture s0 must crack to key_a standalone"
);
assert_eq!(
crate::css::stevenson::crack_title_key(&s1),
Some(key_b),
"fixture s1 must crack to key_b standalone"
);
let mut buf = s0;
buf.extend_from_slice(&s1);
// Cache primed to key_a only — exactly what the one-shot scan crack yields.
let mut keys = DecryptKeys::Css { title_key: key_a };
decrypt_sectors(&mut buf, &mut keys, 0).expect("CSS multi-region decrypt is Ok");
assert_eq!(
&buf[0x80..2048],
&p0[0x80..2048],
"region A sector descrambles with the cached (primed) key"
);
assert_eq!(
&buf[2048 + 0x80..4096],
&p1[0x80..2048],
"region B sector must descramble after the path re-cracks its own key"
);
// The cache must have advanced to region B's key.
match keys {
DecryptKeys::Css { title_key } => assert_eq!(
title_key, key_b,
"cache must hold region B's key after the rekey"
),
_ => unreachable!(),
}
}
/// The CSS path leaves UNSCRAMBLED sectors (flag clear) byte-for-byte /// The CSS path leaves UNSCRAMBLED sectors (flag clear) byte-for-byte
/// untouched — descramble_sector early-returns on a zero flag. A clear /// untouched — descramble_sector early-returns on a zero flag. A clear
/// sector mixed into the buffer must not be corrupted. /// sector mixed into the buffer must not be corrupted.
@@ -622,8 +743,8 @@ mod tests {
let mut sector = vec![0x77u8; 2048]; let mut sector = vec![0x77u8; 2048];
sector[0x14] = 0x00; // not scrambled sector[0x14] = 0x00; // not scrambled
let snapshot = sector.clone(); let snapshot = sector.clone();
let keys = DecryptKeys::Css { title_key }; let mut keys = DecryptKeys::Css { title_key };
decrypt_sectors(&mut sector, &keys, 0).unwrap(); decrypt_sectors(&mut sector, &mut keys, 0).unwrap();
assert_eq!(sector, snapshot, "clear CSS sector must be left untouched"); assert_eq!(sector, snapshot, "clear CSS sector must be left untouched");
} }
@@ -636,8 +757,8 @@ mod tests {
#[test] #[test]
fn css_empty_buffer_is_ok() { fn css_empty_buffer_is_ok() {
let mut buf: Vec<u8> = Vec::new(); let mut buf: Vec<u8> = Vec::new();
let keys = DecryptKeys::Css { title_key: [0; 5] }; let mut keys = DecryptKeys::Css { title_key: [0; 5] };
assert!(decrypt_sectors(&mut buf, &keys, 0).is_ok()); assert!(decrypt_sectors(&mut buf, &mut keys, 0).is_ok());
} }
// ── AACS unit-key index selection ────────────────────────────────────── // ── AACS unit-key index selection ──────────────────────────────────────
@@ -653,12 +774,12 @@ mod tests {
/// fails. /// fails.
#[test] #[test]
fn aacs_out_of_range_unit_key_idx_errors() { fn aacs_out_of_range_unit_key_idx_errors() {
let keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])], unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None, read_data_key: None,
}; };
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN); let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
let err = decrypt_sectors(&mut buf, &keys, 5) let err = decrypt_sectors(&mut buf, &mut keys, 5)
.expect_err("unit_key_idx 5 is out of range for a 1-key list"); .expect_err("unit_key_idx 5 is out of range for a 1-key list");
assert_eq!( assert_eq!(
err.code(), err.code(),
@@ -673,12 +794,12 @@ mod tests {
/// Mutation: defaulting to [0u8;16] on None would proceed; this fails. /// Mutation: defaulting to [0u8;16] on None would proceed; this fails.
#[test] #[test]
fn aacs_empty_unit_keys_errors() { fn aacs_empty_unit_keys_errors() {
let keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![], unit_keys: vec![],
read_data_key: None, read_data_key: None,
}; };
let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN); let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN);
let err = decrypt_sectors(&mut buf, &keys, 0).expect_err("empty unit_keys must error"); let err = decrypt_sectors(&mut buf, &mut keys, 0).expect_err("empty unit_keys must error");
assert_eq!(err.code(), crate::error::Error::DecryptFailed.code()); assert_eq!(err.code(), crate::error::Error::DecryptFailed.code());
} }
@@ -751,14 +872,14 @@ mod tests {
"encrypted unit must look scrambled before decrypt" "encrypted unit must look scrambled before decrypt"
); );
let keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key0), (1, key1)], // two CPS units unit_keys: vec![(0, key0), (1, key1)], // two CPS units
read_data_key: None, read_data_key: None,
}; };
// Call with the default hint (idx 0) — the fix must fall back to key1. // Call with the default hint (idx 0) — the fix must fall back to key1.
let mut buf = unit; let mut buf = unit;
decrypt_sectors(&mut buf, &keys, 0).expect("multi-CPS decrypt must succeed"); decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed");
assert!( assert!(
!aacs::is_aacs_scrambled(&buf), !aacs::is_aacs_scrambled(&buf),
@@ -785,12 +906,12 @@ mod tests {
let mut unit = clear_ts_unit(); let mut unit = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit, &key); aacs_encrypt_unit_for_test(&mut unit, &key);
let keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)], unit_keys: vec![(0, key)],
read_data_key: None, read_data_key: None,
}; };
let mut buf = unit; let mut buf = unit;
decrypt_sectors(&mut buf, &keys, 0).expect("single-key disc must decrypt"); decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
assert!( assert!(
!aacs::is_aacs_scrambled(&buf), !aacs::is_aacs_scrambled(&buf),
"single-key disc: TS syncs must be restored" "single-key disc: TS syncs must be restored"
@@ -829,13 +950,13 @@ mod tests {
"encrypted unit must look scrambled going in" "encrypted unit must look scrambled going in"
); );
let keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, wrong_key)], unit_keys: vec![(0, wrong_key)],
read_data_key: None, read_data_key: None,
}; };
let mut buf = unit; let mut buf = unit;
let dropped = let dropped = decrypt_sectors(&mut buf, &mut keys, 0)
decrypt_sectors(&mut buf, &keys, 0).expect("undecryptable unit is not a hard error"); .expect("undecryptable unit is not a hard error");
assert_eq!( assert_eq!(
dropped, dropped,
@@ -872,11 +993,11 @@ mod tests {
buf.extend_from_slice(&unit_a); buf.extend_from_slice(&unit_a);
buf.extend_from_slice(&unit_b); buf.extend_from_slice(&unit_b);
let keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)], unit_keys: vec![(0, key)],
read_data_key: None, read_data_key: None,
}; };
let dropped = decrypt_sectors(&mut buf, &keys, 0).expect("partial decrypt is Ok"); let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("partial decrypt is Ok");
assert_eq!( assert_eq!(
dropped, dropped,
@@ -901,12 +1022,12 @@ mod tests {
let key = [0x77u8; 16]; let key = [0x77u8; 16];
let mut unit = clear_ts_unit(); let mut unit = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit, &key); aacs_encrypt_unit_for_test(&mut unit, &key);
let keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)], unit_keys: vec![(0, key)],
read_data_key: None, read_data_key: None,
}; };
let mut buf = unit; let mut buf = unit;
let dropped = decrypt_sectors(&mut buf, &keys, 0).expect("clean decrypt"); let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("clean decrypt");
assert_eq!(dropped, 0, "a fully-decrypted buffer must report no loss"); assert_eq!(dropped, 0, "a fully-decrypted buffer must report no loss");
} }
+11 -8
View File
@@ -148,14 +148,15 @@ pub fn dvd_cell_row(idx: usize, cell: &crate::ifo::DvdCell, dropped: bool) -> St
"keep(plain-feature)" "keep(plain-feature)"
}; };
format!( format!(
"tag=dvd.cell idx={idx} cat=0x{:02X} type={} block_mode={} block_type={} \ "tag=dvd.cell idx={idx} cat=0x{:02X} block_mode={} block_type={} \
seamless={} ilv={} plain={} first={} last={} dur={:.1}s {}", seamless={} ilv={} stc={} angle={} plain={} first={} last={} dur={:.1}s {}",
cell.category, cell.category,
c.cell_type,
c.block_mode, c.block_mode,
c.block_type, c.block_type,
c.seamless_play as u8, c.seamless_play as u8,
c.interleaved as u8, c.interleaved as u8,
c.stc_discontinuity as u8,
c.seamless_angle as u8,
c.is_plain_feature() as u8, c.is_plain_feature() as u8,
cell.first_sector, cell.first_sector,
cell.last_sector, cell.last_sector,
@@ -726,23 +727,25 @@ mod tests {
}; };
let row = dvd_cell_row(0, &plain, false); let row = dvd_cell_row(0, &plain, false);
assert!(row.contains("cat=0x00"), "{row}"); assert!(row.contains("cat=0x00"), "{row}");
assert!(row.contains("type=0"), "{row}"); assert!(row.contains("block_mode=0"), "{row}");
assert!(row.contains("first=100"), "{row}"); assert!(row.contains("first=100"), "{row}");
assert!(row.contains("last=199"), "{row}"); assert!(row.contains("last=199"), "{row}");
assert!(row.contains("dur=12.5s"), "{row}"); assert!(row.contains("dur=12.5s"), "{row}");
assert!(row.contains("keep(plain-feature)"), "{row}"); assert!(row.contains("keep(plain-feature)"), "{row}");
assert!(!row.contains("DROP"), "{row}"); assert!(!row.contains("DROP"), "{row}");
// 0x80 = middle-of-angle-block (cell_type=2), shown dropped. // 0x90 = in-block cell of an angle block (block_mode=2, block_type=1),
// shown dropped as a leading secondary piece.
let sec = crate::ifo::DvdCell { let sec = crate::ifo::DvdCell {
first_sector: 0, first_sector: 0,
last_sector: 9, last_sector: 9,
category: 0x80, category: 0x90,
duration_secs: 1.0, duration_secs: 1.0,
}; };
let row = dvd_cell_row(0, &sec, true); let row = dvd_cell_row(0, &sec, true);
assert!(row.contains("cat=0x80"), "{row}"); assert!(row.contains("cat=0x90"), "{row}");
assert!(row.contains("type=2"), "{row}"); assert!(row.contains("block_mode=2"), "{row}");
assert!(row.contains("block_type=1"), "{row}");
assert!(row.contains("DROP(leading-secondary-block-piece)"), "{row}"); assert!(row.contains("DROP(leading-secondary-block-piece)"), "{row}");
} }
} }
+6 -6
View File
@@ -1098,14 +1098,14 @@ mod tests {
} }
/// End-to-end bug-4 fix: a feature PGC that opens with a leading /// End-to-end bug-4 fix: a feature PGC that opens with a leading
/// interleaved-angle sub-block cell (category 0x80 = middle-of-angle-block) /// interleaved-angle sub-block cell (category 0x90 = in-block cell of an
/// must have that cell DROPPED from the muxed extents, so the rip starts at /// angle block) must have that cell DROPPED from the muxed extents, so the
/// the real feature. Chapters shift earlier by the dropped duration. /// rip starts at the real feature. Chapters shift earlier by the dropped duration.
#[test] #[test]
fn scan_dvd_titles_drops_leading_scene_index_cell() { fn scan_dvd_titles_drops_leading_scene_index_cell() {
let mut disc = MemDisc::new(); let mut disc = MemDisc::new();
let vmg = build_vmg(&[(2, 1, 1)]); let vmg = build_vmg(&[(2, 1, 1)]);
// Cell 0: leading scene-index/angle sub-block (cat 0x80), 5s, sectors 0..9. // Cell 0: leading scene-index/angle sub-block (cat 0x90), 5s, sectors 0..9.
// Cell 1: feature start (cat 0x00), 59s, sectors 100..199. // Cell 1: feature start (cat 0x00), 59s, sectors 100..199.
// Cell 2: feature (cat 0x00), 59s, sectors 300..399. // Cell 2: feature (cat 0x00), 59s, sectors 300..399.
// Programs: prog0 → cell 1 (feature start), prog1 → cell 3. // Programs: prog0 → cell 1 (feature start), prog1 → cell 3.
@@ -1113,7 +1113,7 @@ mod tests {
1000, 1000,
0x00, 0x00,
&[ &[
(0, 9, 0x80, 0x05), (0, 9, 0x90, 0x05),
(100, 199, 0x00, 0x59), (100, 199, 0x00, 0x59),
(300, 399, 0x00, 0x59), (300, 399, 0x00, 0x59),
], ],
@@ -1137,7 +1137,7 @@ mod tests {
], ],
); );
let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0]; let t = &Disc::scan_dvd_titles(&mut disc, &udf)[0];
// The leading 0x80 cell is dropped: 2 feature extents, not 3. // The leading 0x90 cell is dropped: 2 feature extents, not 3.
assert_eq!(t.extents.len(), 2, "leading angle sub-block cell dropped"); assert_eq!(t.extents.len(), 2, "leading angle sub-block cell dropped");
// First extent starts at the feature cell (vob 1000 + 100), not at 1000+0. // First extent starts at the feature cell (vob 1000 + 100), not at 1000+0.
assert_eq!(t.extents[0].start_lba, 1000 + 100); assert_eq!(t.extents[0].start_lba, 1000 + 100);
+67 -62
View File
@@ -58,12 +58,12 @@ pub struct DvdTitle {
pub struct DvdCell { pub struct DvdCell {
pub first_sector: u32, pub first_sector: u32,
pub last_sector: u32, pub last_sector: u32,
/// Raw cell-category byte at `cell_playback + 0` (DVD-Video spec). /// Raw cell-category byte at `cell_playback + 0` (libdvdread layout).
/// Packs cell_type (bits 7-6), block_mode (bits 5-4), block_type /// Packs block_mode (bits 7-6), block_type (bits 5-4), seamless_play
/// (bits 3-2), seamless_play (bit 1), interleaved (bit 0). Carried so /// (bit 3), interleaved (bit 2), stc_discontinuity (bit 1),
/// the extent builder can recognise non-feature leading cells /// seamless_angle (bit 0). Carried so the extent builder can recognise
/// (scene-index / interleaved angle sub-blocks) and the diagnostic dump /// non-feature leading cells (interleaved angle sub-blocks) and the
/// can show why a cell was kept or dropped. /// diagnostic dump can show why a cell was kept or dropped.
pub category: u8, pub category: u8,
/// Per-cell playback duration in seconds (BCD time at `cell_playback + 4`). /// Per-cell playback duration in seconds (BCD time at `cell_playback + 4`).
/// Used by the diagnostic dump and the conservative leading-cell filter /// Used by the diagnostic dump and the conservative leading-cell filter
@@ -72,51 +72,54 @@ pub struct DvdCell {
} }
/// Decoded view of a cell-category byte (`cell_playback + 0`), per the /// Decoded view of a cell-category byte (`cell_playback + 0`), per the
/// DVD-Video spec `cell_playback_information` layout. /// DVD-Video spec / libdvdread `cell_playback_t` layout. Byte-0 bitfields,
/// MSB-first: `block_mode`(7-6), `block_type`(5-4), `seamless_play`(3),
/// `interleaved`(2), `stc_discontinuity`(1), `seamless_angle`(0). (The real
/// `cell_type` is a karaoke-only field in byte 1, not used here.)
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CellCategory { pub struct CellCategory {
/// bits 7-6: 0=normal, 1=first cell of angle block, 2=middle, 3=last. /// bits 7-6: 0=not in block, 1=first cell of block, 2=in block, 3=last cell.
pub cell_type: u8,
/// bits 5-4: 0=not in block, 1=first cell of block, 2=in block, 3=last.
pub block_mode: u8, pub block_mode: u8,
/// bits 3-2: 0=not part of a block, 1=angle block. /// bits 5-4: 0=not part of a block, 1=angle block.
pub block_type: u8, pub block_type: u8,
/// bit 1: seamless playback (STC continuous). /// bit 3: seamless playback (STC continuous).
pub seamless_play: bool, pub seamless_play: bool,
/// bit 0: interleaved (multi-angle / seamless-branch interleave). /// bit 2: interleaved (multi-angle / seamless-branch interleave).
pub interleaved: bool, pub interleaved: bool,
/// bit 1: STC discontinuity at the start of this cell.
pub stc_discontinuity: bool,
/// bit 0: seamless angle change.
pub seamless_angle: bool,
} }
impl CellCategory { impl CellCategory {
/// Decode the raw `cell_playback + 0` byte. /// Decode the raw `cell_playback + 0` byte (libdvdread `read_cell_playback`).
pub fn decode(raw: u8) -> Self { pub fn decode(raw: u8) -> Self {
CellCategory { CellCategory {
cell_type: (raw >> 6) & 0x03, block_mode: (raw >> 6) & 0x03,
block_mode: (raw >> 4) & 0x03, block_type: (raw >> 4) & 0x03,
block_type: (raw >> 2) & 0x03, seamless_play: (raw & 0x08) != 0,
seamless_play: (raw & 0x02) != 0, interleaved: (raw & 0x04) != 0,
interleaved: (raw & 0x01) != 0, stc_discontinuity: (raw & 0x02) != 0,
seamless_angle: (raw & 0x01) != 0,
} }
} }
/// A plain feature cell: not part of any angle/interleave block. Every /// A plain feature cell: not part of any angle/interleave block. Every cell
/// cell of a normal single-angle feature decodes to this (`category` /// of a normal single-angle feature decodes to this (`block_mode` and
/// byte `0x00`, or `0x00` in every block field with only the /// `block_type` both 0, only the seamless/interleaved flags possibly set).
/// seamless/interleaved flags possibly set). Such a cell is NEVER /// Such a cell is NEVER dropped by the leading-cell filter.
/// dropped by the leading-cell filter.
pub fn is_plain_feature(&self) -> bool { pub fn is_plain_feature(&self) -> bool {
self.cell_type == 0 && self.block_mode == 0 && self.block_type == 0 self.block_mode == 0 && self.block_type == 0
} }
/// Marks a non-first piece of an angle / interleaved block: a "middle" or /// Marks a non-first piece of an angle block: an "in-block" or "last of
/// "last" cell of an angle block (`cell_type ∈ {2,3}`), or an /// block" cell (`block_mode ∈ {2,3}`) of an angle block (`block_type==1`).
/// in-block / last-of-block cell (`block_mode ∈ {2,3}`). Concatenating /// Concatenating these back-to-back with the first angle duplicates content
/// these back-to-back with the first angle duplicates content at the head /// at the head of the feature. Conservative: the FIRST cell of a block
/// of the feature. Conservative: the FIRST cell of a block /// (`block_mode==1`) is NOT flagged — it is the angle we keep.
/// (`cell_type==1` / `block_mode==1`) is NOT flagged — it is the angle we
/// keep.
pub fn is_secondary_block_piece(&self) -> bool { pub fn is_secondary_block_piece(&self) -> bool {
matches!(self.cell_type, 2 | 3) || matches!(self.block_mode, 2 | 3) self.block_type == 1 && matches!(self.block_mode, 2 | 3)
} }
} }
@@ -567,7 +570,7 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAttr> {
_ => Codec::Unknown(coding_mode), _ => Codec::Unknown(coding_mode),
}; };
let sample_rate_flag = (b0 >> 3) & 0x03; let sample_rate_flag = (b1 >> 4) & 0x03; // sample_frequency: byte 1 bits 5-4 (libdvdread audio_attr_t)
let sample_rate = match sample_rate_flag { let sample_rate = match sample_rate_flag {
0 => 48000, 0 => 48000,
1 => 96000, 1 => 96000,
@@ -1174,11 +1177,11 @@ mod tests {
#[test] #[test]
fn audio_attr_dts() { fn audio_attr_dts() {
let mut data = vec![0u8; 16]; let mut data = vec![0u8; 16];
// DTS (coding=6), 96kHz (rate=1), 2 channels (stored as 1) // DTS (coding=6), 96kHz (rate=1, byte1 bits 5-4), 2 channels (stored as 1)
// b0: bits 7-5=110(DTS), bits 4-3=01(96k) => 0b110_01_000 = 0xC8 // b0: bits 7-5=110(DTS) => 0b110_00000 = 0xC0
data[0] = 0xC8; data[0] = 0xC0;
// b1: bits 2-0=001 (channels-1=1) => 0x01 // b1: bits 5-4=01(96k), bits 2-0=001(channels-1=1) => 0b00_01_0_001 = 0x11
data[1] = 0x01; data[1] = 0x11;
data[2] = b'f'; data[2] = b'f';
data[3] = b'r'; data[3] = b'r';
@@ -1546,13 +1549,13 @@ mod tests {
} }
} }
/// CellCategory decodes the spec bitfields: cell_type (7-6), block_mode /// CellCategory decodes the libdvdread byte-0 bitfields: block_mode (7-6),
/// (5-4), block_type (3-2), seamless (1), interleaved (0). /// block_type (5-4), seamless_play (3), interleaved (2),
/// stc_discontinuity (1), seamless_angle (0).
#[test] #[test]
fn cell_category_decode_bits() { fn cell_category_decode_bits() {
// 0x00 → plain feature, nothing set. // 0x00 → plain feature, nothing set.
let c = CellCategory::decode(0x00); let c = CellCategory::decode(0x00);
assert_eq!(c.cell_type, 0);
assert_eq!(c.block_mode, 0); assert_eq!(c.block_mode, 0);
assert_eq!(c.block_type, 0); assert_eq!(c.block_type, 0);
assert!(!c.seamless_play); assert!(!c.seamless_play);
@@ -1560,27 +1563,29 @@ mod tests {
assert!(c.is_plain_feature()); assert!(c.is_plain_feature());
assert!(!c.is_secondary_block_piece()); assert!(!c.is_secondary_block_piece());
// cell_type=1 (first of angle block), block_mode=1 (first of block): // block_mode=1 (first cell of block), block_type=1 (angle block):
// 0b01_01_00_0_0 = 0x50. This is the angle we KEEP — not secondary. // 0b01_01_0000 = 0x50. This is the angle we KEEP — not secondary.
let c = CellCategory::decode(0b01_01_00_00); let c = CellCategory::decode(0b01_01_0000);
assert_eq!(c.cell_type, 1);
assert_eq!(c.block_mode, 1); assert_eq!(c.block_mode, 1);
assert_eq!(c.block_type, 1);
assert!(!c.is_plain_feature()); assert!(!c.is_plain_feature());
assert!(!c.is_secondary_block_piece()); assert!(!c.is_secondary_block_piece());
// cell_type=2 (middle of angle block): 0b10_00_00_00 = 0x80 → secondary. // block_mode=2 (in block) / 3 (last of block) of an angle block
assert!(CellCategory::decode(0b10_00_00_00).is_secondary_block_piece()); // (block_type=1) → secondary.
// cell_type=3 (last of angle block) → secondary. assert!(CellCategory::decode(0b10_01_0000).is_secondary_block_piece());
assert!(CellCategory::decode(0b11_00_00_00).is_secondary_block_piece()); assert!(CellCategory::decode(0b11_01_0000).is_secondary_block_piece());
// block_mode=2 (in block) → secondary; block_mode=3 (last of block) → secondary. // First cell of the block (block_mode=1) is NEVER secondary.
assert!(CellCategory::decode(0b00_10_00_00).is_secondary_block_piece()); assert!(!CellCategory::decode(0b01_01_0000).is_secondary_block_piece());
assert!(CellCategory::decode(0b00_11_00_00).is_secondary_block_piece());
// seamless (bit1) + interleaved (bit0) on an otherwise-plain cell must // The low flags (seamless_play bit3, interleaved bit2, stc bit1,
// NOT make it secondary — they don't mark non-feature content. // seamless_angle bit0) on an otherwise-plain cell must NOT make it
let c = CellCategory::decode(0b00_00_00_11); // secondary — they don't mark non-feature content.
let c = CellCategory::decode(0b0000_1111);
assert!(c.seamless_play); assert!(c.seamless_play);
assert!(c.interleaved); assert!(c.interleaved);
assert!(c.stc_discontinuity);
assert!(c.seamless_angle);
assert!(c.is_plain_feature()); assert!(c.is_plain_feature());
assert!(!c.is_secondary_block_piece()); assert!(!c.is_secondary_block_piece());
} }
@@ -1614,8 +1619,8 @@ mod tests {
chapters: 2, chapters: 2,
duration_secs: 100.0, duration_secs: 100.0,
cells: vec![ cells: vec![
cell(0, 9, 0b10_00_00_00), // middle of angle block → drop cell(0, 9, 0b10_01_0000), // in-block cell of angle block → drop
cell(10, 19, 0b00_11_00_00), // last of block → drop cell(10, 19, 0b11_01_0000), // last cell of angle block → drop
cell(20, 119, 0x00), // feature starts here cell(20, 119, 0x00), // feature starts here
cell(120, 219, 0x00), cell(120, 219, 0x00),
], ],
@@ -1637,7 +1642,7 @@ mod tests {
let t = DvdTitle { let t = DvdTitle {
chapters: 1, chapters: 1,
duration_secs: 100.0, duration_secs: 100.0,
cells: vec![cell(0, 9, 0b10_00_00_00), cell(10, 19, 0b11_00_00_00)], cells: vec![cell(0, 9, 0b10_01_0000), cell(10, 19, 0b11_01_0000)],
chapter_times: vec![0.0], chapter_times: vec![0.0],
palette: None, palette: None,
}; };
@@ -1669,8 +1674,8 @@ mod tests {
pgc[0xE8] = 0x00; pgc[0xE8] = 0x00;
pgc[0xE9] = 0xEA; pgc[0xE9] = 0xEA;
pgc.resize(0xEA + 48, 0); pgc.resize(0xEA + 48, 0);
// Cell 0: category byte = 0x80 (middle of angle block), 5s BCD. // Cell 0: category byte = 0x90 (in-block cell of angle block), 5s BCD.
pgc[0xEA] = 0x80; pgc[0xEA] = 0x90;
pgc[0xEA + 6] = 0x05; pgc[0xEA + 6] = 0x05;
pgc[0xEA + 8..0xEA + 12].copy_from_slice(&10u32.to_be_bytes()); pgc[0xEA + 8..0xEA + 12].copy_from_slice(&10u32.to_be_bytes());
// Cell 1: category 0x00 (plain feature), 7s BCD. // Cell 1: category 0x00 (plain feature), 7s BCD.
@@ -1678,7 +1683,7 @@ mod tests {
pgc[0xEA + 24 + 6] = 0x07; pgc[0xEA + 24 + 6] = 0x07;
pgc[0xEA + 24 + 8..0xEA + 24 + 12].copy_from_slice(&20u32.to_be_bytes()); pgc[0xEA + 24 + 8..0xEA + 24 + 12].copy_from_slice(&20u32.to_be_bytes());
let title = parse_pgc(&pgc, 0, 2).unwrap(); let title = parse_pgc(&pgc, 0, 2).unwrap();
assert_eq!(title.cells[0].category, 0x80); assert_eq!(title.cells[0].category, 0x90);
assert!((title.cells[0].duration_secs - 5.0).abs() < 0.01); assert!((title.cells[0].duration_secs - 5.0).abs() < 0.01);
assert_eq!(title.cells[1].category, 0x00); assert_eq!(title.cells[1].category, 0x00);
assert!((title.cells[1].duration_secs - 7.0).abs() < 0.01); assert!((title.cells[1].duration_secs - 7.0).abs() < 0.01);
+359 -156
View File
@@ -101,32 +101,39 @@ pub struct Mpeg2Parser {
/// `(absolute ES offset of a PES's first byte, PTS in ns)` for every PES /// `(absolute ES offset of a PES's first byte, PTS in ns)` for every PES
/// that carried a timestamp, in ascending offset order. /// that carried a timestamp, in ascending offset order.
pts_marks: VecDeque<(u64, i64)>, pts_marks: VecDeque<(u64, i64)>,
/// Per-frame presentation interval (ns), derived from the sequence header /// Full-frame presentation interval (ns) at the sequence-header display rate
/// frame rate. DVD stamps a PTS only ~once per VOBU (every ~0.5 s), so /// (`1/frame_rate`). The field period is half this. Per-frame durations are
/// frames between marks must be timed by `temporal_reference` × this /// `nb_fields × field_period`, so 2:3-telecined frames alternate 2- and
/// interval. 0 until a sequence header with a valid frame rate is seen. /// 3-field durations. 0 until a sequence header with a valid frame rate.
frame_duration_ns: i64, frame_duration_ns: i64,
/// Cumulative count of coded pictures emitted in all GOPs before the /// `progressive_sequence` from the sequence extension — selects the
/// current one. `temporal_reference` is GOP-relative (display order within /// `nb_fields` rules for `repeat_first_field` pictures.
/// the GOP); adding this base makes a whole-stream display index. progressive_sequence: bool,
gop_base: u64, /// Pictures of the current GOP, buffered in DECODE order until the GOP
/// Coded pictures emitted in the current GOP so far (folded into /// completes (the next GOP/sequence header). Held so each frame's PTS can be
/// `gop_base` at the next GOP boundary). /// the display-order prefix-sum of field durations — exact for 2:3 pulldown
gop_count: u64, /// without ever reordering emitted blocks (B-frames keep decode order; only
/// Display index of the last frame that carried an explicit PES PTS, used /// their PTS is lower).
/// to anchor interpolated timestamps to the real disc timeline (so video gop_buf: Vec<BufferedPicture>,
/// stays in sync with the PES-timestamped audio tracks). /// Total field-display periods of all frames already emitted, in display
anchor_index: Option<u64>, /// order — the running base for each new frame's display time.
/// PTS (ns) of the anchor frame. emitted_fields: u64,
anchor_pts: i64, /// PTS (ns) that display-field 0 of the whole stream maps to. Re-locked from
/// Frames emitted before the first PES PTS anchor is known, held with their /// each GOP's first PES PTS so video stays in sync with the PES-timestamped
/// display index. A DVD title can open with a still-frame/first-play /// audio. None until the first PES timestamp is seen.
/// sequence whose PTS lands a few frames in; buffering until the anchor lets origin_pts_ns: Option<i64>,
/// those leading frames take the disc's real timeline instead of a 0 base. }
pending: Vec<(u64, Frame)>,
/// Accumulated `data.len()` of frames currently in `pending`. Bounds the /// One coded picture buffered awaiting its GOP's completion (see `gop_buf`).
/// pre-anchor hold by BYTES, not just frame count (see [`MAX_PENDING_BYTES`]). struct BufferedPicture {
pending_bytes: usize, /// `temporal_reference` — display order within the GOP.
tr: u64,
/// Field-display periods this picture occupies (`picture_nb_fields`).
nb_fields: u8,
/// This picture's own PES PTS (ns), if its access unit carried one.
explicit_pts: Option<i64>,
/// The emitted frame (PTS + duration filled in at GOP flush).
frame: Frame,
} }
impl Default for Mpeg2Parser { impl Default for Mpeg2Parser {
@@ -144,12 +151,10 @@ impl Mpeg2Parser {
base_offset: 0, base_offset: 0,
pts_marks: VecDeque::new(), pts_marks: VecDeque::new(),
frame_duration_ns: 0, frame_duration_ns: 0,
gop_base: 0, progressive_sequence: false,
gop_count: 0, gop_buf: Vec::new(),
anchor_index: None, emitted_fields: 0,
anchor_pts: 0, origin_pts_ns: None,
pending: Vec::new(),
pending_bytes: 0,
} }
} }
@@ -174,22 +179,6 @@ impl Mpeg2Parser {
parse_aspect_ratio(hdr) parse_aspect_ratio(hdr)
} }
/// The PTS (ns) to assign to an access unit whose first relevant byte is at
/// absolute ES offset `target`: the most recent PES timestamp at or before
/// that offset (the PES that contains the access unit's start). Falls back
/// to 0 when no timestamp has been seen yet.
fn pts_for(&self, target: u64) -> i64 {
let mut best = 0;
for &(off, pts) in &self.pts_marks {
if off <= target {
best = pts;
} else {
break;
}
}
best
}
/// Drain every complete access unit from `buf`, returning one Frame each. /// Drain every complete access unit from `buf`, returning one Frame each.
/// When `force` is true (EOF flush, or buffer-cap backstop) the trailing /// When `force` is true (EOF flush, or buffer-cap backstop) the trailing
/// in-progress access unit is emitted even without a following boundary. /// in-progress access unit is emitted even without a following boundary.
@@ -247,12 +236,12 @@ impl Mpeg2Parser {
} else { } else {
0 0
}; };
let pic_abs = self.base_offset + pic as u64;
let end_abs = self.base_offset + end as u64; let end_abs = self.base_offset + end as u64;
let data = self.buf[..end].to_vec(); let data = self.buf[..end].to_vec();
// Phase 2 — mutate self. // Phase 2 — mutate self.
if let Some(h) = hdr { if let Some(h) = hdr {
self.progressive_sequence = parse_progressive_sequence(&h);
self.seq_header = Some(h); self.seq_header = Some(h);
if let Some((num, den)) = self.frame_rate() { if let Some((num, den)) = self.frame_rate() {
if num > 0 { if num > 0 {
@@ -260,11 +249,7 @@ impl Mpeg2Parser {
} }
} }
} }
if gop_boundary && self.gop_count > 0 { let nb_fields = picture_nb_fields(&data, self.progressive_sequence);
self.gop_base += self.gop_count;
self.gop_count = 0;
}
let display_index = self.gop_base + tr;
// An explicit PES PTS for this access unit, if any. By the mark-drain // An explicit PES PTS for this access unit, if any. By the mark-drain
// invariant the front mark's offset is >= this AU's start, so a front // invariant the front mark's offset is >= this AU's start, so a front
@@ -275,71 +260,29 @@ impl Mpeg2Parser {
.filter(|&&(off, _)| off < end_abs) .filter(|&&(off, _)| off < end_abs)
.map(|&(_, p)| p); .map(|&(_, p)| p);
let duration_ns = (self.frame_duration_ns > 0).then_some(self.frame_duration_ns as u64); // A GOP boundary means the buffered run is a COMPLETE GOP (all its
let mut frame = Frame { // pictures display before the next GOP's), so flush it before
// starting the new one. `temporal_reference` resets to 0 at the
// boundary, keeping each GOP's display order self-contained.
if gop_boundary && !self.gop_buf.is_empty() {
self.flush_gop(&mut out);
}
self.gop_buf.push(BufferedPicture {
tr,
nb_fields,
explicit_pts: explicit,
frame: Frame {
pts_ns: 0, pts_ns: 0,
keyframe, keyframe,
data, data,
duration_ns, duration_ns: None,
};
if self.frame_duration_ns > 0 {
// Reconstruct from display order; anchor to the real PES PTS so
// video stays in sync with the PES-timestamped audio.
match explicit {
Some(p) => {
self.anchor_index = Some(display_index);
self.anchor_pts = p;
// Backfill any leading frames held before the anchor was
// known (still-frame / first-play opening): give each the
// disc's real timeline relative to this anchor.
for (di, mut held) in self.pending.drain(..) {
held.pts_ns =
p + (di as i64 - display_index as i64) * self.frame_duration_ns;
out.push(held);
}
self.pending_bytes = 0;
frame.pts_ns = p;
out.push(frame);
}
None => match self.anchor_index {
Some(ai) => {
frame.pts_ns = self.anchor_pts
+ (display_index as i64 - ai as i64) * self.frame_duration_ns;
out.push(frame);
}
None if self.pending.len() < MAX_PENDING_FRAMES
&& self.pending_bytes < MAX_PENDING_BYTES =>
{
// No anchor yet — hold so leading frames get the
// disc's real timeline once the first PTS arrives,
// not a 0 base.
self.pending_bytes += frame.data.len();
self.pending.push((display_index, frame));
}
None => {
// Hold cap (count OR bytes) reached without a PTS
// anchor ever arriving. Release everything held so
// far on the 0-base timeline rather than growing the
// buffer unbounded, then emit this frame the same way.
for (di, mut held) in self.pending.drain(..) {
held.pts_ns = di as i64 * self.frame_duration_ns;
out.push(held);
}
self.pending_bytes = 0;
frame.pts_ns = display_index as i64 * self.frame_duration_ns;
out.push(frame);
}
}, },
});
// Safety cap: a stream with no GOP/sequence boundaries would buffer
// unbounded. Force-flush a pathologically long run as its own GOP.
if self.gop_buf.len() >= MAX_PENDING_FRAMES {
self.flush_gop(&mut out);
} }
} else {
// No frame rate yet (no sequence header) — fall back to the
// nearest preceding PES timestamp.
frame.pts_ns = self.pts_for(pic_abs);
out.push(frame);
}
self.gop_count += 1;
self.buf.drain(..end); self.buf.drain(..end);
self.base_offset = end_abs; self.base_offset = end_abs;
// Drop PTS marks fully consumed by the emitted AU; keep the mark at // Drop PTS marks fully consumed by the emitted AU; keep the mark at
@@ -352,8 +295,64 @@ impl Mpeg2Parser {
} }
} }
} }
// EOF: emit the final (possibly incomplete) GOP so nothing is dropped.
if force {
self.flush_gop(&mut out);
}
out out
} }
/// Emit the buffered GOP. Each frame's PTS is the display-order prefix-sum of
/// field durations from the timeline origin; its block duration is its own
/// `nb_fields × field_period`. Frames are emitted in DECODE (buffer) order —
/// B-frames keep their position with a correctly LOWER PTS, never reordered
/// (reordering emitted blocks is what corrupts the picture). The origin is
/// (re-)locked to the GOP's PES PTS; because that is a *presentation*
/// timestamp, backing out the carrying frame's display-field offset keeps the
/// timeline continuous and monotonic across GOP boundaries.
fn flush_gop(&mut self, out: &mut Vec<Frame>) {
let n = self.gop_buf.len();
if n == 0 {
return;
}
let field_period = self.frame_duration_ns / 2;
if field_period <= 0 {
// No sequence header / frame rate yet (malformed lead-in): emit in
// decode order off each AU's own PES PTS, with no field timing.
for bp in self.gop_buf.drain(..) {
let mut f = bp.frame;
f.pts_ns = bp.explicit_pts.unwrap_or(0);
out.push(f);
}
return;
}
// Fields displayed BEFORE each picture within this GOP: order indices by
// temporal_reference (display order) and prefix-sum `nb_fields`.
let mut order: Vec<usize> = (0..n).collect();
order.sort_by_key(|&i| self.gop_buf[i].tr);
let mut cum_before = vec![0u64; n];
let mut running = 0u64;
for &i in &order {
cum_before[i] = running;
running += self.gop_buf[i].nb_fields as u64;
}
let gop_fields = running;
let base = self.emitted_fields;
// (Re-)lock the timeline origin to the GOP's PES PTS.
for &i in &order {
if let Some(p) = self.gop_buf[i].explicit_pts {
self.origin_pts_ns = Some(p - field_period * (base + cum_before[i]) as i64);
break;
}
}
let origin = self.origin_pts_ns.unwrap_or(0);
for (i, mut bp) in self.gop_buf.drain(..).enumerate() {
bp.frame.pts_ns = origin + field_period * (base + cum_before[i]) as i64;
bp.frame.duration_ns = Some(bp.nb_fields as u64 * field_period as u64);
out.push(bp.frame);
}
self.emitted_fields += gop_fields;
}
} }
impl CodecParser for Mpeg2Parser { impl CodecParser for Mpeg2Parser {
@@ -374,23 +373,9 @@ impl CodecParser for Mpeg2Parser {
} }
fn flush(&mut self) -> Vec<Frame> { fn flush(&mut self) -> Vec<Frame> {
let mut out = self.drain_complete_aus(true); // drain_complete_aus(true) force-completes the trailing access unit and
// EOF: if no PES ever supplied a PTS/DTS, `self.pending` still holds the // flushes the final GOP, so nothing is left buffered at EOF.
// frames buffered while waiting for an anchor (the opening keyframe + self.drain_complete_aus(true)
// first ~20s). Without this they'd be silently dropped — a 100%-recovery
// violation. Emit each with the same 0-base fallback the no-anchor
// overflow arm uses (`display_index * frame_duration_ns`), ordered by
// display_index so presentation order is preserved.
if !self.pending.is_empty() {
let mut held: Vec<(u64, Frame)> = self.pending.drain(..).collect();
held.sort_by_key(|(di, _)| *di);
for (di, mut frame) in held {
frame.pts_ns = di as i64 * self.frame_duration_ns;
out.push(frame);
}
self.pending_bytes = 0;
}
out
} }
fn codec_private(&self) -> Option<Vec<u8>> { fn codec_private(&self) -> Option<Vec<u8>> {
@@ -493,11 +478,145 @@ fn parse_aspect_ratio(hdr: &[u8]) -> Option<(u8, u8)> {
Some(ASPECT_RATIOS[ar_code]) Some(ASPECT_RATIOS[ar_code])
} }
/// Number of field-display periods a coded picture occupies, from its picture
/// coding extension (`00 00 01 B5`, ext-id `1000`), per ISO/IEC 13818-2 §6.3.10
/// and ffmpeg `mpeg_field_start` (`nb_fields = repeat_pict + 2`). This is what
/// times soft-telecined (2:3 pulldown) DVD video correctly: a
/// `repeat_first_field` frame occupies 3 fields, a normal frame 2, so honoring
/// it spreads the ~23.976 coded frames across the 29.97 display span with no
/// gap (the "play, pause, play" judder). `progressive_sequence` comes from the
/// sequence extension. Returns 2 (a normal frame) when no picture coding
/// extension is present.
fn picture_nb_fields(au: &[u8], progressive_sequence: bool) -> u8 {
let mut search = 0;
while let Some(q) = find_code(au, search, SEQ_EXT_CODE) {
search = q + 4;
// The picture coding extension is the B5 whose ext-id nibble is 1000.
if au.get(q + 4).map(|b| b >> 4) != Some(0b1000) {
continue;
}
// Extension bytes e2..=e4 = au[q+6 ..= q+8].
let (Some(&e2), Some(&e3), Some(&e4)) = (au.get(q + 6), au.get(q + 7), au.get(q + 8))
else {
break;
};
// picture_structure (e2 bits 1-0): 11 = frame picture. A field picture
// (01/10) occupies a single field; two combine into one frame upstream.
if e2 & 0x03 != 0b11 {
return 1;
}
let tff = (e3 >> 7) & 1;
let rff = (e3 >> 1) & 1;
let progressive_frame = (e4 >> 7) & 1;
let repeat_pict = if rff == 0 {
0
} else if progressive_sequence {
if tff == 1 { 4 } else { 2 }
} else if progressive_frame == 1 {
1
} else {
0
};
return repeat_pict + 2;
}
2
}
/// Read `progressive_sequence` from a captured sequence header's sequence
/// extension (`00 00 01 B5`, ext-id `0001`). False when absent (MPEG-1 / no
/// extension) — the interlaced default. Bit layout after the start code:
/// ext-id(4) profile_and_level(8) **progressive_sequence(1)** … so it is bit 3
/// of the second extension byte (`hdr[q+5]`).
fn parse_progressive_sequence(hdr: &[u8]) -> bool {
let mut search = 0;
while let Some(q) = find_code(hdr, search, SEQ_EXT_CODE) {
search = q + 4;
if hdr.get(q + 4).map(|b| b >> 4) != Some(0b0001) {
continue;
}
return hdr.get(q + 5).map(|&b| (b >> 3) & 1 == 1).unwrap_or(false);
}
false
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::mux::ts::PesPacket; use crate::mux::ts::PesPacket;
/// Build a picture coding extension (`00 00 01 B5`, ext-id 1000) carrying the
/// given pulldown flags, for `picture_nb_fields` tests.
fn pic_coding_ext(tff: u8, rff: u8, progressive_frame: u8, frame_picture: bool) -> Vec<u8> {
let e0 = 0x80; // ext-id 1000, f_code high nibble 0
let e1 = 0x00;
let e2 = if frame_picture { 0x03 } else { 0x01 }; // picture_structure bits 1-0
let e3 = (tff << 7) | (rff << 1);
let e4 = progressive_frame << 7;
vec![0x00, 0x00, 0x01, SEQ_EXT_CODE, e0, e1, e2, e3, e4]
}
#[test]
fn nb_fields_normal_frame_is_two() {
assert_eq!(picture_nb_fields(&pic_coding_ext(0, 0, 0, true), false), 2);
}
#[test]
fn nb_fields_telecine_repeat_field_is_three() {
// NTSC 2:3 soft telecine: interlaced sequence, progressive frame, rff=1.
assert_eq!(picture_nb_fields(&pic_coding_ext(0, 1, 1, true), false), 3);
}
#[test]
fn nb_fields_field_picture_is_one() {
assert_eq!(picture_nb_fields(&pic_coding_ext(0, 0, 0, false), false), 1);
}
#[test]
fn nb_fields_progressive_seq_rff_tff_is_six() {
assert_eq!(picture_nb_fields(&pic_coding_ext(1, 1, 0, true), true), 6);
}
#[test]
fn nb_fields_progressive_seq_rff_no_tff_is_four() {
assert_eq!(picture_nb_fields(&pic_coding_ext(0, 1, 0, true), true), 4);
}
#[test]
fn nb_fields_no_picture_ext_defaults_two() {
// A picture header with no coding extension → assume a normal 2-field frame.
assert_eq!(picture_nb_fields(&[0, 0, 1, 0x00, 0, 0], false), 2);
}
#[test]
fn progressive_sequence_parsed_from_seq_ext() {
// Sequence extension: 00 00 01 B5, e0 ext-id 0001 (0x1_), e1 bit3 = progressive_sequence.
assert!(parse_progressive_sequence(&[
0,
0,
1,
SEQ_EXT_CODE,
0x10,
0x08
]));
assert!(!parse_progressive_sequence(&[
0,
0,
1,
SEQ_EXT_CODE,
0x10,
0x00
]));
// No sequence extension at all → interlaced default (false).
assert!(!parse_progressive_sequence(&[
0,
0,
1,
SEQ_HEADER_CODE,
0,
0
]));
}
fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket { fn make_pes(data: Vec<u8>, pts: Option<i64>) -> PesPacket {
PesPacket { PesPacket {
pid: 0x1011, pid: 0x1011,
@@ -651,9 +770,11 @@ mod tests {
} }
#[test] #[test]
fn two_pictures_emit_two_frames_at_the_boundary() { fn two_pictures_in_one_gop_emit_both_on_flush() {
// pic1's frame is emitted as soon as pic2's start code is seen; pic2 on // Two pictures with no GOP/sequence boundary between them are ONE GOP.
// flush. Each frame contains exactly its own picture. // The VFR timeline needs the whole GOP (a P-frame's PTS depends on its
// later B-frames), so they buffer until the GOP closes / EOF, then emit
// in DECODE order, each containing exactly its own picture.
let mut parser = Mpeg2Parser::new(); let mut parser = Mpeg2Parser::new();
let mut pic1 = make_picture_header(PICTURE_TYPE_I); let mut pic1 = make_picture_header(PICTURE_TYPE_I);
@@ -664,17 +785,13 @@ mod tests {
let mut stream = pic1.clone(); let mut stream = pic1.clone();
stream.extend_from_slice(&pic2); stream.extend_from_slice(&pic2);
let mut frames = parser.parse(&make_pes(stream, Some(0))); let frames = parser.parse(&make_pes(stream, Some(0)));
assert_eq!( assert!(frames.is_empty(), "same GOP — buffered until flush");
frames.len(),
1, let frames = parser.flush();
"first picture emitted at second's boundary" assert_eq!(frames.len(), 2);
);
assert_eq!(frames[0].data, pic1); assert_eq!(frames[0].data, pic1);
assert!(frames[0].keyframe); assert!(frames[0].keyframe);
frames.extend(parser.flush());
assert_eq!(frames.len(), 2);
assert_eq!(frames[1].data, pic2); assert_eq!(frames[1].data, pic2);
assert!(!frames[1].keyframe); assert!(!frames[1].keyframe);
} }
@@ -703,23 +820,24 @@ mod tests {
#[test] #[test]
fn each_picture_gets_the_pts_of_the_pes_that_began_it() { fn each_picture_gets_the_pts_of_the_pes_that_began_it() {
// With no sequence header (no frame rate) the parser falls back to each
// AU's own PES PTS. Both pictures are one GOP → emitted on flush in
// decode order, each carrying the PTS of the PES that began it.
let mut parser = Mpeg2Parser::new(); let mut parser = Mpeg2Parser::new();
// PES 1: pic1 (PTS 90000) + start of pic2's bytes carried later.
let mut pic1 = make_picture_header(PICTURE_TYPE_I); let mut pic1 = make_picture_header(PICTURE_TYPE_I);
pic1.extend_from_slice(&vec![0x11; 50]); pic1.extend_from_slice(&vec![0x11; 50]);
let frames1 = parser.parse(&make_pes(pic1, Some(90000))); let frames1 = parser.parse(&make_pes(pic1, Some(90000)));
assert!(frames1.is_empty(), "pic1 awaits pic2's boundary"); assert!(frames1.is_empty(), "buffered until flush");
// PES 2: pic2 (PTS 180000).
let mut pic2 = make_picture_header(2); let mut pic2 = make_picture_header(2);
pic2.extend_from_slice(&vec![0x22; 50]); pic2.extend_from_slice(&vec![0x22; 50]);
let mut frames = parser.parse(&make_pes(pic2, Some(180000))); let frames2 = parser.parse(&make_pes(pic2, Some(180000)));
assert_eq!(frames.len(), 1, "pic1 emitted when pic2 starts"); assert!(frames2.is_empty(), "same GOP — still buffered");
assert_eq!(frames[0].pts_ns, 1_000_000_000, "pic1 → PTS 90000");
frames.extend(parser.flush()); let frames = parser.flush();
assert_eq!(frames.len(), 2); assert_eq!(frames.len(), 2);
assert_eq!(frames[0].pts_ns, 1_000_000_000, "pic1 → PTS 90000");
assert_eq!(frames[1].pts_ns, 2_000_000_000, "pic2 → PTS 180000"); assert_eq!(frames[1].pts_ns, 2_000_000_000, "pic2 → PTS 180000");
} }
@@ -761,6 +879,89 @@ mod tests {
assert_eq!(frames[0].duration_ns, Some(40_000_000)); assert_eq!(frames[0].duration_ns, Some(40_000_000));
} }
/// A frame-picture AU with a picture coding extension carrying pulldown
/// flags (progressive_frame=1, so rff=1 → 3 fields), for VFR timing tests.
fn make_pulldown_picture(coding_type: u8, tr: u16, rff: u8) -> Vec<u8> {
let mut au = make_picture_header_tr(coding_type, tr);
// 00 00 01 B5 | e0 ext-id 1000 | e1 | e2 frame-pic | e3 rff<<1 | e4 prog_frame
au.extend_from_slice(&[
0x00,
0x00,
0x01,
SEQ_EXT_CODE,
0x80,
0x00,
0x03,
rff << 1,
0x80,
]);
au.extend_from_slice(&[0xAA; 16]);
au
}
#[test]
fn telecine_pts_accumulates_by_field_durations_not_a_fixed_grid() {
// NTSC film, frame_rate_code 4 = 29.97 → field_period ≈ 16.683 ms. A 2:3
// frame (rff=1) occupies 3 fields, a 2:2 frame 2 fields. PTS must
// accumulate by ACTUAL field durations so the next frame starts exactly
// when this one ends — closing the fixed-29.97-grid gap that judders.
let mut p = Mpeg2Parser::new();
let field = 1_000_000_000i64 * 1001 / 30000 / 2;
let mut a = make_seq_header(720, 480, 2, 4);
a.extend_from_slice(&gop());
a.extend(make_pulldown_picture(1, 0, 1)); // I tr0, 3 fields, PES anchor 0
a.extend(make_pulldown_picture(2, 1, 0)); // P tr1, 2 fields
let mut frames = p.parse(&make_pes(a, Some(0)));
frames.extend(p.flush());
assert_eq!(frames.len(), 2);
assert_eq!(frames[0].pts_ns, 0, "I anchored to PES PTS 0");
assert_eq!(
frames[0].duration_ns,
Some(3 * field as u64),
"I = 3 fields"
);
assert_eq!(
frames[1].pts_ns,
3 * field,
"P starts exactly at I-end (3 fields), not the 1/29.97 grid"
);
assert_eq!(
frames[1].duration_ns,
Some(2 * field as u64),
"P = 2 fields"
);
assert!(frames[1].pts_ns > frames[0].pts_ns, "strictly monotonic");
}
#[test]
fn b_frames_emit_in_decode_order_with_lower_display_pts() {
// Decode order I(tr0) P(tr2) B(tr1): emitted in DECODE order, but the
// B-frame carries a LOWER (earlier) display PTS than the P that precedes
// it in the stream — never reordered (reordering corrupts the picture).
let mut p = Mpeg2Parser::new();
let field = 1_000_000_000i64 * 1001 / 30000 / 2;
let mut a = make_seq_header(720, 480, 2, 4);
a.extend_from_slice(&gop());
a.extend(make_pulldown_picture(1, 0, 0)); // I tr0 (displays 1st), PES anchor 0
a.extend(make_pulldown_picture(2, 2, 0)); // P tr2 (displays 3rd)
a.extend(make_pulldown_picture(3, 1, 0)); // B tr1 (displays 2nd)
let mut frames = p.parse(&make_pes(a, Some(0)));
frames.extend(p.flush());
assert_eq!(frames.len(), 3);
assert!(frames[0].keyframe, "decode order preserved: I first");
assert_eq!(frames[0].pts_ns, 0, "I (tr0) displays 1st");
assert_eq!(frames[1].pts_ns, 4 * field, "P (tr2) displays 3rd");
assert_eq!(frames[2].pts_ns, 2 * field, "B (tr1) displays 2nd");
assert!(
frames[2].pts_ns < frames[1].pts_ns,
"B emitted AFTER P (decode order) but displays BEFORE it (lower PTS)"
);
}
#[test] #[test]
fn temporal_reference_resets_each_gop_via_gop_base() { fn temporal_reference_resets_each_gop_via_gop_base() {
// Across a GOP boundary, temporal_reference restarts at 0 but the // Across a GOP boundary, temporal_reference restarts at 0 but the
@@ -973,9 +1174,10 @@ mod tests {
let mut a = make_seq_header(1920, 1080, 3, 4); let mut a = make_seq_header(1920, 1080, 3, 4);
a.extend_from_slice(&make_picture_header(PICTURE_TYPE_I)); a.extend_from_slice(&make_picture_header(PICTURE_TYPE_I));
a.extend_from_slice(&[0xAA; 20]); a.extend_from_slice(&[0xAA; 20]);
a.extend_from_slice(&gop()); // boundary → AU A emits a.extend_from_slice(&gop()); // trailing GOP header starts the next GOP
let fa = parser.parse(&make_pes(a, Some(0))); let _fa = parser.parse(&make_pes(a, Some(0)));
assert_eq!(fa.len(), 1); // Header A is captured during parse (codec_private) even though its GOP
// only emits once header B's picture closes it / on flush.
assert_eq!(parser.resolution(), Some((1920, 1080))); assert_eq!(parser.resolution(), Some((1920, 1080)));
// AU B: a NEW 720x480 seq header + I picture. Its extension/header must // AU B: a NEW 720x480 seq header + I picture. Its extension/header must
@@ -1062,11 +1264,12 @@ mod tests {
// > MAX_AU_BUFFER of slice bytes with no following picture/seq/GOP. // > MAX_AU_BUFFER of slice bytes with no following picture/seq/GOP.
data.extend(std::iter::repeat_n(0xAA, MAX_AU_BUFFER + 1024)); data.extend(std::iter::repeat_n(0xAA, MAX_AU_BUFFER + 1024));
let frames = parser.parse(&make_pes(data, Some(0))); let frames = parser.parse(&make_pes(data, Some(0)));
assert_eq!( assert!(
frames.len(), frames.is_empty(),
1, "over-cap AU is force-COMPLETED (bounded) but buffered in its GOP"
"over-cap AU force-flushed rather than buffered"
); );
let frames = parser.flush();
assert_eq!(frames.len(), 1, "force-flushed at EOF, not dropped");
assert!(frames[0].keyframe); assert!(frames[0].keyframe);
} }
+7 -6
View File
@@ -435,17 +435,18 @@ pub const FIELD_ORDER: u32 = 0x9D;
// FlagInterlaced values: 1 = interlaced, 2 = progressive (0 = undetermined). // FlagInterlaced values: 1 = interlaced, 2 = progressive (0 = undetermined).
pub const INTERLACED_INTERLACED: u64 = 1; pub const INTERLACED_INTERLACED: u64 = 1;
pub const INTERLACED_PROGRESSIVE: u64 = 2; pub const INTERLACED_PROGRESSIVE: u64 = 2;
// FieldOrder values (Matroska): 0/2 = top-field-first, 1/9 = bottom-field-first. // FieldOrder values (Matroska / RFC 9559, element 0x9D): 1 = top-field-first,
// NTSC DVD (480i), PAL DVD (576i) and HD (1080i) are all emitted top-field-first // 6 = bottom-field-first, 2 = undetermined, 0 = progressive. NTSC DVD (480i),
// — the muxer hardcodes TFF for every interlaced DVD/HD source (DV is the only // PAL DVD (576i) and HD (1080i) are all emitted top-field-first — the muxer
// common BFF source and freemkv does not produce it). 0xFF is our sentinel for // hardcodes TFF for every interlaced DVD/HD source (DV is the only common BFF
// source and freemkv does not produce it). 0xFF is our sentinel for
// "undetermined / omit". // "undetermined / omit".
pub const FIELD_ORDER_TFF: u8 = 2; pub const FIELD_ORDER_TFF: u8 = 1;
// Bottom-field-first. Retained for completeness/round-trip tests; the muxer // Bottom-field-first. Retained for completeness/round-trip tests; the muxer
// emits TFF for all DVD/HD interlaced content (DV is the only common BFF // emits TFF for all DVD/HD interlaced content (DV is the only common BFF
// source and freemkv does not produce it). // source and freemkv does not produce it).
#[allow(dead_code)] #[allow(dead_code)]
pub const FIELD_ORDER_BFF: u8 = 9; pub const FIELD_ORDER_BFF: u8 = 6;
pub const FIELD_ORDER_UNDETERMINED: u8 = 0xFF; pub const FIELD_ORDER_UNDETERMINED: u8 = 0xFF;
pub const DISPLAY_WIDTH: u32 = 0x54B0; pub const DISPLAY_WIDTH: u32 = 0x54B0;
pub const DISPLAY_HEIGHT: u32 = 0x54BA; pub const DISPLAY_HEIGHT: u32 = 0x54BA;
+1 -1
View File
@@ -149,7 +149,7 @@ impl<S: SectorSource> SectorSource for DecryptingSectorSource<S> {
// count is bytes of scrambled units no key could decrypt — silent // count is bytes of scrambled units no key could decrypt — silent
// decrypt loss the TS assembler will drop. Tally it so the mux loss // decrypt loss the TS assembler will drop. Tally it so the mux loss
// accounting (and the abort gate) can see partial decrypt failure. // accounting (and the abort gate) can see partial decrypt failure.
let dropped = decrypt_sectors(&mut buf[..n], &self.keys, self.unit_key_idx)?; let dropped = decrypt_sectors(&mut buf[..n], &mut self.keys, self.unit_key_idx)?;
if dropped > 0 { if dropped > 0 {
self.decrypt_dropped self.decrypt_dropped
.fetch_add(dropped as u64, Ordering::Relaxed); .fetch_add(dropped as u64, Ordering::Relaxed);
+6 -6
View File
@@ -31,13 +31,13 @@ fn decrypt_sectors_with_aacs_keys_works() {
aacs::decrypt_unit(&mut unit, &unit_key); // decrypt_unit is idempotent on already-encrypted data aacs::decrypt_unit(&mut unit, &unit_key); // decrypt_unit is idempotent on already-encrypted data
// Now we have encrypted data - create DecryptKeys with actual keys // Now we have encrypted data - create DecryptKeys with actual keys
let keys = DecryptKeys::Aacs { let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0u32, unit_key)], unit_keys: vec![(0u32, unit_key)],
read_data_key: None, read_data_key: None,
}; };
// decrypt_sectors should handle this without error // decrypt_sectors should handle this without error
let result = libfreemkv::decrypt::decrypt_sectors(&mut unit, &keys, 0); let result = libfreemkv::decrypt::decrypt_sectors(&mut unit, &mut keys, 0);
assert!( assert!(
result.is_ok(), result.is_ok(),
@@ -50,8 +50,8 @@ fn decrypt_sectors_with_aacs_keys_works() {
fn decrypt_sectors_with_none_keys_is_noop() { fn decrypt_sectors_with_none_keys_is_noop() {
let mut sector = vec![0x42u8; 2048]; let mut sector = vec![0x42u8; 2048];
let keys = DecryptKeys::None; let mut keys = DecryptKeys::None;
let result = libfreemkv::decrypt::decrypt_sectors(&mut sector, &keys, 0); let result = libfreemkv::decrypt::decrypt_sectors(&mut sector, &mut keys, 0);
assert!(result.is_ok()); assert!(result.is_ok());
assert_eq!( assert_eq!(
@@ -70,10 +70,10 @@ fn decrypt_sectors_with_css_keys_works() {
sector[0x14] |= 0x30; sector[0x14] |= 0x30;
let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF]; // Not used - defined later let title_key: [u8; 5] = [0x42, 0x13, 0x37, 0xBE, 0xEF]; // Not used - defined later
let keys = DecryptKeys::Css { title_key }; let mut keys = DecryptKeys::Css { title_key };
// Descramble (CSS uses same operation for encrypt/decrypt) // Descramble (CSS uses same operation for encrypt/decrypt)
libfreemkv::decrypt::decrypt_sectors(&mut sector, &keys, 0).unwrap(); libfreemkv::decrypt::decrypt_sectors(&mut sector, &mut keys, 0).unwrap();
// Flag should be cleared // Flag should be cleared
assert_eq!(sector[0x14] & 0x30, 0x00, "CSS flag should be cleared"); assert_eq!(sector[0x14] & 0x30, 0x00, "CSS flag should be cleared");