From 8e0797eab0c1825060517c60b3a59d7c41e125e3 Mon Sep 17 00:00:00 2001 From: Matthew Jackson <1085847+MattJackson@users.noreply.github.com> Date: Thu, 25 Jun 2026 17:32:17 -0700 Subject: [PATCH] wip: rc6 VFR/DVD/CSS base (held for bulletproofing + split) --- src/css/stevenson.rs | 55 ++-- src/decrypt.rs | 191 +++++++++++--- src/diag.rs | 19 +- src/disc/dvd.rs | 12 +- src/ifo.rs | 131 +++++----- src/mux/codec/mpeg2.rs | 523 ++++++++++++++++++++++++++------------ src/mux/ebml.rs | 13 +- src/sector/decrypting.rs | 2 +- tests/pass_n_patch_fix.rs | 12 +- 9 files changed, 653 insertions(+), 305 deletions(-) diff --git a/src/css/stevenson.rs b/src/css/stevenson.rs index b73f86e..750e6c5 100644 --- a/src/css/stevenson.rs +++ b/src/css/stevenson.rs @@ -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 /// out so the public entry point can wall-clock the whole attempt for the /// 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 { 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. 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 // crib starts at `0x80 - (best_plen/best_p)*best_p` and continues // 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 plain_start = 0x80 - cycles * best_p; - // The cipher is the 10 bytes at 0x80; the crib is their predicted - // plaintext. The periodic run (period `best_p`) is known to continue - // 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 + // 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 // equals `sec[plain_start + i]`; for offsets at/after 0x80 the raw // byte is ciphertext, so we MUST wrap within the period rather than // read it. (Reading `&sec[plain_start..+10]` directly — as before — // pulled ciphertext into the crib whenever the run covered fewer than // 10 bytes before 0x80, producing false-negative key recovery.) - let crypted = §or[0x80..0x80 + 10]; let mut plain = [0u8; 10]; for (i, p) in plain.iter_mut().enumerate() { *p = sector[plain_start + (i % best_p)]; } + Some(plain) + } else { + None + } +} - if let Some(key) = recover_title_key_from_plain(crypted, &plain, &seed) { - // Verify against the same predicted plaintext. - if descramble_matches(sector, &key, &plain) { - return Some(key); - } +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 = §or[0x80..0x80 + 10]; + if let Some(key) = recover_title_key_from_plain(crypted, &plain, &seed) { + // Verify against the same predicted plaintext. + if descramble_matches(sector, &key, &plain) { + return Some(key); } } - None } diff --git a/src/decrypt.rs b/src/decrypt.rs index 7d7c0c9..71673ca 100644 --- a/src/decrypt.rs +++ b/src/decrypt.rs @@ -183,7 +183,7 @@ impl DecryptKeys { /// scrambled unit decrypted. pub fn decrypt_sectors( buf: &mut [u8], - keys: &DecryptKeys, + keys: &mut DecryptKeys, unit_key_idx: usize, ) -> Result { let dropped: usize = match keys { @@ -351,8 +351,41 @@ pub fn decrypt_sectors( dropped_bytes.into_inner() } 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) { + if chunk.len() < 2048 || !css::is_scrambled(chunk) { + continue; + } + let crib = css::stevenson::attack_crib(chunk); + let original: Option> = crib.as_ref().map(|_| chunk.to_vec()); 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 } @@ -381,11 +414,11 @@ mod tests { } let snapshot = unit.clone(); - let keys = DecryptKeys::Aacs { + let mut keys = DecryptKeys::Aacs { unit_keys: vec![(0, [0xAB; 16])], read_data_key: None, }; - decrypt_sectors(&mut unit, &keys, 0).unwrap(); + decrypt_sectors(&mut unit, &mut keys, 0).unwrap(); assert_eq!( unit, snapshot, "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. #[test] fn aacs_clear_trailing_partial_is_tolerated_unchanged() { - let keys = DecryptKeys::Aacs { + let mut keys = DecryptKeys::Aacs { unit_keys: vec![(0, [0xAB; 16])], read_data_key: None, }; @@ -434,7 +467,7 @@ mod tests { let mut buf = unit; 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!( &buf[aacs::ALIGNED_UNIT_LEN..], @@ -449,7 +482,7 @@ mod tests { /// corruption, so we must fail loud with `DecryptFailed`. #[test] fn aacs_scrambled_trailing_partial_is_rejected() { - let keys = DecryptKeys::Aacs { + let mut keys = DecryptKeys::Aacs { unit_keys: vec![(0, [0xAB; 16])], read_data_key: None, }; @@ -459,7 +492,7 @@ mod tests { let mut buf = unit; 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"); assert_eq!( err.code(), @@ -471,12 +504,12 @@ mod tests { /// An empty buffer is a valid no-op (zero units), not an error. #[test] fn aacs_empty_buffer_is_ok() { - let keys = DecryptKeys::Aacs { + let mut keys = DecryptKeys::Aacs { unit_keys: vec![(0, [0xAB; 16])], read_data_key: None, }; let mut buf: Vec = 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 @@ -484,14 +517,14 @@ mod tests { /// attempted. Two clear units must round-trip untouched and return `Ok`. #[test] fn aacs_exact_multiple_unchanged() { - let keys = DecryptKeys::Aacs { + let mut keys = DecryptKeys::Aacs { unit_keys: vec![(0, [0xAB; 16])], read_data_key: None, }; let mut buf = clear_ts_region(aacs::ALIGNED_UNIT_LEN * 2); 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!( buf, snapshot, @@ -512,7 +545,7 @@ mod tests { fn none_keys_is_noop() { let mut buf: Vec = (0..4096u32).map(|i| (i % 256) as u8).collect(); 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"); } @@ -564,8 +597,8 @@ mod tests { let title_key = [0x42, 0x13, 0x37, 0xBE, 0xEF]; let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42]; let (mut sector, plaintext) = make_css_sector(&title_key, &seed, 0xA5); - let keys = DecryptKeys::Css { title_key }; - decrypt_sectors(&mut sector, &keys, 0).expect("CSS decrypt is Ok"); + let mut keys = DecryptKeys::Css { title_key }; + decrypt_sectors(&mut sector, &mut keys, 0).expect("CSS decrypt is Ok"); assert_eq!( §or[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 mut buf = s0; buf.extend_from_slice(&s1); - let keys = DecryptKeys::Css { title_key }; - decrypt_sectors(&mut buf, &keys, 0).expect("CSS multi-sector decrypt is Ok"); + let mut keys = DecryptKeys::Css { title_key }; + decrypt_sectors(&mut buf, &mut keys, 0).expect("CSS multi-sector decrypt is Ok"); assert_eq!( &buf[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, Vec) { + 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 = (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 /// untouched — descramble_sector early-returns on a zero flag. A clear /// sector mixed into the buffer must not be corrupted. @@ -622,8 +743,8 @@ mod tests { let mut sector = vec![0x77u8; 2048]; sector[0x14] = 0x00; // not scrambled let snapshot = sector.clone(); - let keys = DecryptKeys::Css { title_key }; - decrypt_sectors(&mut sector, &keys, 0).unwrap(); + let mut keys = DecryptKeys::Css { title_key }; + decrypt_sectors(&mut sector, &mut keys, 0).unwrap(); assert_eq!(sector, snapshot, "clear CSS sector must be left untouched"); } @@ -636,8 +757,8 @@ mod tests { #[test] fn css_empty_buffer_is_ok() { let mut buf: Vec = Vec::new(); - let keys = DecryptKeys::Css { title_key: [0; 5] }; - assert!(decrypt_sectors(&mut buf, &keys, 0).is_ok()); + let mut keys = DecryptKeys::Css { title_key: [0; 5] }; + assert!(decrypt_sectors(&mut buf, &mut keys, 0).is_ok()); } // ── AACS unit-key index selection ────────────────────────────────────── @@ -653,12 +774,12 @@ mod tests { /// fails. #[test] fn aacs_out_of_range_unit_key_idx_errors() { - let keys = DecryptKeys::Aacs { + let mut keys = DecryptKeys::Aacs { unit_keys: vec![(0, [0xAB; 16])], read_data_key: None, }; 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"); assert_eq!( err.code(), @@ -673,12 +794,12 @@ mod tests { /// Mutation: defaulting to [0u8;16] on None would proceed; this fails. #[test] fn aacs_empty_unit_keys_errors() { - let keys = DecryptKeys::Aacs { + let mut keys = DecryptKeys::Aacs { unit_keys: vec![], read_data_key: None, }; 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()); } @@ -751,14 +872,14 @@ mod tests { "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 read_data_key: None, }; // Call with the default hint (idx 0) — the fix must fall back to key1. 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!( !aacs::is_aacs_scrambled(&buf), @@ -785,12 +906,12 @@ mod tests { let mut unit = clear_ts_unit(); aacs_encrypt_unit_for_test(&mut unit, &key); - let keys = DecryptKeys::Aacs { + let mut keys = DecryptKeys::Aacs { unit_keys: vec![(0, key)], read_data_key: None, }; 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!( !aacs::is_aacs_scrambled(&buf), "single-key disc: TS syncs must be restored" @@ -829,13 +950,13 @@ mod tests { "encrypted unit must look scrambled going in" ); - let keys = DecryptKeys::Aacs { + let mut keys = DecryptKeys::Aacs { unit_keys: vec![(0, wrong_key)], read_data_key: None, }; let mut buf = unit; - let dropped = - decrypt_sectors(&mut buf, &keys, 0).expect("undecryptable unit is not a hard error"); + let dropped = decrypt_sectors(&mut buf, &mut keys, 0) + .expect("undecryptable unit is not a hard error"); assert_eq!( dropped, @@ -872,11 +993,11 @@ mod tests { buf.extend_from_slice(&unit_a); buf.extend_from_slice(&unit_b); - let keys = DecryptKeys::Aacs { + let mut keys = DecryptKeys::Aacs { unit_keys: vec![(0, key)], 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!( dropped, @@ -901,12 +1022,12 @@ mod tests { let key = [0x77u8; 16]; let mut unit = clear_ts_unit(); aacs_encrypt_unit_for_test(&mut unit, &key); - let keys = DecryptKeys::Aacs { + let mut keys = DecryptKeys::Aacs { unit_keys: vec![(0, key)], read_data_key: None, }; 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"); } diff --git a/src/diag.rs b/src/diag.rs index 0848e4a..23d49b5 100644 --- a/src/diag.rs +++ b/src/diag.rs @@ -148,14 +148,15 @@ pub fn dvd_cell_row(idx: usize, cell: &crate::ifo::DvdCell, dropped: bool) -> St "keep(plain-feature)" }; format!( - "tag=dvd.cell idx={idx} cat=0x{:02X} type={} block_mode={} block_type={} \ -seamless={} ilv={} plain={} first={} last={} dur={:.1}s {}", + "tag=dvd.cell idx={idx} cat=0x{:02X} block_mode={} block_type={} \ +seamless={} ilv={} stc={} angle={} plain={} first={} last={} dur={:.1}s {}", cell.category, - c.cell_type, c.block_mode, c.block_type, c.seamless_play as u8, c.interleaved as u8, + c.stc_discontinuity as u8, + c.seamless_angle as u8, c.is_plain_feature() as u8, cell.first_sector, cell.last_sector, @@ -726,23 +727,25 @@ mod tests { }; let row = dvd_cell_row(0, &plain, false); 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("last=199"), "{row}"); assert!(row.contains("dur=12.5s"), "{row}"); assert!(row.contains("keep(plain-feature)"), "{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 { first_sector: 0, last_sector: 9, - category: 0x80, + category: 0x90, duration_secs: 1.0, }; let row = dvd_cell_row(0, &sec, true); - assert!(row.contains("cat=0x80"), "{row}"); - assert!(row.contains("type=2"), "{row}"); + assert!(row.contains("cat=0x90"), "{row}"); + assert!(row.contains("block_mode=2"), "{row}"); + assert!(row.contains("block_type=1"), "{row}"); assert!(row.contains("DROP(leading-secondary-block-piece)"), "{row}"); } } diff --git a/src/disc/dvd.rs b/src/disc/dvd.rs index 3a23cc8..9bbd01f 100644 --- a/src/disc/dvd.rs +++ b/src/disc/dvd.rs @@ -1098,14 +1098,14 @@ mod tests { } /// 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) - /// must have that cell DROPPED from the muxed extents, so the rip starts at - /// the real feature. Chapters shift earlier by the dropped duration. + /// interleaved-angle sub-block cell (category 0x90 = in-block cell of an + /// angle block) must have that cell DROPPED from the muxed extents, so the + /// rip starts at the real feature. Chapters shift earlier by the dropped duration. #[test] fn scan_dvd_titles_drops_leading_scene_index_cell() { let mut disc = MemDisc::new(); 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 2: feature (cat 0x00), 59s, sectors 300..399. // Programs: prog0 → cell 1 (feature start), prog1 → cell 3. @@ -1113,7 +1113,7 @@ mod tests { 1000, 0x00, &[ - (0, 9, 0x80, 0x05), + (0, 9, 0x90, 0x05), (100, 199, 0x00, 0x59), (300, 399, 0x00, 0x59), ], @@ -1137,7 +1137,7 @@ mod tests { ], ); 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"); // First extent starts at the feature cell (vob 1000 + 100), not at 1000+0. assert_eq!(t.extents[0].start_lba, 1000 + 100); diff --git a/src/ifo.rs b/src/ifo.rs index 5ab8462..79cfad9 100644 --- a/src/ifo.rs +++ b/src/ifo.rs @@ -58,12 +58,12 @@ pub struct DvdTitle { pub struct DvdCell { pub first_sector: u32, pub last_sector: u32, - /// Raw cell-category byte at `cell_playback + 0` (DVD-Video spec). - /// Packs cell_type (bits 7-6), block_mode (bits 5-4), block_type - /// (bits 3-2), seamless_play (bit 1), interleaved (bit 0). Carried so - /// the extent builder can recognise non-feature leading cells - /// (scene-index / interleaved angle sub-blocks) and the diagnostic dump - /// can show why a cell was kept or dropped. + /// Raw cell-category byte at `cell_playback + 0` (libdvdread layout). + /// Packs block_mode (bits 7-6), block_type (bits 5-4), seamless_play + /// (bit 3), interleaved (bit 2), stc_discontinuity (bit 1), + /// seamless_angle (bit 0). Carried so the extent builder can recognise + /// non-feature leading cells (interleaved angle sub-blocks) and the + /// diagnostic dump can show why a cell was kept or dropped. pub category: u8, /// Per-cell playback duration in seconds (BCD time at `cell_playback + 4`). /// 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 -/// 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)] pub struct CellCategory { - /// bits 7-6: 0=normal, 1=first cell of angle block, 2=middle, 3=last. - pub cell_type: u8, - /// bits 5-4: 0=not in block, 1=first cell of block, 2=in block, 3=last. + /// bits 7-6: 0=not in block, 1=first cell of block, 2=in block, 3=last cell. 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, - /// bit 1: seamless playback (STC continuous). + /// bit 3: seamless playback (STC continuous). pub seamless_play: bool, - /// bit 0: interleaved (multi-angle / seamless-branch interleave). + /// bit 2: interleaved (multi-angle / seamless-branch interleave). 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 { - /// Decode the raw `cell_playback + 0` byte. + /// Decode the raw `cell_playback + 0` byte (libdvdread `read_cell_playback`). pub fn decode(raw: u8) -> Self { CellCategory { - cell_type: (raw >> 6) & 0x03, - block_mode: (raw >> 4) & 0x03, - block_type: (raw >> 2) & 0x03, - seamless_play: (raw & 0x02) != 0, - interleaved: (raw & 0x01) != 0, + block_mode: (raw >> 6) & 0x03, + block_type: (raw >> 4) & 0x03, + seamless_play: (raw & 0x08) != 0, + interleaved: (raw & 0x04) != 0, + stc_discontinuity: (raw & 0x02) != 0, + seamless_angle: (raw & 0x01) != 0, } } - /// A plain feature cell: not part of any angle/interleave block. Every - /// cell of a normal single-angle feature decodes to this (`category` - /// byte `0x00`, or `0x00` in every block field with only the - /// seamless/interleaved flags possibly set). Such a cell is NEVER - /// dropped by the leading-cell filter. + /// A plain feature cell: not part of any angle/interleave block. Every cell + /// of a normal single-angle feature decodes to this (`block_mode` and + /// `block_type` both 0, only the seamless/interleaved flags possibly set). + /// Such a cell is NEVER dropped by the leading-cell filter. 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 - /// "last" cell of an angle block (`cell_type ∈ {2,3}`), or an - /// in-block / last-of-block cell (`block_mode ∈ {2,3}`). Concatenating - /// these back-to-back with the first angle duplicates content at the head - /// of the feature. Conservative: the FIRST cell of a block - /// (`cell_type==1` / `block_mode==1`) is NOT flagged — it is the angle we - /// keep. + /// Marks a non-first piece of an angle block: an "in-block" or "last of + /// block" cell (`block_mode ∈ {2,3}`) of an angle block (`block_type==1`). + /// Concatenating these back-to-back with the first angle duplicates content + /// at the head of the feature. Conservative: the FIRST cell of a block + /// (`block_mode==1`) is NOT flagged — it is the angle we keep. 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 { _ => 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 { 0 => 48000, 1 => 96000, @@ -1174,11 +1177,11 @@ mod tests { #[test] fn audio_attr_dts() { let mut data = vec![0u8; 16]; - // DTS (coding=6), 96kHz (rate=1), 2 channels (stored as 1) - // b0: bits 7-5=110(DTS), bits 4-3=01(96k) => 0b110_01_000 = 0xC8 - data[0] = 0xC8; - // b1: bits 2-0=001 (channels-1=1) => 0x01 - data[1] = 0x01; + // DTS (coding=6), 96kHz (rate=1, byte1 bits 5-4), 2 channels (stored as 1) + // b0: bits 7-5=110(DTS) => 0b110_00000 = 0xC0 + data[0] = 0xC0; + // b1: bits 5-4=01(96k), bits 2-0=001(channels-1=1) => 0b00_01_0_001 = 0x11 + data[1] = 0x11; data[2] = b'f'; data[3] = b'r'; @@ -1546,13 +1549,13 @@ mod tests { } } - /// CellCategory decodes the spec bitfields: cell_type (7-6), block_mode - /// (5-4), block_type (3-2), seamless (1), interleaved (0). + /// CellCategory decodes the libdvdread byte-0 bitfields: block_mode (7-6), + /// block_type (5-4), seamless_play (3), interleaved (2), + /// stc_discontinuity (1), seamless_angle (0). #[test] fn cell_category_decode_bits() { // 0x00 → plain feature, nothing set. let c = CellCategory::decode(0x00); - assert_eq!(c.cell_type, 0); assert_eq!(c.block_mode, 0); assert_eq!(c.block_type, 0); assert!(!c.seamless_play); @@ -1560,27 +1563,29 @@ mod tests { assert!(c.is_plain_feature()); assert!(!c.is_secondary_block_piece()); - // cell_type=1 (first of angle block), block_mode=1 (first of block): - // 0b01_01_00_0_0 = 0x50. This is the angle we KEEP — not secondary. - let c = CellCategory::decode(0b01_01_00_00); - assert_eq!(c.cell_type, 1); + // block_mode=1 (first cell of block), block_type=1 (angle block): + // 0b01_01_0000 = 0x50. This is the angle we KEEP — not secondary. + let c = CellCategory::decode(0b01_01_0000); assert_eq!(c.block_mode, 1); + assert_eq!(c.block_type, 1); assert!(!c.is_plain_feature()); assert!(!c.is_secondary_block_piece()); - // cell_type=2 (middle of angle block): 0b10_00_00_00 = 0x80 → secondary. - assert!(CellCategory::decode(0b10_00_00_00).is_secondary_block_piece()); - // cell_type=3 (last of angle block) → secondary. - assert!(CellCategory::decode(0b11_00_00_00).is_secondary_block_piece()); - // block_mode=2 (in block) → secondary; block_mode=3 (last of block) → secondary. - assert!(CellCategory::decode(0b00_10_00_00).is_secondary_block_piece()); - assert!(CellCategory::decode(0b00_11_00_00).is_secondary_block_piece()); + // block_mode=2 (in block) / 3 (last of block) of an angle block + // (block_type=1) → secondary. + assert!(CellCategory::decode(0b10_01_0000).is_secondary_block_piece()); + assert!(CellCategory::decode(0b11_01_0000).is_secondary_block_piece()); + // First cell of the block (block_mode=1) is NEVER secondary. + assert!(!CellCategory::decode(0b01_01_0000).is_secondary_block_piece()); - // seamless (bit1) + interleaved (bit0) on an otherwise-plain cell must - // NOT make it secondary — they don't mark non-feature content. - let c = CellCategory::decode(0b00_00_00_11); + // The low flags (seamless_play bit3, interleaved bit2, stc bit1, + // seamless_angle bit0) on an otherwise-plain cell must NOT make it + // secondary — they don't mark non-feature content. + let c = CellCategory::decode(0b0000_1111); assert!(c.seamless_play); assert!(c.interleaved); + assert!(c.stc_discontinuity); + assert!(c.seamless_angle); assert!(c.is_plain_feature()); assert!(!c.is_secondary_block_piece()); } @@ -1614,9 +1619,9 @@ mod tests { chapters: 2, duration_secs: 100.0, cells: vec![ - cell(0, 9, 0b10_00_00_00), // middle of angle block → drop - cell(10, 19, 0b00_11_00_00), // last of block → drop - cell(20, 119, 0x00), // feature starts here + cell(0, 9, 0b10_01_0000), // in-block cell of angle block → drop + cell(10, 19, 0b11_01_0000), // last cell of angle block → drop + cell(20, 119, 0x00), // feature starts here cell(120, 219, 0x00), ], chapter_times: vec![0.0, 50.0], @@ -1637,7 +1642,7 @@ mod tests { let t = DvdTitle { chapters: 1, 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], palette: None, }; @@ -1669,8 +1674,8 @@ mod tests { pgc[0xE8] = 0x00; pgc[0xE9] = 0xEA; pgc.resize(0xEA + 48, 0); - // Cell 0: category byte = 0x80 (middle of angle block), 5s BCD. - pgc[0xEA] = 0x80; + // Cell 0: category byte = 0x90 (in-block cell of angle block), 5s BCD. + pgc[0xEA] = 0x90; pgc[0xEA + 6] = 0x05; pgc[0xEA + 8..0xEA + 12].copy_from_slice(&10u32.to_be_bytes()); // Cell 1: category 0x00 (plain feature), 7s BCD. @@ -1678,7 +1683,7 @@ mod tests { pgc[0xEA + 24 + 6] = 0x07; pgc[0xEA + 24 + 8..0xEA + 24 + 12].copy_from_slice(&20u32.to_be_bytes()); 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_eq!(title.cells[1].category, 0x00); assert!((title.cells[1].duration_secs - 7.0).abs() < 0.01); diff --git a/src/mux/codec/mpeg2.rs b/src/mux/codec/mpeg2.rs index be74559..8766cd9 100644 --- a/src/mux/codec/mpeg2.rs +++ b/src/mux/codec/mpeg2.rs @@ -101,32 +101,39 @@ pub struct Mpeg2Parser { /// `(absolute ES offset of a PES's first byte, PTS in ns)` for every PES /// that carried a timestamp, in ascending offset order. pts_marks: VecDeque<(u64, i64)>, - /// Per-frame presentation interval (ns), derived from the sequence header - /// frame rate. DVD stamps a PTS only ~once per VOBU (every ~0.5 s), so - /// frames between marks must be timed by `temporal_reference` × this - /// interval. 0 until a sequence header with a valid frame rate is seen. + /// Full-frame presentation interval (ns) at the sequence-header display rate + /// (`1/frame_rate`). The field period is half this. Per-frame durations are + /// `nb_fields × field_period`, so 2:3-telecined frames alternate 2- and + /// 3-field durations. 0 until a sequence header with a valid frame rate. frame_duration_ns: i64, - /// Cumulative count of coded pictures emitted in all GOPs before the - /// current one. `temporal_reference` is GOP-relative (display order within - /// the GOP); adding this base makes a whole-stream display index. - gop_base: u64, - /// Coded pictures emitted in the current GOP so far (folded into - /// `gop_base` at the next GOP boundary). - gop_count: u64, - /// Display index of the last frame that carried an explicit PES PTS, used - /// to anchor interpolated timestamps to the real disc timeline (so video - /// stays in sync with the PES-timestamped audio tracks). - anchor_index: Option, - /// PTS (ns) of the anchor frame. - anchor_pts: i64, - /// Frames emitted before the first PES PTS anchor is known, held with their - /// display index. A DVD title can open with a still-frame/first-play - /// sequence whose PTS lands a few frames in; buffering until the anchor lets - /// 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 - /// pre-anchor hold by BYTES, not just frame count (see [`MAX_PENDING_BYTES`]). - pending_bytes: usize, + /// `progressive_sequence` from the sequence extension — selects the + /// `nb_fields` rules for `repeat_first_field` pictures. + progressive_sequence: bool, + /// Pictures of the current GOP, buffered in DECODE order until the GOP + /// completes (the next GOP/sequence header). Held so each frame's PTS can be + /// the display-order prefix-sum of field durations — exact for 2:3 pulldown + /// without ever reordering emitted blocks (B-frames keep decode order; only + /// their PTS is lower). + gop_buf: Vec, + /// Total field-display periods of all frames already emitted, in display + /// order — the running base for each new frame's display time. + emitted_fields: u64, + /// PTS (ns) that display-field 0 of the whole stream maps to. Re-locked from + /// each GOP's first PES PTS so video stays in sync with the PES-timestamped + /// audio. None until the first PES timestamp is seen. + origin_pts_ns: Option, +} + +/// One coded picture buffered awaiting its GOP's completion (see `gop_buf`). +struct BufferedPicture { + /// `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, + /// The emitted frame (PTS + duration filled in at GOP flush). + frame: Frame, } impl Default for Mpeg2Parser { @@ -144,12 +151,10 @@ impl Mpeg2Parser { base_offset: 0, pts_marks: VecDeque::new(), frame_duration_ns: 0, - gop_base: 0, - gop_count: 0, - anchor_index: None, - anchor_pts: 0, - pending: Vec::new(), - pending_bytes: 0, + progressive_sequence: false, + gop_buf: Vec::new(), + emitted_fields: 0, + origin_pts_ns: None, } } @@ -174,22 +179,6 @@ impl Mpeg2Parser { 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. /// When `force` is true (EOF flush, or buffer-cap backstop) the trailing /// in-progress access unit is emitted even without a following boundary. @@ -247,12 +236,12 @@ impl Mpeg2Parser { } else { 0 }; - let pic_abs = self.base_offset + pic as u64; let end_abs = self.base_offset + end as u64; let data = self.buf[..end].to_vec(); // Phase 2 — mutate self. if let Some(h) = hdr { + self.progressive_sequence = parse_progressive_sequence(&h); self.seq_header = Some(h); if let Some((num, den)) = self.frame_rate() { if num > 0 { @@ -260,11 +249,7 @@ impl Mpeg2Parser { } } } - if gop_boundary && self.gop_count > 0 { - self.gop_base += self.gop_count; - self.gop_count = 0; - } - let display_index = self.gop_base + tr; + let nb_fields = picture_nb_fields(&data, self.progressive_sequence); // 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 @@ -275,71 +260,29 @@ impl Mpeg2Parser { .filter(|&&(off, _)| off < end_abs) .map(|&(_, p)| p); - let duration_ns = (self.frame_duration_ns > 0).then_some(self.frame_duration_ns as u64); - let mut frame = Frame { - pts_ns: 0, - keyframe, - data, - duration_ns, - }; - - 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); - } - }, - } - } 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); + // A GOP boundary means the buffered run is a COMPLETE GOP (all its + // 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, + keyframe, + data, + duration_ns: None, + }, + }); + // 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); } - - self.gop_count += 1; self.buf.drain(..end); self.base_offset = end_abs; // 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 } + + /// 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) { + 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 = (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 { @@ -374,23 +373,9 @@ impl CodecParser for Mpeg2Parser { } fn flush(&mut self) -> Vec { - let mut out = self.drain_complete_aus(true); - // EOF: if no PES ever supplied a PTS/DTS, `self.pending` still holds the - // frames buffered while waiting for an anchor (the opening keyframe + - // 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 + // drain_complete_aus(true) force-completes the trailing access unit and + // flushes the final GOP, so nothing is left buffered at EOF. + self.drain_complete_aus(true) } fn codec_private(&self) -> Option> { @@ -493,11 +478,145 @@ fn parse_aspect_ratio(hdr: &[u8]) -> Option<(u8, u8)> { 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)] mod tests { use super::*; 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 { + 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, pts: Option) -> PesPacket { PesPacket { pid: 0x1011, @@ -651,9 +770,11 @@ mod tests { } #[test] - fn two_pictures_emit_two_frames_at_the_boundary() { - // pic1's frame is emitted as soon as pic2's start code is seen; pic2 on - // flush. Each frame contains exactly its own picture. + fn two_pictures_in_one_gop_emit_both_on_flush() { + // Two pictures with no GOP/sequence boundary between them are ONE GOP. + // 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 pic1 = make_picture_header(PICTURE_TYPE_I); @@ -664,17 +785,13 @@ mod tests { let mut stream = pic1.clone(); stream.extend_from_slice(&pic2); - let mut frames = parser.parse(&make_pes(stream, Some(0))); - assert_eq!( - frames.len(), - 1, - "first picture emitted at second's boundary" - ); + let frames = parser.parse(&make_pes(stream, Some(0))); + assert!(frames.is_empty(), "same GOP — buffered until flush"); + + let frames = parser.flush(); + assert_eq!(frames.len(), 2); assert_eq!(frames[0].data, pic1); assert!(frames[0].keyframe); - - frames.extend(parser.flush()); - assert_eq!(frames.len(), 2); assert_eq!(frames[1].data, pic2); assert!(!frames[1].keyframe); } @@ -703,23 +820,24 @@ mod tests { #[test] 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(); - // PES 1: pic1 (PTS 90000) + start of pic2's bytes carried later. let mut pic1 = make_picture_header(PICTURE_TYPE_I); pic1.extend_from_slice(&vec![0x11; 50]); 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); pic2.extend_from_slice(&vec![0x22; 50]); - let mut frames = parser.parse(&make_pes(pic2, Some(180000))); - assert_eq!(frames.len(), 1, "pic1 emitted when pic2 starts"); - assert_eq!(frames[0].pts_ns, 1_000_000_000, "pic1 → PTS 90000"); + let frames2 = parser.parse(&make_pes(pic2, Some(180000))); + assert!(frames2.is_empty(), "same GOP — still buffered"); - frames.extend(parser.flush()); + let frames = parser.flush(); 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"); } @@ -761,6 +879,89 @@ mod tests { 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 { + 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] fn temporal_reference_resets_each_gop_via_gop_base() { // 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); a.extend_from_slice(&make_picture_header(PICTURE_TYPE_I)); a.extend_from_slice(&[0xAA; 20]); - a.extend_from_slice(&gop()); // boundary → AU A emits - let fa = parser.parse(&make_pes(a, Some(0))); - assert_eq!(fa.len(), 1); + a.extend_from_slice(&gop()); // trailing GOP header starts the next GOP + let _fa = parser.parse(&make_pes(a, Some(0))); + // 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))); // 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. data.extend(std::iter::repeat_n(0xAA, MAX_AU_BUFFER + 1024)); let frames = parser.parse(&make_pes(data, Some(0))); - assert_eq!( - frames.len(), - 1, - "over-cap AU force-flushed rather than buffered" + assert!( + frames.is_empty(), + "over-cap AU is force-COMPLETED (bounded) but buffered in its GOP" ); + let frames = parser.flush(); + assert_eq!(frames.len(), 1, "force-flushed at EOF, not dropped"); assert!(frames[0].keyframe); } diff --git a/src/mux/ebml.rs b/src/mux/ebml.rs index b5c4ad9..29bd22b 100644 --- a/src/mux/ebml.rs +++ b/src/mux/ebml.rs @@ -435,17 +435,18 @@ pub const FIELD_ORDER: u32 = 0x9D; // FlagInterlaced values: 1 = interlaced, 2 = progressive (0 = undetermined). pub const INTERLACED_INTERLACED: u64 = 1; pub const INTERLACED_PROGRESSIVE: u64 = 2; -// FieldOrder values (Matroska): 0/2 = top-field-first, 1/9 = bottom-field-first. -// NTSC DVD (480i), PAL DVD (576i) and HD (1080i) are all emitted top-field-first -// — the muxer 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 +// FieldOrder values (Matroska / RFC 9559, element 0x9D): 1 = top-field-first, +// 6 = bottom-field-first, 2 = undetermined, 0 = progressive. NTSC DVD (480i), +// PAL DVD (576i) and HD (1080i) are all emitted top-field-first — the muxer +// 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". -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 // emits TFF for all DVD/HD interlaced content (DV is the only common BFF // source and freemkv does not produce it). #[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 DISPLAY_WIDTH: u32 = 0x54B0; pub const DISPLAY_HEIGHT: u32 = 0x54BA; diff --git a/src/sector/decrypting.rs b/src/sector/decrypting.rs index 723aeda..3e86b82 100644 --- a/src/sector/decrypting.rs +++ b/src/sector/decrypting.rs @@ -149,7 +149,7 @@ impl SectorSource for DecryptingSectorSource { // count is bytes of scrambled units no key could decrypt — silent // decrypt loss the TS assembler will drop. Tally it so the mux loss // 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 { self.decrypt_dropped .fetch_add(dropped as u64, Ordering::Relaxed); diff --git a/tests/pass_n_patch_fix.rs b/tests/pass_n_patch_fix.rs index 451b589..94c3579 100644 --- a/tests/pass_n_patch_fix.rs +++ b/tests/pass_n_patch_fix.rs @@ -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 // 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)], read_data_key: None, }; // 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!( result.is_ok(), @@ -50,8 +50,8 @@ fn decrypt_sectors_with_aacs_keys_works() { fn decrypt_sectors_with_none_keys_is_noop() { let mut sector = vec![0x42u8; 2048]; - let keys = DecryptKeys::None; - let result = libfreemkv::decrypt::decrypt_sectors(&mut sector, &keys, 0); + let mut keys = DecryptKeys::None; + let result = libfreemkv::decrypt::decrypt_sectors(&mut sector, &mut keys, 0); assert!(result.is_ok()); assert_eq!( @@ -70,10 +70,10 @@ fn decrypt_sectors_with_css_keys_works() { sector[0x14] |= 0x30; 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) - libfreemkv::decrypt::decrypt_sectors(&mut sector, &keys, 0).unwrap(); + libfreemkv::decrypt::decrypt_sectors(&mut sector, &mut keys, 0).unwrap(); // Flag should be cleared assert_eq!(sector[0x14] & 0x30, 0x00, "CSS flag should be cleared");