diff --git a/CHANGELOG.md b/CHANGELOG.md index 2be8b7a..4cbbcf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,38 @@ # Changelog +## [1.4.1] — 2026-07-14 + +### Fixed + +- **Mux no longer discards good video over a single defective packet.** AACS + content decryption judged a 6144-byte aligned unit "undecryptable" unless + **every** content packet was conformant MPEG-TS. A single authored-bad packet + — a pressing/encoding defect, or an AACS 2.1 forensic-variant frame — made the + mux conceal the **whole** unit as NULL TS, destroying up to 31 of 32 good + packets and tallying them as loss. On discs carrying such packets this + surfaced as false "corruption" over large runs of otherwise-perfect video + (observed across two UHD titles: ~466 MB concealed, every unit decryptable). + The decrypt path now asks only *"did a key OPEN this unit?"* — a padding-aware + **≥75% supermajority** of content packets restoring their `0x47` sync, a gate + no wrong key can reach (uniform-AES noise floor ≈ 256⁻ⁿ) yet one that tolerates + a minority of authored-bad packets. Opened units pass through **verbatim**; a + non-conforming packet is left for the demuxer to drop on sync-loss and resync + past — TS-sync conformance is a muxer concern, never a decryption verdict. The + read/decrypt path no longer rewrites content bytes. The post-read verify/sweep + gate now shares the exact same primitive (`decrypt_unit` for TS), so verify can + never disagree with the mux decrypt and never false-marks a defect unit as a + bad read. +- **MVC (Blu-ray 3D) track signals unified and hardened.** The `mvcC` + `CodecPrivate` extension, the `BlockAdditionMapping`, and each frame's + `BlockAdditional` now all derive from a single `MVCDecoderConfigurationRecord` + built once per track, so they can no longer diverge. A track is flagged 3D + only when that record actually builds — a malformed dependent-view parameter + set no longer emits a mapping with no matching record (previously the flag was + taken from `mvc_params.is_some()`, which could orphan a `BlockAddID`). The base + track's `CodecPrivate` now carries the `mvcC` extension block + (`avcC ‖ u32be(size) ‖ "mvcC" ‖ record`, Matroska-spec size = block − 4) so + players and mediainfo detect MVC at the track level. + ## [1.4.0] — 2026-07-13 ### Added diff --git a/src/aacs/content.rs b/src/aacs/content.rs index 766014d..056aeca 100644 --- a/src/aacs/content.rs +++ b/src/aacs/content.rs @@ -113,51 +113,68 @@ pub fn aacs_unit_needs_decrypt(unit: &[u8]) -> bool { aacs_unit_encrypted(unit) && ts_sync_destroyed(unit) } -/// PADDING-AWARE "is this aligned unit STILL genuine ciphertext?" — the conceal- -/// path twin of [`decrypt_unit`]'s acceptance criterion, run on the POST-decrypt -/// bytes. +/// The one canonical "did a key OPEN this unit?" signal — a padding-aware, +/// defect-TOLERANT structural check on the POST-decrypt bytes. /// -/// [`aacs_unit_needs_decrypt`] cannot answer this: it composes CPI with the -/// MAJORITY-VOTE [`ts_sync_destroyed`] (`ts_sync_count <= total/2`). A -/// successfully padding-aware-decrypted content-fragment TAIL unit (e.g. 11 of 32 -/// packets are real content, the other 21 source-zero padding) carries only 11 TS -/// syncs after decrypt, so the majority vote calls it "destroyed" and -/// `aacs_unit_needs_decrypt` returns true — even though the unit decrypted -/// PERFECTLY. Concealing on that predicate overwrites the good decrypted tail with -/// NULL-TS, silently discarding correct video (the bug this fixes; the 1.2.0 -/// fragment-tail fix in [`decrypt_unit`] must not be undone by the conceal loop). +/// ARCHITECTURE (why this is the only TS-sync test the read/decrypt path may +/// use): TS-sync presence is a *muxer* concern, not a decryption verdict. The +/// read/decrypt path is not allowed to reject or conceal a unit just because a +/// packet isn't conformant MPEG-TS — a non-conforming packet inside an otherwise +/// perfectly-decrypted unit is an authoring/pressing defect (or an AACS 2.1 +/// forensic-variant frame), which the demuxer drops on sync-loss and resyncs +/// past. The ONLY thing the decrypt path legitimately needs from TS structure is +/// KEY SELECTION: "did this candidate key turn ciphertext back into MPEG-TS at +/// all?" — used to pick among held keys (multi-CPS-unit discs) and to detect a +/// genuinely-missing key. That is a coarse, all-or-nothing question, and this is +/// its answer. /// -/// The correct, padding-aware notion of "still ciphertext", checkable on the -/// post-decrypt bytes, uses the SAME discriminator [`decrypt_unit`] uses: -/// * A genuinely-FAILED unit was restored to on-disc ciphertext by -/// `decrypt_unit_try_keys`/`decrypt_buf` → its packets are scrambled: a -/// non-padding (non-zero payload) packet LACKS the `0x47` sync at offset 4. -/// * A SUCCESSFULLY-decrypted unit (full OR padding-tail) → every non-zero -/// (content) packet carries `0x47`; padding packets are all-zero. +/// Verdict: the key opened the unit iff a SUPERMAJORITY (>= 75%) of the +/// non-padding content packets carry their `0x47` TS sync. Rationale: +/// * WRONG key → AES output is uniform random → each content packet carries +/// `0x47` at offset 4 with probability 1/256 → ~0 synced. Reaching 75% by +/// chance is cryptographically impossible (e.g. 17-of-22 ≈ 256^-17). So a +/// wrong key can NEVER pass — no silent-corruption hole. +/// * RIGHT key → every real content packet decrypts to clear TS. A handful of +/// authored-bad packets (pressing defects / variant frames) legitimately +/// lack `0x47`, but they are a small minority and MUST NOT reject the unit — +/// the decrypted bytes (defects and all) pass through verbatim; the muxer +/// handles the bad packets. This is the whole point: correctness of a +/// *packet* is not a decryption verdict. /// -/// So this is true iff `aacs_unit_encrypted` AND at least one 192-byte packet -/// whose 188-byte payload (`[off+4..off+192]`) is NOT all-zero is missing its -/// `0x47` sync at `off+4`. A full content unit (no zero-payload packets) reduces -/// to the strict all-32 check, so the common cases are unchanged: a fully-clear / -/// fully-decrypted unit is never flagged; a fully-ciphertext unit always is. -pub fn aacs_unit_still_ciphertext(unit: &[u8]) -> bool { - if !aacs_unit_encrypted(unit) { - return false; - } +/// Padding-aware: a 192-byte packet whose 188-byte payload is all-zero is source +/// padding (or a NULL packet) and is excluded from the count, so a legitimate +/// content-fragment TAIL (a few real packets + source-zero padding) is judged on +/// its real packets only. A full content unit reduces to "nearly all 32 synced". +pub fn unit_content_decrypted(unit: &[u8]) -> bool { const PKT: usize = BD_SOURCE_PACKET_BYTES; // 192 let limit = ALIGNED_UNIT_LEN.min(unit.len()); + let mut content = 0usize; + let mut synced = 0usize; let mut off = 0; while off + PKT <= limit { - // A packet whose 188-byte payload is all-zero is padding (source zeros) — - // excluded from the verdict, exactly as `decrypt_unit` excludes it. Any - // other (content) packet that lacks its TS sync is un-restored ciphertext. + // All-zero payload → padding / NULL packet → excluded from the verdict. let payload = &unit[off + 4..off + PKT]; - if !payload.iter().all(|&b| b == 0) && unit[off + 4] != TS_SYNC { - return true; + if !payload.iter().all(|&b| b == 0) { + content += 1; + if unit[off + 4] == TS_SYNC { + synced += 1; + } } off += PKT; } - false + // No content packets (all padding) → trivially "opened". Otherwise require a + // >=75% supermajority of content packets restored — the wrong-key-proof gate. + content == 0 || synced * 4 >= content * 3 +} + +/// "Is this aligned unit STILL genuine ciphertext (no held key opened it)?" — the +/// conceal-path twin of [`unit_content_decrypted`], run on the POST-decrypt +/// bytes. True iff the unit is flagged encrypted (CPI set) AND no key opened it +/// (below the supermajority-sync gate). A unit the right key opened — even one +/// carrying a few defective packets — is NOT ciphertext and is never concealed; +/// its bytes belong to the muxer. +pub fn aacs_unit_still_ciphertext(unit: &[u8]) -> bool { + aacs_unit_encrypted(unit) && !unit_content_decrypted(unit) } /// Overwrite an aligned unit (6144 bytes) IN PLACE with valid NULL MPEG-TS @@ -349,20 +366,25 @@ pub fn decrypt_unit(unit: &mut [u8], unit_key: &[u8; 16]) -> bool { // Final 6128 bytes of the aligned unit under the Block Key; first 16 = clear seed. [BD] §3.10.1. aes_cbc_decrypt(&decrypt_key, &mut unit[16..ALIGNED_UNIT_LEN]); - // Verify content packets; zero out padding packets (their decrypted bytes are - // garbage from AES-decrypting zeros, but the source was zero so a clean zero - // fill is lossless and gives the demux a tidy gap instead of garbage). + // Restore source-zero padding packets to zero (their decrypted bytes are + // garbage from AES-decrypting zeros, but the source WAS zero, so writing the + // true source back is faithful — not concealment — and gives the demux a tidy + // gap instead of AES noise). Content packets are left EXACTLY as decrypted: + // the read/decrypt path never rewrites content, so an authored-bad packet + // (no `0x47`) passes through verbatim for the muxer to drop. for (p, &is_pad) in pad.iter().enumerate().take(npkt) { - let off = p * PKT; if is_pad { + let off = p * PKT; for b in unit[off..off + PKT].iter_mut() { *b = 0; } - } else if unit[off + 4] != TS_SYNC { - return false; // a real content packet failed → genuinely undecryptable } } - true + // KEY-SELECTION verdict only: did this key OPEN the unit (restore the TS + // structure of a supermajority of content packets)? A wrong key can't; the + // right key can even when a few content packets are authored-bad. This is NOT + // a per-packet conformance gate — that belongs to the muxer. + unit_content_decrypted(unit) } /// Decrypt an AACS aligned unit in place, accepting the key only when `accept` @@ -882,6 +904,192 @@ mod tests { ); } + // ── Defect-tolerant "did a key OPEN this unit?" verdict ───────────────── + // + // The Bourne-UHD bug: a commercial disc carries the odd authored-bad TS + // packet (a pressing/encoding defect, or an AACS 2.1 forensic-variant frame) + // — one non-conforming packet inside an otherwise perfectly-decrypted 6144 + // unit. The OLD strict per-packet acceptance rejected the WHOLE unit over + // that single packet, so the mux concealed ~all of it as NULL and 21/22 good + // video packets were destroyed and tallied as loss ("false corruption"). + // The read/decrypt path must instead ACCEPT the unit (the key opened it) and + // pass the defective packet through VERBATIM for the muxer to drop. TS-sync + // conformance is a muxer concern, never a decryption verdict. + + /// Build a unit that decrypts to 32 content packets, with `defect_pkts` + /// marked authored-bad (a non-`0x47` at the sync position + non-zero payload + /// so they count as genuine content, not padding), encrypted under `key`. + fn unit_with_defects(key: &[u8; 16], defect_pkts: &[usize]) -> Vec { + let mut unit = clear_unit(); + for &p in defect_pkts { + let off = p * BD_SOURCE_PACKET_BYTES; + unit[off + 4] = 0x80; // NOT a TS sync + unit[off + 5] = 0xAB; // non-zero payload => real content, not padding + } + aacs_encrypt_unit(&mut unit, key); + unit + } + + /// A POST-DECRYPT-looking unit: `content` non-padding packets, `synced` of + /// them carrying `0x47`; the remaining packets are source-zero padding. CPI + /// (encrypted flag) set iff `cpi`. Feeds the key-independent predicates. + fn decrypted_shape(content: usize, synced: usize, cpi: bool) -> Vec { + let mut u = vec![0u8; ALIGNED_UNIT_LEN]; + for p in 0..content { + let off = p * BD_SOURCE_PACKET_BYTES; + u[off + 5] = 0xAB; // non-zero payload => counted as content + u[off + 4] = if p < synced { TS_SYNC } else { 0x80 }; + } + if cpi { + u[0] |= 0xC0; + } + u + } + + #[test] + fn single_authored_bad_packet_still_decrypts_and_passes_verbatim() { + // 1 defective content packet in an otherwise-perfect unit (the real case). + let key = [0x5Au8; 16]; + let mut unit = unit_with_defects(&key, &[17]); + assert!( + decrypt_unit(&mut unit, &key), + "31/32 content packets synced -> the key OPENED the unit" + ); + let off = 17 * BD_SOURCE_PACKET_BYTES; + assert_eq!( + unit[off + 4], + 0x80, + "defect packet's bytes pass through VERBATIM (no null-fill, no zeroing)" + ); + assert_eq!(unit[off + 5], 0xAB, "defect payload untouched"); + assert!( + !aacs_unit_still_ciphertext(&unit), + "an opened unit is NOT ciphertext -> the mux never conceals it" + ); + for p in 0..32 { + if p == 17 { + continue; + } + assert_eq!( + unit[p * BD_SOURCE_PACKET_BYTES + 4], + TS_SYNC, + "every other packet restored its sync (pkt {p})" + ); + } + } + + #[test] + fn several_defect_packets_within_tolerance_still_decrypt() { + // Up to 25% authored-bad content packets are tolerated (opened + passed + // through); the muxer drops them. + let key = [0x33u8; 16]; + let mut unit = unit_with_defects(&key, &[3, 9, 17, 24, 30]); // 5/32 ≈ 16% + assert!( + decrypt_unit(&mut unit, &key), + "27/32 synced (>=75%) -> opened" + ); + } + + #[test] + fn wrong_key_never_opens_a_unit() { + // A wrong key restores ~0 syncs -> far below the supermajority gate. + let key = [0x5Au8; 16]; + let mut unit = clear_unit(); + aacs_encrypt_unit(&mut unit, &key); + assert!( + !decrypt_unit(&mut unit, &[0x22u8; 16]), + "wrong key cannot open the unit" + ); + } + + #[test] + fn threshold_accepts_at_75pct_and_rejects_just_below() { + // 24/32 = exactly 75% opens; 23/32 does not. + assert!(unit_content_decrypted(&decrypted_shape(32, 24, false))); + assert!(!unit_content_decrypted(&decrypted_shape(32, 23, false))); + } + + #[test] + fn still_ciphertext_tracks_the_open_verdict() { + // Fully clean -> opened, not ciphertext. + assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 32, true))); + // One defect -> still opened, still not ciphertext. + assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 31, true))); + // Wrong-key noise floor -> not opened -> ciphertext (concealable). + assert!(aacs_unit_still_ciphertext(&decrypted_shape(32, 3, true))); + // CPI-clear bytes are never "ciphertext" regardless of sync count. + assert!(!aacs_unit_still_ciphertext(&decrypted_shape(32, 0, false))); + } + + #[test] + fn all_padding_unit_is_trivially_opened() { + // CPI set but every packet is source-zero padding: nothing to decrypt. + assert!(unit_content_decrypted(&decrypted_shape(0, 0, true))); + assert!(!aacs_unit_still_ciphertext(&decrypted_shape(0, 0, true))); + } + + #[test] + fn defect_count_boundary_via_real_decrypt() { + // Pin the 75% gate on the REAL decrypt path (not just the predicate): + // 8/32 defects => 24 synced = exactly 75% => opens; 9/32 => 23 synced => + // does NOT open. Packets 1.. avoid the clear-seed packet 0. + let key = [0x77u8; 16]; + let eight: Vec = (1..9).collect(); + let mut u_ok = unit_with_defects(&key, &eight); + assert!( + decrypt_unit(&mut u_ok, &key), + "8 defects (24/32 = 75%) -> opened" + ); + let nine: Vec = (1..10).collect(); + let mut u_no = unit_with_defects(&key, &nine); + assert!( + !decrypt_unit(&mut u_no, &key), + "9 defects (23/32 < 75%) -> NOT opened (too corrupt / wrong key)" + ); + } + + #[test] + fn small_content_units_keep_the_wrong_key_floor() { + // Tiny content units are where a coincidental wrong-key sync matters most. + // The gate stays strict enough that a single fluke can't "open" them. + assert!(unit_content_decrypted(&decrypted_shape(2, 2, false))); // 2/2 -> open + assert!(!unit_content_decrypted(&decrypted_shape(2, 1, false))); // 1/2 -> not + assert!(unit_content_decrypted(&decrypted_shape(4, 3, false))); // 3/4=75% -> open + assert!(!unit_content_decrypted(&decrypted_shape(4, 2, false))); // 2/4 -> not + } + + #[test] + fn fragment_tail_padding_plus_one_defect_still_decrypts() { + // Both tolerances at once: source-zero padding tail EXCLUDED, and the one + // authored-bad packet in the real prefix TOLERATED. 20 real packets + // (pkt 5 defective) => 19/20 synced => opened; padding emitted as zeros. + let key = [0x5Au8; 16]; + let mut unit = clear_unit(); + let off = 5 * BD_SOURCE_PACKET_BYTES; + unit[off + 4] = 0x80; // defect inside the real content + unit[off + 5] = 0xAB; + for b in unit[20 * BD_SOURCE_PACKET_BYTES..].iter_mut() { + *b = 0; // source-zero padding tail (packets 20..32) + } + aacs_encrypt_unit(&mut unit, &key); + assert!( + decrypt_unit(&mut unit, &key), + "19/20 real packets synced + zero pad -> opened" + ); + assert_eq!( + unit[off + 4], + 0x80, + "the defect packet passes through verbatim" + ); + for p in 20..32 { + let o = p * BD_SOURCE_PACKET_BYTES; + assert!( + unit[o..o + BD_SOURCE_PACKET_BYTES].iter().all(|&b| b == 0), + "padding pkt {p} emitted as clean zeros" + ); + } + } + #[test] fn wrong_key_full_unit_is_not_decryptable() { let mut unit = clear_unit(); diff --git a/src/disc/verify.rs b/src/disc/verify.rs index 2a025bd..997f7ad 100644 --- a/src/disc/verify.rs +++ b/src/disc/verify.rs @@ -6,8 +6,11 @@ //! decrypt-verify are meaningful is each clip's FILE-anchored 6144-byte unit //! grid (clips can start off the 6144 grid and fragment across UDF extents). So //! this gate BUFFERS the disc-absolute read stream and re-ALIGNS it into -//! clip-file units, then applies the standards-correct -//! [`crate::aacs::content::unit_is_clean_ts`] gate (all-32 TS syncs). +//! clip-file units, then decrypts each with the SAME primitive the mux/rip path +//! uses ([`crate::aacs::content::decrypt_unit`] for TS) so verify and the real +//! decrypt can never disagree. That verdict is "did a key OPEN this unit?" — +//! defect-TOLERANT: an opened unit carrying a few authored-bad packets (pressing +//! defects / forensic-variant frames) is NOT bad; only a unit no key opens is. //! //! FAIL-SAFE CONTRACT (this sits in the middle of every read, so it must never //! break a good read): the gate can ONLY downgrade a unit it is *confident* is @@ -62,6 +65,15 @@ pub enum ContainerKind { Ps, } +/// PS-container per-unit decrypt for the verify gate — the analogue of +/// [`aacs::content::decrypt_unit`] (TS) for HD-DVD program-stream content. Wraps +/// [`aacs::content::decrypt_unit_checked`] with the pack-start structural check. +/// A free `fn` (not a closure) so it has the same `fn(&mut [u8], &[u8;16]) -> bool` +/// type as `decrypt_unit`, letting [`Verifier::decrypt_for`] return either. +fn decrypt_unit_ps(unit: &mut [u8], key: &[u8; 16]) -> bool { + aacs::content::decrypt_unit_checked(unit, key, aacs::content::unit_is_clean_ps) +} + /// A clip's on-disc layout: declared file size, its absolute disc extents in /// FILE order, and its stream container. `extents` is `(disc_lba, byte_len)`; /// the verifier reuses exactly the same `(abs_lba, byte_len)` extents the @@ -185,7 +197,9 @@ impl UnitVerifier { }) } - /// The post-decrypt structural check for a clip's container. + /// The post-decrypt structural check for a clip's container — used ONLY on the + /// CPI-clear branch (a plaintext unit that is structurally clean is + /// decryptable-as-is; if not, we return Unknown, never bad). fn accept_for(&self, clip: u32) -> fn(&[u8]) -> bool { match self.containers[clip as usize] { ContainerKind::Ts => aacs::content::unit_is_clean_ts, @@ -193,6 +207,21 @@ impl UnitVerifier { } } + /// The per-unit DECRYPT for a clip's container — the key-selection primitive + /// verify uses to prove a candidate key opens an ENCRYPTED unit. It MUST be + /// the exact same primitive the mux/rip decrypt path uses, so verify and the + /// real decrypt never disagree (a unit the mux will happily decrypt must not + /// be marked bad here). For TS that is [`aacs::content::decrypt_unit`], whose + /// verdict is defect-TOLERANT (a supermajority of content packets restored) — + /// an authored-bad packet or forensic-variant frame no longer false-flags the + /// whole unit as undecryptable. + fn decrypt_for(&self, clip: u32) -> fn(&mut [u8], &[u8; 16]) -> bool { + match self.containers[clip as usize] { + ContainerKind::Ts => aacs::content::decrypt_unit, + ContainerKind::Ps => decrypt_unit_ps, + } + } + /// Feed a just-read, just-`Finished` disc byte range (`bytes` starts at disc /// sector `disc_lba`). Routes each backing sector into its clip-file unit; /// every unit that becomes fully assembled is verified immediately. Returns @@ -215,7 +244,8 @@ impl UnitVerifier { self.fill(clip, unit, slot, lba, &bytes[off..off + sector]); if let Some((raw, lbas)) = self.take_if_complete(clip, unit) { let accept = self.accept_for(clip); - match self.decryptability(&raw, accept) { + let decrypt = self.decrypt_for(clip); + match self.decryptability(&raw, accept, decrypt) { Decryptability::Undecryptable => push_ranges(&mut bad, &lbas), Decryptability::Decryptable | Decryptability::Unknown => {} } @@ -309,6 +339,7 @@ impl UnitVerifier { &mut self, raw: &[u8; ALIGNED_UNIT_LEN], accept: fn(&[u8]) -> bool, + decrypt: fn(&mut [u8], &[u8; 16]) -> bool, ) -> Decryptability { // CPI clear -> the unit is plaintext by spec (no key needed). If it is // structurally clean, it is decryptable-as-is. If it ISN'T, we @@ -327,7 +358,7 @@ impl UnitVerifier { }; } // Encrypted: any held key that decrypts to a structurally-clean unit. - if self.try_keys(raw, accept) { + if self.try_keys(raw, decrypt) { return Decryptability::Decryptable; } // No held key works. Ask the application's key source ONCE for this @@ -350,7 +381,7 @@ impl UnitVerifier { self.fetch_spent = true; // service has nothing new; stop asking return Decryptability::Unknown; } - if self.try_keys(raw, accept) { + if self.try_keys(raw, decrypt) { return Decryptability::Decryptable; } return Decryptability::Undecryptable; // service's keys don't open it -> bad ciphertext @@ -359,12 +390,18 @@ impl UnitVerifier { Decryptability::Unknown } - /// True if any currently-held key decrypts `raw` to a structurally-clean unit - /// under the container's `accept` check. - fn try_keys(&self, raw: &[u8; ALIGNED_UNIT_LEN], accept: fn(&[u8]) -> bool) -> bool { + /// True if any currently-held key OPENS `raw` — i.e. the container's per-unit + /// `decrypt` (the same primitive the mux uses) reports the key restored the + /// unit's TS structure. Defect-tolerant for TS: an opened unit carrying a few + /// authored-bad packets still counts as decryptable (never marked bad). + fn try_keys( + &self, + raw: &[u8; ALIGNED_UNIT_LEN], + decrypt: fn(&mut [u8], &[u8; 16]) -> bool, + ) -> bool { for k in &self.keys { let mut scratch = *raw; - if aacs::content::decrypt_unit_checked(&mut scratch, k, accept) { + if decrypt(&mut scratch, k) { return true; } } @@ -439,9 +476,10 @@ impl UnitVerifier { } } let accept = self.accept_for(clip); + let decrypt = self.decrypt_for(clip); if readable && matches!( - self.decryptability(&raw, accept), + self.decryptability(&raw, accept, decrypt), Decryptability::Undecryptable ) { @@ -898,6 +936,31 @@ mod tests { ); } + #[test] + fn reverify_iso_defect_packet_unit_is_not_flagged_bad() { + // A unit with ONE authored-bad content packet (a pressing defect). The + // held key OPENS it (31/32 syncs restored), so verify must AGREE with the + // mux decrypt and NOT mark it bad — the bad packet is the muxer's problem, + // not a read/decrypt failure. (Old strict all-32 gate flagged it bad.) + let key = [0x5a; 16]; + let mut u = clear_unit(); + let off = 17 * 192; // corrupt packet 17's sync in the plaintext + u[off + 4] = 0x80; + u[off + 5] = 0xAB; + encrypt_unit(&mut u, &key); + let mut iso = MockIso { + sectors: Default::default(), + err_lba: None, + }; + place_unit(&mut iso, [100, 101, 102], &u); + let mut v = UnitVerifier::new(&one_clip(100), &aacs_keys(&[key]), None).unwrap(); + let bad = v.reverify_iso(&mut iso, &[(100 * 2048, 3 * 2048)], &|_| true); + assert!( + bad.is_empty(), + "defect-packet unit is opened by the held key -> not flagged bad" + ); + } + #[test] fn reverify_iso_fragmented_unit_reads_distant_sectors() { let key = [0x5a; 16]; diff --git a/src/mux/mkv.rs b/src/mux/mkv.rs index ac954c2..5d0b395 100644 --- a/src/mux/mkv.rs +++ b/src/mux/mkv.rs @@ -121,6 +121,29 @@ fn mvc_decoder_config_record(subset_sps: &[u8], pps: &[u8]) -> Option> { Some(record) } +/// Build the `CodecPrivate` for an MVC (Blu-ray 3D) base track: the base view's +/// `AVCDecoderConfigurationRecord` (`avcc`) followed by an `mvcC` extension +/// block, per the Matroska Codec Specifications §4.3.9: +/// +/// ```text +/// avcC ‖ u32be(extension_block_size − 4) ‖ "mvcC" ‖ MVCDecoderConfigurationRecord +/// ``` +/// +/// The size field is the extension block length **excluding the 4-byte size +/// field itself** — i.e. `4 ("mvcC") + record.len()`. This is the track-level +/// MVC signal that decoders and mediainfo read (the per-frame `BlockAdditional` +/// under the `mvcC` BlockAdditionMapping carries the dependent view's data). A +/// plain (2D) track never calls this — it writes its `avcc` verbatim. +fn mvc_codec_private(avcc: &[u8], record: &[u8]) -> Vec { + let ext_size = (4 + record.len()) as u32; // "mvcC" (4) + record; = block size − 4 + let mut out = Vec::with_capacity(avcc.len() + 8 + record.len()); + out.extend_from_slice(avcc); + out.extend_from_slice(&ext_size.to_be_bytes()); + out.extend_from_slice(b"mvcC"); + out.extend_from_slice(record); + out +} + /// Resolve a video stream's CICP colour code points — `(matrix, transfer, /// primaries, range)`, ITU-T H.273 — using a single precedence so EVERY sink /// (the MKV muxer here AND the FVI sidecar in `videomap.rs`) agrees and can @@ -882,9 +905,23 @@ impl MkvMuxer { let mut track_uids: Vec = Vec::with_capacity(tracks.len()); let mut ac3_channel_fixups: std::collections::HashMap = std::collections::HashMap::new(); + // Per track: whether it emitted a conforming `mvcC` BlockAdditionMapping. + // Filled below from the SAME built record that drives the CodecPrivate + // mvcC extension, so the three MVC signals never diverge. + let mut track_has_mvc_mapping: Vec = Vec::with_capacity(tracks.len()); for (i, track) in tracks.iter().enumerate() { let track_uid = (i + 1) as u64 | 0x100_0000; track_uids.push(track_uid); + // Build the MVC (Blu-ray 3D) MVCDecoderConfigurationRecord ONCE per + // track from the dependent view's subset-SPS/PPS. `None` for every + // non-3D track (and if the params are malformed) — the single source + // of truth for the CodecPrivate mvcC extension, the + // BlockAdditionMapping, and whether BlockAdditionals are conforming. + let mvc_record = track + .mvc_params + .as_ref() + .and_then(|(sps, pps)| mvc_decoder_config_record(sps, pps)); + track_has_mvc_mapping.push(mvc_record.is_some()); let entry_pos = ebml::start_master(&mut writer, ebml::TRACK_ENTRY)?; ebml::write_uint(&mut writer, ebml::TRACK_NUMBER, (i + 1) as u64)?; ebml::write_uint(&mut writer, ebml::TRACK_UID, track_uid)?; @@ -904,7 +941,20 @@ impl MkvMuxer { } if let Some(ref cp) = track.codec_private { - ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?; + match mvc_record.as_ref() { + // MVC (Blu-ray 3D) base track: CodecPrivate = base-view avcC + // followed by the `mvcC` extension block. This is the + // track-level signal decoders/mediainfo read to recognise the + // stereoscopic MVC track (the per-frame dependent view rides + // in BlockAdditional under the mapping below). + Some(record) => { + let cp_mvc = mvc_codec_private(cp, record); + ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, &cp_mvc)?; + } + // Non-MVC (2D/UHD/audio/…): write the codec_private verbatim — + // the unchanged path, byte-identical to a 2D mux. + None => ebml::write_binary(&mut writer, ebml::CODEC_PRIVATE, cp)?, + } } // Pre-0.13 a deferred codecPrivate path existed for video tracks // (placeholder reserve + later seek-back fill via @@ -1004,8 +1054,8 @@ impl MkvMuxer { // Video) carries the mvcC MVCDecoderConfigurationRecord so players / // mediainfo recognise the dependent (right-eye) view that rides as a // per-frame BlockAdditional under this mapping (BlockAddIDValue = 2). - if let Some((subset_sps, pps)) = track.mvc_params.as_ref() { - if let Some(record) = mvc_decoder_config_record(subset_sps, pps) { + match mvc_record.as_ref() { + Some(record) => { let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?; ebml::write_uint( &mut writer, @@ -1013,18 +1063,25 @@ impl MkvMuxer { BLOCK_ADD_ID_VALUE_MVC, )?; ebml::write_uint(&mut writer, ebml::BLOCK_ADD_ID_TYPE, BLOCK_ADD_ID_TYPE_MVCC)?; - ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, &record)?; + ebml::write_binary(&mut writer, ebml::BLOCK_ADD_ID_EXTRA_DATA, record)?; ebml::end_master(&mut writer, map_pos)?; - } else { + } + // `mvc_params` present but the record failed to build (malformed + // parameter sets): no mapping, and `track_has_mvc_mapping` above + // is already `false`, so BlockAdditionals are dropped — the file + // stays conforming rather than carrying an orphaned BlockAddID. + None if track.mvc_params.is_some() => { + let (s, p) = track.mvc_params.as_ref().unwrap(); tracing::warn!( target: "mux", "MVC track: could not build MVCDecoderConfigurationRecord from the \ dependent view's parameter sets (subset_sps={} B, pps={} B); \ emitting no mvcC mapping — the 3D pairing will not be signalled.", - subset_sps.len(), - pps.len(), + s.len(), + p.len(), ); } + None => {} } // Dolby Vision signaling — BlockAdditionMapping is a child of the @@ -1117,7 +1174,7 @@ impl MkvMuxer { primary_video_track: tracks .iter() .position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO), - track_has_mvc_mapping: tracks.iter().map(|t| t.mvc_params.is_some()).collect(), + track_has_mvc_mapping, continuity: TimelineContinuity::new(), cues: Vec::new(), frame_count: 0, @@ -4600,6 +4657,60 @@ mod tests { assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none()); } + #[test] + fn mvc_codec_private_appends_mvcc_extension_block() { + // avcC ‖ u32be(4 + record.len()) ‖ "mvcC" ‖ record (Matroska Codec Spec §4.3.9). + let avcc = vec![0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0xAA]; + let record = vec![0x01, 0x80, 0x00, 0x33, 0xBF, 0x01, 0xCC]; // 7 bytes + let out = mvc_codec_private(&avcc, &record); + assert_eq!(&out[..avcc.len()], &avcc[..], "avcC preserved verbatim"); + // size field = 4 ("mvcC") + 7 (record) = 11 = extension block size minus 4. + assert_eq!(&out[avcc.len()..avcc.len() + 4], &11u32.to_be_bytes()); + assert_eq!(&out[avcc.len() + 4..avcc.len() + 8], b"mvcC"); + assert_eq!( + &out[avcc.len() + 8..], + &record[..], + "record after the mvcC fourcc" + ); + } + + #[test] + fn mvc_track_codec_private_carries_avcc_plus_mvcc() { + // An MVC base track's CodecPrivate must be the base avcC followed by the + // mvcC extension — the track-level signal mediainfo/decoders read. + let avcc = vec![ + 0x01, 0x64, 0x00, 0x33, 0xFF, 0xE1, 0x00, 0x05, 0x67, 0x64, 0x00, 0x33, 0x99, + ]; + let subset_sps = vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22]; + let pps = vec![0x68, 0xEE, 0x3C]; + let mut v = make_video_track(); + v.codec_private = Some(avcc.clone()); + v.mvc_params = Some((subset_sps.clone(), pps.clone())); + let data = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[]) + .unwrap() + .writer + .into_inner(); + let record = mvc_decoder_config_record(&subset_sps, &pps).unwrap(); + let expected = mvc_codec_private(&avcc, &record); + assert!( + data.windows(expected.len()) + .any(|w| w == expected.as_slice()), + "emitted CodecPrivate must be avcC + mvcC extension" + ); + + // A non-MVC (2D) track writes its avcC VERBATIM — no mvcC appended. + let mut v2 = make_video_track(); + v2.codec_private = Some(avcc.clone()); + let d2 = MkvMuxer::new(Cursor::new(Vec::new()), &[v2], None, 0.0, &[]) + .unwrap() + .writer + .into_inner(); + assert!( + !d2.windows(4).any(|w| w == b"mvcC"), + "2D track CodecPrivate must not carry an mvcC extension" + ); + } + // ---- CodecPrivate emission (avcC / hvcC / VC-1 / MPEG-2) ------------- // // `MkvTrack::video` always builds with `codec_private: None`; the PES mux