Compare commits

...
19 Commits
Author SHA1 Message Date
Matthew Jackson 82c4e018d5 v1.4.2: bump version (freemkv-unlock git-pinned for the tag) 2026-07-15 19:37:51 -07:00
Matthew Jackson 08e962694f Mux decrypt/verify redesign, HD DVD first-class, MVC 3D
decrypt:
- decrypt_sectors is now a pure decrypt (apply key, leave plaintext, report
  unverified bytes); TS-structure is a separate primitive (is_clean_ts/ps) used
  only for key selection and read-verify. The mux passes decrypted bytes through
  (the demuxer drops non-conforming packets), ending the NULL-TS conceal loop and
  the per-unit key-server refetch storm. Key-proof floor replaces the 75%
  supermajority.

recovery:
- Removed the post-read decrypt-verify gate (verify.rs) that mis-aligned the
  disc-absolute unit grid against clip-anchored AACS units and false-failed good
  clips (e.g. Dunkirk's orphan-CPS clip). Bad sectors are marked by physical read
  result; decryptability is proven at scan + mux time.

HD DVD (first-class AACS):
- Role-based candidate-list file sourcing so an HD DVD's /ANY!/ files
  (MKBROM.AACS, VTKF000.AACS, CONTENT_CERT.AACS) are found with no disc-type
  branch. parse_vtkf parses VTKF000.AACS into the same UnitKeyFile as a BD
  Unit_Key_RO.inf, so the shared VUK unwrap applies unchanged. set_unit_base
  clip-anchoring. Two decrypt-axis assumptions remain UNVERIFIED-HDDVD-DECRYPT
  (no encrypted disc to test).

mux:
- MVC (Blu-ray 3D) track signals unified into one MVCDecoderConfigurationRecord;
  release-safe track_vint (3-byte VINT) and pid_index (i32) guards.

hardening:
- Container-aware is_clean / encryption detection; bytes_bad_in_title fail-safe
  on a corrupt mapfile; CSS crack gated on DiscFormat::Dvd (HD DVD excluded);
  non-vacuous CSS tests; patch NOT_READY/HARDWARE/ILLEGAL_REQUEST/ABORTED
  sense-path tests.
2026-07-15 19:35:12 -07:00
Matthew Jackson 37e563b8ef Mux: pure decrypt, policy at the caller (no null, no key-server storm)
decrypt_sectors is now a pure decrypt — apply the CPS unit key, leave the
plaintext, report how many bytes did not reach clean TS ("unverified"). It
never restores ciphertext, nulls, or re-fetches. "Did a key produce clean TS?"
is a key-selection / read-verify signal, not the verdict "did we decrypt?": a
correct key can decrypt a bad-encoded region, and broken TS is a muxer concern
(the demuxer drops the packet and resyncs).

Callers own the policy:
- mux (read > decrypt > mux): pass the decrypted bytes to the muxer, whatever
  they are; fail loud only on a genuine can't-decrypt (no key / misaligned).
- sweep/patch (reading from a disc): an unverified unit is a bad read — recover
  a fresh key and retry, or fail loud so disc-recovery re-reads it.

Removes three duplicated decisions — the decrypt-time ciphertext restore, the
mux NULL-TS conceal loop, and the per-unit key-server refetch — plus the dead
aacs_unit_still_ciphertext predicate. Key-fetch recovery now samples the on-disc
ciphertext explicitly (a pure decrypt leaves the buffer plaintext) and lives
only on the rip/verify path, never the mux.

Fixes the 30-90s/region mux stalls and key-server storm on bad-encoded UHD runs
that 1.4.1 left behind (it relaxed the gate but not the surrounding machinery).
2026-07-14 21:04:24 -07:00
Matthew Jackson e659613ab8 restore freemkv-unlock path dep for local dev (post-v1.4.1) 2026-07-14 14:45:33 -07:00
Matthew Jackson eaa7ddc683 v1.4.1: bump version (freemkv-unlock git-pinned for the tag) 2026-07-14 14:45:30 -07:00
Matthew Jackson 723c7edf45 Fix mux concealing decryptable video over a single defective packet
AACS content decryption rejected a whole 6144-byte aligned unit unless
EVERY content packet was conformant MPEG-TS. One authored-bad packet (a
pressing/encoding defect or an AACS 2.1 forensic-variant frame) made the
mux conceal the entire unit as NULL TS — destroying up to 31/32 good
packets and tallying them as loss, surfacing as false "corruption" on
otherwise-clean discs (observed across two UHD titles).

decrypt_unit 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) 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 post-read verify/sweep gate now shares the same primitive so
it can never disagree with the mux decrypt.

Also unify the MVC (Blu-ray 3D) track signals: the mvcC CodecPrivate
extension, the BlockAdditionMapping, and each per-frame BlockAdditional
all derive from one MVCDecoderConfigurationRecord built once per track, so
a malformed dependent-view parameter set can no longer orphan a BlockAddID.
2026-07-14 14:43:06 -07:00
Matthew Jackson d9881c57c0 restore freemkv-unlock path dep for local dev (post-v1.4.0) 2026-07-13 19:28:55 -07:00
Matthew Jackson 61b4486ae9 v1.4.0: bump version (freemkv-unlock git-pinned for the tag) 2026-07-13 19:28:52 -07:00
Matthew Jackson 72d39544ac Changelog: 1.4.0 (Blu-ray 3D / MVC) 2026-07-13 12:47:03 -07:00
Matthew Jackson 1e394eaeb8 3D MVC mux: audit round 2 (converged)
Second audit round converged (severity collapsed 6 HIGH -> 1; the one
HIGH was a bounded 32-element scan, not a defect; the sole spec MEDIUM
was the same false-positive re-raised — 0xBF matches ISO/IEC 14496-15
§7.6.2 verbatim). One genuine robustness fix plus coverage:

- extract_mvc_params: skip a zero-length NAL instead of abandoning the
  scan, so a stray length prefix before the subset SPS/PPS no longer
  silently drops 3D signalling. Test proves params after a zero-length
  NAL are still found.
- Tests: parser_for_mvc_dependent routes H.264 to a passthrough parser;
  passthrough with an IDR does not re-assert param sets (the keyframe &&
  !mvc branch).
- Document the per-playlist (not per-clip) is_3d latching as a known
  limitation (real main-feature playlists are uniformly 3D).
2026-07-13 11:39:07 -07:00
Matthew Jackson 4d1aa6bbf7 Harden 3D MVC mux: robustness + tests (audit round 1)
Triage of a 10-lens code audit of the 3D branch. Fixes for real defects;
rejected three spec false-positives that matched the ISO/IEC 14496-15
§7.6.2 record verbatim.

Robustness / correctness:
- Never panic when a title's only video is the MVC dependent view: the
  base is now the first NON-dependent video, so a dependent-only title
  sets up no merge (muxed as an ordinary track) instead of hitting an
  `expect` on the skipped track slot.
- Drop a per-frame BlockAdditional (BlockAddID=2) when the track declared
  no mvcC mapping (dependent params not captured before the header) — a
  plain block keeps the file conforming instead of an orphaned add.
- A non-keyframe MVC base frame always carries a ReferenceBlock (fall back
  to a 0 offset in the pre-first-keyframe corner) so it is never mistaken
  for a seek point.
- Reference the last keyframe on the PRIMARY video track only, so a
  secondary video track's keyframe can't become a cross-track reference.
- dep_by_pts overflow: bound BEFORE inserting so the just-arrived
  dependent survives the drift-clear; count a displaced duplicate-PTS
  dependent as an orphan instead of losing it silently.

API / docs:
- Fold write_frame_with_additional into write_frame(..., Option<&[u8]>)
  per the "no foo_with_X" convention.
- Fix mvc_params doc (StereoMode is intentionally not emitted); remove a
  stale PAT/PMT comment describing an approach that was never taken.

Tests: MVCDecoderConfigurationRecord over-length guards; write_int minimal
two's-complement widths; BlockGroup/BlockAdditions/BlockAdditional +
ReferenceBlock emission; additional dropped without a mapping; h264 MVC
passthrough keeps param sets in-band; extract_mvc_params no-panic on
truncated/empty input; pairing window + dep-overflow edges; no-panic on a
dependent-only title.
2026-07-13 11:24:23 -07:00
Matthew Jackson 9ce03b4d1d Mux Blu-ray 3D (MVC) as a single MVC video track
Fold the MVC dependent (right-eye) view into the base H.264 track as a
per-frame BlockAdditional under an mvcC BlockAdditionMapping, so a 3D
title produces one MVC video track instead of two independent H.264
tracks.

- h264: MVC-passthrough parser mode keeps the dependent view's subset
  SPS/PPS in-band, so each emitted frame is a self-contained dependent
  access unit for a BlockAdditional
- resolve: route the dependent stream through the passthrough parser
- mkvstream: detect the dependent view, pair it to the base frame by
  PTS (bounded FIFO), attach it as a BlockAdditional (BlockAddID=2),
  and skip building its own track; build the mvcC
  MVCDecoderConfigurationRecord from the captured subset SPS/PPS and set
  it on the base track at activation
- mkv: emit the mvcC BlockAdditionMapping and BlockGroup/BlockAdditions,
  with a ReferenceBlock on non-keyframe base frames
- ebml: add BlockAdditions/BlockMore/BlockAdditional/BlockAddID/
  BlockAddIDValue/ReferenceBlock elements and a signed-int writer

Verified against a Blu-ray 3D ISO: ffprobe shows a single MVC track,
the mvcC mapping is present, ~144k BlockAdditionals carry the dependent
view (8.7 GB), and the base view decodes cleanly with no regression.
MVCDecoderConfigurationRecord follows ISO/IEC 14496-15 7.6.2; StereoMode
is intentionally omitted (no enum value describes MVC-in-BlockAdditional;
the mvcC mapping is the primary 3D signal per RFC 9559).
2026-07-13 11:00:04 -07:00
Matthew Jackson f099a4a31a feat(3d): read the SSIF and enumerate the MVC dependent view
Blu-ray 3D as a mux-path variant:
- Detect 3D (a clip has STREAM/SSIF/<clip>.ssif — note the SSIF/ subdir, which
  the old .ssif fallback path got wrong) and use the SSIF extents for the mux:
  one transport stream carrying both the base (left) and MVC dependent (right)
  eyes on distinct PIDs, so muxing it captures the full 3D.
- Add the dependent-view video stream. The base STN table lists only the left
  eye; the on-disc PAT/PMT are AACS-encrypted (unreadable pre-key) and the base
  STN omits the dependent view (it lives in the MPLS STN_table_SS), so use the
  BD-3D PID convention: dependent = base video PID + 1 (0x1011 -> 0x1012),
  coding 0x20 -> H.264. The demux routes its packets from the SSIF.

info now reports two video streams for a 3D title; the mux writes both eyes.
2026-07-12 23:41:47 -07:00
Matthew Jackson e606b5e3a2 feat(3d): enumerate MVC dependent view (stream_type 0x20 -> H.264 video)
from_coding_type maps 0x20 (MVC dependent view) to Codec::H264, so the existing
PAT/PMT scan surfaces the SSIF right-eye substream as a second video stream on
its own PID instead of dropping it as Unknown. No new parser: the dependent eye
rides the same demux path as any other TS video stream. Removes the throwaway
3D-structure probes (findings captured in prior commit messages).
2026-07-12 22:16:13 -07:00
Matthew Jackson de809189ec feat(3d): add MVC dependent-view coding type (0x20)
First step of Blu-ray 3D as a mux-path format variant: name stream_type 0x20
(H.264/MVC dependent view, the SSIF right-eye substream). Recognition in the
scan/demux + dependent-stream enumeration follow.
2026-07-12 22:11:01 -07:00
Matthew Jackson 253b0924e3 wip(3d): prove SSIF de-interleave = ssif−base, dependent decrypts on same UK
Empirical (300, clip 00042): base .m2ts (1567 extents, 25.85 GB) is entirely
inside the SSIF LBA span; dependent = SSIF − base = 1567 ranges, 9.59 GB
(exact vs 35.43−25.85). 200/200 sampled dependent-view units decrypt under the
base UK. => de-interleave needs no SSIF parsing (complement of the base extents
we already resolve), and the dependent eye needs no extra key.
2026-07-12 20:40:58 -07:00
Matthew Jackson 98a7892c34 wip(3d): read-only 3D-structure probe (SSIF/m2ts + MPLS ExtensionData)
Dev tool for the 3D build: uses the library UDF reader (7z cannot parse these
ISOs) to dump base .m2ts vs SSIF sizes and the MPLS ExtensionData entries
(STN_table_SS + SS sub-path). Not product code.
2026-07-12 20:38:37 -07:00
Matthew Jackson b82d21756a fix(udf): follow allocation-descriptor continuation past the AED header
read_icb_extents parsed a continuation block's allocation descriptors from
offset 0, but a continuation block begins with a 24-byte Allocation Extent
Descriptor (ECMA-167 4/14.5): 16-byte descriptor tag + prev-loc(4) +
length_of_allocation_descriptors(4); the real ADs start at offset 24. Reading
from 0 mis-parsed the AED tag as a descriptor -> one garbage extent, then an
unknown-type break, silently truncating any file whose extents spill into a
continuation block.

Few-extent files store their ADs inline and never hit this path, so it stayed
hidden; a heavily fragmented file (~1600 fragments) truncated to 113 extents +
a bogus non-unit-aligned fragment that then aborted the mux
(ExtentNotUnitAligned). Skip the 24-byte AED header, read l_ad bytes of
descriptors from offset 24. Adds a regression test with a non-zero AED tag.
2026-07-12 20:37:55 -07:00
Matthew Jackson 02c584a3d9 restore freemkv-unlock path dep for local dev (post-v1.3.2) 2026-07-10 14:26:11 -07:00
38 changed files with 3292 additions and 3257 deletions
+84
View File
@@ -1,5 +1,89 @@
# Changelog
## [1.4.2] — 2026-07-15
### Fixed
- **Mux no longer nulls decryptable video or storms the key server on a
bad-encoded region.** 1.4.1 relaxed the decrypt gate but left the surrounding
machinery in place. On a unit whose key *decrypted* but whose plaintext didn't
reassemble to clean MPEG-TS, the read path still restored ciphertext, tallied
loss, and re-asked the online key server (forever returning the same correct key)
while the mux concealed the unit as NULL TS. The root cause: *"did a key produce
clean TS?"* was used as the verdict *"did we decrypt?"* — they are not the same.
A correct key can decrypt content with broken encoding; broken TS is a muxer
concern, never a decrypt verdict.
### Changed
- **One decrypt authority; policy at the caller.** `decrypt_sectors` is now a
pure decrypt: applies the CPS unit key in place, leaves plaintext, and reports
unverified bytes. It never restores ciphertext, nulls, or re-fetches a key.
Clean-TS status is only a key-*selection* hint (multi-CPS) or a read-*verify*
signal (sweep/patch). Callers own the policy: the mux passes decrypted bytes
through unconditionally (the demuxer handles bad TS); sweep/patch treat an
unverified unit as a failed read and re-read it. Removes the decrypt-time
ciphertext restore, the mux NULL-TS conceal loop, and the per-unit key-server
refetch, plus the dead `aacs_unit_still_ciphertext` predicate.
- **Decrypt and TS-structure are now separate primitives.** AACS has no MAC;
the only "did it decrypt?" signal is whether plaintext looks like MPEG-TS —
a data-quality / key-selection question, not a decrypt verdict. The old
`decrypt_unit(...) -> bool` is split into `decrypt_unit_raw` (pure crypto) and
`is_clean_ts` (structural check), composed explicitly only where needed. The
mux calls only `decrypt_unit_raw`.
- **Key-proof floor replaces the 75% supermajority.** The old proportion
(≥75% of content packets synced) conflated *the key worked* with *the content
is well-encoded*. `is_clean_ts` now requires `synced >= min(E, 4)` on
**encrypted** packets (skipping packet 0 whose `0x47` is in the clear seed):
four synced packets ≈ 1-in-4-billion false-positive; `min(E, 4)` scales to
short fragment tails so they're never false-rejected. A unit is "opened" when
a handful of packets prove the key — bad-encoded packets are the muxer's job.
## [1.4.1] — 2026-07-14
### Fixed
- **Mux no longer discards good video over a single defective packet.** AACS
decryption required **every** content packet to be conformant MPEG-TS: one
authored-bad packet (encoding defect, AACS 2.1 forensic-variant frame) made
the mux conceal the **whole** 6144-byte aligned unit as NULL TS (up to 31/32
good packets discarded, tallied as loss). On affected discs this produced
false "corruption" over otherwise-perfect video (~466 MB concealed across two
UHD titles). The gate is now a padding-aware **≥75% supermajority** of content
packets restoring their `0x47` sync — no wrong key reaches this threshold
(uniform-AES noise floor ≈ 256⁻ⁿ), but a minority of authored-bad packets
still passes. Opened units flow through verbatim; the demuxer drops
non-conforming packets on sync-loss. TS-sync conformance is a muxer concern,
never a decrypt verdict. (The supermajority threshold is tightened in 1.4.2.)
- **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
- **Blu-ray 3D (MVC) support.** A 3D disc now rips to an MKV that preserves
**both eyes** as a single MVC video track — the AVC base (left) view in each
Block, and the MVC dependent (right-eye) view as a per-frame `BlockAdditional`
under an `mvcC` `BlockAdditionMapping` (`MVCDecoderConfigurationRecord` per
ISO/IEC 14496-15 §7.6.2), paired to the base by PTS. Remux only — no
transcode, no side-by-side conversion. The Blu-ray scan reads the interleaved
`STREAM/SSIF/<clip>.ssif`, enumerates the dependent view (stream_type `0x20`)
by the BD-3D PID convention, and parses it in a parameter-set-passthrough mode
so every dependent frame is a self-contained access unit. Verified on
*300: Rise of an Empire*: one MVC track, ~8.7 GB dependent payload carried in
per-frame BlockAdditionals, base view byte-identical to the 2D rip.
## [1.3.2] — 2026-07-10
### Added
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "libfreemkv"
version = "1.3.2"
version = "1.4.2"
edition = "2024"
rust-version = "1.86"
license = "MIT"
@@ -28,7 +28,7 @@ cbc = "0.1"
# Interim path dep for local cross-repo dev; the release script re-pins this to
# `{ git = ".../freemkv-unlock", tag = "vX.Y.Z" }` before tagging libfreemkv (so
# the released tag resolves freemkv-unlock from git, not a sibling path).
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.3.2" }
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.4.2" }
num-bigint = "0.4"
num-traits = "0.2"
num-integer = "0.1"
+530 -662
View File
File diff suppressed because it is too large Load Diff
+4 -3
View File
@@ -463,7 +463,7 @@ pub enum KeyCandidate {
/// PURE DERIVATION — no unit sampling, no validation. `unit_keys` holds every
/// CPS-unit key the disc's `Unit_Key_RO.inf` yields from the VUK (paired with
/// its declared CPS-unit number); the caller runs
/// [`super::content::unit_key_validates`] to find which one actually opens the
/// `decrypt_unit` + `is_clean_ts` to find which one actually opens the
/// disc. Rungs above the candidate are `None`.
#[derive(Debug, Clone)]
pub struct ResolvedChain {
@@ -487,7 +487,7 @@ pub struct ResolvedChain {
///
/// PURE DERIVATION: no sampling, no validation, no position recovery. Validate
/// `unit_keys` against a real encrypted unit with
/// [`super::content::unit_key_validates`] to prove the candidate opens the disc.
/// `decrypt_unit` + `is_clean_ts` to prove the candidate opens the disc.
///
/// Returns `None` only when derivation itself cannot proceed: a PK its MKB
/// rejects, a `Dk` the MKB can't process, a missing VID on a path that needs
@@ -505,7 +505,8 @@ pub fn resolve_candidate(
let version = mkb_type(mkb)
.map(|t| t.generation())
.unwrap_or(AacsVersion::V10);
let ukf = parse_unit_key_ro(unit_key_ro, version)?;
// BD/UHD Unit_Key_RO.inf or HD DVD VTKF000.AACS — dispatched by magic.
let ukf = parse_title_keys(unit_key_ro, version)?;
if ukf.encrypted_keys.is_empty() {
return None;
}
+172
View File
@@ -161,6 +161,87 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
})
}
/// HD DVD Video Title Key File (`VTKF000.AACS`) magic — "DVD HD Video TKF".
pub const VTKF_MAGIC: &[u8; 12] = b"DVD_HD_V_TKF";
/// Fixed header length before the first title-key entry.
const VTKF_HEADER_LEN: usize = 0x80;
/// Each title-key entry: BE32 flag + 16-byte encrypted key + 12-byte 0xFF pad.
const VTKF_ENTRY_LEN: usize = 0x20;
/// Parse an HD DVD `VTKF000.AACS` into the SAME [`UnitKeyFile`] a BD/UHD
/// `Unit_Key_RO.inf` yields — so the shared AACS crypto (`derive_unit_keys` →
/// `decrypt_unit_key(vuk, …)`) unwraps HD DVD title keys with no change. Only
/// the on-disc CONTAINER differs between BD and HD DVD; the title-key unwrap is
/// the identical AES-128 VUK step (`Kt = AES-128D(Kvu, Kte)`).
///
/// Layout (grounded in real discs — Shaun of the Dead, Anchorman, Harry Potter):
/// ```text
/// [0x00..0x0C] magic "DVD_HD_V_TKF"
/// [0x0C..0x10] BE32 total file length
/// [0x10..0x1C] associated playlist name ("VPLST000.XPL")
/// [0x1C..0x80] reserved (zero)
/// [0x80..] 32-byte entries: BE32 flag | 16-byte ENCRYPTED title key | 12-byte 0xFF pad
/// flag bit 31 (0x8000_0000) set = present; a cleared flag ends the table
/// [tail] 16-byte signature/MAC (never a key — the cleared-flag stop guards it)
/// ```
/// Entries number 1..=N as CPS units, matching `Unit_Key_RO`'s 1-based CPS
/// numbering, so a title's CPS unit indexes this list identically. The
/// title→CPS mapping itself is playlist-driven (`VPLST000.XPL`) and owned by the
/// HD DVD enumerator, so `title_cps_unit` is left empty here.
pub fn parse_vtkf(data: &[u8]) -> Option<UnitKeyFile> {
if data.len() < VTKF_HEADER_LEN || &data[..12] != VTKF_MAGIC {
return None;
}
// SHA1 of the WHOLE file — the KEYDB lookup key. BackupHDDVD-family key
// databases index an HD DVD disc by SHA1(VTKF000.AACS), the same role the
// BD disc_hash plays for `Unit_Key_RO.inf`.
let hash = disc_hash(data);
let mut encrypted_keys = Vec::new();
let mut pos = VTKF_HEADER_LEN;
let mut cps: u32 = 1;
while pos + VTKF_ENTRY_LEN <= data.len() {
let flag = u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]);
// A cleared present-bit terminates the key table. The file's trailing
// 16-byte signature then follows and must NOT be read as a key.
if flag & 0x8000_0000 == 0 {
break;
}
let mut key = [0u8; 16];
key.copy_from_slice(&data[pos + 4..pos + 20]);
encrypted_keys.push((cps, key));
cps += 1;
pos += VTKF_ENTRY_LEN;
}
if encrypted_keys.is_empty() {
return None;
}
Some(UnitKeyFile {
disc_hash: hash,
app_type: 0, // HD DVD VTKF carries no BD-ROM app_type
num_bdmv_dir: 0, // BD-only concept
use_skb_mkb: false,
version: AacsVersion::V10, // HD DVD is always AACS 1.0
encrypted_keys,
title_cps_unit: Vec::new(),
})
}
/// Parse a disc's title-key file, dispatching on the self-describing magic:
/// an HD DVD `VTKF000.AACS` (`DVD_HD_V_TKF`) → [`parse_vtkf`]; anything else is a
/// BD/UHD `Unit_Key_RO.inf` → [`parse_unit_key_ro`]. Both return the same
/// [`UnitKeyFile`], so every downstream AACS derivation stays container-agnostic
/// — the single seam where BD-vs-HD-DVD key layout is resolved (mirrors the key
/// service, which classifies HD DVD by the very same magic).
pub fn parse_title_keys(data: &[u8], version: AacsVersion) -> Option<UnitKeyFile> {
if data.len() >= 12 && &data[..12] == VTKF_MAGIC {
parse_vtkf(data)
} else {
parse_unit_key_ro(data, version)
}
}
/// MKB disc structure format code.
const MKB_DISC_STRUCTURE_FORMAT: u8 = 0x83;
@@ -283,3 +364,94 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
version,
})
}
#[cfg(test)]
mod vtkf_tests {
use super::*;
/// Build a synthetic `VTKF000.AACS` matching the real on-disc layout
/// (Shaun of the Dead / Anchorman): magic, BE32 size, playlist name,
/// reserved to 0x80, then 32-byte present-flagged entries, a cleared-flag
/// terminator, and a 16-byte trailer.
fn synth_vtkf(keys: &[[u8; 16]]) -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(VTKF_MAGIC); // 0x00
v.extend_from_slice(&0u32.to_be_bytes()); // 0x0C size (patched below)
v.extend_from_slice(b"VPLST000.XPL"); // 0x10
v.resize(0x80, 0); // reserve to first entry
for k in keys {
v.extend_from_slice(&0x8000_0000u32.to_be_bytes()); // present flag
v.extend_from_slice(k); // 16-byte encrypted title key
v.extend_from_slice(&[0xFFu8; 12]); // 0xFF pad → 32-byte entry
}
// Cleared-flag terminator entry (must NOT be read as a key).
v.extend_from_slice(&[0u8; VTKF_ENTRY_LEN]);
// 16-byte trailing signature (must NOT be read as a key).
v.extend_from_slice(&[0xABu8; 16]);
let len = v.len() as u32;
v[0x0C..0x10].copy_from_slice(&len.to_be_bytes());
v
}
#[test]
fn parse_vtkf_extracts_present_entries_and_stops_at_terminator() {
let k1 = [0x11u8; 16];
let k2 = [0x22u8; 16];
let k3 = [0x33u8; 16];
let data = synth_vtkf(&[k1, k2, k3]);
let ukf = parse_vtkf(&data).expect("valid VTKF must parse");
// Exactly the three present entries — the cleared-flag terminator and
// the 16-byte trailer are NOT mistaken for keys.
assert_eq!(ukf.encrypted_keys.len(), 3, "must stop at the cleared flag");
assert_eq!(ukf.encrypted_keys[0], (1, k1), "CPS units number 1..=N");
assert_eq!(ukf.encrypted_keys[1], (2, k2));
assert_eq!(ukf.encrypted_keys[2], (3, k3));
assert_eq!(ukf.version, AacsVersion::V10, "HD DVD is AACS 1.0");
// disc_hash is SHA1 of the whole file (the KEYDB lookup key).
assert_eq!(ukf.disc_hash, disc_hash(&data));
}
#[test]
fn parse_vtkf_rejects_non_magic() {
let mut data = synth_vtkf(&[[0x11u8; 16]]);
data[0] = b'X'; // corrupt magic
assert!(
parse_vtkf(&data).is_none(),
"non-VTKF magic must be rejected"
);
assert!(
parse_vtkf(&[0u8; 4]).is_none(),
"too short must be rejected"
);
}
#[test]
fn parse_title_keys_dispatches_by_magic() {
// VTKF magic → parse_vtkf.
let data = synth_vtkf(&[[0x44u8; 16], [0x55u8; 16]]);
let ukf = parse_title_keys(&data, AacsVersion::V10).expect("VTKF dispatch");
assert_eq!(ukf.encrypted_keys.len(), 2);
// Non-VTKF → parse_unit_key_ro (a 2-byte buffer is not a valid inf, so
// this proves it ROUTED to the BD parser rather than parse_vtkf).
assert!(
parse_title_keys(&[0x00, 0x00], AacsVersion::V10).is_none(),
"non-magic input must route to parse_unit_key_ro"
);
}
/// The whole point of the seam: a parsed VTKF feeds the SHARED VUK→title-key
/// crypto (`decrypt_unit_key`) exactly like a BD `Unit_Key_RO.inf` would —
/// no HD-DVD-specific crypto path.
#[test]
fn vtkf_encrypted_keys_feed_shared_vuk_unwrap() {
let enc = [0x9Au8; 16];
let data = synth_vtkf(&[enc]);
let ukf = parse_vtkf(&data).unwrap();
let vuk = [0x5Cu8; 16];
let derived = super::super::derive::decrypt_unit_key(&vuk, &ukf.encrypted_keys[0].1);
// Same as applying the shared unwrap directly to the stored enc key.
assert_eq!(derived, super::super::derive::decrypt_unit_key(&vuk, &enc));
}
}
+53 -5
View File
@@ -40,10 +40,18 @@ pub mod types;
pub mod variant;
pub mod variant_select;
/// On-disc UDF paths to the AACS key-input files (with their fallbacks).
/// Centralised so every reader (`resolve_vid_only`, `read_aacs_inputs`,
/// `read_mkb_content`, `read_aacs_version`) walks the exact same files — adding
/// or changing a fallback in one place can then never silently diverge the
/// On-disc UDF paths to the AACS key-input files.
///
/// BD and UHD keep their key material under `/AACS/…`; HD DVD keeps the
/// equivalents under `/ANY!/…` with different names (`VTKF000.AACS` is the
/// title-key file — magic `DVD_HD_V_TKF`; `MKBROM.AACS` is the MKB). The
/// container difference is expressed here purely as DATA: each ROLE
/// ([`UNIT_KEY_RO_PATHS`], [`MKB_PATHS`], [`CONTENT_CERT_PATHS`]) is an ordered
/// candidate list, and every reader walks it with [`read_first`] taking the
/// first that reads. No reader ever branches on disc type — a BD/UHD disc has
/// the `/AACS/` files so those win; an HD DVD has neither, so it falls through
/// to the `/ANY!/` entry. Centralised so `resolve_vid_only`, `read_aacs_inputs`,
/// `read_mkb_content`, and `read_aacs_version` can never silently diverge the
/// disc_hash / MKB / VID that another reader feeds a key service.
pub const PATH_UNIT_KEY_RO: &str = "/AACS/Unit_Key_RO.inf";
pub const PATH_UNIT_KEY_RO_DUPLICATE: &str = "/AACS/DUPLICATE/Unit_Key_RO.inf";
@@ -51,6 +59,46 @@ pub const PATH_MKB_RO: &str = "/AACS/MKB_RO.inf";
pub const PATH_MKB_RW: &str = "/AACS/MKB_RW.inf";
pub const PATH_CONTENT_CERT: &str = "/AACS/Content000.cer";
pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
/// HD DVD title-key file (`/ANY!/`), forwarded as `inf_b64`; the key service
/// recognises it by its `DVD_HD_V_TKF` magic.
pub const PATH_VTKF_HDDVD: &str = "/ANY!/VTKF000.AACS";
/// HD DVD Media Key Block (`/ANY!/`), forwarded as `mkb_b64`.
pub const PATH_MKBROM_HDDVD: &str = "/ANY!/MKBROM.AACS";
/// HD DVD content certificate (`/ANY!/`); byte 0 gives the AACS major (0x00 → V10).
pub const PATH_CONTENT_CERT_HDDVD: &str = "/ANY!/CONTENT_CERT.AACS";
/// Title-key / `Unit_Key_RO.inf` role, in resolution order (BD/UHD, then HD DVD).
pub const UNIT_KEY_RO_PATHS: &[&str] = &[
PATH_UNIT_KEY_RO,
PATH_UNIT_KEY_RO_DUPLICATE,
PATH_VTKF_HDDVD,
];
/// MKB role, in resolution order (BD/UHD RO then RW, then HD DVD).
pub const MKB_PATHS: &[&str] = &[PATH_MKB_RO, PATH_MKB_RW, PATH_MKBROM_HDDVD];
/// Content-certificate role, in resolution order (BD/UHD, then HD DVD).
pub const CONTENT_CERT_PATHS: &[&str] = &[
PATH_CONTENT_CERT,
PATH_CONTENT_CERT_ALT,
PATH_CONTENT_CERT_HDDVD,
];
/// Walk an AACS role's candidate paths and return the first that reads.
///
/// `read` performs the actual per-path read (full file or bounded prefix), so
/// callers share the same first-present walk regardless of read style. Returns
/// [`Error::AacsNoKeys`] if no candidate is present. This is the single place
/// the `/AACS/` (BD/UHD) vs `/ANY!/` (HD DVD) layout difference is resolved.
pub(crate) fn read_first<F>(candidates: &[&str], mut read: F) -> crate::error::Result<Vec<u8>>
where
F: FnMut(&str) -> crate::error::Result<Vec<u8>>,
{
for path in candidates {
if let Ok(buf) = read(path) {
return Ok(buf);
}
}
Err(crate::error::Error::AacsNoKeys)
}
// The module structure IS the public API — consumers import from the owning
// module directly (e.g. `aacs::content::decrypt_unit`, `aacs::mkb::MkbType`,
@@ -61,7 +109,7 @@ pub const PATH_CONTENT_CERT_ALT: &str = "/AACS/Content001.cer";
// content-decrypt entry points that downstream key-source crates import through
// the `aacs::` path. These are the stable, load-bearing names; keeping them here
// lets those crates track the module refactor without a lockstep re-pin.
pub use content::{ALIGNED_UNIT_LEN, decrypt_unit_try_keys};
pub use content::ALIGNED_UNIT_LEN;
pub use derive::derive_vuk;
pub use types::{DeviceKey, HostCert, MediaKey, ProcessingKey, UnitKey, Vid, Vuk};
+4 -3
View File
@@ -156,7 +156,7 @@ pub fn resolve_keys_v2(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
/// equivalent of path 2 — there's no host-side PK derivation against a
/// Variant MKB.)
pub fn resolve_keys_v21(ctx: &ResolveContext<'_>) -> Option<ResolvedKeys> {
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, AacsVersion::V20)?;
let uk_file = parse_title_keys(ctx.unit_key_ro, AacsVersion::V20)?;
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
let bus_encryption = ctx
.content_cert
@@ -280,8 +280,9 @@ fn resolve_keys_classical(ctx: &ResolveContext<'_>, version: AacsVersion) -> Opt
.map(|cc| cc.bus_encryption)
.unwrap_or(false);
// Parse Unit_Key_RO.inf at the version-appropriate stride.
let uk_file = parse_unit_key_ro(ctx.unit_key_ro, version)?;
// Parse the disc's title-key file (BD/UHD Unit_Key_RO.inf at the
// version-appropriate stride, or HD DVD VTKF000.AACS) → common UnitKeyFile.
let uk_file = parse_title_keys(ctx.unit_key_ro, version)?;
let hash_hex = disc_hash_hex(&uk_file.disc_hash);
let has_vid = *ctx.volume_id != [0u8; 16];
+4
View File
@@ -66,6 +66,10 @@ pub mod coding_type {
pub const MPEG2_VIDEO: u8 = 0x02;
/// H.264 / AVC video (ISO/IEC 13818-1 Table 2-34).
pub const H264: u8 = 0x1B;
/// H.264 / MVC dependent view (Blu-ray 3D right-eye substream). Carried in
/// the SSIF interleaved stream under its own PID; the base view is [`H264`].
/// ISO/IEC 13818-1 stream_type 0x20 (MVC video sub-bitstream).
pub const H264_MVC: u8 = 0x20;
/// HEVC / H.265 video (ISO/IEC 13818-1 Table 2-34, 2015 amendment).
pub const HEVC: u8 = 0x24;
/// SMPTE VC-1 video (BD-ROM convention, ISO user-private range).
+163 -72
View File
@@ -150,10 +150,15 @@ pub fn decrypt_threads() -> usize {
pub enum DecryptKeys {
/// No encryption on this disc.
None,
/// AACS (Blu-ray / UHD). Unit keys + optional read data key.
/// AACS (Blu-ray / UHD / HD-DVD). Unit keys + optional read data key. The
/// `format` is the disc's content container (BD/UHD/FMTS = Transport Stream,
/// HD-DVD `.evo` = Program Stream); it travels with the keys because both are
/// resolved once per disc, and the key SELECTOR (`is_clean`) needs it to prove
/// a key structurally against the right container.
Aacs {
unit_keys: Vec<(u32, [u8; 16])>,
read_data_key: Option<[u8; 16]>,
format: crate::disc::ContentFormat,
},
/// CSS (DVD). Title key for sector descrambling.
Css { title_key: [u8; 5] },
@@ -180,14 +185,16 @@ impl DecryptKeys {
/// Returns `Err` if decryption was expected but keys are missing or invalid.
/// Never produces silently corrupted output.
///
/// On success returns the number of bytes belonging to scrambled AACS units
/// that **no available key could decrypt** — those units are restored to their
/// original encrypted bytes (so a clear nav-file is never corrupted), but for
/// genuine encrypted content this is silent data loss the downstream TS
/// assembler will drop without a sync. The decrypt-on-read decorator folds this
/// count into the mux loss accounting so a partial key failure can't be reported
/// as a perfect rip. `0` for `None` / `Css` and for any AACS buffer where every
/// scrambled unit decrypted.
/// Pure decrypt: every encrypted unit has a key APPLIED in place and the
/// plaintext is left as-is — this function applies NO policy (it never restores
/// ciphertext, nulls, or re-fetches). On success it returns the number of bytes
/// belonging to units a key was applied to but that did NOT reassemble to clean
/// MPEG-TS ("unverified"). "Did a key open it to clean TS?" is a key-SELECTION /
/// read-VERIFY signal, NOT a "did we decrypt?" verdict — a correct key can
/// decrypt content whose encoding is broken. The caller decides what an
/// unverified unit means: the mux passes the bytes to the muxer; the sweep/patch
/// verify path recovers a key and retries, or fails the read. `0` for `None` /
/// `Css` and for any AACS buffer where every unit reached clean TS.
pub fn decrypt_sectors(
buf: &mut [u8],
keys: &mut DecryptKeys,
@@ -204,7 +211,8 @@ pub fn decrypt_sectors(
/// no TS sync, which would otherwise be mistaken for ciphertext). `base_lba` is
/// the absolute LBA of `buf`'s first sector; aligned units are 3 sectors.
///
/// `content_ranges` is sorted, merged, disjoint `[start_lba, end_lba)`.
/// `content_ranges` is sorted, merged, disjoint `(start_lba, sector_count)`
/// tuples (each covering `[start_lba, start_lba + sector_count)`).
pub fn decrypt_sectors_in_content(
buf: &mut [u8],
keys: &mut DecryptKeys,
@@ -240,6 +248,7 @@ fn decrypt_sectors_impl(
DecryptKeys::Aacs {
unit_keys,
read_data_key,
format,
} => {
// Validate that unit_key_idx is in-range before doing anything else.
// This preserves the existing contract: an out-of-range explicit index
@@ -248,8 +257,13 @@ fn decrypt_sectors_impl(
return Err(crate::error::Error::DecryptFailed);
}
// Strip CPS-unit IDs — the decrypt primitives only want the raw key bytes.
let raw_keys: Vec<[u8; 16]> = unit_keys.iter().map(|(_, k)| *k).collect();
// Container of this disc's content — the key SELECTOR (`is_clean`)
// checks the decrypted plaintext against the right structure (TS vs PS).
let format = *format;
// Index `unit_keys` directly for the raw key bytes (the `.1` of each
// `(cps_id, key)`); no per-call `Vec` of stripped keys — the decrypt
// closures only ever need `len()` / `[idx].1`, so collecting one would
// just be a heap alloc/free on every batch of the mux hot path.
let rdk: Option<[u8; 16]> = *read_data_key;
let unit_len = aacs::content::ALIGNED_UNIT_LEN;
// AACS decrypts whole 6144-byte aligned units. The live mux path
@@ -293,7 +307,13 @@ fn decrypt_sectors_impl(
Some((base, ranges)) => lba_in_ranges(base.saturating_add(nfull * 3), ranges),
None => true,
};
if partial_in_content {
// TS-only: a scrambled trailing PARTIAL unit (< a full 6144-byte
// unit) can't be unit-decrypted, so fail loud. The heuristic is
// MPEG-TS sync density, which a PS (`.evo`) partial lacks entirely —
// running it on PS would false-trip `DecryptFailed`. HD-DVD partial-
// scramble detection is not yet wired (consistent with the UNVERIFIED
// PS path in `aacs_unit_encrypted`).
if partial_in_content && format == crate::disc::ContentFormat::BdTs {
let partial = &buf[buf.len() - partial_len..];
let packets = aacs::content::ts_packet_total(partial);
if packets > 0 && aacs::content::ts_sync_count(partial) <= packets / 2 {
@@ -320,27 +340,34 @@ fn decrypt_sectors_impl(
// accounting so a partial key failure isn't reported as a clean rip.
let dropped_bytes = AtomicUsize::new(0);
// Per-unit decrypt closure. For a scrambled full aligned unit:
// Per-unit PURE decrypt closure. For a scrambled full aligned unit:
// 1. Try the cached key index first (avoids scanning all keys on the
// common case where a disc run uses one CPS unit throughout).
// 2. On miss, try every key in order (multi-CPS-unit discs).
// 3. Accept the first key whose output passes the TS-sync verify.
// 4. Only restore-to-original if NO key validates (non-m2ts unit or
// genuine decrypt failure). See test
// `nav_file_unit_survives_decrypt_attempt`.
// 3. Select the first key whose output passes the TS-sync verify.
// 4. If NONE yields clean TS, keep the applied-key plaintext anyway
// (a key WAS applied — bad TS is the caller's/muxer's concern) and
// tally the unit as unverified. Never restore ciphertext / null.
// Nav protection is the caller's content gate, not a restore here.
//
// If a read_data_key is present (AACS 2.0 bus encryption), bus-decrypt
// must happen first — it's a shared layer on top that is key-independent
// across all CPS units on the disc.
let decrypt_one = |chunk: &mut [u8]| {
if chunk.len() != unit_len || !aacs::content::aacs_unit_needs_decrypt(chunk) {
// Gate on `aacs_unit_needs_decrypt` (encrypted-flag set AND structure
// not yet restored): the flag alone isn't enough because it lives in
// the plaintext header and survives decryption, so an already-decrypted
// unit would be decrypted a SECOND time (scrambling it) on any re-run of
// this pass. The structure-restored half makes it idempotent. This is
// ALSO the sole gate protecting the now-pure `decrypt_unit` from
// decrypting a clear unit.
if chunk.len() != unit_len || !aacs::content::aacs_unit_needs_decrypt(chunk, format)
{
return;
}
// Save original bytes so we can restore if no key validates.
let original: Vec<u8> = chunk.to_vec();
// Build a bus-decrypted copy to try unit keys against, or work
// in-place when there is no bus layer.
// Bus-decrypt (AACS 2.0) in place first — a shared layer under every
// CPS unit key. Whatever we do below operates on the bus-clear bytes.
if let Some(ref rdk_key) = rdk {
aacs::content::decrypt_bus(chunk, rdk_key);
}
@@ -349,29 +376,68 @@ fn decrypt_sectors_impl(
// back to the full list skipping the hint.
let hint = last_key_idx.load(Ordering::Relaxed);
let try_order =
std::iter::once(hint).chain((0..raw_keys.len()).filter(move |&i| i != hint));
std::iter::once(hint).chain((0..unit_keys.len()).filter(move |&i| i != hint));
// Compose the two SEGREGATED primitives explicitly. `decrypt_unit`
// is the decrypt (apply the key, leave the plaintext). `is_clean`
// is a SEPARATE structural question used here ONLY as a multi-CPS-unit
// key SELECTOR — the first key whose output is clean for the disc's
// container (`format`: TS or PS) is the match. "Did a key produce
// clean structure?" is NOT "did we decrypt?": a correct key can
// decrypt content whose encoding is broken (a muxer concern). When
// NO key yields clean structure we STILL decrypted (the cached-hint
// key is applied): keep those bytes and report the unit UNVERIFIED.
// This function applies no policy; the caller decides what unverified
// means (mux passes it to the muxer; sweep/patch recover or fail).
// Single-key fast path (the vast majority of titles): with no
// alternate key to fall back on there is nothing to try/rollback,
// so decrypt in place — no per-unit scratch alloc or copy-back.
// Clean → cache the hint; unclean → keep the applied bytes and
// tally unverified, exactly as the loop below would with one key.
if unit_keys.len() == 1 {
aacs::content::decrypt_unit(chunk, &unit_keys[0].1);
if aacs::content::is_clean(chunk, format) {
last_key_idx.store(0, Ordering::Relaxed);
} else {
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
}
return;
}
// Trial each key against a STACK scratch (unit_len is always
// ALIGNED_UNIT_LEN and the guard above proved chunk.len() == unit_len)
// so a failing attempt doesn't clobber the bus-decrypted base in
// `chunk` that the next key retries on — with no per-key heap Vec.
// `chunk` is NOT mutated in this loop, so on total miss we simply
// re-apply the first key in place (decrypt_unit is pure), which
// reproduces the first attempt without stashing its bytes.
let mut scratch = [0u8; aacs::content::ALIGNED_UNIT_LEN];
let scratch = &mut scratch[..chunk.len()];
let mut first_idx: Option<usize> = None;
for idx in try_order {
if let Some(key) = raw_keys.get(idx) {
// Work on a per-key copy so a failing attempt doesn't
// clobber the bus-decrypted base we'll retry on.
let mut attempt: Vec<u8> = chunk.to_vec();
if aacs::content::decrypt_unit(&mut attempt, key) {
chunk.copy_from_slice(&attempt);
if let Some((_, key)) = unit_keys.get(idx) {
scratch.copy_from_slice(chunk);
aacs::content::decrypt_unit(scratch, key);
if aacs::content::is_clean(scratch, format) {
chunk.copy_from_slice(scratch);
last_key_idx.store(idx, Ordering::Relaxed);
return;
}
if first_idx.is_none() {
first_idx = Some(idx);
}
}
}
// No key validated — restore the original encrypted bytes and
// tally the loss. The unit is flagged encrypted (we only reach
// here past the CPI gate) but no key applied: genuine encrypted
// content with a missing/wrong sub-key. We always tally; the mux
// read path treats
// the count as loss (its extents are real content), while
// metadata-probe callers that don't install a loss sink ignore it.
chunk.copy_from_slice(&original);
// No key yielded clean structure. Keep the first-tried key's
// plaintext (the pool is non-empty past the guard, so `first_idx` is
// always `Some`) and tally the unit as unverified. Never restore
// ciphertext; that is a caller concern, threaded through the recovery
// ciphertext, not this seam.
if let Some(idx) = first_idx {
aacs::content::decrypt_unit(chunk, &unit_keys[idx].1);
}
dropped_bytes.fetch_add(chunk.len(), Ordering::Relaxed);
};
@@ -408,11 +474,14 @@ fn decrypt_sectors_impl(
// back to the serial path rather than panic.
match decrypt_pool() {
Some(pool) => {
let chunks: Vec<&mut [u8]> = buf.chunks_mut(unit_len).collect();
// `par_chunks_mut` iterates the units in place — no
// intermediate `Vec<&mut [u8]>` allocation per batch.
pool.install(|| {
chunks.into_par_iter().enumerate().for_each(|(idx, chunk)| {
process(idx, chunk);
});
buf.par_chunks_mut(unit_len)
.enumerate()
.for_each(|(idx, chunk)| {
process(idx, chunk);
});
});
}
None => {
@@ -442,13 +511,15 @@ fn decrypt_sectors_impl(
mod tests {
use super::*;
/// Regression for the 0.18.1 nav-file scramble bug. A non-m2ts unit (here
/// an MPLS file: starts "MPLS", carries no TS syncs) reads as scrambled
/// under `ts_sync_destroyed`, gets AES-decrypted with the unit key, fails
/// the TS-sync verification, and must be restored to its original bytes —
/// not left scrambled.
/// Regression for the 0.18.1 nav-file scramble bug, modern form. A non-m2ts
/// unit (here an MPLS file: starts "MPLS", whose byte-0 'M'=0x4D coincidentally
/// sets the CPI bits, so it reads as encrypted) must never be scrambled by a
/// decrypt attempt. The decrypter applies NO policy and no longer restores — so
/// nav protection is the CALLER's content gate: a real read (sweep/patch) is
/// content-gated, and every whole-disc caller passes the encrypted-content
/// extents so nav LBAs are skipped entirely and left untouched.
#[test]
fn nav_file_unit_survives_decrypt_attempt() {
fn nav_file_unit_survives_when_gated_out_of_content() {
let mut unit = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
unit[0] = b'M';
unit[1] = b'P';
@@ -462,11 +533,14 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
decrypt_sectors(&mut unit, &mut keys, 0).unwrap();
// The unit sits at LBA 0..3; the content extents are elsewhere (100..110),
// so this nav unit is OUTSIDE content and the gate skips it untouched.
decrypt_sectors_in_content(&mut unit, &mut keys, 0, 0, &[(100, 10)]).unwrap();
assert_eq!(
unit, snapshot,
"non-m2ts unit must be restored after failed decrypt"
"a nav unit outside the content extents must be left untouched by the gate"
);
}
@@ -531,6 +605,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
@@ -564,6 +639,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
// unit0 @ LBA 0 (clear/skip), unit1 @ LBA 3 (content). Content = [(3,3)].
@@ -582,6 +658,7 @@ mod tests {
let mut keys_g = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut keys_u = keys_g.clone();
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
@@ -632,6 +709,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let original = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
let mut buf = original.clone();
@@ -650,6 +728,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let original = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
let mut buf = original.clone();
@@ -768,6 +847,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let u = aacs::content::ALIGNED_UNIT_LEN;
let mut buf = vec![0u8; 3 * u];
@@ -785,6 +865,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = scrambled_region(2 * aacs::content::ALIGNED_UNIT_LEN);
// unit0 @ LBA0 content, unit1 @ LBA3 out. Content = [(0,3)].
@@ -804,6 +885,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// One full clear unit + a scrambled single-sector partial, all OUTSIDE
// content → the partial must be tolerated (Ok), not DecryptFailed.
@@ -826,6 +908,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// One full scrambled unit + a 2048-byte (single-sector) CLEAR tail.
let unit = scrambled_region(aacs::content::ALIGNED_UNIT_LEN);
@@ -851,6 +934,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// One full unit + a 4096-byte (two-sector) SCRAMBLED tail.
let unit = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
@@ -873,6 +957,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf: Vec<u8> = Vec::new();
assert!(decrypt_sectors(&mut buf, &mut keys, 0).is_ok());
@@ -886,6 +971,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN * 2);
let snapshot = buf.clone();
@@ -927,6 +1013,7 @@ mod tests {
DecryptKeys::Aacs {
unit_keys: vec![(0, [0; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}
.is_encrypted()
);
@@ -1141,6 +1228,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, [0xAB; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
let err = decrypt_sectors(&mut buf, &mut keys, 5)
@@ -1161,6 +1249,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = clear_ts_region(aacs::content::ALIGNED_UNIT_LEN);
let err = decrypt_sectors(&mut buf, &mut keys, 0).expect_err("empty unit_keys must error");
@@ -1242,6 +1331,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key0), (1, key1)], // two CPS units
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// Call with the default hint (idx 0) — the fix must fall back to key1.
@@ -1276,6 +1366,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = unit;
decrypt_sectors(&mut buf, &mut keys, 0).expect("single-key disc must decrypt");
@@ -1290,21 +1381,18 @@ mod tests {
);
}
/// Regression for the silent partial-decrypt-loss defect: a scrambled AACS
/// unit that NO supplied key can decrypt is restored to its original
/// ciphertext (so a clear nav-file is never corrupted) AND `decrypt_sectors`
/// returns the unit's byte length as the dropped count. Before the fix this
/// returned `()` and the still-encrypted bytes flowed downstream to be
/// silently dropped by the TS assembler with zero loss accounting — a rip
/// missing real content reported `lost_video_secs=0` and passed the abort
/// gate even under `abort_on_lost_secs=0`.
/// A unit no supplied key opens to clean TS is still DECRYPTED in place (the
/// key is applied — decryption ran; a broken result is bad data, not a decrypt
/// failure) and NEVER restored to ciphertext. `decrypt_sectors` still returns
/// the unit's byte length as the UNVERIFIED count — the read-verify signal the
/// sweep/patch caller consumes (the mux ignores it and passes the bytes to the
/// muxer). This is the single decrypt authority applying no policy.
///
/// Grounding: the `dropped_bytes.fetch_add(chunk.len(), …)` on the
/// no-key-validated restore path; the function returns that tally.
/// Mutation: drop the `fetch_add` (or return a constant 0) → dropped == 0,
/// this fails.
/// Grounding: `dropped_bytes.fetch_add(chunk.len(), …)` in `decrypt_one`, and
/// the removal of the `copy_from_slice(&original)` restore.
/// Mutation: re-add the restore → `buf == ciphertext`, this fails.
#[test]
fn aacs_undecryptable_unit_reports_dropped_bytes() {
fn aacs_undecryptable_unit_is_decrypted_not_restored() {
let real_key = [0x33u8; 16];
let wrong_key = [0x44u8; 16]; // not the encrypting key
@@ -1320,19 +1408,20 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, wrong_key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = unit;
let dropped = decrypt_sectors(&mut buf, &mut keys, 0)
.expect("undecryptable unit is not a hard error");
let unverified =
decrypt_sectors(&mut buf, &mut keys, 0).expect("applying a key is never a hard error");
assert_eq!(
dropped,
unverified,
aacs::content::ALIGNED_UNIT_LEN,
"the whole scrambled unit must be reported as dropped when no key validates"
"a unit that did not reach clean TS is reported unverified"
);
assert_eq!(
assert_ne!(
buf, ciphertext,
"an undecryptable unit must be restored to its original ciphertext, not garbled"
"the unit must be DECRYPTED in place (key applied), never restored to ciphertext"
);
}
@@ -1363,22 +1452,23 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("partial decrypt is Ok");
assert_eq!(
dropped,
aacs::content::ALIGNED_UNIT_LEN,
"exactly one unit's worth of bytes must be reported dropped"
"exactly one unit's worth of bytes must be reported unverified"
);
assert!(
!aacs::content::ts_sync_destroyed(&buf[..aacs::content::ALIGNED_UNIT_LEN]),
"the decryptable unit must come out clear"
);
assert_eq!(
assert_ne!(
&buf[aacs::content::ALIGNED_UNIT_LEN..],
&unit_b_ciphertext[..],
"the undecryptable unit must be restored to ciphertext"
"the unverified unit is DECRYPTED in place (key applied), never restored to ciphertext"
);
}
@@ -1392,6 +1482,7 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![(0, key)],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = unit;
let dropped = decrypt_sectors(&mut buf, &mut keys, 0).expect("clean decrypt");
+59 -5
View File
@@ -72,6 +72,11 @@ impl Disc {
// Parse each clip for size, duration, and sector extents
let mut extents = Vec::new();
let mut total_size: u64 = 0;
// Set when any clip resolves to a STREAM/SSIF/<clip>.ssif — a Blu-ray 3D
// interleaved stream carrying both the base (left) and MVC dependent
// (right) views. Drives reading the SSIF for both eyes and adding the
// dependent-view stream below.
let mut is_3d = false;
let mut clips = Vec::with_capacity(parsed.play_items.len());
// BD playlists legally reference the same .m2ts clip_id from
// multiple PlayItems (multi-angle, seamless splits, looped
@@ -110,10 +115,23 @@ impl Disc {
// use `.ssif` (see [`CLIP_STREAM_EXTS`]). A normal `.m2ts`
// clip is unchanged — the fallback only runs when `.m2ts`
// is absent, which is exactly when `file_extents` errors.
let file_exts = CLIP_STREAM_EXTS.iter().find_map(|ext| {
let path = format!("/BDMV/STREAM/{}.{}", play_item.clip_id, ext);
udf_fs.file_extents(reader, &path).ok()
});
// 3D discs interleave the left (base) and right (MVC
// dependent) views in STREAM/SSIF/<clip>.ssif — note the
// SSIF/ subdir. Prefer it when present: the SSIF is one
// transport stream carrying BOTH eyes on distinct PIDs,
// so muxing it captures the full 3D. 2D clips fall back to
// the base .m2ts / .fmts as before.
let ssif = format!("/BDMV/STREAM/SSIF/{}.ssif", play_item.clip_id);
let file_exts = match udf_fs.file_extents(reader, &ssif) {
Ok(exts) => {
is_3d = true;
Some(exts)
}
Err(_) => CLIP_STREAM_EXTS.iter().find_map(|ext| {
let path = format!("/BDMV/STREAM/{}.{}", play_item.clip_id, ext);
udf_fs.file_extents(reader, &path).ok()
}),
};
if let Some(file_exts) = file_exts {
for (lba, sectors) in file_exts {
if sectors > 0 && lba > 0 {
@@ -138,7 +156,7 @@ impl Disc {
}
// Build streams from STN table
let streams: Vec<Stream> = parsed
let mut streams: Vec<Stream> = parsed
.streams
.iter()
.filter_map(|s| {
@@ -232,6 +250,42 @@ impl Disc {
})
.collect();
// 3D: add the MVC dependent (right-eye) video stream. The base STN table
// lists only the left-eye video; the dependent view is a second video
// PID (stream_type 0x20) carried in the SSIF. The on-disc PAT/PMT are
// AACS-encrypted (unreadable pre-key) and the base STN omits the
// dependent view (it lives in the MPLS STN_table_SS), so we use the
// BD-3D PID convention: dependent = base-view video PID + 1
// (e.g. 0x1011 -> 0x1012). Reading the SSIF (above) provides its packets.
//
// Limitation: `is_3d` latches per PLAYLIST, not per clip. A playlist that
// mixed a 3D clip (has an SSIF) with a 2D clip (no SSIF) would tag the
// whole title 3D; the 2D clip's frames then mux as plain Blocks (no
// dependent PID → no BlockAdditional) under a track that still advertises
// the mvcC mapping. That output is valid (per-frame BlockAdditional is
// optional) but over-claims 3D for those frames. Real 3D main-feature
// playlists are single-clip or uniformly 3D, so this is not exercised;
// per-clip 3D would need per-clip stream sets (a larger change).
if is_3d {
if let Some(base) = streams.iter().find_map(|s| match s {
Stream::Video(v) => Some(v.clone()),
_ => None,
}) {
let dep_pid = base.pid.wrapping_add(1);
let have_dep = streams
.iter()
.any(|s| matches!(s, Stream::Video(v) if v.pid == dep_pid));
if !have_dep {
streams.push(Stream::Video(VideoStream {
pid: dep_pid,
secondary: true,
label: crate::disc::MVC_DEPENDENT_LABEL.to_string(),
..base
}));
}
}
}
// Convert marks to chapters. mark_type == 1 is an entry-mark
// (chapter); type 2 is a link point and type 0 is reserved, so
// neither is a chapter.
+3 -7
View File
@@ -311,15 +311,11 @@ impl Disc {
) -> Result<AacsState> {
use crate::aacs;
let uk_ro_data = udf_fs
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
.map_err(|_| Error::AacsNoKeys)?;
let uk_ro_data =
aacs::read_first(aacs::UNIT_KEY_RO_PATHS, |p| udf_fs.read_file(reader, p))?;
let dh = aacs::inf::disc_hash(&uk_ro_data);
let cc = udf_fs
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
let cc = aacs::read_first(aacs::CONTENT_CERT_PATHS, |p| udf_fs.read_file(reader, p))
.ok()
.as_deref()
.and_then(aacs::inf::parse_content_cert);
-57
View File
@@ -288,63 +288,6 @@ impl Disc {
}
}
/// True for the AACS-encrypted stream files (`.m2ts`, `.ssif`). Every other UDF
/// file is clear (nav / playlists / filesystem) and needs no decrypt verify.
fn is_aacs_clip(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
lower.ends_with(".m2ts") || lower.ends_with(".ssif")
}
/// Enumerate the disc's AACS clip (`.m2ts`/`.ssif`) files as
/// [`crate::disc::verify::ClipLayout`]s for the post-read verify gate: each
/// clip's declared size plus its absolute disc extents in FILE order. Reads the
/// UDF tree through `reader`.
///
/// FAIL-SAFE: any enumeration error (bad UDF read, name collision, …) yields an
/// EMPTY list — the verify gate then covers nothing and the sweep behaves as
/// today. Enumeration must never break a rip, so the error is logged, not
/// propagated.
pub(crate) fn clip_layouts(reader: &mut dyn SectorSource) -> Vec<crate::disc::verify::ClipLayout> {
let result = (|| -> Result<Vec<crate::disc::verify::ClipLayout>> {
let fs = udf::read_filesystem(reader)?;
let mut planned: Vec<PlannedFile> = Vec::new();
let mut dirs: Vec<PathBuf> = Vec::new();
let mut seen_hosts: std::collections::HashMap<PathBuf, String> =
std::collections::HashMap::new();
plan_tree(
reader,
&fs,
&fs.root,
Path::new(""),
"",
true,
&mut planned,
&mut dirs,
&mut seen_hosts,
)?;
Ok(planned
.into_iter()
.filter(|pf| pf.inline.is_none() && is_aacs_clip(&pf.disc_name))
.map(|pf| crate::disc::verify::ClipLayout {
size: pf.size,
extents: pf.extents,
// Every AACS clip we enumerate today is BD-TS (`.m2ts`/`.ssif`).
// HD-DVD `.evo` (program stream) maps to `ContainerKind::Ps` here
// once `is_aacs_clip` recognises it — the one-line HD-DVD hook.
container: crate::disc::verify::ContainerKind::Ts,
})
.collect())
})();
result.unwrap_or_else(|e| {
tracing::warn!(
target: "freemkv::verify",
error = %e,
"clip enumeration failed; post-read verify disabled for this pass"
);
Vec::new()
})
}
/// A borrowing `SectorSource` wrapper. Lets the decrypting decorator "own" an
/// inner source for its lifetime while the caller keeps the underlying
/// `&mut dyn SectorSource` (the decorator is a `DecryptingSectorSource<S>`
+97 -125
View File
@@ -10,7 +10,7 @@
mod bluray;
mod dvd;
pub mod dvd_audio_probe;
pub(crate) mod dvd_audio_probe;
mod encrypt;
mod extract;
mod hddvd;
@@ -19,7 +19,6 @@ mod patch;
pub mod read_error;
mod section_recover;
mod sweep;
pub mod verify;
use crate::drive::{Drive, extract_scsi_context};
use crate::error::{Error, Result};
@@ -85,7 +84,8 @@ pub struct Disc {
pub enum ContentFormat {
/// Blu-ray BD Transport Stream (192-byte packets)
BdTs,
/// DVD MPEG-2 Program Stream (VOB)
/// MPEG-2 Program Stream — DVD (`.vob`) and HD-DVD (`.evo`). For AACS content
/// this selects the PS-aware encrypted-flag / structural checks.
MpegPs,
}
@@ -216,6 +216,22 @@ pub struct VideoStream {
pub measured_cicp: Option<MeasuredCicp>,
}
/// Label marking a video stream as the Blu-ray 3D **MVC dependent (right-eye)
/// view** — the paired substream of the AVC base view. Set by the BD scan
/// (`bluray.rs`) and recognised by the mux path (`resolve.rs` builds its parser
/// in param-set-preserving mode; `mkvstream.rs` folds it into the base track as
/// per-frame `BlockAdditional`). Single source of truth for the contract.
pub const MVC_DEPENDENT_LABEL: &str = "MVC dependent view (3D right eye)";
impl VideoStream {
/// Whether this video stream is the MVC dependent (right-eye) view — the
/// 3D substream that the muxer merges into the base track as a per-frame
/// `BlockAdditional` rather than emitting as an independent track.
pub fn is_mvc_dependent(&self) -> bool {
self.label == MVC_DEPENDENT_LABEL
}
}
/// Measured CICP colour signalling read directly from a video elementary stream
/// (ITU-T H.273). Preferred over the coarse [`ColorSpace`] enum when present.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -811,7 +827,11 @@ impl Codec {
use crate::consts::coding_type as c;
match ct {
c::HEVC => Codec::Hevc,
c::H264 => Codec::H264,
// 0x1B base-view AVC and 0x20 MVC dependent-view (Blu-ray 3D right
// eye) are both H.264. Mapping 0x20 to video is what makes the PMT
// scan enumerate the dependent view as a second H.264 stream (its
// own PID in the SSIF) instead of dropping it — the basis of 3D.
c::H264 | c::H264_MVC => Codec::H264,
c::VC1 => Codec::Vc1,
c::MPEG2_VIDEO => Codec::Mpeg2,
c::TRUEHD => Codec::TrueHd,
@@ -1603,12 +1623,11 @@ impl Disc {
// detection needs the read, the read needs auth, auth needs detection.
// The handshake is itself the detector: on a non-CSS (unencrypted) DVD
// the disc-key read fails, `resolve` returns None, and the disc is left
// in the clear. This block is DVD-only (MPEG-PS); BD/UHD (MPEG-TS) goes
// through the AACS handshake above and never reaches here.
if disc.css.is_none()
&& disc.content_format == ContentFormat::MpegPs
&& !disc.titles.is_empty()
{
// in the clear. This block is DVD-only: gate on `DiscFormat::Dvd`, NOT
// `content_format == MpegPs` — HD-DVD `.evo` is ALSO MPEG-PS but is AACS,
// not CSS, so it must never enter the CSS/REPORT-KEY handshake (it goes
// through the AACS path above). BD/UHD are MPEG-TS and never reach here.
if disc.css.is_none() && disc.format == DiscFormat::Dvd && !disc.titles.is_empty() {
// CSS title keys are per-VTS, and ONLY the scrambled movie content
// carries a non-zero key. Menu / VMG / logo cells (often the
// low-LBA first extent) return a ZERO title key over REPORT KEY —
@@ -1724,10 +1743,13 @@ impl Disc {
// pre-decrypted one. A pre-decrypted image has its scramble flags clear,
// so `crack_key` finds no crackable sector and the disc stays in the
// clear. AACS images go through KEYDB VUK lookup, not here.
if disc.css.is_none()
&& disc.content_format == ContentFormat::MpegPs
&& !disc.titles.is_empty()
{
//
// Gate on `DiscFormat::Dvd`, NOT `content_format == MpegPs`: HD-DVD
// `.evo` images are ALSO MPEG-PS but are AACS, not CSS — they must not
// enter the CSS crack path. A CSS DVD's IFO (which defines the titles
// this branch reads) is unscrambled, so `detect_format` reliably sets
// `Dvd` from the SD-resolution titles even on a still-scrambled image.
if disc.css.is_none() && disc.format == DiscFormat::Dvd && !disc.titles.is_empty() {
let main_extents = match disc
.titles
.iter()
@@ -1779,10 +1801,9 @@ impl Disc {
reader: &mut dyn SectorSource,
udf_fs: &udf::UdfFs,
) -> Result<(Vec<u8>, Vec<u8>, u8)> {
let inf = udf_fs
.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_UNIT_KEY_RO_DUPLICATE))
.map_err(|_| Error::AacsNoKeys)?;
let inf = crate::aacs::read_first(crate::aacs::UNIT_KEY_RO_PATHS, |p| {
udf_fs.read_file(reader, p)
})?;
let mkb = Self::read_mkb_content(reader, udf_fs)?;
let version = Self::read_aacs_version(reader, udf_fs);
Ok((inf, mkb, version))
@@ -1800,12 +1821,12 @@ impl Disc {
/// mis-strided title keys (silent wrong unit keys), so a missing cert must
/// not quietly pick the V10 stride for a UHD disc.
fn read_aacs_version(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> u8 {
match udf_fs
.read_file(reader, crate::aacs::PATH_CONTENT_CERT)
.or_else(|_| udf_fs.read_file(reader, crate::aacs::PATH_CONTENT_CERT_ALT))
.ok()
.as_deref()
.and_then(crate::aacs::inf::parse_content_cert)
match crate::aacs::read_first(crate::aacs::CONTENT_CERT_PATHS, |p| {
udf_fs.read_file(reader, p)
})
.ok()
.as_deref()
.and_then(crate::aacs::inf::parse_content_cert)
{
Some(c) => c.version.major(),
None => {
@@ -1836,10 +1857,9 @@ impl Disc {
const MAX_BYTES: usize = 64 * 1024 * 1024;
let mut want = START_BYTES;
loop {
let buf = udf_fs
.read_file_prefix(reader, crate::aacs::PATH_MKB_RO, want)
.or_else(|_| udf_fs.read_file_prefix(reader, crate::aacs::PATH_MKB_RW, want))
.map_err(|_| Error::AacsNoKeys)?;
let buf = crate::aacs::read_first(crate::aacs::MKB_PATHS, |p| {
udf_fs.read_file_prefix(reader, p, want)
})?;
let n = crate::aacs::mkb::mkb_content_len(&buf);
// `n` strictly inside `buf` => the record walk reached the padding
// boundary (full content captured). `buf` shorter than `want` =>
@@ -2277,12 +2297,15 @@ fn aligned_unit_keys_validate(
unit_keys: &[(u32, [u8; 16])],
read_data_key: Option<&[u8; 16]>,
samples: &[Vec<u8>],
format: ContentFormat,
) -> bool {
use crate::aacs::content::{ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_unit_full};
use crate::aacs::content::{
ALIGNED_UNIT_LEN, aacs_unit_needs_decrypt, decrypt_bus, decrypt_unit, is_clean,
};
let scrambled: Vec<&[u8]> = samples
.iter()
.map(|s| s.as_slice())
.filter(|s| aacs_unit_needs_decrypt(s))
.filter(|s| aacs_unit_needs_decrypt(s, format))
.collect();
if scrambled.is_empty() {
return true; // nothing to disprove against — accept
@@ -2304,7 +2327,13 @@ fn aligned_unit_keys_validate(
hb.tick_cpu(tried, total);
tried += 1;
probe.copy_from_slice(&sample[..ALIGNED_UNIT_LEN]);
if decrypt_unit_full(&mut probe, k, read_data_key) {
// bus layer (AACS 2.0) first, then the CPS unit key, then the structural
// proof — the composed form of the old `decrypt_unit_full`.
if let Some(rdk) = read_data_key {
decrypt_bus(&mut probe, rdk);
}
decrypt_unit(&mut probe, k);
if is_clean(&probe, format) {
covered = true;
break;
}
@@ -2331,6 +2360,7 @@ impl Disc {
crate::decrypt::DecryptKeys::Aacs {
unit_keys: aacs.unit_keys.clone(),
read_data_key: aacs.read_data_key,
format: self.content_format,
}
} else if let Some(ref css) = self.css {
crate::decrypt::DecryptKeys::Css {
@@ -2820,7 +2850,12 @@ impl Disc {
// de-scramble it. With no samples (or only clear ones) there is nothing
// to disprove against, so the key is accepted as-is — keeping the
// sample-less paths (resume / mapfile cache) byte-for-byte unchanged.
if !aligned_unit_keys_validate(&candidate_unit_keys, read_data_key.as_ref(), samples) {
if !aligned_unit_keys_validate(
&candidate_unit_keys,
read_data_key.as_ref(),
samples,
self.content_format,
) {
return Err(crate::error::Error::AacsKeyRejected);
}
@@ -3053,10 +3088,6 @@ impl Disc {
progress: opts.progress,
halt: opts.halt.clone(),
key_fetch: opts.key_fetch.clone(),
// Disc::copy's internal patch grinds each range fully (it's a
// single-call recovery); the breadth-first fast-capture ordering is
// an autorip multi-pass concern.
fast_capture: false,
};
let pr = self.patch(reader, path, &patch_opts)?;
tracing::info!(
@@ -3111,27 +3142,15 @@ impl Disc {
// A decrypting sweep (`opts.decrypt`, e.g. `disc:// → iso://` without
// `--raw`) decrypts each unit IN PLACE → the ISO holds plaintext.
//
// A NON-decrypting MULTIPASS sweep (`!opts.decrypt && skip_on_error`, the
// autorip / `--multipass` path) writes the ISO as CIPHERTEXT, but we
// still resolve the keys and VERIFY each unit on a scratch copy: a unit
// that won't decrypt fails the read (`DECRYPT_VERIFY_READ`) exactly like
// a SCSI error, and flows into the SAME read-error recovery (skip /
// NonTrimmed / patch). This is the one spot that makes "a read succeeded"
// mean "read AND decrypts" — everything downstream is unchanged. With no
// usable AACS keys (no keydb) it degrades to a plain pass-through.
//
// A plain `--raw` single-pass (no `skip_on_error`) stays a pass-through:
// the user asked for the raw image, untouched and unchecked.
// The sweep COPIES ciphertext (multipass / `--raw`) or decrypts IN PLACE
// (`opts.decrypt`, the rare disc→decrypted-ISO). It deliberately does NOT
// decrypt-VERIFY: a whole-disc sweep reads disc-absolute, but AACS aligned
// units are anchored to each clip's FILE start and clips can be non-6144-
// aligned OR fragmented across UDF extents — so a disc-absolute verify
// mis-aligns the unit grid and false-fails good clips (it skipped the
// ~990 MB orphan-CPS clip on Dunkirk). Verification moved to the
// clip-anchored [`Disc::verify_clips`] pass that runs AFTER the sweep,
// reading each clip file-order-anchored from the ISO. The read here stays
// a fail-safe copy; alignment is never assumed.
// Every other sweep (`!opts.decrypt`: the autorip / `--multipass` path and
// plain `--raw`) writes the ISO as CIPHERTEXT verbatim — keys = `None`, a
// pure pass-through. Bad sectors are found by PHYSICAL read success (a SCSI
// read error → skip / NonTrimmed → patch re-read), NOT by decrypt structure.
// (The old decrypt-VERIFY read gate — which mis-aligned the disc-absolute
// unit grid against clip-file-anchored AACS units and false-failed good
// clips like Dunkirk's orphan-CPS clip — was removed. There is no scratch
// verify and no post-sweep clip-anchored pass; decryptability is proven at
// mux time, not at capture time.)
let keys = if opts.decrypt {
self.decrypt_keys()
} else {
@@ -3157,22 +3176,6 @@ impl Disc {
};
let reader = &mut reader;
// Post-read verify gate (universal `read -> verify -> sign-off`). Built
// ONLY for the ciphertext sweep (`!opts.decrypt`, the multipass rip
// path) so `observe` always sees on-disc ciphertext and never
// double-decrypts already-plaintext bytes. `UnitVerifier::new` is itself
// fail-safe: it returns `None` (verify disabled, behavior unchanged) for
// a non-AACS disc, no keys, the kill-switch off, or an empty clip
// enumeration. We resolve the REAL AACS keys here even though the sweep
// copies ciphertext, and reuse the application's key-fetch seam.
let mut verifier = if opts.decrypt {
None
} else {
let verify_keys = self.decrypt_keys();
let layouts = extract::clip_layouts(&mut *reader);
crate::disc::verify::UnitVerifier::new(&layouts, &verify_keys, opts.key_fetch.clone())
};
// Mapfile: load if resuming, else wipe + recreate.
let mapfile_path = self.mapfile_for(path);
// covers_disc reconciliation. A resume against a mapfile whose total
@@ -3463,18 +3466,6 @@ impl Disc {
// The consumer thread sees decrypted bytes; the
// pre-0.18 inline decrypt_sectors call lived here.
// Post-read verify: observe the just-read ciphertext
// BEFORE it is moved into the channel, collecting the
// clip units this batch completes that are confidently
// undecryptable. Sent as `MarkBad` AFTER the `Good`
// below so the FIFO pipe records `Finished` first and the
// downgrade to `NonTrimmed` last. No-op when the gate is
// disabled (`verifier` is `None`).
let verify_bad = verifier
.as_mut()
.map(|v| v.observe(block_lba, &buf[..block_bytes as usize]))
.unwrap_or_default();
// Move the batch into the channel via fresh
// owned Vec. The producer's `buf` is reused
// for the next read.
@@ -3483,26 +3474,6 @@ impl Disc {
producer_err = Some(consumer_gone());
break 'outer;
}
// Downgrade any unit that failed verify (decrypt-fail ==
// bad read). decrypt-fail is NOT physical damage, so it
// deliberately does not touch the damage-jump window.
let mut send_failed = false;
for (bad_lba, bad_cnt) in verify_bad {
if pipe
.send(WorkItem::MarkBad {
pos: bad_lba as u64 * 2048,
len: bad_cnt as u64 * 2048,
})
.is_err()
{
producer_err = Some(consumer_gone());
send_failed = true;
break;
}
}
if send_failed {
break 'outer;
}
bytes_done = bytes_done.saturating_add(block_bytes);
pos += block_bytes;
}
@@ -3989,18 +3960,6 @@ pub struct PatchOptions<'a> {
/// On-decrypt-miss key fetch (see [`CopyOptions::key_fetch`]). Lets Pass N
/// recover an orphan CPS unit's key when re-reading its bad range.
pub key_fetch: Option<crate::sector::KeyFetch>,
/// Fast-capture pass: read each bad range ONCE at the full batch and leave
/// every failed block `NonTrimmed` for a later pass — WITHOUT bisecting,
/// re-reading, or grinding it here. This lets a first retry pass grab the
/// readable blocks (the sweep's good skip-ahead overshoot) of EVERY range
/// quickly, before any single range's slow per-sector recovery — so
/// recovered data surfaces across the whole disc first instead of grinding
/// section 1 to exhaustion before even touching section 2. A later pass
/// (`fast_capture = false`) does the granular bisect/retry on what's left.
/// No data is dropped: a failed block stays `NonTrimmed` until a granular
/// pass recovers it or finally gives up. A transport fault (bridge crash)
/// still aborts — it isn't a recoverable bad sector.
pub fast_capture: bool,
}
/// Result returned by [`Disc::patch`].
@@ -4847,6 +4806,7 @@ mod tests {
crate::decrypt::DecryptKeys::Aacs {
unit_keys,
read_data_key,
..
} => {
assert_eq!(unit_keys, uk, "injected UK must be the decrypt key");
assert_eq!(read_data_key, None, "ISO mux needs no bus key");
@@ -5373,7 +5333,8 @@ mod tests {
assert!(super::aligned_unit_keys_validate(
&[(0, [0x11u8; 16])],
None,
&[]
&[],
ContentFormat::BdTs
));
// A clear unit (TS syncs intact) is not scrambled -> proves nothing ->
@@ -5388,7 +5349,8 @@ mod tests {
assert!(super::aligned_unit_keys_validate(
&[(0, [0x11u8; 16])],
None,
&[clear.clone()]
&[clear.clone()],
ContentFormat::BdTs
));
// A genuinely scrambled unit the RIGHT key restores to clear TS.
@@ -5403,16 +5365,23 @@ mod tests {
assert!(super::aligned_unit_keys_validate(
&[(7, uk)],
None,
&[enc.clone()]
&[enc.clone()],
ContentFormat::BdTs
));
// Wrong key -> cannot de-scramble a scrambled sample -> reject.
assert!(!super::aligned_unit_keys_validate(
&[(7, [0x00u8; 16])],
None,
&[enc.clone()]
&[enc.clone()],
ContentFormat::BdTs
));
// Empty key set against a scrambled sample -> reject.
assert!(!super::aligned_unit_keys_validate(&[], None, &[enc]));
assert!(!super::aligned_unit_keys_validate(
&[],
None,
&[enc],
ContentFormat::BdTs
));
}
#[test]
@@ -5447,21 +5416,24 @@ mod tests {
assert!(!super::aligned_unit_keys_validate(
&[(0, uk0)],
None,
&samples
&samples,
ContentFormat::BdTs
));
// Complete key set (both CPS units) -> accept.
assert!(super::aligned_unit_keys_validate(
&[(0, uk0), (1, uk1)],
None,
&samples
&samples,
ContentFormat::BdTs
));
// Order-independent: covering key present anywhere in the set is fine.
assert!(super::aligned_unit_keys_validate(
&[(1, uk1), (0, uk0)],
None,
&samples
&samples,
ContentFormat::BdTs
));
}
+32 -86
View File
@@ -405,9 +405,15 @@ pub(super) fn compute_initial_state(
bad_ranges.reverse();
}
let work_total: u64 = bad_ranges.iter().map(|(_, sz)| *sz).sum();
// Fail SAFE when metadata is indeterminate: assume a regular file so a
// real `sync_all` failure is surfaced, not swallowed. `/dev/null` and pipes
// report success-with-non-file here (so they still correctly map to
// `false`); only a genuine metadata error (e.g. transient NFS ESTALE) hits
// the default, and for a data-integrity guard "surface the error" is the
// right side to err on.
let is_regular = std::fs::metadata(path)
.map(|m| m.file_type().is_file())
.unwrap_or(false);
.unwrap_or(true);
Ok((
map,
initial_stats,
@@ -1247,7 +1253,24 @@ impl Disc {
pub fn bytes_bad_in_title(&self, mapfile_path: &std::path::Path, title: &DiscTitle) -> u64 {
let map = match mapfile::Mapfile::load(mapfile_path) {
Ok(m) => m,
Err(_) => return 0,
// A MISSING mapfile is legitimate (no damage was ever tracked — e.g. a
// clean single-pass rip): 0 bad bytes is correct. Any OTHER load error
// (corrupt / unreadable mapfile) means we CANNOT know the damage — and
// a returned 0 reads to the caller as "clean." Logging alone is not
// fail-safe: the RETURN VALUE drives the loss/abort accounting, not the
// log. So fail safe by reporting the ENTIRE title as bad (its full
// in-extent byte count) — a corrupt damage record must surface as
// maximal loss, never as a clean rip.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return 0,
Err(e) => {
tracing::warn!(
target: "freemkv::disc",
path = %mapfile_path.display(),
error = %e,
"bytes_bad_in_title: mapfile load failed; reporting whole title bad (fail-safe: cannot confirm clean)"
);
return bytes_bad_in_title(title, &[(0, u64::MAX)]);
}
};
let bad_ranges = map.ranges_with(&[
mapfile::SectorStatus::NonTrimmed,
@@ -1299,33 +1322,13 @@ impl Disc {
let bytes_good_before = initial_stats.bytes_good;
let bytes_good_start = bytes_good_before;
// Post-read verify gate for the patch pass (ciphertext multipass only,
// `!opts.decrypt`). Built here from the raw reader's UDF enumeration;
// reused AFTER the recovery loop (`reverify_iso`) to re-check the units
// this pass touched by reading them WHOLE back from the patched ISO —
// patch re-reads only the bad sectors of a unit, so per-unit verify
// can't run live. Fail-safe `None` when disabled / non-AACS / no keys.
let mut verifier = if opts.decrypt {
None
} else {
let verify_keys = self.decrypt_keys();
let layouts = crate::disc::extract::clip_layouts(&mut *reader);
crate::disc::verify::UnitVerifier::new(&layouts, &verify_keys, opts.key_fetch.clone())
};
// Decrypt-aware read — symmetric with `Disc::sweep`. A decrypting patch
// (`opts.decrypt`) decrypts in place (plaintext ISO). A NON-decrypting
// patch (the multipass / `--raw --multipass` path) resolves the keys and
// VERIFIES each unit on a scratch copy: a re-read that STILL won't decrypt
// fails the read (`DECRYPT_VERIFY_READ`) and stays NonTrimmed, so the
// retry loop keeps re-reading it "until it decrypts or retries exhaust"
// exactly as for a SCSI read error — and a unit that DOES decrypt on a
// fresh read (the drive returned different bytes) is recovered for free.
// With no usable AACS keys this degrades to a plain pass-through.
// Symmetric with `Disc::sweep`: the patch COPIES ciphertext (multipass /
// `--raw`) or decrypts IN PLACE (`opts.decrypt`). It does NOT decrypt-
// VERIFY — the disc-absolute read can't anchor to a clip's file-relative
// unit grid (see `Disc::sweep` + `Disc::verify_clips`). Re-reads recover
// bad sectors; the clip-anchored verify pass re-checks them afterward.
// (`opts.decrypt`) decrypts in place (plaintext ISO); a NON-decrypting
// patch (the multipass / `--raw --multipass` path) copies ciphertext
// verbatim (keys = `None` → pass-through). Bad sectors are found by
// PHYSICAL read success, not by decrypt structure: a re-read that returns
// good bytes recovers the range; a read that errors leaves it NonTrimmed
// for the next pass. (The old decrypt-VERIFY read gate was removed.)
let keys = if opts.decrypt {
self.decrypt_keys()
} else {
@@ -1435,64 +1438,7 @@ impl Disc {
// sink's summary. `close` failing on a regular-file sync_all is
// surfaced here as `Error::IoError`, matching pre-split
// behaviour.
let mut summary = pipe.finish()?;
// Scoped post-read re-verify (decrypt-fail == bad read). The consumer
// has flushed the ISO + mapfile; re-read each clip unit this pass touched
// WHOLE from the patched ISO and downgrade any that still won't decrypt
// to NonTrimmed, so the orchestrator's end-of-recovery promotion
// terminalizes it. Reuses the same verifier as the sweep. Fail-safe:
// disabled gate / unreadable ISO / load failure all leave the pass as-is.
if let Some(mut v) = verifier.take() {
if let Ok(mut m) = mapfile::Mapfile::load(&mapfile_path) {
// Only units whose every backing sector was actually READ
// (Finished) may be re-verified — we can't verify what wasn't read
// (a non-Finished sector is zero-filled because the read failed),
// and must not waste a key lookup on a known-bad block.
let finished = m.ranges_with(&[mapfile::SectorStatus::Finished]);
let is_finished = |lba: u32| -> bool {
let p = lba as u64 * 2048;
finished.iter().any(|&(s, sz)| p >= s && p < s + sz)
};
if let Ok(mut iso) = crate::io::file_sector_source::FileSectorSource::open(path) {
let bad = v.reverify_iso(&mut iso, &bad_ranges, &is_finished);
if !bad.is_empty() {
let n: usize = bad.len();
for (lba, cnt) in bad {
if let Err(e) = m.record(
lba as u64 * 2048,
cnt as u64 * 2048,
mapfile::SectorStatus::NonTrimmed,
) {
tracing::warn!(
lba,
"reverify downgrade: mapfile record failed ({e}) — unit may stay mismarked as good"
);
}
}
if let Err(e) = m.flush() {
tracing::warn!(
"reverify downgrade: mapfile flush failed ({e}) — downgrade not persisted; a resume could mismark it good"
);
}
// The re-verify ran AFTER `pipe.finish()` snapshotted
// `summary.stats`, so those stats still count the just-
// downgraded units as good. Refresh from the mapfile so
// `build_outcome` reports the true post-downgrade picture
// (bytes_good ↓, bytes_pending ↑) — otherwise the caller
// over-reports recovery and can call an imperfect rip
// "complete".
summary.stats = m.stats();
tracing::info!(
target: "freemkv::verify",
phase = "patch.reverify",
downgraded_ranges = n,
"post-read re-verify downgraded undecryptable units to NonTrimmed"
);
}
}
}
}
let summary = pipe.finish()?;
let outcome = build_outcome(
&state,
-14
View File
@@ -66,14 +66,6 @@ pub(super) enum WorkItem {
/// tell them apart without parsing a flag.
GapFill { pos: u64, len: u64 },
/// Post-read verify downgrade. The producer's `UnitVerifier` found that the
/// just-`Finished` clip unit at `[pos, pos+len)` is confidently undecryptable
/// (a silent bad read). The consumer re-records the range as `NonTrimmed` so
/// the patch pass re-reads it — the ISO bytes (ciphertext) already written by
/// the preceding `Good` are left in place for the patch to overwrite. FIFO
/// pipe ordering guarantees this arrives AFTER the `Good` that wrote them.
MarkBad { pos: u64, len: u64 },
/// Producer wants the latest mapfile stats for the progress
/// callback. Consumer responds on `prog_tx` with a fresh
/// [`ProgressSnapshot`]. Best-effort: if the producer hasn't
@@ -182,12 +174,6 @@ impl Sink<WorkItem> for SweepSink {
}
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
}
WorkItem::MarkBad { pos, len } => {
// Verify downgrade: the ISO bytes are already written by the
// preceding Good; only the mapfile status changes so patch
// re-reads this range. No file write.
self.map.record(pos, len, SectorStatus::NonTrimmed)?;
}
WorkItem::StatsRequest => {
let stats = self.map.stats();
// DAMAGE only — NOT NonTried. NonTried is the unread remainder
-1022
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -78,8 +78,8 @@ use crate::consts::{SECTOR_BYTES, SECTOR_BYTES_U64};
/// read side. Mirrors `WRITEBACK_CHUNK_BYTES` so the read-side page
/// cache stays bounded the same way the write side does.
///
/// 32 MiB is the empirically tuned value on the rip1 test bed (single
/// 7200rpm HDD via SATA): smaller windows (8 / 16 MiB) shorten the
/// 32 MiB is the empirically tuned value on a 7200rpm HDD via SATA:
/// smaller windows (8 / 16 MiB) shorten the
/// kernel-readahead overlap and slow the producer; larger windows
/// (64 / 128 MiB) let the page cache pin enough of the ISO to
/// pressure concurrent writes. Override via `FREEMKV_READ_DROP_CHUNK_MIB`.
+3 -3
View File
@@ -407,7 +407,7 @@ pub fn read_encrypted_units(
break;
}
let u = &buf[o..o + ALIGNED_UNIT_LEN];
if aacs_unit_encrypted(u) {
if aacs_unit_encrypted(u, title.content_format) {
out.push(u.to_vec());
if out.len() >= n {
return out;
@@ -717,7 +717,7 @@ mod tests {
);
for s in &samples {
assert!(
aacs_unit_encrypted(s),
aacs_unit_encrypted(s, crate::disc::ContentFormat::BdTs),
"every sample is a CPI-flagged encrypted unit (byte0 & 0xC0 != 0)"
);
}
@@ -800,7 +800,7 @@ mod tests {
);
for s in &samples {
assert!(
aacs_unit_encrypted(s),
aacs_unit_encrypted(s, crate::disc::ContentFormat::BdTs),
"only CPI-flagged units are selected"
);
assert_eq!(
+114 -3
View File
@@ -56,6 +56,13 @@ pub struct H264Parser {
/// (HD-DVD EVO) path where the source stamps a PTS once per GOP. `None` on
/// the BD/UHD transport path, which carries a per-frame PTS.
reorder: Option<super::reorder::SparsePtsReorder>,
/// MVC dependent-view (Blu-ray 3D right-eye) passthrough mode. When set, the
/// parser does NOT strip SPS/PPS (nor re-assert at keyframes): every NAL —
/// subset SPS (type 15), prefix (14), coded-slice-extension (20), PPS (8) —
/// is length-prefixed in-band, so each emitted frame is a self-contained
/// dependent access unit suitable for a Matroska `BlockAdditional`. The base
/// view's avcC/param-set stripping is unchanged (separate parser instance).
mvc_passthrough: bool,
}
impl Default for H264Parser {
@@ -73,6 +80,7 @@ impl H264Parser {
cur_sps: None,
cur_pps: None,
reorder: None,
mvc_passthrough: false,
}
}
@@ -85,6 +93,15 @@ impl H264Parser {
self
}
/// Enable MVC dependent-view passthrough (see the `mvc_passthrough` field):
/// keep every parameter set in-band so each frame is a self-contained
/// dependent access unit for a Matroska `BlockAdditional`. Used only for the
/// Blu-ray 3D dependent (right-eye) stream.
pub(crate) fn with_mvc_passthrough(mut self, enabled: bool) -> Self {
self.mvc_passthrough = enabled;
self
}
/// Route a finished frame through the PTS reorderer when enabled, else emit
/// it directly (unchanged transport-stream behaviour).
fn finish(&mut self, explicit: Option<i64>, frame: Frame) -> Vec<Frame> {
@@ -192,17 +209,23 @@ impl CodecParser for H264Parser {
// frame in the mux hot path (mirrors the HEVC parser).
let mut frame_data = Vec::with_capacity(pes.data.len() + 64);
// MVC dependent-view passthrough: keep ALL param sets in-band (the frame
// is a self-contained BlockAdditional access unit), never strip/re-assert.
let mvc = self.mvc_passthrough;
for nal in NalIterator::new(&pes.data) {
let nal_type = nal[0] & 0x1F;
match nal_type {
// Param sets: seed avcC, strip if unchanged vs the active set,
// emit in-band on any change (incl. reverting to the avcC copy).
NAL_SPS => {
// In MVC passthrough these fall through to the default arm so the
// subset SPS / PPS stay in-band (self-contained dependent AU).
NAL_SPS if !mvc => {
emitted_sps |=
handle_param_set(&mut self.sps, &mut self.cur_sps, nal, &mut frame_data)
}
NAL_PPS => {
NAL_PPS if !mvc => {
emitted_pps |=
handle_param_set(&mut self.pps, &mut self.cur_pps, nal, &mut frame_data)
}
@@ -250,7 +273,7 @@ impl CodecParser for H264Parser {
// ahead of the slices (even when unchanged vs codecPrivate) so a decoder
// that dropped the set at a reset recovers, and a stale avcC re-apply
// can't revert it. Skipped per-type only when this AU already carried it.
if keyframe {
if keyframe && !mvc {
let mut prefix = Vec::new();
reassert_active(&mut prefix, &self.cur_sps, emitted_sps);
reassert_active(&mut prefix, &self.cur_pps, emitted_pps);
@@ -651,6 +674,94 @@ mod tests {
v
}
#[test]
fn mvc_passthrough_keeps_param_sets_inband() {
// A dependent-view access unit: subset SPS (NAL 15) + PPS (NAL 8) +
// coded-slice-extension (NAL 20). No IDR (type 5), so keyframe stays
// false and there is no keyframe re-assertion.
let au = || {
let mut d = Vec::new();
d.extend_from_slice(&h264_nal(0x6F, &[0x80, 0x00, 0x33, 0xAA])); // subset SPS (15)
d.extend_from_slice(&h264_nal(0x68, &[0xCE, 0x01])); // PPS (8)
d.extend_from_slice(&h264_nal(0x74, &[0x11, 0x22])); // slice-ext (20)
d
};
let nal_types =
|f: &Frame| -> Vec<u8> { h264_nals_in(&f.data).iter().map(|n| n[0] & 0x1F).collect() };
// Normal parser strips the PPS from a non-keyframe AU (it is captured for
// the avcC and, without an IDR, never re-asserted in-band).
let mut normal = H264Parser::new();
let f = normal.parse(&make_pes(au(), Some(90000)));
assert_eq!(f.len(), 1);
assert!(
!nal_types(&f[0]).contains(&8),
"normal parser strips PPS from a non-keyframe AU: {:?}",
nal_types(&f[0])
);
// Passthrough keeps EVERY parameter set in-band, so each dependent frame
// is a self-contained access unit for a BlockAdditional.
let mut pt = H264Parser::new().with_mvc_passthrough(true);
let f = pt.parse(&make_pes(au(), Some(90000)));
assert_eq!(f.len(), 1);
let types = nal_types(&f[0]);
assert!(types.contains(&15), "subset SPS kept in-band: {types:?}");
assert!(
types.contains(&8),
"PPS kept in-band under passthrough: {types:?}"
);
assert!(types.contains(&20), "slice kept: {types:?}");
}
#[test]
fn parser_for_mvc_dependent_h264_is_passthrough() {
// The dependent-view stream must get a passthrough parser: a PPS in a
// non-keyframe AU is kept in-band, not stripped like the base parser.
let mut p = crate::mux::codec::parser_for_mvc_dependent(crate::disc::Codec::H264, false);
let mut d = Vec::new();
d.extend_from_slice(&h264_nal(0x68, &[0xCE, 0x01])); // PPS (8)
d.extend_from_slice(&h264_nal(0x74, &[0x11, 0x22])); // slice-ext (20)
let f = p.parse(&make_pes(d, Some(90000)));
assert_eq!(f.len(), 1);
let types: Vec<u8> = h264_nals_in(&f[0].data)
.iter()
.map(|n| n[0] & 0x1F)
.collect();
assert!(
types.contains(&8),
"dependent parser keeps PPS in-band (passthrough): {types:?}"
);
}
#[test]
fn mvc_passthrough_with_idr_does_not_reassert_param_sets() {
// With an IDR present (keyframe=true), passthrough must NOT re-assert the
// param sets (the `keyframe && !mvc` guard), so SPS/PPS appear exactly
// once — a duplicate would corrupt the dependent BlockAdditional.
let mut p = H264Parser::new().with_mvc_passthrough(true);
let mut d = Vec::new();
d.extend_from_slice(&h264_nal(0x67, &[0x42, 0x00, 0x1E, 0x01])); // SPS (7)
d.extend_from_slice(&h264_nal(0x68, &[0xCE, 0x01])); // PPS (8)
d.extend_from_slice(&h264_nal(0x65, &[0x88, 0x00])); // IDR slice (5)
let f = p.parse(&make_pes(d, Some(90000)));
assert_eq!(f.len(), 1);
let types: Vec<u8> = h264_nals_in(&f[0].data)
.iter()
.map(|n| n[0] & 0x1F)
.collect();
assert_eq!(
types.iter().filter(|&&t| t == 7).count(),
1,
"exactly one SPS, no keyframe re-assert under passthrough: {types:?}"
);
assert_eq!(
types.iter().filter(|&&t| t == 8).count(),
1,
"exactly one PPS, no keyframe re-assert under passthrough: {types:?}"
);
}
#[test]
fn h264_populates_measured_coding_type_and_source() {
use super::super::coding::CodingType;
+16
View File
@@ -207,6 +207,22 @@ pub fn parser_for_codec(
}
}
/// Build the codec parser for a Blu-ray 3D **MVC dependent (right-eye)** video
/// stream. Same codec space as the base view (H.264), but in param-set
/// passthrough mode so each emitted frame is a self-contained dependent access
/// unit for a Matroska `BlockAdditional`. Non-H.264 (unexpected) falls back to
/// the ordinary parser.
pub fn parser_for_mvc_dependent(codec: Codec, is_dvd_ps: bool) -> Box<dyn CodecParser> {
match codec {
Codec::H264 => Box::new(
h264::H264Parser::new()
.with_ps_reorder(is_dvd_ps)
.with_mvc_passthrough(true),
),
_ => parser_for_codec(codec, None, is_dvd_ps),
}
}
#[cfg(test)]
mod tests {
use super::*;
+17 -5
View File
@@ -241,11 +241,23 @@ impl EsWriter for AnnexBWriter {
/// Delegates to the canonical hvcC/avcC → Annex-B converters in
/// [`crate::mux::hevc`] — the single source of truth across all muxers.
fn annexb_param_sets(codec: Codec, record: &[u8]) -> Vec<u8> {
match codec {
Codec::Hevc => hvcc_to_annex_b(record).unwrap_or_default(),
Codec::H264 => avcc_to_annex_b(record).unwrap_or_default(),
_ => Vec::new(),
}
let converted = match codec {
Codec::Hevc => hvcc_to_annex_b(record),
Codec::H264 => avcc_to_annex_b(record),
_ => return Vec::new(),
};
converted.unwrap_or_else(|| {
// A malformed hvcC/avcC record yields no parameter sets. Returning empty
// means keyframes ship WITHOUT in-band SPS/PPS — playable from the first
// keyframe but broken for seek-to-arbitrary-point and hardware decoders.
// Surface it rather than silently degrading the output.
tracing::warn!(
target: "mux",
?codec,
"codec-private (hvcC/avcC) parse failed; keyframes will lack in-band SPS/PPS"
);
Vec::new()
})
}
/// PGS `.sup` writer: rebuilds the HDMV segment framing the parser stripped.
+1 -1
View File
@@ -7,7 +7,7 @@
//! With [`crate::sector::PrefetchedSectorSource`] alone, read+decrypt
//! already runs on a producer thread; the *consumer* (main) thread
//! still serialises `ts_demuxer.feed` (M2TS parsing) with the codec
//! parsers. Profiling on the rip1 testbed showed feed at ~37 % and
//! parsers. Profiling showed feed at ~37 % and
//! codec parse at ~44 % of consumer wall time — i.e. feed is heavy
//! enough that pipelining it with parse pays for itself.
//!
+12 -22
View File
@@ -108,11 +108,6 @@ pub struct DiscStream {
/// inline `decrypt::decrypt_sectors` step. `DecryptKeys::None`
/// (raw / unencrypted disc) makes the decorator a pass-through.
reader: DecryptingSectorSource<Box<dyn SectorSource>>,
/// Shared decrypt-loss counter, cloned once at construction from
/// `reader.decrypt_loss()`. `lost_bytes()` loads it directly so the
/// per-frame hot path performs no per-call `Arc::clone` (matching the
/// `PipelinedPesStream` pattern).
decrypt_loss: std::sync::Arc<std::sync::atomic::AtomicU64>,
title: DiscTitle,
/// Mirror of the keys handed in at construction. The decorator
/// owns the cryptographic state; this field is kept for
@@ -237,12 +232,12 @@ impl DiscStream {
// CSS/unencrypted content needs a decrypting wrapper to yield plaintext
// VOB bytes before the AC-3 sub-stream probe can read real `acmod`s.
// MUX path: tolerate decrypt loss — conceal an undecryptable unit (NULL TS
// fill) + tally + log rather than abort the stream (P3). DiscStream is a
// decode/mux stream (live-drive single-pass / direct), never the
// ciphertext-preserving sweep, so concealment is always correct here.
let mut reader =
DecryptingSectorSource::new(reader, decrypt_keys.clone()).tolerate_decrypt_loss();
// MUX path (read > decrypt > mux): decrypt every unit in place and pass the
// bytes to the muxer; a unit that decrypts to broken TS is the muxer's
// concern, never conceal / re-fetch / count as loss (fail loud only on a
// genuine can't-decrypt). DiscStream is a decode/mux stream (live-drive
// single-pass / direct), never the ciphertext-preserving sweep.
let mut reader = DecryptingSectorSource::new(reader, decrypt_keys.clone());
// Wrong-substream fix (Silence-of-the-Lambs): re-route the title's
// declared AC-3 audio onto the physically-correct `0x8x` sub-streams by
@@ -293,9 +288,6 @@ impl DiscStream {
// the decorator is a pass-through). Reset the unit base the probe read
// advanced so the first fill_extents read starts cleanly.
reader.set_unit_base(0);
// Clone the shared loss counter once here so `lost_bytes()` never
// clones an Arc per frame on the mux hot path.
let decrypt_loss = reader.decrypt_loss();
// B1 resync gates: one per stream, video flagged so the gate only
// drop-to-keyframes video (audio/subtitle always admit). Computed before
@@ -311,7 +303,6 @@ impl DiscStream {
Self {
reader,
decrypt_loss,
title,
decrypt_keys,
unit_align,
@@ -1003,14 +994,12 @@ impl crate::pes::Stream for DiscStream {
}
fn lost_bytes(&self) -> u64 {
// Read-error zero-fill loss (counted in fill_extents) PLUS decrypt-time
// loss — bytes of scrambled AACS units the decorator could not decrypt
// and passed through still encrypted (the TS assembler silently drops
// them). Both are real missing content the abort gate must see; without
// the decrypt term a partial key failure reports lost_bytes=0 and a rip
// missing segments passes even under abort_on_lost_secs=0.
// Read-error zero-fill loss (counted in fill_extents) — real missing
// content the abort gate must see. There is no decrypt-loss term: the
// decrypt path passes bad-encoded/undecryptable units through (a broken-TS
// unit is the muxer's concern, and a missing key is indistinguishable from
// bad authoring here), so only physical read loss is reported.
self.lost_bytes
.saturating_add(self.decrypt_loss.load(std::sync::atomic::Ordering::Relaxed))
}
}
@@ -1489,6 +1478,7 @@ mod tests {
let keys = crate::decrypt::DecryptKeys::Aacs {
unit_keys: vec![(0, [0u8; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut stream = DiscStream::new(Box::new(reader), title, keys, 8, ContentFormat::BdTs);
stream.skip_errors = true;
+69
View File
@@ -94,6 +94,31 @@ pub fn write_uint(w: &mut impl Write, id: u32, val: u64) -> io::Result<()> {
}
}
/// Write a complete EBML signed-integer element (two's-complement, big-endian,
/// minimal width). Used for `ReferenceBlock` (0xFB), whose value is a signed
/// tick offset relative to the current block's timestamp.
pub fn write_int(w: &mut impl Write, id: u32, val: i64) -> io::Result<()> {
write_id(w, id)?;
// Minimal two's-complement width: shrink while the top byte is pure sign
// extension of the next byte's MSB.
let be = val.to_be_bytes();
let mut start = 0usize;
while start < 7 {
let sign_ext = if be[start + 1] & 0x80 != 0 {
0xFF
} else {
0x00
};
if be[start] != sign_ext {
break;
}
start += 1;
}
let bytes = &be[start..];
write_size(w, bytes.len() as u64)?;
w.write_all(bytes)
}
/// Write a complete EBML float element (8-byte double).
pub fn write_float(w: &mut impl Write, id: u32, val: f64) -> io::Result<()> {
write_id(w, id)?;
@@ -481,6 +506,25 @@ pub const LUMINANCE_MIN: u32 = 0x55DA;
pub const BLOCK_ADDITION_MAPPING: u32 = 0x41E4;
pub const BLOCK_ADD_ID_TYPE: u32 = 0x41E7;
pub const BLOCK_ADD_ID_EXTRA_DATA: u32 = 0x41ED;
/// BlockAddIDValue (RFC 9559) — the value a per-frame `BlockAddID` references
/// to select this BlockAdditionMapping. Values ≥ 2 (1 is the default plain
/// BlockAdditional). Used by the MVC (`mvcC`) mapping for Blu-ray 3D.
pub const BLOCK_ADD_ID_VALUE: u32 = 0x41F0;
// Block additions carried inside a BlockGroup — per-frame side data. For
// Blu-ray 3D (MVC) the dependent (right-eye) view NAL units for an access unit
// ride here as a BlockAdditional under the track's `mvcC` mapping (RFC 9559
// §5.1.4.1.4; Matroska Codec Specifications §4.1.5).
pub const BLOCK_ADDITIONS: u32 = 0x75A1;
pub const BLOCK_MORE: u32 = 0xA6;
pub const BLOCK_ADDITIONAL: u32 = 0xA5;
pub const BLOCK_ADD_ID: u32 = 0xEE;
/// ReferenceBlock (RFC 9559 element 0xFB, child of BlockGroup) — signed
/// timestamp (in TimestampScale ticks) of a block this one references, relative
/// to this block's own timestamp. Its PRESENCE marks the Block as non-keyframe
/// (a keyframe Block in a BlockGroup carries none). Written for non-keyframe
/// video frames that must live in a BlockGroup to carry an MVC BlockAdditional.
pub const REFERENCE_BLOCK: u32 = 0xFB;
// Audio
pub const AUDIO: u32 = 0xE1;
@@ -960,6 +1004,31 @@ mod tests {
assert_eq!(buf, [0x42, 0x86, 0x81, 0x00]);
}
#[test]
fn write_int_minimal_two_complement_width() {
// ReferenceBlock (0xFB) signed offsets, minimal two's-complement width.
let enc = |v: i64| {
let mut b = Vec::new();
write_int(&mut b, REFERENCE_BLOCK, v).unwrap();
b
};
assert_eq!(enc(0), [0xFB, 0x81, 0x00], "0 -> 1 byte 0x00");
assert_eq!(enc(-1), [0xFB, 0x81, 0xFF], "-1 -> 1 byte 0xFF");
assert_eq!(enc(127), [0xFB, 0x81, 0x7F], "127 -> 1 byte");
assert_eq!(
enc(128),
[0xFB, 0x82, 0x00, 0x80],
"128 needs 2 bytes (0x80 alone is -128)"
);
assert_eq!(enc(-128), [0xFB, 0x81, 0x80], "-128 -> 1 byte 0x80");
assert_eq!(enc(-129), [0xFB, 0x82, 0xFF, 0x7F], "-129 needs 2 bytes");
// i64::MIN is the widest: 8 bytes, size 0x88.
let mn = enc(i64::MIN);
assert_eq!(mn[0], 0xFB);
assert_eq!(mn[1], 0x88);
assert_eq!(&mn[2..], &i64::MIN.to_be_bytes());
}
// ============================================================
// write_float — EBML floats here are always 8-byte IEEE-754 doubles,
// big-endian (Matroska SamplingFrequency/Duration). size byte = 0x88.
+536 -41
View File
@@ -68,6 +68,82 @@ const COLOUR_RANGE_LIMITED: u8 = 1;
/// Vision configuration record (RFC 9559 + Dolby Vision-in-Matroska spec).
const BLOCK_ADD_ID_TYPE_DVCC: u64 = 0x6476_6343;
/// BlockAddIDType "mvcC" — the MVCDecoderConfigurationRecord fourcc, big-endian
/// ASCII 'm''v''c''C'. Matroska BlockAdditionMapping/BlockAddIDType for a
/// Blu-ray 3D MVC configuration (RFC 9559 + Matroska Codec Specifications
/// §4.1.5; equals ISO/IEC 14496-15 MVCConfigurationBox('mvcC')). The per-frame
/// dependent (right-eye) view rides as a BlockAdditional under this mapping.
const BLOCK_ADD_ID_TYPE_MVCC: u64 = 0x6D76_6343;
/// BlockAddIDValue for the MVC mapping — the value each per-frame `BlockAddID`
/// references (RFC 9559 requires ≥ 2; 1 is the default plain BlockAdditional).
const BLOCK_ADD_ID_VALUE_MVC: u64 = 2;
/// Build an `MVCDecoderConfigurationRecord` (ISO/IEC 14496-15:2013 §7.6.2) from
/// the dependent view's subset-SPS (NAL type 15) and PPS NAL units. This is the
/// `BlockAddIDExtraData` for the `mvcC` BlockAdditionMapping (and, in the ISO
/// container, the `MVCConfigurationBox('mvcC')` payload).
///
/// Layout mirrors the AVCDecoderConfigurationRecord except byte[4] repurposes
/// the AVC record's `bit(6) reserved` as
/// `complete_representation(1) | explicit_au_track(1) | reserved '1111'(4)`.
/// `profile`/`compat`/`level` describe the WHOLE MVC stream and come from the
/// subset SPS (bytes 1..=3). `length_size_minus_one` MUST match the base avcC
/// (freemkv always emits 4-byte length prefixes → 3). SPSs precede subset SPSs
/// in the array; here the array carries the dependent view's parameter sets.
///
/// Returns `None` if either param set is absent, too short, or exceeds the
/// 16-bit length field (a param set > 65535 bytes is non-conforming and would
/// mis-frame the record).
fn mvc_decoder_config_record(subset_sps: &[u8], pps: &[u8]) -> Option<Vec<u8>> {
if subset_sps.len() < 4 || subset_sps.len() > 0xFFFF || pps.is_empty() || pps.len() > 0xFFFF {
return None;
}
let mut record = vec![
1, // configurationVersion
subset_sps[1], // AVCProfileIndication (whole MVC stream, from subset SPS)
subset_sps[2], // profile_compatibility
subset_sps[3], // AVCLevelIndication
// complete_representation(1)=1 | explicit_au_track(1)=0 |
// reserved '1111'(4) | lengthSizeMinusOne(2)=3(11) → 1_0_1111_11 = 0xBF.
0xBF,
// reserved '0'(1) | numOfSequenceParameterSets(7)=1 → 0_0000001 = 0x01.
// NB: distinct from the AVC record's byte[5] (reserved(3)|numSPS(5)).
0x01,
(subset_sps.len() >> 8) as u8,
subset_sps.len() as u8,
];
record.extend_from_slice(subset_sps);
record.push(1); // numOfPictureParameterSets
record.push((pps.len() >> 8) as u8);
record.push(pps.len() as u8);
record.extend_from_slice(pps);
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<u8> {
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
@@ -176,6 +252,16 @@ pub struct MkvTrack {
/// stream is parsed. When `Some`, the serializer emits MasteringMetadata +
/// MaxCLL/MaxFALL inside Colour; when `None` they are omitted entirely.
pub hdr10: Option<crate::mux::codec::Hdr10Metadata>,
/// Blu-ray 3D (MVC): the dependent (right-eye) view's `(subset_sps, pps)`
/// NAL units, from which the serializer builds the `mvcC`
/// MVCDecoderConfigurationRecord (ISO/IEC 14496-15 §7.6.2) for the track's
/// BlockAdditionMapping. `None` for non-3D tracks. Set at muxer activation
/// from the dependent stream's parameter sets — the same deferred path
/// `hdr10`/FieldOrder use — never at construction. When `Some`, the per-frame
/// dependent view rides as a `BlockAdditional`. (No `StereoMode` is written:
/// RFC 9559 assigns no StereoMode value to MVC-in-BlockAdditional; the mvcC
/// mapping is the 3D signal.)
pub mvc_params: Option<(Vec<u8>, Vec<u8>)>,
}
/// Build a DOVIDecoderConfigurationRecord (dvcC) — 24 bytes — for the Matroska
@@ -380,6 +466,7 @@ impl MkvTrack {
// coded picture's PictureInfo before the header is written (the same
// deferred path FieldOrder uses). `None` here → omitted unless seen.
hdr10: None,
mvc_params: None,
}
}
@@ -448,6 +535,7 @@ impl MkvTrack {
bit_depth: 0,
dv_config: None,
hdr10: None,
mvc_params: None,
}
}
@@ -485,6 +573,7 @@ impl MkvTrack {
bit_depth: 0,
dv_config: None,
hdr10: None,
mvc_params: None,
}
}
}
@@ -530,6 +619,12 @@ pub struct MkvMuxer<W: Write + Seek> {
/// the primary video. `None` when the title has no video track (no track
/// drives epochs).
primary_video_track: Option<usize>,
/// Per-track: whether the track declared an `mvcC` BlockAdditionMapping (its
/// `mvc_params` was set at activation). A `BlockAdditional` with BlockAddID=2
/// is only conforming when the track carries the matching mapping, so
/// `write_frame`'s additional is dropped for a track without it (e.g. the
/// dependent view's parameter sets were never captured before activation).
track_has_mvc_mapping: Vec<bool>,
/// Cross-clip timeline-continuity corrector (clip-boundary PTS rebasing).
continuity: TimelineContinuity,
cues: Vec<CuePoint>,
@@ -565,6 +660,11 @@ pub struct MkvMuxer<W: Write + Seek> {
/// Highest block timestamp (TimestampScale ticks) written across all tracks —
/// the muxed runtime, used to back-patch the DURATION placeholder.
max_block_ticks: i64,
/// Timestamp (TimestampScale ticks) of the last video keyframe written on the
/// primary video track. A non-keyframe MVC base frame lives in a BlockGroup
/// (to carry its dependent-view BlockAdditional) and needs a `ReferenceBlock`
/// so players don't mistake it for a keyframe; it references this keyframe.
last_video_keyframe_ticks: Option<i64>,
/// Per-AC-3-audio-track channel-correction state. The DVD IFO audio nibble
/// is unreliable, so the channel count written in the track header is
/// corrected from the AC-3 bitstream `acmod` of the first frame on the
@@ -685,23 +785,35 @@ fn block_ts(is_video: bool, prev: Option<i64>, pts_ticks: i64) -> i64 {
/// Encode a Matroska track number as an EBML VINT into a stack buffer,
/// returning the buffer and the used length. Track numbers are small (1-based,
/// a handful of tracks), so 1 byte covers `< 0x80` and 2 bytes covers the rest;
/// no heap allocation, called once per block on the mux hot path.
/// a handful of tracks), so 1 byte covers `< 0x80`, 2 bytes covers `< 0x4000`,
/// and 3 bytes covers `< 0x20_0000`; no heap allocation, called once per block
/// on the mux hot path.
///
/// The 2-byte form holds 14 payload bits (max 0x3FFF). The `debug_assert`
/// guards the 0x4000 bound: at or above it, `(track_num >> 8)` is >= 0x40 and
/// OR-ing the 0x40 length marker would clobber it, corrupting the track
/// number. Not reachable today (track numbers are `i+1` over a few streams),
/// so this documents the bound rather than handling 3-byte VINTs.
fn track_vint(track_num: usize) -> ([u8; 2], usize) {
/// Each width uses a marker bit that must NOT collide with the payload's top
/// byte: the 1-byte marker is 0x80 (7 payload bits), the 2-byte marker 0x40
/// (14 payload bits), the 3-byte marker 0x20 (21 payload bits). Handling all
/// three in RELEASE (not just `debug_assert`) means an out-of-2-byte-range
/// track number can never silently clobber the marker bit and corrupt the
/// block. Real discs never approach even the 2-byte range; the 21-bit ceiling
/// is an absurd upper bound kept as a `debug_assert`.
fn track_vint(track_num: usize) -> ([u8; 3], usize) {
if track_num < 0x80 {
([(track_num as u8) | 0x80, 0], 1)
([(track_num as u8) | 0x80, 0, 0], 1)
} else if track_num < 0x4000 {
([0x40 | ((track_num >> 8) as u8), track_num as u8, 0], 2)
} else {
debug_assert!(
track_num < 0x4000,
"track number {track_num} exceeds the 14-bit 2-byte EBML VINT range"
track_num < 0x20_0000,
"track number {track_num} exceeds the 21-bit 3-byte EBML VINT range"
);
([0x40 | ((track_num >> 8) as u8), track_num as u8], 2)
(
[
0x20 | ((track_num >> 16) as u8),
(track_num >> 8) as u8,
track_num as u8,
],
3,
)
}
}
@@ -805,9 +917,23 @@ impl<W: Write + Seek> MkvMuxer<W> {
let mut track_uids: Vec<u64> = Vec::with_capacity(tracks.len());
let mut ac3_channel_fixups: std::collections::HashMap<usize, Ac3ChannelFixup> =
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<bool> = 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)?;
@@ -827,7 +953,20 @@ impl<W: Write + Seek> MkvMuxer<W> {
}
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
@@ -923,6 +1062,40 @@ impl<W: Write + Seek> MkvMuxer<W> {
ebml::end_master(&mut writer, vid_pos)?;
}
// Blu-ray 3D (MVC) signaling — BlockAdditionMapping (sibling of
// 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).
match mvc_record.as_ref() {
Some(record) => {
let map_pos = ebml::start_master(&mut writer, ebml::BLOCK_ADDITION_MAPPING)?;
ebml::write_uint(
&mut writer,
ebml::BLOCK_ADD_ID_VALUE,
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::end_master(&mut writer, map_pos)?;
}
// `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.",
s.len(),
p.len(),
);
}
None => {}
}
// Dolby Vision signaling — BlockAdditionMapping is a child of the
// TrackEntry (sibling of Video). Carries the dvcC so players /
// mediainfo recognise the track as Dolby Vision.
@@ -1013,6 +1186,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
primary_video_track: tracks
.iter()
.position(|t| t.track_type == ebml::TRACK_TYPE_VIDEO),
track_has_mvc_mapping,
continuity: TimelineContinuity::new(),
cues: Vec::new(),
frame_count: 0,
@@ -1027,6 +1201,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
duration_secs,
duration_patch_pos,
max_block_ticks: 0,
last_video_keyframe_ticks: None,
ac3_channel_fixups,
opening_capture: None,
})
@@ -1058,6 +1233,13 @@ impl<W: Write + Seek> MkvMuxer<W> {
/// (`None`) leaves the written value untouched: an interlaced track keeps
/// its guess rather than being cleared via a multi-element change. The byte
/// width is fixed (FieldOrder is 0..=14), so the in-place rewrite is valid.
///
/// `block_additional`, when `Some`, is attached to the frame as a Matroska
/// `BlockAdditional` (BlockAddID=2) — Blu-ray 3D (MVC): the base view is the
/// Block and the dependent (right-eye) access unit rides as the
/// BlockAdditional under the track's `mvcC` mapping. Such a frame is always a
/// `BlockGroup` (never a SimpleBlock), with a `ReferenceBlock` when it is not
/// a keyframe. `None` for every non-3D frame.
pub fn write_frame(
&mut self,
track_idx: usize,
@@ -1065,6 +1247,7 @@ impl<W: Write + Seek> MkvMuxer<W> {
keyframe: bool,
data: &[u8],
duration_ns: Option<u64>,
block_additional: Option<&[u8]>,
) -> io::Result<()> {
// --log-level 3: capture the first ~100 coded frames per track to the
// side file BEFORE any timeline mangling, with the codec parser's own
@@ -1226,15 +1409,72 @@ impl<W: Write + Seek> MkvMuxer<W> {
self.max_block_ticks = self.max_block_ticks.max(block_end_ticks);
let relative_ts = (pts_ticks - self.cluster_ts_ticks) as i16;
match duration_ns {
Some(dur_ns) => {
// BlockDuration is in TimestampScale ticks, floored at 1.
let duration_ticks = (dur_ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64;
self.write_block_group(track_idx + 1, relative_ts, keyframe, data, duration_ticks)?;
let duration_ticks =
duration_ns.map(|dur_ns| (dur_ns as i64 / TIMESTAMP_SCALE_NS).max(1) as u64);
// A BlockAdditional with BlockAddID=2 is only conforming when the track
// declared the matching mvcC BlockAdditionMapping. If it did not (the
// dependent view's parameter sets were never captured before the header
// was written), drop the additional and emit a plain block rather than a
// non-conforming file with an orphaned BlockAddID.
let block_additional = match block_additional {
Some(a)
if self
.track_has_mvc_mapping
.get(track_idx)
.copied()
.unwrap_or(false) =>
{
Some(a)
}
None => {
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
Some(_) => None,
None => None,
};
match block_additional {
// MVC: base view Block + dependent-view BlockAdditional, always a
// BlockGroup. Non-keyframe base frames get a ReferenceBlock to the
// last keyframe (a keyframe carries none), so a player never treats a
// P/B frame as a seek point.
Some(additional) => {
let reference = if keyframe {
None
} else {
// Offset (ticks) of the referenced keyframe relative to this
// block. A non-keyframe MUST carry a ReferenceBlock or a reader
// treats it as a seek point; fall back to 0 (self-relative) in
// the pre-first-keyframe corner (unreachable in practice — such
// frames are dropped before a cluster opens) so the marker is
// never absent.
Some(
self.last_video_keyframe_ticks
.map(|kf| kf - pts_ticks)
.unwrap_or(0),
)
};
self.write_block_group_mvc(
track_idx + 1,
relative_ts,
data,
additional,
reference,
duration_ticks,
)?;
}
None => match duration_ticks {
// BlockDuration present (PGS subtitles) → BlockGroup.
Some(dt) => {
self.write_block_group(track_idx + 1, relative_ts, keyframe, data, dt)?;
}
None => {
self.write_simple_block(track_idx + 1, relative_ts, keyframe, data)?;
}
},
}
// Remember the last PRIMARY-video keyframe's tick so a later non-keyframe
// MVC base frame references a keyframe on its OWN track (see the
// block_additional path above). Gating to the primary video track avoids a
// secondary video track's keyframe becoming a cross-track reference target.
if keyframe && Some(track_idx) == self.primary_video_track {
self.last_video_keyframe_ticks = Some(pts_ticks);
}
self.frame_count += 1;
@@ -1519,6 +1759,52 @@ impl<W: Write + Seek> MkvMuxer<W> {
ebml::end_master(&mut self.writer, bg_pos)?;
Ok(())
}
/// Write a BlockGroup carrying the base view Block plus the MVC dependent
/// (right-eye) access unit as a `BlockAdditional` (BlockAddID=2), per the
/// track's `mvcC` BlockAdditionMapping. A non-keyframe frame gets a
/// `ReferenceBlock` (`reference` = referenced keyframe offset in ticks) so it
/// is not mistaken for a seek point; `BlockDuration` is written when known.
fn write_block_group_mvc(
&mut self,
track_num: usize,
relative_ts: i16,
data: &[u8],
additional: &[u8],
reference: Option<i64>,
duration_ticks: Option<u64>,
) -> io::Result<()> {
let (tv, tv_len) = track_vint(track_num);
let track_vint = &tv[..tv_len];
// The 0x80 Keyframe flag is SimpleBlock-only; inside a BlockGroup Block
// it is reserved and MUST be 0 — keyframe-ness is signalled by the
// presence/absence of ReferenceBlock.
let flags: u8 = 0x00;
let block_size = track_vint.len() + 2 + 1 + data.len();
let bg_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_GROUP)?;
ebml::write_id(&mut self.writer, ebml::BLOCK)?;
ebml::write_size(&mut self.writer, block_size as u64)?;
self.writer.write_all(track_vint)?;
self.writer.write_all(&relative_ts.to_be_bytes())?;
self.writer.write_all(&[flags])?;
self.writer.write_all(data)?;
if let Some(dt) = duration_ticks {
ebml::write_uint(&mut self.writer, ebml::BLOCK_DURATION, dt)?;
}
if let Some(ref_off) = reference {
ebml::write_int(&mut self.writer, ebml::REFERENCE_BLOCK, ref_off)?;
}
// BlockAdditions → BlockMore { BlockAddID=2, BlockAdditional=dependent AU }.
let adds_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_ADDITIONS)?;
let more_pos = ebml::start_master(&mut self.writer, ebml::BLOCK_MORE)?;
ebml::write_uint(&mut self.writer, ebml::BLOCK_ADD_ID, BLOCK_ADD_ID_VALUE_MVC)?;
ebml::write_binary(&mut self.writer, ebml::BLOCK_ADDITIONAL, additional)?;
ebml::end_master(&mut self.writer, more_pos)?;
ebml::end_master(&mut self.writer, adds_pos)?;
ebml::end_master(&mut self.writer, bg_pos)?;
Ok(())
}
}
// ============================================================
@@ -1702,6 +1988,7 @@ mod tests {
bit_depth: 0,
dv_config: None,
hdr10: None,
mvc_params: None,
}
}
@@ -1731,6 +2018,7 @@ mod tests {
bit_depth: 0,
dv_config: None,
hdr10: None,
mvc_params: None,
}
}
@@ -1814,7 +2102,7 @@ mod tests {
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(buf, &tracks, None, 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF], None)
.write_frame(0, 0, true, &[0xDE, 0xAD, 0xBE, 0xEF], None, None)
.unwrap();
let data = muxer.writer.into_inner();
assert!(
@@ -1834,7 +2122,7 @@ mod tests {
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(writer, &tracks, Some("Cue Test"), 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None)
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None, None)
.unwrap();
muxer.finish().unwrap();
@@ -1860,7 +2148,7 @@ mod tests {
let tracks = [make_video_track()];
let mut muxer = MkvMuxer::new(writer, &tracks, Some("NoCue"), 60.0, &[]).unwrap();
muxer
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None)
.write_frame(0, 0, true, &[0x01, 0x02, 0x03], None, None)
.unwrap();
// Force the zero-cue branch: drop every cue before finalizing.
let cues_entry_pos = muxer.cues_seek_entry_pos.expect("CUES seek entry recorded");
@@ -2111,16 +2399,16 @@ mod tests {
let mut muxer = MkvMuxer::new(buf, &tracks, Some("Multi"), 120.0, &[]).unwrap();
// Write frames to both tracks
muxer
.write_frame(0, 0, true, &[0x00, 0x00, 0x01], None)
.write_frame(0, 0, true, &[0x00, 0x00, 0x01], None, None)
.unwrap();
muxer
.write_frame(1, 0, false, &[0x0B, 0x77, 0x00], None)
.write_frame(1, 0, false, &[0x0B, 0x77, 0x00], None, None)
.unwrap();
muxer
.write_frame(0, 40_000_000, false, &[0x00, 0x00, 0x01], None)
.write_frame(0, 40_000_000, false, &[0x00, 0x00, 0x01], None, None)
.unwrap();
muxer
.write_frame(1, 32_000_000, false, &[0x0B, 0x77, 0x01], None)
.write_frame(1, 32_000_000, false, &[0x0B, 0x77, 0x01], None, None)
.unwrap();
// Should not panic
let data = muxer.writer.into_inner();
@@ -2135,11 +2423,11 @@ mod tests {
// Record position before first frame
let pos_before_kf = muxer.writer.position();
muxer.write_frame(0, 0, true, &[0xAA], None).unwrap();
muxer.write_frame(0, 0, true, &[0xAA], None, None).unwrap();
let pos_after_kf = muxer.writer.position();
muxer
.write_frame(0, 1_000_000, false, &[0xBB], None)
.write_frame(0, 1_000_000, false, &[0xBB], None, None)
.unwrap();
let pos_after_nkf = muxer.writer.position();
@@ -2385,7 +2673,7 @@ mod tests {
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, tracks, None, 0.0, chapters).unwrap();
for (t, pts, kf, data) in frames {
muxer.write_frame(*t, *pts, *kf, data, None).unwrap();
muxer.write_frame(*t, *pts, *kf, data, None, None).unwrap();
}
let frame_count = muxer.frame_count;
muxer.finish().unwrap();
@@ -2747,7 +3035,7 @@ mod tests {
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
for f in &frames {
muxer
.write_frame(0, f.pts_ns, f.keyframe, &f.data, f.duration_ns)
.write_frame(0, f.pts_ns, f.keyframe, &f.data, f.duration_ns, None)
.unwrap();
}
muxer.finish().unwrap();
@@ -3136,12 +3424,14 @@ mod tests {
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
// Audio frames (track 1) and non-keyframe video — no track-0 keyframe.
muxer.write_frame(1, 0, true, &[0xAA; 8], None).unwrap();
muxer
.write_frame(0, 10_000_000, false, &[0xBB; 8], None)
.write_frame(1, 0, true, &[0xAA; 8], None, None)
.unwrap();
muxer
.write_frame(1, 20_000_000, true, &[0xCC; 8], None)
.write_frame(0, 10_000_000, false, &[0xBB; 8], None, None)
.unwrap();
muxer
.write_frame(1, 20_000_000, true, &[0xCC; 8], None, None)
.unwrap();
let err = muxer.finish().unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidData);
@@ -3216,7 +3506,7 @@ mod tests {
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, &tracks, None, 0.0, &[]).unwrap();
for (t, pts, kf, data) in &frames_in_order {
muxer.write_frame(*t, *pts, *kf, data, None).unwrap();
muxer.write_frame(*t, *pts, *kf, data, None, None).unwrap();
}
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
@@ -3229,7 +3519,7 @@ mod tests {
}
#[test]
fn track_vint_encodes_one_and_two_byte_forms() {
fn track_vint_encodes_one_two_and_three_byte_forms() {
// 1-byte form for track numbers < 0x80, high bit set.
let (b, n) = track_vint(1);
assert_eq!(&b[..n], &[0x81]);
@@ -3240,6 +3530,12 @@ mod tests {
assert_eq!(&b[..n], &[0x40, 0x80]);
let (b, n) = track_vint(0x3FFF);
assert_eq!(&b[..n], &[0x7F, 0xFF]);
// 3-byte form at/above 0x4000, 0x20 length marker in the top byte —
// handled in RELEASE (no silent marker-bit clobber), not just debug.
let (b, n) = track_vint(0x4000);
assert_eq!(&b[..n], &[0x20, 0x40, 0x00]);
let (b, n) = track_vint(0x1F_FFFF);
assert_eq!(&b[..n], &[0x3F, 0xFF, 0xFF]);
}
// ============================================================
@@ -3286,7 +3582,7 @@ mod tests {
let writer = SharedWriter(shared.clone());
let mut muxer = MkvMuxer::new(writer, tracks, None, 0.0, &[]).unwrap();
for (t, pts, kf, data, dur) in frames {
muxer.write_frame(*t, *pts, *kf, data, *dur).unwrap();
muxer.write_frame(*t, *pts, *kf, data, *dur, None).unwrap();
}
muxer.finish().unwrap();
shared.lock().unwrap().clone().into_inner()
@@ -4135,10 +4431,10 @@ mod tests {
let mut muxer = MkvMuxer::new(writer, &tracks, None, 10.0, &[]).unwrap();
// Video keyframe 1000 bytes; audio frame 500 bytes.
muxer
.write_frame(0, 0, true, &vec![0xABu8; 1000], None)
.write_frame(0, 0, true, &vec![0xABu8; 1000], None, None)
.unwrap();
muxer
.write_frame(1, 0, false, &vec![0xCDu8; 500], None)
.write_frame(1, 0, false, &vec![0xCDu8; 500], None, None)
.unwrap();
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
@@ -4188,8 +4484,10 @@ mod tests {
// lfeon(0) = 0b0100_0000 = 0x40. acmod_channels only needs >= 8 bytes.
let ac3 = vec![0x0B, 0x77, 0x00, 0x00, 0x00, 8 << 3, 0x40, 0x00];
// Open a cluster with a video keyframe first (cluster invariant).
muxer.write_frame(0, 0, true, &[0x01, 0x02], None).unwrap();
muxer.write_frame(1, 0, false, &ac3, None).unwrap();
muxer
.write_frame(0, 0, true, &[0x01, 0x02], None, None)
.unwrap();
muxer.write_frame(1, 0, false, &ac3, None, None).unwrap();
muxer.finish().unwrap();
let data = shared.lock().unwrap().clone().into_inner();
@@ -4234,6 +4532,203 @@ mod tests {
assert!(find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_none());
}
#[test]
fn mvc_decoder_config_record_matches_iso_14496_15_layout() {
// subset SPS (NAL type 15): [nal_hdr, profile, compat, level, ...].
let subset_sps = vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22];
let pps = vec![0x68, 0xEE, 0x3C];
let rec = mvc_decoder_config_record(&subset_sps, &pps).expect("record builds");
// ISO/IEC 14496-15:2013 §7.6.2 byte layout, verbatim:
let expected: Vec<u8> = [
vec![
1, // configurationVersion
0x80, // AVCProfileIndication = subset_sps[1]
0x00, // profile_compatibility = subset_sps[2]
0x33, // AVCLevelIndication = subset_sps[3]
0xBF, // complete_rep(1) explicit_au(0) reserved'1111' lengthSizeMinusOne=3
0x01, // reserved'0'(1) numOfSequenceParameterSets(7)=1
0x00, 0x06, // sequenceParameterSetLength = 6
],
subset_sps.clone(),
vec![
1, // numOfPictureParameterSets
0x00, 0x03, // pictureParameterSetLength = 3
],
pps.clone(),
]
.concat();
assert_eq!(rec, expected, "MVCDecoderConfigurationRecord byte layout");
// Guards: too-short SPS and empty PPS both refuse (no corrupt record).
assert!(mvc_decoder_config_record(&[0x6F, 0x80, 0x00], &pps).is_none());
assert!(mvc_decoder_config_record(&subset_sps, &[]).is_none());
// Over-length param sets (> 65535) would mis-frame the 16-bit length
// field; both must refuse rather than emit a truncated record.
let huge = vec![0u8; 0x1_0000];
assert!(mvc_decoder_config_record(&huge, &pps).is_none());
assert!(mvc_decoder_config_record(&subset_sps, &huge).is_none());
}
#[test]
fn mvc_frame_emits_blockgroup_additional_and_reference() {
// A track declaring an mvcC mapping: a keyframe base frame carrying a
// dependent AU emits BlockGroup > BlockAdditions > BlockMore
// {BlockAddID=2, BlockAdditional=dep}; a following non-keyframe adds a
// ReferenceBlock so it is not mistaken for a seek point.
let mut v = make_video_track();
v.mvc_params = Some((
vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22],
vec![0x68, 0xEE, 0x3C],
));
let mut muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[]).unwrap();
let dep_kf = [0xDEu8, 0xAD, 0xBE, 0xEF];
let dep_p = [0xCAu8, 0xFE];
muxer
.write_frame(0, 0, true, &[0x65, 0x01, 0x02], None, Some(&dep_kf))
.unwrap();
muxer
.write_frame(0, 40_000_000, false, &[0x41, 0x03], None, Some(&dep_p))
.unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::BLOCK_ADDITIONS).is_some(),
"BlockAdditions (0x75A1) present"
);
// BlockAddID = 2 (uint): element 0xEE, size 0x81, value 0x02.
assert!(
data.windows(3).any(|w| w == [0xEE, 0x81, 0x02]),
"BlockAddID must be 2"
);
assert!(
data.windows(4).any(|w| w == dep_kf),
"keyframe dependent AU present as BlockAdditional"
);
assert!(
data.windows(2).any(|w| w == dep_p),
"non-keyframe dependent AU present"
);
assert!(
find_id(&data, ebml::REFERENCE_BLOCK).is_some(),
"non-keyframe MVC base frame must carry a ReferenceBlock"
);
}
#[test]
fn additional_dropped_when_track_has_no_mvc_mapping() {
// If a track did NOT declare an mvcC mapping (mvc_params None), a stray
// block_additional must be dropped (no orphaned BlockAddID=2 / no
// BlockAdditions) so the file stays conforming.
let mut muxer = MkvMuxer::new(
Cursor::new(Vec::new()),
&[make_video_track()],
None,
0.0,
&[],
)
.unwrap();
muxer
.write_frame(0, 0, true, &[0x65, 0x01], None, Some(&[0xDE, 0xAD]))
.unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::BLOCK_ADDITIONS).is_none(),
"no BlockAdditions without a declared mvcC mapping"
);
}
#[test]
fn mvc_track_emits_mvcc_block_addition_mapping() {
// A track with mvc_params must emit BlockAdditionMapping (0x41E4) with the
// mvcC BlockAddIDType (0x6D766343) and a BlockAddIDValue (0x41F0), so
// players / mediainfo recognise the Blu-ray 3D dependent view.
let mut v = make_video_track();
v.mvc_params = Some((
vec![0x6F, 0x80, 0x00, 0x33, 0x11, 0x22],
vec![0x68, 0xEE, 0x3C],
));
let muxer = MkvMuxer::new(Cursor::new(Vec::new()), &[v], None, 0.0, &[]).unwrap();
let data = muxer.writer.into_inner();
assert!(
find_id(&data, ebml::BLOCK_ADDITION_MAPPING).is_some(),
"MVC track must emit BlockAdditionMapping"
);
assert!(
find_id(&data, ebml::BLOCK_ADD_ID_VALUE).is_some(),
"MVC mapping must carry BlockAddIDValue"
);
assert!(
data.windows(4).any(|w| w == [0x6D, 0x76, 0x63, 0x43]),
"mvcC fourcc must be present as the BlockAddIDType value"
);
// Without mvc_params, no mapping.
let muxer = MkvMuxer::new(
Cursor::new(Vec::new()),
&[make_video_track()],
None,
0.0,
&[],
)
.unwrap();
let data = muxer.writer.into_inner();
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
+554 -53
View File
@@ -120,14 +120,206 @@ struct PendingMux {
video_track: Option<usize>,
/// `--log-level 3` opening-capture side-file path (if any).
opening_capture_path: Option<std::path::PathBuf>,
/// Frames received before activation, replayed in order once built.
buffered: Vec<crate::pes::PesFrame>,
/// Frames received before activation, replayed in order once built. Each
/// carries an optional MVC dependent-view `BlockAdditional` (present only
/// for a 3D base-view frame that was already paired before activation).
buffered: Vec<(crate::pes::PesFrame, Option<Vec<u8>>)>,
}
/// Matroska container stream.
pub struct MkvStream {
disc_title: DiscTitle,
mode: Mode,
/// Blu-ray 3D (MVC) merge state — present iff the title carries an MVC
/// dependent (right-eye) view. Folds the dependent stream's frames into the
/// base video track as per-frame `BlockAdditional`, paired by PTS, so the
/// output is a single MVC track instead of two independent H.264 tracks.
mvc: Option<MvcMerge>,
}
/// Largest number of base frames held awaiting their PTS-matching dependent AU
/// before the oldest is flushed unpaired (a plain Block). The SSIF interleaves
/// base and dependent access units per unit, so a base's dependent normally
/// arrives within one or two frames; this window only bounds memory/latency for
/// a stream where the pairing drifts.
const MVC_PAIR_WINDOW: usize = 32;
/// A base-view frame (track already remapped to the muxer's base track index)
/// awaiting — or already carrying — its dependent-view `BlockAdditional`.
struct PendingBase {
frame: crate::pes::PesFrame,
additional: Option<Vec<u8>>,
}
/// State for folding the MVC dependent (right-eye) view into the base track.
struct MvcMerge {
/// `title.streams` index of the base (left-eye) video stream.
base_stream_idx: usize,
/// `title.streams` index of the dependent (right-eye) video stream.
dep_stream_idx: usize,
/// Muxer track index of the base view — where the dependent AU is attached
/// as a `BlockAdditional` and where `mvc_params` (the `mvcC` mapping) lives.
base_track_idx: usize,
/// `title.streams` index → muxer track index. The dependent maps to `None`
/// (it becomes a BlockAdditional, not a track); every other stream shifts
/// down by one if it followed the dependent in stream order.
stream_to_track: Vec<Option<usize>>,
/// Base frames (decode order) awaiting their dependent or a window flush.
pending_base: std::collections::VecDeque<PendingBase>,
/// Dependent AU data keyed by PTS, waiting for the matching base.
dep_by_pts: std::collections::HashMap<i64, Vec<u8>>,
/// `(subset_sps, pps)` from the first dependent AU — builds the `mvcC`
/// MVCDecoderConfigurationRecord for the base track's BlockAdditionMapping.
captured_params: Option<(Vec<u8>, Vec<u8>)>,
/// Count of dependent AUs dropped with no matching base (diagnostic).
orphan_deps: u64,
}
impl MvcMerge {
/// Ingest one incoming frame; returns `(frame, additional)` pairs ready to
/// hand to the muxer, in emit order. Base frames buffer briefly to pair with
/// their dependent by PTS; the dependent stream produces no frames of its own
/// (it becomes `BlockAdditional`); all other streams pass straight through
/// with their track index remapped.
fn ingest(
&mut self,
frame: &crate::pes::PesFrame,
) -> Vec<(crate::pes::PesFrame, Option<Vec<u8>>)> {
let mut out = Vec::new();
if frame.track == self.dep_stream_idx {
if self.captured_params.is_none() {
self.captured_params = extract_mvc_params(&frame.data);
}
// Attach to a waiting base of the same PTS, else stash by PTS.
if let Some(pb) = self
.pending_base
.iter_mut()
.find(|pb| pb.frame.pts == frame.pts && pb.additional.is_none())
{
pb.additional = Some(frame.data.clone());
} else {
// Bound the orphan map BEFORE inserting: if dependents pile up
// unpaired (pairing badly drifted), drop the drifted buffer so it
// stays bounded — but keep THIS just-arrived dependent, whose base
// frame commonly arrives next. Clearing after the insert would
// discard it and overcount orphans by one.
if self.dep_by_pts.len() >= MVC_PAIR_WINDOW * 4 {
self.orphan_deps += self.dep_by_pts.len() as u64;
self.dep_by_pts.clear();
}
// A duplicate-PTS dependent (e.g. a stale repeat after a stream
// discontinuity) displaces the prior one — count it as an orphan
// rather than losing it silently.
if self
.dep_by_pts
.insert(frame.pts, frame.data.clone())
.is_some()
{
self.orphan_deps += 1;
}
}
} else if frame.track == self.base_stream_idx {
let additional = self.dep_by_pts.remove(&frame.pts);
let mut remapped = frame.clone();
remapped.track = self.base_track_idx;
self.pending_base.push_back(PendingBase {
frame: remapped,
additional,
});
} else {
// Audio / subtitle / other video: remap the track index and forward.
let mut remapped = frame.clone();
if let Some(Some(t)) = self.stream_to_track.get(frame.track) {
remapped.track = *t;
out.push((remapped, None));
}
}
self.drain_ready(&mut out);
out
}
/// Emit base frames from the FIFO front once each has its dependent attached,
/// or flush the oldest unpaired base as a plain Block when the window is full.
fn drain_ready(&mut self, out: &mut Vec<(crate::pes::PesFrame, Option<Vec<u8>>)>) {
loop {
let front_ready = self
.pending_base
.front()
.map(|pb| pb.additional.is_some())
.unwrap_or(false);
if front_ready || self.pending_base.len() > MVC_PAIR_WINDOW {
if let Some(pb) = self.pending_base.pop_front() {
out.push((pb.frame, pb.additional));
continue;
}
}
break;
}
}
/// Flush every remaining buffered base frame (unpaired → plain Block) at EOF.
fn flush(&mut self) -> Vec<(crate::pes::PesFrame, Option<Vec<u8>>)> {
let mut out = Vec::new();
for pb in self.pending_base.drain(..) {
out.push((pb.frame, pb.additional));
}
self.orphan_deps += self.dep_by_pts.len() as u64;
self.dep_by_pts.clear();
out
}
}
/// Hand a frame to the muxer, attaching the MVC dependent view as a
/// `BlockAdditional` when `additional` is `Some` (a 3D base frame), else a
/// plain block.
fn emit_to_muxer(
m: &mut MkvMuxer<Box<dyn WriteSeek + Send>>,
frame: &crate::pes::PesFrame,
additional: Option<&[u8]>,
) -> io::Result<()> {
m.write_frame(
frame.track,
frame.pts,
frame.keyframe,
&frame.data,
frame.duration_ns,
additional,
)
}
/// Scan a length-prefixed (4-byte big-endian) H.264 NAL stream for the first
/// subset SPS (NAL type 15) and first PPS (NAL type 8) — the two parameter sets
/// that populate the `mvcC` MVCDecoderConfigurationRecord. Returns
/// `Some((subset_sps, pps))` only when BOTH are found; `None` otherwise (the
/// serializer then emits no mvcC mapping and logs it).
fn extract_mvc_params(data: &[u8]) -> Option<(Vec<u8>, Vec<u8>)> {
let mut subset_sps: Option<Vec<u8>> = None;
let mut pps: Option<Vec<u8>> = None;
let mut i = 0usize;
while i + 4 <= data.len() {
let len = u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
i += 4;
// A zero-length NAL (a stray length prefix) is skipped, not fatal — the
// subset SPS / PPS may still follow. A length that runs past the buffer
// end IS unrecoverable (the NAL can't be read), so stop there.
if len == 0 {
continue;
}
if i + len > data.len() {
break;
}
let nal = &data[i..i + len];
i += len;
match nal[0] & 0x1F {
15 if subset_sps.is_none() => subset_sps = Some(nal.to_vec()),
8 if pps.is_none() => pps = Some(nal.to_vec()),
_ => {}
}
if subset_sps.is_some() && pps.is_some() {
break;
}
}
Some((subset_sps?, pps?))
}
impl MkvStream {
@@ -146,10 +338,38 @@ impl MkvStream {
title: &DiscTitle,
output_path: Option<&std::path::Path>,
) -> io::Result<Self> {
// Blu-ray 3D (MVC): a dependent (right-eye) view stream is NOT emitted as
// its own track — it is folded into the base track as per-frame
// BlockAdditional. Detect it so we skip building a track for it and set up
// the merge. `base_stream_idx` is the first video stream.
let dep_stream_idx = title
.streams
.iter()
.position(|s| matches!(s, crate::disc::Stream::Video(v) if v.is_mvc_dependent()));
// The base is the first NON-dependent video. Excluding the dependent here
// means a (malformed / hand-built) title whose only video IS the dependent
// yields `base_stream_idx == None` → no merge (the dependent is muxed as an
// ordinary track) instead of `base == dep` and a panic on the skipped slot.
let base_stream_idx = title
.streams
.iter()
.position(|s| matches!(s, crate::disc::Stream::Video(v) if !v.is_mvc_dependent()));
// The merge is only active when BOTH a dependent and a distinct base exist;
// only then is the dependent's track skipped/folded.
let mvc_active = dep_stream_idx.is_some() && base_stream_idx.is_some();
let skip_stream_idx = if mvc_active { dep_stream_idx } else { None };
let mut tracks = Vec::new();
let mut has_default_video = false;
let mut has_default_audio = false;
// `title.streams` index → muxer track index (`None` = the dependent view,
// which has no track). Streams after the dependent shift down by one.
let mut stream_to_track: Vec<Option<usize>> = Vec::with_capacity(title.streams.len());
for (idx, s) in title.streams.iter().enumerate() {
if Some(idx) == skip_stream_idx {
stream_to_track.push(None);
continue;
}
let mut track = match s {
crate::disc::Stream::Video(v) => MkvTrack::video(v),
crate::disc::Stream::Audio(a) => MkvTrack::audio(a),
@@ -166,9 +386,31 @@ impl MkvStream {
if let Some(cp) = title.codec_privates.get(idx).and_then(|c| c.as_ref()) {
track.codec_private = Some(cp.clone());
}
stream_to_track.push(Some(tracks.len()));
tracks.push(track);
}
// Assemble the MVC merge only when active — i.e. a dependent AND a
// distinct base video both exist (established above). `base_stream_idx`
// then always has a built track, so its remap is `Some` (no panic path).
let mvc = match (mvc_active, dep_stream_idx, base_stream_idx) {
(true, Some(dep_stream_idx), Some(base_stream_idx)) => stream_to_track
.get(base_stream_idx)
.copied()
.flatten()
.map(|base_track_idx| MvcMerge {
base_stream_idx,
dep_stream_idx,
base_track_idx,
stream_to_track,
pending_base: std::collections::VecDeque::new(),
dep_by_pts: std::collections::HashMap::new(),
captured_params: None,
orphan_deps: 0,
}),
_ => None,
};
// Defer muxer construction (and the TrackEntry dump) until the first
// coded picture arrives, so the primary video track's FieldOrder is set
// from the parser's MEASURED value before the header is written — never
@@ -177,6 +419,7 @@ impl MkvStream {
Ok(Self {
disc_title: title.clone(),
mvc,
mode: Mode::Write(WriteMode::Pending(Box::new(PendingMux {
writer,
tracks,
@@ -209,6 +452,24 @@ impl MkvStream {
if let Some(vt) = pending.video_track {
apply_coding_to_track(&mut pending.tracks[vt], coding, video_picture_seen);
}
// Blu-ray 3D: set the base video track's `mvc_params` from the dependent
// view's captured subset-SPS/PPS BEFORE the header is written, so the
// TrackEntry carries the `mvcC` BlockAdditionMapping. Captured from the
// first dependent AU (which arrives right after the first base AU in the
// SSIF), so it is available by the time the first base frame activates.
if let Some(mvc) = &self.mvc {
if let Some(params) = &mvc.captured_params {
if let Some(t) = pending.tracks.get_mut(mvc.base_track_idx) {
t.mvc_params = Some(params.clone());
}
} else {
tracing::warn!(
target: "mux",
"MVC: no dependent-view subset-SPS/PPS captured before activation; \
the base track will carry no mvcC mapping (3D not signalled)."
);
}
}
// --log-level 3: dump the FINAL TrackEntry metadata (field order set).
for (i, track) in pending.tracks.iter().enumerate() {
crate::diag::dump_mkv_track((i + 1) as u64, track);
@@ -223,18 +484,72 @@ impl MkvStream {
if let Some(path) = &pending.opening_capture_path {
muxer.set_opening_capture(crate::diag::OpeningCapture::new(path, pending.tracks.len()));
}
for f in pending.buffered.drain(..) {
muxer.write_frame(f.track, f.pts, f.keyframe, &f.data, f.duration_ns)?;
for (f, additional) in pending.buffered.drain(..) {
muxer.write_frame(
f.track,
f.pts,
f.keyframe,
&f.data,
f.duration_ns,
additional.as_deref(),
)?;
}
self.mode = Mode::Write(WriteMode::Active(Box::new(muxer)));
Ok(())
}
/// Emit one frame (track index already muxer-relative) with an optional MVC
/// dependent-view `BlockAdditional`, honouring the deferred-activation
/// machinery: the first video frame triggers muxer construction (its coding
/// sets FieldOrder); earlier frames buffer. `additional` is `None` for every
/// non-3D frame and for the 3D base frames that had no paired dependent.
fn emit(&mut self, frame: &crate::pes::PesFrame, additional: Option<&[u8]>) -> io::Result<()> {
match &mut self.mode {
Mode::Read(_) => return Err(crate::error::Error::StreamReadOnly.into()),
Mode::Write(WriteMode::Active(m)) => {
return emit_to_muxer(m, frame, additional);
}
Mode::Write(WriteMode::Building) => return Ok(()),
Mode::Write(WriteMode::Pending(_)) => {}
}
// Pending: the first video frame (or the safety cap) triggers muxer
// construction; that frame's coding sets the field order. Other frames
// buffer until then.
let (activate_now, use_coding) = match &self.mode {
Mode::Write(WriteMode::Pending(p)) => {
let is_video = match p.video_track {
Some(vt) => frame.track == vt,
// No video track: nothing to wait for — build on frame one.
None => true,
};
(is_video || p.buffered.len() >= MAX_PENDING_FRAMES, is_video)
}
_ => unreachable!("guarded above"),
};
if activate_now {
// Pass the trigger frame's coding only when it IS the video frame; a
// cap-triggered build never saw the video frame, so nothing measured
// is passed (apply_coding_to_track then logs + leaves UNDETERMINED).
self.activate(if use_coding { frame.coding } else { None }, use_coding)?;
if let Mode::Write(WriteMode::Active(m)) = &mut self.mode {
return emit_to_muxer(m, frame, additional);
}
Ok(())
} else {
if let Mode::Write(WriteMode::Pending(p)) = &mut self.mode {
p.buffered
.push((frame.clone(), additional.map(|a| a.to_vec())));
}
Ok(())
}
}
/// Open an MKV file for reading → PES frames.
pub fn open(mut reader: impl Read + Send + 'static) -> io::Result<Self> {
let (disc_title, codec_privates, ts_scale_ns) = parse_mkv_header(&mut reader)?;
Ok(Self {
disc_title,
mvc: None,
mode: Mode::Read(ReadState {
reader: Box::new(reader),
cluster_ts_ticks: 0,
@@ -425,59 +740,40 @@ impl crate::pes::Stream for MkvStream {
}
fn write(&mut self, frame: &crate::pes::PesFrame) -> io::Result<()> {
// Fast paths.
match &mut self.mode {
Mode::Read(_) => return Err(crate::error::Error::StreamReadOnly.into()),
Mode::Write(WriteMode::Active(m)) => {
return m.write_frame(
frame.track,
frame.pts,
frame.keyframe,
&frame.data,
frame.duration_ns,
);
}
Mode::Write(WriteMode::Building) => return Ok(()),
Mode::Write(WriteMode::Pending(_)) => {}
if matches!(self.mode, Mode::Read(_)) {
return Err(crate::error::Error::StreamReadOnly.into());
}
// Pending: the first video frame (or the safety cap) triggers muxer
// construction; that frame's coding sets the field order. Other frames
// buffer until then.
let (activate_now, use_coding) = match &self.mode {
Mode::Write(WriteMode::Pending(p)) => {
let is_video = match p.video_track {
Some(vt) => frame.track == vt,
// No video track: nothing to wait for — build on frame one.
None => true,
};
(is_video || p.buffered.len() >= MAX_PENDING_FRAMES, is_video)
}
_ => unreachable!("guarded above"),
};
if activate_now {
// Pass the trigger frame's coding only when it IS the video frame; a
// cap-triggered build never saw the video frame, so nothing measured
// is passed (apply_coding_to_track then logs + leaves UNDETERMINED).
self.activate(if use_coding { frame.coding } else { None }, use_coding)?;
if let Mode::Write(WriteMode::Active(m)) = &mut self.mode {
return m.write_frame(
frame.track,
frame.pts,
frame.keyframe,
&frame.data,
frame.duration_ns,
);
}
Ok(())
} else {
if let Mode::Write(WriteMode::Pending(p)) = &mut self.mode {
p.buffered.push(frame.clone());
}
Ok(())
// Non-3D fast path: emit the frame directly, no clone, no buffering.
if self.mvc.is_none() {
return self.emit(frame, None);
}
// Blu-ray 3D: run the frame through the MVC merge, which remaps track
// indices, folds the dependent view into the base as BlockAdditional
// (paired by PTS), and yields 0+ frames ready to emit. `ingest` returns
// owned pairs so the `self.mvc` borrow is released before `emit`.
let emits = self.mvc.as_mut().unwrap().ingest(frame);
for (f, additional) in emits {
self.emit(&f, additional.as_deref())?;
}
Ok(())
}
fn finish(&mut self) -> io::Result<()> {
// Blu-ray 3D: flush any base frames still awaiting a dependent (emitted
// unpaired as plain Blocks) before finalizing.
if let Some(mvc) = self.mvc.as_mut() {
let tail = mvc.flush();
let orphans = mvc.orphan_deps;
if orphans > 0 {
tracing::debug!(
target: "mux",
"MVC: {orphans} dependent-view access units had no matching base frame (dropped)"
);
}
for (f, additional) in tail {
self.emit(&f, additional.as_deref())?;
}
}
// A title that produced no frames (or only buffered ones) is still
// finalized into a valid MKV: activate now with no measured coding.
if matches!(self.mode, Mode::Write(WriteMode::Pending(_))) {
@@ -720,7 +1016,11 @@ fn parse_track(
arem = arem.saturating_sub(ahlen as u64 + as_);
match aid {
ebml::SAMPLING_FREQUENCY => sr = ebml::read_float_val(r, as_ as usize)?,
ebml::CHANNELS => ch = read_uint_bounded(r, as_)? as u8,
// Clamp instead of `as u8`: a foreign/corrupt MKV with a
// CHANNELS value that is a multiple of 256 would truncate to
// 0 (an invalid channel count) on a bare cast. Saturate to
// u8::MAX so an absurd count degrades to "many", never to 0.
ebml::CHANNELS => ch = read_uint_bounded(r, as_)?.min(u8::MAX as u64) as u8,
_ => {
skip_bytes(r, as_)?;
}
@@ -916,6 +1216,207 @@ mod tests {
use crate::pes::Stream as _;
use std::io::Cursor;
/// Length-prefix (4-byte big-endian) each NAL, as the H.264 parser emits.
fn lp(nals: &[&[u8]]) -> Vec<u8> {
let mut v = Vec::new();
for n in nals {
v.extend_from_slice(&(n.len() as u32).to_be_bytes());
v.extend_from_slice(n);
}
v
}
fn mvc_frame(track: usize, pts: i64, keyframe: bool, data: Vec<u8>) -> crate::pes::PesFrame {
crate::pes::PesFrame {
track,
pts,
keyframe,
data,
duration_ns: None,
source: None,
coding: None,
}
}
// A subset SPS (NAL type 15), a PPS (type 8), and a coded-slice-extension
// (type 20) — the shape of a dependent-view access unit.
const SUBSET_SPS: [u8; 5] = [0x6F, 0x80, 0x00, 0x33, 0xAA]; // 0x6F & 0x1F = 15
const DEP_PPS: [u8; 3] = [0x68, 0xEE, 0x3C]; // 0x68 & 0x1F = 8
const DEP_SLICE: [u8; 3] = [0x74, 0x11, 0x22]; // 0x74 & 0x1F = 20
#[test]
fn extract_mvc_params_finds_subset_sps_and_pps() {
let data = lp(&[&SUBSET_SPS, &DEP_PPS, &DEP_SLICE]);
let (s, p) = extract_mvc_params(&data).expect("both param sets present");
assert_eq!(s, SUBSET_SPS, "subset SPS (NAL 15) captured verbatim");
assert_eq!(p, DEP_PPS, "PPS (NAL 8) captured verbatim");
// Missing PPS → None (the serializer then emits no mvcC mapping).
assert!(extract_mvc_params(&lp(&[&SUBSET_SPS, &DEP_SLICE])).is_none());
// Missing subset SPS → None.
assert!(extract_mvc_params(&lp(&[&DEP_PPS, &DEP_SLICE])).is_none());
}
fn empty_merge() -> MvcMerge {
MvcMerge {
base_stream_idx: 0,
dep_stream_idx: 2,
base_track_idx: 0,
stream_to_track: vec![Some(0), Some(1), None],
pending_base: std::collections::VecDeque::new(),
dep_by_pts: std::collections::HashMap::new(),
captured_params: None,
orphan_deps: 0,
}
}
#[test]
fn mvc_merge_pairs_base_and_dependent_by_pts() {
let mut m = empty_merge();
let dep = lp(&[&SUBSET_SPS, &DEP_PPS, &DEP_SLICE]);
// Base arrives first (SSIF order): buffered, nothing emitted yet.
let e = m.ingest(&mvc_frame(0, 100, true, lp(&[&[0x65, 1, 2]])));
assert!(e.is_empty(), "base held until its dependent arrives");
// Dependent arrives → base is emitted, remapped to the base track, with
// the dependent AU as its BlockAdditional; params are captured.
let e = m.ingest(&mvc_frame(2, 100, false, dep.clone()));
assert_eq!(e.len(), 1, "the paired base frame is emitted");
assert_eq!(e[0].0.track, 0, "remapped to the base muxer track");
assert_eq!(
e[0].1.as_deref(),
Some(dep.as_slice()),
"dependent attached"
);
assert!(m.captured_params.is_some(), "mvcC params captured");
// Audio passes straight through (remapped, no additional).
let e = m.ingest(&mvc_frame(1, 100, true, vec![0xAA]));
assert_eq!(e.len(), 1);
assert_eq!(e[0].0.track, 1);
assert!(e[0].1.is_none());
// Dependent-before-base (reordered) also pairs.
let dep2 = lp(&[&DEP_SLICE]);
assert!(m.ingest(&mvc_frame(2, 200, false, dep2.clone())).is_empty());
let e = m.ingest(&mvc_frame(0, 200, false, lp(&[&[0x61, 3, 4]])));
assert_eq!(e.len(), 1);
assert_eq!(e[0].1.as_deref(), Some(dep2.as_slice()));
}
#[test]
fn mvc_merge_flushes_unpaired_base_at_eof() {
let mut m = empty_merge();
// Base with no dependent ever → held, then flushed unpaired at EOF.
assert!(
m.ingest(&mvc_frame(0, 10, true, vec![0, 0, 0, 1]))
.is_empty()
);
let tail = m.flush();
assert_eq!(tail.len(), 1, "unpaired base still emitted");
assert!(tail[0].1.is_none(), "no BlockAdditional when unpaired");
}
#[test]
fn extract_mvc_params_no_panic_on_truncated_or_empty() {
// Empty, sub-header, zero-length NAL, and a length prefix claiming more
// than is present must all return None without panicking (untrusted AU).
assert!(extract_mvc_params(&[]).is_none());
assert!(extract_mvc_params(&[0, 0, 0]).is_none());
assert!(
extract_mvc_params(&[0, 0, 0, 0]).is_none(),
"lone zero-length NAL yields no params"
);
assert!(
extract_mvc_params(&[0, 0, 0, 10, 0x6F]).is_none(),
"length prefix past end breaks, no slice panic"
);
// A zero-length NAL is SKIPPED, not fatal: valid param sets that follow
// are still found (a stray length prefix must not abandon the whole AU).
let mut d = vec![0, 0, 0, 0];
d.extend_from_slice(&lp(&[&SUBSET_SPS, &DEP_PPS]));
let (s, p) = extract_mvc_params(&d).expect("params found past the zero-length NAL");
assert_eq!(s, SUBSET_SPS);
assert_eq!(p, DEP_PPS);
}
#[test]
fn mvc_merge_flushes_oldest_base_once_past_window() {
let mut m = empty_merge();
// Push more unpaired base frames than the window; the excess flush as
// plain (unpaired) blocks in FIFO order once len exceeds MVC_PAIR_WINDOW.
let n = MVC_PAIR_WINDOW + 8;
let mut emitted = 0usize;
for pts in 0..n {
emitted += m
.ingest(&mvc_frame(0, pts as i64, false, vec![0, 0, 0, 1]))
.len();
}
assert_eq!(emitted, 8, "the {n} bases beyond the window flush unpaired");
assert_eq!(m.pending_base.len(), MVC_PAIR_WINDOW, "window still held");
assert!(m.flush().iter().all(|(_, add)| add.is_none()));
}
#[test]
fn mvc_merge_dep_overflow_drops_old_keeps_newest() {
let mut m = empty_merge();
// Fill dep_by_pts to the bound with unpaired dependents (unique PTS).
for pts in 0..(MVC_PAIR_WINDOW * 4) {
assert!(
m.ingest(&mvc_frame(2, pts as i64, false, lp(&[&DEP_SLICE])))
.is_empty()
);
}
assert_eq!(m.dep_by_pts.len(), MVC_PAIR_WINDOW * 4);
// One more overflows: the drifted buffer is cleared BUT the newest survives
// so its (soon-to-arrive) base can still pair.
let dep_new = lp(&[&DEP_SLICE]);
m.ingest(&mvc_frame(2, 9_999, false, dep_new.clone()));
assert_eq!(m.dep_by_pts.len(), 1, "old cleared, newest kept");
assert!(m.dep_by_pts.contains_key(&9_999));
assert_eq!(
m.orphan_deps,
(MVC_PAIR_WINDOW * 4) as u64,
"old buffer counted once"
);
// The surviving dependent pairs with its base.
let e = m.ingest(&mvc_frame(0, 9_999, false, vec![0x61, 1]));
assert_eq!(e.len(), 1);
assert_eq!(e[0].1.as_deref(), Some(dep_new.as_slice()));
}
#[test]
fn create_does_not_panic_when_only_video_is_mvc_dependent() {
// A (malformed / hand-built) title whose single video IS the dependent
// must NOT panic: base_stream_idx is None, so no merge is set up and the
// dependent is muxed as an ordinary track.
use crate::disc::{
Codec, ColorSpace, DiscTitle, FrameRate, HdrFormat, Resolution, Stream, VideoStream,
};
let dep = VideoStream {
pid: 0x1012,
codec: Codec::H264,
resolution: Resolution::R1080p,
frame_rate: FrameRate::F24,
hdr: HdrFormat::Sdr,
color_space: ColorSpace::Bt709,
display_aspect: None,
secondary: true,
label: crate::disc::MVC_DEPENDENT_LABEL.to_string(),
measured_cicp: None,
};
let title = DiscTitle {
streams: vec![Stream::Video(dep)],
..DiscTitle::empty()
};
let s = MkvStream::create(Box::new(Cursor::new(Vec::new())), &title)
.expect("create must succeed, not panic");
assert!(
s.mvc.is_none(),
"no merge when there is no distinct base view"
);
}
#[test]
fn apply_coding_to_track_sets_measured_field_order_never_guesses() {
use crate::disc::{Codec, ColorSpace, FrameRate, HdrFormat, Resolution, VideoStream};
+3 -34
View File
@@ -56,14 +56,6 @@ pub struct PipelinedPesStream {
/// `std::env::var_os` takes a process-wide lock, so the per-batch /
/// per-poll reads it replaces were needless hot-path overhead.
skip_parse: bool,
/// Cumulative bytes of scrambled AACS units the producer's decrypt step
/// could not decrypt — silent decrypt loss the demux drops without a sync.
/// Shared with the producer thread's [`DecryptingSectorSource`]
/// (`crate::sector::DecryptingSectorSource::decrypt_loss`). Surfaced through
/// [`Stream::lost_bytes`] so the file-backed mux abort gate sees a partial
/// decrypt failure instead of reporting a perfect rip. `None` for pipelines
/// with no AACS decrypt step (e.g. the M2TS byte-stream path).
decrypt_loss: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
/// Count of dropped DVD navigation packets (private_stream_2, 0xBF). These
/// are expected on every disc; instead of a per-packet WARN they're tallied
/// and summarised once at EOF.
@@ -134,7 +126,6 @@ impl PipelinedPesStream {
pending_frames: std::collections::VecDeque::new(),
eof: false,
skip_parse: std::env::var_os("FREEMKV_SKIP_PARSE").is_some(),
decrypt_loss: None,
dropped_nav_packets: 0,
resync,
is_video,
@@ -142,20 +133,6 @@ impl PipelinedPesStream {
}
}
/// Attach the producer's decrypt-loss counter so [`Stream::lost_bytes`]
/// reports bytes of scrambled AACS units that could not be decrypted (and
/// were therefore silently dropped downstream). Obtained from the
/// producer's `DecryptingSectorSource::decrypt_loss()` before it is moved
/// into the prefetch thread. The M2TS / no-decrypt pipelines leave this
/// unset.
pub(crate) fn with_decrypt_loss(
mut self,
loss: std::sync::Arc<std::sync::atomic::AtomicU64>,
) -> Self {
self.decrypt_loss = Some(loss);
self
}
/// Pull one batch of `PesPacket`s from the demux thread, run
/// codec parse on each, enqueue resulting `PesFrame`s on
/// `pending_frames`. Returns Ok(true) on success, Ok(false) on
@@ -464,17 +441,9 @@ impl Stream for PipelinedPesStream {
.and_then(|(_, parser)| parser.codec_private())
}
fn lost_bytes(&self) -> u64 {
// The file-backed highway has no read-error zero-fill term (resolve
// tracks read loss separately), but the producer's decrypt step can
// pass scrambled units through undecrypted — silent loss the demux
// drops. Surface that so the mux abort gate sees a partial AACS/CSS
// decrypt failure rather than reporting a perfect rip.
self.decrypt_loss
.as_ref()
.map(|c| c.load(std::sync::atomic::Ordering::Relaxed))
.unwrap_or(0)
}
// `lost_bytes` uses the trait default (0): the file-backed highway has no
// read-error zero-fill term (resolve/mapfile tracks physical read loss
// separately) and the decrypt path no longer reports a decrypt-loss term.
}
#[cfg(test)]
+42 -28
View File
@@ -391,11 +391,11 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
let title = disc.titles[idx].clone();
let format = disc.content_format;
// ISO file: 8192-sector batch (16 MiB at 2048 B/sector) —
// sequential read from fast storage, no bad sectors. Measured
// optimum on the rip1 testbed; bumping to 16384 sectors (32 MiB)
// regressed (more cache pressure, longer per-batch latency starves
// the consumer between iterations). Physical drives keep smaller
// batches for adaptive error handling.
// sequential read from fast storage, no bad sectors. Empirically
// optimal; bumping to 16384 sectors (32 MiB) regressed (more cache
// pressure, longer per-batch latency starves the consumer between
// iterations). Physical drives keep smaller batches for adaptive
// error handling.
const ISO_MUX_BATCH_SECTORS: u16 = 8192;
// Pass `DecryptKeys::None` to the decrypt decorator when
@@ -575,7 +575,17 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
pids.push(pid);
pid_to_track.push((pid, idx));
let is_dvd_ps = matches!(format, ContentFormat::MpegPs);
parsers.push((pid, super::codec::parser_for_codec(codec, None, is_dvd_ps)));
// The Blu-ray 3D MVC dependent (right-eye) view uses a param-set-
// passthrough H.264 parser so each frame is a self-contained dependent
// access unit for a BlockAdditional; every other stream uses the
// ordinary parser for its codec.
let parser = match s {
crate::disc::Stream::Video(v) if v.is_mvc_dependent() => {
super::codec::parser_for_mvc_dependent(codec, is_dvd_ps)
}
_ => super::codec::parser_for_codec(codec, None, is_dvd_ps),
};
parsers.push((pid, parser));
}
let (ts, ps) = match format {
ContentFormat::MpegPs => (None, Some(super::ps::PsDemuxer::new())),
@@ -611,7 +621,8 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
/// - `fetch`: optional fresh-key-on-failure callback (see
/// [`crate::sector::KeyFetch`]). When a unit no held key decrypts, the
/// decrypt decorator hands that ciphertext to `fetch` and adds any key it
/// returns. `None` keeps the prior behaviour (the unit is counted as loss).
/// returns, then re-decrypts. `None` means no mid-stream key recovery — the
/// unit's best-effort bytes pass through to the muxer as-is.
// Eight reader/title/keys/tuning/callback params is inherent to the mux entry
// point; grouping them into a struct would only move the same fields around.
#[allow(clippy::too_many_arguments)]
@@ -635,27 +646,27 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
crate::decrypt::DecryptKeys::Aacs { .. } => 3,
_ => 1,
};
// MUX path: tolerate decrypt loss. An undecryptable content unit is concealed
// (NULL TS fill) + tallied + logged, never an abort — decrypt-verify is a RIP
// gate, not a mux gate (P3). The rip's own read paths keep their fail-loud
// decorator; only this mux pipeline opts in.
// MUX path: read > decrypt > mux. The decrypt seam applies the CPS unit key and
// passes the bytes to the muxer; a unit that decrypts to broken TS is the
// muxer's problem, not a decrypt failure, so the mux never conceals a unit or
// counts it as loss.
let mut decrypting =
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys)
.tolerate_decrypt_loss();
// Install the fresh-key-on-failure callback (if any) so a unit no held key
// decrypts is re-tried via the application's key source before being counted
// as loss. An AACS 2.1 forensic-segment unit that no key opens is just an
// undecryptable unit like any other: concealed and counted as decrypt loss —
// a loss is a loss, no FMTS special casing.
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
// Install the fresh-key-on-failure callback (if the app supplied one). This is
// how multi-CPS is muxed: each CPS unit's key is fetched when the mux reaches a
// unit no held key opens — "get the key when we need it." It fires only on a
// genuine miss: now that key selection is accurate (`is_clean_ts`), a unit that
// decrypted correctly but has bad-encoded TS is NOT a miss, so this no longer
// storms the key source the way the old TS supermajority gate did.
if let Some(cb) = fetch {
decrypting = decrypting.with_key_fetch(cb);
}
// Grab the loss counters before the decorator is moved into the producer
// thread. It tracks bytes of scrambled AACS units no key could decrypt —
// silent loss the demux drops; the consuming stream surfaces it through
// `lost_bytes()` so the mux abort gate sees a partial decrypt failure rather
// than a clean rip. Forensic (2.1) undecryptable units land here too.
let decrypt_loss = decrypting.decrypt_loss();
// Loss-counter handle. The mux does NOT tally decrypt-quality misses: a
// broken-TS unit is the muxer's concern, and a missing key is an up-front
// resolve failure — indistinguishable from bad authoring at this seam, so
// counting it would false-abort a bad-encoded-but-decryptable disc. A genuine
// can't-decrypt surfaces as `Err`; `lost_bytes()` reflects physical read loss
// only (there is no decrypt-loss term to fold in).
// Wrong-substream fix (Silence-of-the-Lambs): before the prefetcher takes
// the reader, probe the feature head through the (plaintext) decrypting
@@ -681,10 +692,13 @@ pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
let (demux_thread, demux_rx) =
super::demux_thread::DemuxThread::spawn_zero_copy(rx, recycle_tx, shell, halt, ts, ps)
.map_err(|e| -> io::Error { e.into() })?;
Ok(
PipelinedPesStream::new(demux_thread, demux_rx, title, parsers, pid_to_track)
.with_decrypt_loss(decrypt_loss),
)
Ok(PipelinedPesStream::new(
demux_thread,
demux_rx,
title,
parsers,
pid_to_track,
))
}
/// Assemble the M2TS file mux pipeline (read → demux → parse) for a
+25 -21
View File
@@ -12,9 +12,11 @@ use crate::consts::TS_PACKET_BYTES;
/// TS sync byte.
const SYNC_BYTE: u8 = 0x47;
/// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream; the P3
/// concealment fill emits null packets on this PID, tagged with an
/// adaptation-field discontinuity_indicator to signal a concealed gap.
/// MPEG-TS null-packet PID (0x1FFF). Carries no elementary stream. The demuxer
/// still recognises a `0x1FFF` packet with an adaptation-field
/// discontinuity_indicator as a concealed-gap loss signal, but the in-tree WRITER
/// that emitted these (the removed NULL-TS concealment fill) is gone — the mux no
/// longer conceals; only externally-authored markers reach this path now.
const NULL_PID: u16 = 0x1FFF;
/// A reassembled PES packet with timestamp info.
@@ -34,8 +36,10 @@ pub struct PesPacket {
pub source: Option<crate::pes::SourcePos>,
/// True when one or more packets for this stream were lost before this PES —
/// a continuity break (CC gap or adaptation-field discontinuity_indicator) on
/// a tracked PID, or the CC-independent concealment marker the mux emits when
/// it replaces an undecryptable unit with NULL-TS packets (P3/A2). This PES is
/// a tracked PID, or a CC-independent NULL-TS concealment marker (P3/B1). NOTE:
/// the mux no longer emits such markers (the concealment writer was removed);
/// this now flags only real discontinuities and externally-authored markers.
/// This PES is
/// the FIRST whose data is entirely after the gap: a mid-frame loss drops the
/// truncated partial and flags the next complete PES; a loss landing on a PES
/// boundary flags the PES STARTING after it (never the one just flushed). So
@@ -200,7 +204,7 @@ impl PesAssembler {
/// BD Transport Stream demuxer.
pub struct TsDemuxer {
assemblers: Vec<PesAssembler>,
pid_index: Vec<i16>, // PID → index into assemblers, -1 = not tracked
pid_index: Vec<i32>, // PID → index into assemblers, -1 = not tracked
remainder: Vec<u8>, // leftover bytes from previous feed() call
/// Absolute source byte offset of the NEXT byte to be fed — the running
/// base that turns an in-buffer packet offset into a source position.
@@ -224,20 +228,17 @@ impl TsDemuxer {
/// limits. Empty `pids` yields max_pid 0; the floor still produces a
/// valid (wholly-unused) table.
pub fn new(pids: &[u16]) -> Self {
// The PID→assembler index is stored as i16 (-1 = untracked), so a
// 32768th+ tracked PID would truncate to a negative value and be
// silently treated as untracked. Callers pass a handful of PIDs
// (BD-TS has at most ~8192), so this is a programmer-error guard.
debug_assert!(
pids.len() <= i16::MAX as usize,
"TsDemuxer: too many PIDs for an i16 index table"
);
// The PID→assembler index is stored as i32 (-1 = untracked). PIDs are
// u16 (≤ 65535) and the assembler index `i` is bounded by the number of
// distinct PIDs (≤ 65536), both far below i32::MAX, so `i as i32` can
// never truncate to a negative value and be mis-read as untracked —
// unlike an i16 table, this is safe in RELEASE, not just under debug.
let max_pid = pids.iter().copied().max().unwrap_or(0) as usize;
let table_size = (max_pid + 1).max(8192);
let mut pid_index = vec![-1i16; table_size];
let mut pid_index = vec![-1i32; table_size];
let mut assemblers = Vec::with_capacity(pids.len());
for (i, &pid) in pids.iter().enumerate() {
pid_index[pid as usize] = i as i16;
pid_index[pid as usize] = i as i32;
assemblers.push(PesAssembler::new(pid));
}
Self {
@@ -368,10 +369,13 @@ impl TsDemuxer {
let pusi = ts[1] & 0x40 != 0; // Payload Unit Start Indicator
let adaptation = (ts[3] >> 4) & 0x03;
// P3/B1 CONCEALMENT MARKER. The decrypt layer fills an undecryptable
// aligned unit with NULL-TS packets (PID 0x1FFF) that carry an
// adaptation-field discontinuity_indicator (see `aacs::content::fill_null_ts_unit`).
// This is the authoritative loss signal — unlike a tracked PID's 4-bit
// P3/B1 CONCEALMENT MARKER: a NULL-TS packet (PID 0x1FFF) carrying an
// adaptation-field discontinuity_indicator. NOTE: the in-tree writer that
// laid these down on an undecryptable unit was removed with the pure-decrypt
// passthrough change (the mux no longer conceals), so this recognition now
// only fires on externally-authored markers — a candidate for removal with
// the rest of the retired concealment path.
// As a loss signal it is CC-INDEPENDENT — unlike a tracked PID's 4-bit
// continuity_counter it is CC-INDEPENDENT, so it survives a loss that is
// an exact multiple of 16 packets and a loss at the very start of a PID
// (no prior CC to diff against). The decrypt layer cannot know which
@@ -1057,7 +1061,7 @@ mod tests {
/// One 192-byte BD source packet that is a B1 concealment marker: a PID-0x1FFF
/// null packet carrying the adaptation-field discontinuity_indicator (the byte
/// shape `fill_null_ts_unit` writes for every packet of a concealed unit).
/// shape of a concealed-unit packet).
fn null_marker_packet() -> Vec<u8> {
let mut pkt = vec![0u8; BD_SOURCE_PACKET_BYTES];
pkt[4] = SYNC_BYTE; // 0x47
+187 -838
View File
File diff suppressed because it is too large Load Diff
+62 -12
View File
@@ -129,6 +129,10 @@ impl SectorSource for Box<dyn SectorSource> {
fn set_speed(&mut self, kbs: u16) {
(**self).set_speed(kbs)
}
fn set_unit_base(&mut self, lba: u32) {
(**self).set_unit_base(lba)
}
}
impl SectorSource for &mut (dyn SectorSource + '_) {
@@ -160,6 +164,10 @@ impl SectorSource for &mut (dyn SectorSource + '_) {
fn set_speed(&mut self, kbs: u16) {
(**self).set_speed(kbs)
}
fn set_unit_base(&mut self, lba: u32) {
(**self).set_unit_base(lba)
}
}
/// Write 2048-byte sectors to a disc image or composed sink.
@@ -181,7 +189,7 @@ pub trait SectorSink: Send {
}
pub use crate::io::file_sector_source::FileSectorSource;
pub use decrypting::{DECRYPT_VERIFY_READ, DecryptingSectorSource, KeyFetch};
pub use decrypting::{DecryptingSectorSource, KeyFetch};
pub use file::FileSectorSink;
pub use prefetched::PrefetchedSectorSource;
@@ -198,23 +206,33 @@ mod tests {
capacity: u32,
reads: Arc<Mutex<Vec<(u32, u16, bool)>>>,
speeds: Arc<Mutex<Vec<u16>>>,
unit_bases: Arc<Mutex<Vec<u32>>>,
}
/// A `Spy` under test plus the handles recording its reads and speed sets.
type SpyHarness = (Spy, Arc<Mutex<Vec<(u32, u16, bool)>>>, Arc<Mutex<Vec<u16>>>);
/// A `Spy` under test plus the handles recording its reads, speed sets,
/// and unit-base sets.
type SpyHarness = (
Spy,
Arc<Mutex<Vec<(u32, u16, bool)>>>,
Arc<Mutex<Vec<u16>>>,
Arc<Mutex<Vec<u32>>>,
);
impl Spy {
fn new(capacity: u32) -> SpyHarness {
let reads = Arc::new(Mutex::new(Vec::new()));
let speeds = Arc::new(Mutex::new(Vec::new()));
let unit_bases = Arc::new(Mutex::new(Vec::new()));
(
Self {
capacity,
reads: reads.clone(),
speeds: speeds.clone(),
unit_bases: unit_bases.clone(),
},
reads,
speeds,
unit_bases,
)
}
}
@@ -238,6 +256,16 @@ mod tests {
fn set_speed(&mut self, kbs: u16) {
self.speeds.lock().unwrap().push(kbs);
}
fn set_unit_base(&mut self, lba: u32) {
self.unit_bases.lock().unwrap().push(lba);
}
}
/// Call `set_unit_base` through a generic `S: SectorSource` bound — this is
/// the path that actually exercises the `Box<dyn>` / `&mut dyn` FORWARDING
/// impls (a direct call on a `dyn` value dispatches via the vtable instead).
fn set_unit_base_generic<S: SectorSource>(mut s: S, base: u32) {
s.set_unit_base(base);
}
/// The default `capacity_sectors` is 0 (unknown). Grounding: trait
@@ -286,7 +314,7 @@ mod tests {
/// Box<dyn SectorSource>` forwarding bodies.
#[test]
fn boxed_dyn_forwards_all_methods() {
let (spy, reads, speeds) = Spy::new(777);
let (spy, reads, speeds, unit_bases) = Spy::new(777);
let mut boxed: Box<dyn SectorSource> = Box::new(spy);
assert_eq!(boxed.capacity_sectors(), 777, "capacity must forward");
@@ -308,24 +336,46 @@ mod tests {
vec![5400],
"set_speed must forward"
);
// set_unit_base through the generic bound exercises the forwarding impl
// (a direct `boxed.set_unit_base()` would vtable-dispatch instead). A
// missing forwarding body would silently no-op and record nothing.
set_unit_base_generic(boxed, 64);
assert_eq!(
*unit_bases.lock().unwrap(),
vec![64],
"set_unit_base must forward through Box<dyn>"
);
}
/// `&mut dyn SectorSource` must likewise forward all three methods.
/// `&mut dyn SectorSource` must likewise forward every method.
/// Grounding: `impl SectorSource for &mut (dyn SectorSource + '_)`.
#[test]
fn mut_ref_dyn_forwards_all_methods() {
let (mut spy, reads, speeds) = Spy::new(123);
let r: &mut dyn SectorSource = &mut spy;
let (mut spy, reads, speeds, unit_bases) = Spy::new(123);
assert_eq!(r.capacity_sectors(), 123);
{
let r: &mut dyn SectorSource = &mut spy;
assert_eq!(r.capacity_sectors(), 123);
let mut buf = vec![0u8; 2 * 2048];
let n = r.read_sectors(7, 2, &mut buf, false).unwrap();
assert_eq!(n, 2 * 2048);
let mut buf = vec![0u8; 2 * 2048];
let n = r.read_sectors(7, 2, &mut buf, false).unwrap();
assert_eq!(n, 2 * 2048);
r.set_speed(8800);
r.set_speed(8800);
}
// Pass `&mut dyn` as a generic S so the forwarding impl's set_unit_base
// is the one under test, not the vtable path.
let r2: &mut dyn SectorSource = &mut spy;
set_unit_base_generic(r2, 128);
assert_eq!(*reads.lock().unwrap(), vec![(7, 2, false)]);
assert_eq!(*speeds.lock().unwrap(), vec![8800]);
assert_eq!(
*unit_bases.lock().unwrap(),
vec![128],
"set_unit_base must forward through &mut dyn"
);
}
}
+8 -5
View File
@@ -252,11 +252,14 @@ impl PrefetchedSectorSource {
};
if bytes <= buf.capacity() {
// Re-expose `bytes` without zero-filling pages that
// `read_sectors` is about to overwrite. The enclosing
// capacity guard makes the `set_len` provably sound even
// if a recycled buffer ever comes back smaller than the
// `vec![0u8; batch_bytes]` it was born with.
debug_assert!(bytes <= buf.capacity(), "set_len exceeds capacity");
// `read_sectors` is about to overwrite. Sound because the
// enclosing `bytes <= capacity` guard bounds the length,
// and every byte below `capacity` is physically
// initialised: buffers are born `vec![0u8; batch_bytes]`
// and only ever grown via `resize(_, 0)`, so a recycled
// buffer that came back shorter (consumer `truncate`)
// still has initialised backing storage under `set_len`,
// which `read_sectors` then overwrites before any read.
unsafe { buf.set_len(bytes) };
} else {
buf.resize(bytes, 0);
+58 -30
View File
@@ -103,25 +103,30 @@ pub struct RecoverCtx {
pub prev_dropped: usize,
}
/// A recovery: given a read's still-scrambled `buf` and the **generic**
/// [`DecryptKeys`], make units decrypt (crack or fetch a key into `keys`) and/or
/// classify the loss (see [`MissOutcome`]). The type names NO encryption scheme
/// — the installed recovery decides what to do with the generic keys, so any
/// scheme (an AACS key-fetch, a future CSS re-crack) is just a different
/// [`Recover`] the input stream installs. `FnMut` so per-recovery
/// state (the AACS dedup set / call budget) lives in the closure's captures with
/// no lock; `Send` so it can ride the mux highway's producer thread.
pub type Recover = Box<dyn FnMut(&mut [u8], &mut DecryptKeys, &RecoverCtx) -> MissOutcome + Send>;
/// A recovery: given a read's post-decrypt `target` (pure decrypt leaves the
/// applied-key plaintext), the matching on-disc `ciphertext`, and the **generic**
/// [`DecryptKeys`], make units decrypt (fetch a key into `keys` and retry) and/or
/// classify the loss (see [`MissOutcome`]). Decryption itself lives in ONE place
/// (`decrypt_sectors`); a recovery only supplies the missing KEY and re-runs it.
/// `ciphertext` is separate from `target` because a pure decrypt overwrites the
/// target with plaintext — the key server still needs the original on-disc bytes,
/// and the retry re-decrypts from them. The type names NO encryption scheme; any
/// scheme is just a different [`Recover`] the input stream installs. `FnMut` so
/// per-recovery state (dedup set / call budget) lives in the closure's captures;
/// `Send` so it can ride the mux highway's producer thread.
pub type Recover =
Box<dyn FnMut(&mut [u8], &[u8], &mut DecryptKeys, &RecoverCtx) -> MissOutcome + Send>;
/// The AACS key-fetch step used by [`key_fetch`]: gather the
/// still-scrambled units, ask `fetch` for keys, add any new ones to the pool and
/// re-decrypt. `dry` / `calls` are the caller-owned dedup set and call budget.
/// Returns the post-retry dropped-byte count.
/// The AACS key-fetch step used by [`key_fetch`]: gather the units the pool did
/// NOT open, ask `fetch` for keys, add any new ones to the pool and re-decrypt.
/// `dry` / `calls` are the caller-owned dedup set and call budget. Returns the
/// post-retry unverified-byte count.
fn aacs_fetch_step(
dry: &mut HashSet<u64>,
calls: &mut usize,
fetch: &KeyFetch,
buf: &mut [u8],
target: &mut [u8],
ciphertext: &[u8],
keys: &mut DecryptKeys,
ctx: &RecoverCtx,
) -> usize {
@@ -130,14 +135,25 @@ fn aacs_fetch_step(
return prev_dropped;
}
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN;
// Gather up to MAX_FETCH_SAMPLES still-scrambled aligned units — the exact
// on-disc ciphertext no held key could open. A trailing partial unit
// Container of this disc's content — travels with the keys; drives the
// encrypted-flag / structure check below (TS vs PS).
let format = match &*keys {
DecryptKeys::Aacs { format, .. } => *format,
_ => crate::disc::ContentFormat::BdTs,
};
// Gather up to MAX_FETCH_SAMPLES units the current pool did NOT open. Detect
// them on the post-decrypt TARGET (a failed unit stays TS-destroyed; an opened
// one is now clean TS and is skipped), but SAMPLE the matching on-disc
// `ciphertext` — the exact bytes the key server needs. A trailing partial unit
// (chunks_exact remainder) can't be a whole scrambled unit, so skipping it is
// correct.
let mut samples: Vec<Vec<u8>> = Vec::new();
for chunk in buf.chunks_exact(unit_len) {
if crate::aacs::content::aacs_unit_needs_decrypt(chunk) {
samples.push(chunk.to_vec());
for (t, c) in target
.chunks_exact(unit_len)
.zip(ciphertext.chunks_exact(unit_len))
{
if crate::aacs::content::aacs_unit_needs_decrypt(t, format) {
samples.push(c.to_vec());
if samples.len() >= MAX_FETCH_SAMPLES {
break;
}
@@ -172,10 +188,13 @@ fn aacs_fetch_step(
dry.extend(fps);
return prev_dropped;
}
// Retry now that the pool has grown; a unit that still won't decrypt is
// genuine loss. A retry error must not mask the original count.
// Retry now that the pool has grown. Reset the target to the on-disc
// ciphertext first (a pure decrypt already overwrote it with the failed
// plaintext), then re-run the ONE decrypt. A unit that still won't reach clean
// TS stays unverified; a retry error must not mask the original count.
target.copy_from_slice(ciphertext);
redecrypt(
buf,
target,
keys,
ctx.unit_key_idx,
ctx.lba,
@@ -187,7 +206,7 @@ fn aacs_fetch_step(
/// No recovery: a miss is loss. Equivalent to installing nothing — provided so a
/// caller that wants an explicit "give up" recovery has one.
pub fn none() -> Recover {
Box::new(|_buf, _keys, ctx| MissOutcome::loss(ctx.prev_dropped))
Box::new(|_target, _ciphertext, _keys, ctx| MissOutcome::loss(ctx.prev_dropped))
}
/// AACS key-fetch recovery (BD / UHD): on a miss, ask the application's key
@@ -195,9 +214,9 @@ pub fn none() -> Recover {
pub fn key_fetch(fetch: KeyFetch) -> Recover {
let mut dry: HashSet<u64> = HashSet::new();
let mut calls: usize = 0;
Box::new(move |buf, keys, ctx| {
Box::new(move |target, ciphertext, keys, ctx| {
MissOutcome::loss(aacs_fetch_step(
&mut dry, &mut calls, &fetch, buf, keys, ctx,
&mut dry, &mut calls, &fetch, target, ciphertext, keys, ctx,
))
})
}
@@ -243,8 +262,10 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let out = r(&mut buf, &mut keys, &ctx(0, 6144));
let cipher = buf.clone();
let out = r(&mut buf, &cipher, &mut keys, &ctx(0, 6144));
assert_eq!(out.dropped, 6144);
}
@@ -265,8 +286,10 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
let cipher = buf.clone();
r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
assert_eq!(calls.load(Ordering::SeqCst), 1, "fetch called once");
let DecryptKeys::Aacs { unit_keys, .. } = &keys else {
unreachable!()
@@ -289,11 +312,14 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
let mut buf = scrambled_unit(0x44);
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
let cipher = buf.clone();
r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
let mut buf2 = scrambled_unit(0x44); // identical ciphertext
r(&mut buf2, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
let cipher2 = buf2.clone();
r(&mut buf2, &cipher2, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
assert_eq!(
calls.load(Ordering::SeqCst),
1,
@@ -313,13 +339,15 @@ mod tests {
let mut keys = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
};
// Distinct ciphertext each time so the dry-set never short-circuits; only
// the internal call budget should stop the fetch. The closure self-limits,
// so the decorator can call it unconditionally.
for i in 0..(MAX_FETCH_CALLS as u8 + 5) {
let mut buf = scrambled_unit(i);
r(&mut buf, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
let cipher = buf.clone();
r(&mut buf, &cipher, &mut keys, &ctx(0, ALIGNED_UNIT_LEN));
}
assert_eq!(
calls.load(Ordering::SeqCst),
+109 -5
View File
@@ -593,10 +593,27 @@ impl UdfFs {
match next_block {
Some(cont_lba) => {
read_sector(reader, self.meta_to_abs(cont_lba)?, &mut block)?;
// A continuation block is a list of Short ADs from byte 0,
// spanning the whole sector.
ad_start = 0;
ad_bytes = block.len();
// A continuation block does NOT begin with allocation
// descriptors — it begins with a 24-byte Allocation Extent
// Descriptor (ECMA-167 4/14.5, tag id 258): the 16-byte
// descriptor tag, then prev_allocation_extent_location
// (Uint32 @16) and length_of_allocation_descriptors
// (Uint32 @20). The real ADs (same Short/Long/Extended type
// as the file) start at offset 24, and their total byte
// length is that @20 field.
//
// Reading from offset 0 parses the AED's own tag header as
// allocation descriptors → one garbage extent, then an
// unknown extent_type break — silently truncating every
// file whose ADs spill into a continuation block (a heavily
// fragmented file, e.g. a Blu-ray 3D interleaved base-view
// .m2ts with ~1600 fragments). A normal few-extent .m2ts
// fits inline and never reaches here, which is why this
// stayed hidden.
let aed_l_ad =
u32::from_le_bytes([block[20], block[21], block[22], block[23]]) as usize;
ad_start = 24;
ad_bytes = aed_l_ad.min(block.len().saturating_sub(24));
}
None => break,
}
@@ -1535,8 +1552,15 @@ mod tests {
/// A continuation block: a bare list of short ADs from byte 0.
fn build_cont_block(ads: &[(u32, u32, u32)]) -> [u8; 2048] {
// A continuation block is an Allocation Extent Descriptor (ECMA-167
// 4/14.5): a 16-byte descriptor tag, then prev_allocation_extent_location
// (Uint32 @16) and length_of_allocation_descriptors (Uint32 @20). The
// actual allocation descriptors begin at offset 24. The parser skips the
// 24-byte header and reads `l_ad` bytes of ADs from there.
let mut s = [0u8; 2048];
let mut off = 0usize;
let l_ad = (ads.len() * 8) as u32;
s[20..24].copy_from_slice(&l_ad.to_le_bytes());
let mut off = 24usize;
for &(etype, dlen, dlba) in ads {
let raw_len = (etype << 30) | (dlen & 0x3FFF_FFFF);
s[off..off + 4].copy_from_slice(&raw_len.to_le_bytes());
@@ -1590,6 +1614,35 @@ mod tests {
assert_eq!(extents, vec![(10, 4096), (20, 2048)]);
}
#[test]
fn icb_extents_continuation_skips_aed_header_not_read_as_extent() {
// Regression (Blu-ray 3D): a continuation block begins with a 24-byte
// Allocation Extent Descriptor whose 16-byte descriptor tag holds
// NON-zero bytes (tag id 258, CRC, location, ...). Reading allocation
// descriptors from offset 0 parses that tag header as an AD → a garbage
// extent, then an unknown extent_type break → every fragment past the
// first continuation is lost. On a 3D disc that truncated the 25.8 GB
// interleaved base-view feature to ~1.8 GB (113 of ~1600 fragments) and
// then choked the mux on the bogus, non-unit-aligned extent. The parser
// MUST skip the 24-byte AED header and read the real ADs from offset 24.
let icb = build_efe(6144, &[(0, 4096, 10), (3, 2048, 50)]);
let mut cont = build_cont_block(&[(0, 2048, 20)]);
// Stamp a realistic AED descriptor tag (id 258) into the header so a
// regression that reads from offset 0 mis-parses it as a bogus extent
// instead of finding the real (20, 2048) at offset 24.
cont[0..2].copy_from_slice(&258u16.to_le_bytes());
let mut reader = MapReader::new();
reader.put(5, icb);
reader.put(50, cont);
let fs = fs_with(0, 0, file_entry("3D", 5, 6144));
let extents = fs.read_icb_extents(&mut reader, 5).expect("extents");
assert_eq!(
extents,
vec![(10, 4096), (20, 2048)],
"continuation ADs must be read past the 24-byte AED header, not from offset 0"
);
}
#[test]
fn icb_extents_long_ad_returns_all_extents_not_just_first() {
// Regression: BD-ROM large .m2ts files use Long ADs (16-byte
@@ -1737,6 +1790,57 @@ mod tests {
assert!(inf[2048..].iter().all(|&b| b == 0xBB));
}
#[test]
fn read_aacs_inputs_falls_through_to_hddvd_any_dir() {
// HD DVD keeps its AACS material under /ANY!/ (VTKF000.AACS title-key
// file + MKBROM.AACS), NOT /AACS/Unit_Key_RO.inf + /AACS/MKB_RO.inf. The
// role-based candidate lists must fall through to the /ANY!/ files with
// NO disc-type branch, so the online keyserver POST carries the HD DVD
// title-key file (magic "DVD_HD_V_TKF") as inf_b64 + MKBROM as mkb_b64 —
// the server then classifies the disc as HD DVD by that magic.
let any = DirEntry {
name: "ANY!".to_string(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![
file_entry("VTKF000.AACS", 5, 2048),
file_entry("MKBROM.AACS", 7, 2048),
],
};
let root = DirEntry {
name: String::new(),
is_dir: true,
meta_lba: 0,
size: 0,
entries: vec![any], // deliberately NO /AACS/ dir
};
let mut reader = MapReader::new();
// VTKF000.AACS: one extent whose content opens with the HD DVD magic.
let mut vtkf = [0u8; 2048];
vtkf[..12].copy_from_slice(b"DVD_HD_V_TKF");
reader.put(5, build_efe_long(2048, &[(0, 2048, 10)]));
reader.put(10, vtkf);
// MKBROM.AACS: one extent with a type-0x10 AACS-1.0 (HD DVD) version record.
let mut mkb = [0u8; 2048];
mkb[..12].copy_from_slice(&[
0x10, 0x00, 0x00, 0x0C, 0x00, 0x04, 0x10, 0x03, 0x00, 0x00, 0x00, 0x03,
]);
reader.put(7, build_efe_long(2048, &[(0, 2048, 50)]));
reader.put(50, mkb);
let fs = fs_with(0, 0, root);
let (inf, _mkb, _version) =
crate::disc::Disc::read_aacs_inputs_from_reader(&mut reader, &fs)
.expect("read_aacs_inputs must source the HD DVD /ANY!/ files");
assert_eq!(
&inf[..12],
b"DVD_HD_V_TKF",
"inf must be the HD DVD VTKF (its magic), sourced from /ANY!/ via the \
candidate fall-through — not /AACS/Unit_Key_RO.inf"
);
}
#[test]
fn merge_ranges_saturates_near_u32_max() {
// Adjacent ranges near u32::MAX must not panic (debug) or wrap.
+52 -52
View File
@@ -134,11 +134,7 @@ fn aacs_decrypt_unit_roundtrip() {
assert!(aacs::content::ts_sync_destroyed(&plain));
// Now decrypt
let result = aacs::content::decrypt_unit(&mut plain, &unit_key);
assert!(
result,
"decrypt_unit should return true on valid encrypted unit"
);
aacs::content::decrypt_unit(&mut plain, &unit_key);
assert!(
!aacs::content::ts_sync_destroyed(&plain),
"decrypted unit should read as clear (TS syncs restored)"
@@ -298,13 +294,16 @@ fn aacs_ts_sync_destroyed_detection() {
);
}
/// Test: aacs_decrypt_unit_unencrypted_passthrough
/// Test: aacs_clear_unit_reports_not_encrypted
///
/// A clear unit (TS syncs intact) should pass through decrypt_unit unchanged.
/// `decrypt_unit` is now PURE (applies the key unconditionally). The "leave a
/// clear unit untouched" policy lives at the caller's gate `aacs_unit_encrypted`:
/// a CPI-clear unit reports not-encrypted, so the caller never hands it to
/// decrypt_unit.
#[test]
fn aacs_decrypt_unit_unencrypted_passthrough() {
fn aacs_clear_unit_reports_not_encrypted() {
let mut unit = vec![0x42u8; aacs::content::ALIGNED_UNIT_LEN];
// Intact TS syncs every 192 bytes → not scrambled → passthrough.
// Intact TS syncs every 192 bytes → not scrambled.
let mut off = 4;
while off < aacs::content::ALIGNED_UNIT_LEN {
unit[off] = 0x47;
@@ -312,13 +311,12 @@ fn aacs_decrypt_unit_unencrypted_passthrough() {
}
// CPI bits (byte 0) CLEAR → the authoritative gate reads this as plaintext.
unit[0] &= 0x3F;
let original = unit.clone();
let key = [0xAA; 16];
assert!(!aacs::content::ts_sync_destroyed(&unit));
let result = aacs::content::decrypt_unit(&mut unit, &key);
assert!(result, "clear unit should return true");
assert_eq!(unit, original, "clear unit should be unchanged");
assert!(
!aacs::content::aacs_unit_encrypted(&unit, libfreemkv::disc::ContentFormat::BdTs),
"CPI-clear unit reports not-encrypted; the caller never decrypts it"
);
}
// ── AACS cross-validation with independent AES implementation ──────────────
@@ -413,11 +411,7 @@ fn aacs_cross_validation_encrypt_then_decrypt() {
);
// -- Decrypt with the library --
let ok = aacs::content::decrypt_unit(&mut plaintext, &unit_key);
assert!(
ok,
"decrypt_unit returned false (TS sync verification failed)"
);
aacs::content::decrypt_unit(&mut plaintext, &unit_key);
// Decryption clears no flag, so the unit round-trips byte-for-byte.
assert_eq!(
@@ -458,44 +452,14 @@ fn aacs_cross_validation_alternate_key() {
&mut plaintext[16..aacs::content::ALIGNED_UNIT_LEN],
);
assert!(aacs::content::decrypt_unit(&mut plaintext, &unit_key));
aacs::content::decrypt_unit(&mut plaintext, &unit_key);
// Decryption clears no flag, so the unit round-trips byte-for-byte.
assert_eq!(&plaintext[..], &expected[..]);
}
/// Verify that `decrypt_bus` correctly reverses AES-CBC encryption applied
/// per-sector to bytes 16..2048 (bus encryption layer).
#[test]
fn aacs_bus_decrypt_cross_validation() {
let read_data_key: [u8; 16] = [
0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
0x00,
];
let mut plaintext = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
#[allow(clippy::needless_range_loop)]
for i in 0..aacs::content::ALIGNED_UNIT_LEN {
plaintext[i] = ((i * 3 + 17) & 0xFF) as u8;
}
let expected = plaintext.clone();
// Encrypt per-sector: AES-CBC encrypt bytes 16..2048 of each 2048-byte sector
for sector_start in (0..aacs::content::ALIGNED_UNIT_LEN).step_by(2048) {
ref_aes_cbc_encrypt(
&read_data_key,
&CROSS_AACS_IV,
&mut plaintext[sector_start + 16..sector_start + 2048],
);
}
assert_ne!(&plaintext[16..32], &expected[16..32]);
aacs::content::decrypt_bus(&mut plaintext, &read_data_key);
assert_eq!(
plaintext, expected,
"bus decrypt did not recover original plaintext"
);
}
// (`decrypt_bus` is a crate-internal layer — its cross-validation lives in-crate
// in `aacs::content`'s unit tests, not here.)
// ── CSS roundtrip test vectors ─────────────────────────────────────────────
@@ -663,6 +627,28 @@ fn css_stevenson_attack_validates_cracked_key() {
This is expected: synthetic sectors lack the TAB1 output encoding \
present in real CSS-encrypted DVD sectors."
);
// Never let this test pass vacuously: when the attack can't converge on
// synthetic data, still assert always-true properties of the CSS keystream
// so a real regression is caught on every run — descramble_sector is
// DETERMINISTIC (same key/seed/data → same output) and NON-TRIVIAL (it
// actually transforms the payload, not a silent no-op).
for (key, seed) in candidates {
let mut base = vec![0x00u8; 2048];
base[0x14] = 0x30;
base[0x54..0x59].copy_from_slice(seed);
base[0x80..0x8A]
.copy_from_slice(&[0x00, 0x00, 0x01, 0xE0, 0x00, 0x00, 0x80, 0x80, 0x05, 0x21]);
let mut a = base.clone();
let mut b = base.clone();
css::lfsr::descramble_sector(key, &mut a);
css::lfsr::descramble_sector(key, &mut b);
assert_eq!(a, b, "descramble must be deterministic for key={key:02X?}");
assert_ne!(
&a[0x80..2048],
&base[0x80..2048],
"descramble must transform the payload for key={key:02X?}"
);
}
}
}
@@ -707,6 +693,20 @@ fn css_recover_title_key_with_exact_plaintext() {
The LFSR0 recovery phase may not converge for this combination.",
title_key, seed
);
// Never pass vacuously: when LFSR0 recovery can't converge on this
// synthetic sector, still assert always-true properties of the cipher so
// a real regression is caught on every run — descramble_sector is
// DETERMINISTIC and NON-TRIVIAL (actually transforms the payload).
let mut a = original.clone();
let mut b = original.clone();
css::lfsr::descramble_sector(&title_key, &mut a);
css::lfsr::descramble_sector(&title_key, &mut b);
assert_eq!(a, b, "descramble must be deterministic");
assert_ne!(
&a[0x80..2048],
&original[0x80..2048],
"descramble must transform the payload"
);
}
}
+28 -9
View File
@@ -27,21 +27,39 @@ fn decrypt_sectors_with_aacs_keys_works() {
let unit_key: [u8; 16] = [0xAAu8; 16];
// Encrypt the unit using AACS algorithm
aacs::content::decrypt_unit(&mut unit, &unit_key); // decrypt_unit is idempotent on already-encrypted data
// Apply the key to the pattern to produce ciphertext-shaped bytes for the
// call below. (decrypt_unit is now PURE — it applies the key unconditionally,
// so it is NOT idempotent; never call it twice on the same unit.)
aacs::content::decrypt_unit(&mut unit, &unit_key);
// (byte 0 keeps its CPI bits set from above, so `decrypt_sectors` recognises
// this as encrypted content and actually applies the key.)
// Now we have encrypted data - create DecryptKeys with actual keys
let mut keys = DecryptKeys::Aacs {
let mut aacs_keys = DecryptKeys::Aacs {
unit_keys: vec![(0u32, unit_key)],
read_data_key: None,
format: libfreemkv::disc::ContentFormat::BdTs,
};
let mut none_keys = DecryptKeys::None;
// decrypt_sectors should handle this without error
let result = libfreemkv::decrypt::decrypt_sectors(&mut unit, &mut keys, 0);
// The regression this guards is passing `DecryptKeys::None` where AACS keys
// were meant. Prove the two DIVERGE: AACS applies the key (bytes change), None
// leaves the unit byte-for-byte untouched. is_ok alone can't catch that —
// both variants return Ok.
let mut with_aacs = unit.clone();
let mut with_none = unit.clone();
libfreemkv::decrypt::decrypt_sectors(&mut with_aacs, &mut aacs_keys, 0)
.expect("AACS decrypt must not error");
libfreemkv::decrypt::decrypt_sectors(&mut with_none, &mut none_keys, 0)
.expect("None decrypt must not error");
assert!(
result.is_ok(),
"decrypt_sectors with AACS keys should not error"
assert_ne!(
with_aacs, unit,
"AACS keys must actually transform the unit"
);
assert_eq!(with_none, unit, "None keys must leave the unit untouched");
assert_ne!(
with_aacs, with_none,
"AACS decrypt must differ from the None no-op (the None-vs-Aacs regression)"
);
}
@@ -111,6 +129,7 @@ fn decrypt_keys_is_encrypted_variants() {
let aacs = DecryptKeys::Aacs {
unit_keys: vec![],
read_data_key: None,
format: libfreemkv::disc::ContentFormat::BdTs,
};
assert!(aacs.is_encrypted());
-1
View File
@@ -275,7 +275,6 @@ fn patch_block_sectors_zero_does_not_busy_spin() {
progress: None,
halt: Some(halt.clone()),
key_fetch: None,
fast_capture: false,
};
let outcome = disc.patch(&mut reader, &iso_path, &opts);
+187 -29
View File
@@ -232,8 +232,6 @@ struct Golden {
bytes_unreadable: u64,
/// `bytes_pending` (NonTrimmed) at end.
bytes_pending: u64,
/// Did the pass exit via wedge-detection?
wedged_exit: bool,
/// Sanity bound on trace length — patch makes a finite number of
/// reads bounded by `MAX_SKIPS_PER_RANGE * range_sectors` plus
/// retries. Asserted as an UPPER bound only (so any reduction in
@@ -319,7 +317,6 @@ fn profile_01_clean_all_recoverable() {
bytes_good: capacity_sectors as u64 * 2048,
bytes_unreadable: 0,
bytes_pending: 0,
wedged_exit: false,
max_reads: 8, // adaptive batch=32 reads finishes 16 sectors in 1 read; allow up to 8.
};
assert_eq!(stats.bytes_good, expected.bytes_good, "01_clean bytes_good");
@@ -796,29 +793,190 @@ fn profile_08_batch_fail_singles_ok() {
// ─────────────────────────────────────────────────────────────────────────
//
// Suppressed for now: NOT_READY-then-recover, HARDWARE_ERROR (wedge),
// ILLEGAL_REQUEST (wedge), and ABORTED_COMMAND profiles. Each would
// trigger long real-time sleeps inside `handle_read_failure`:
// Sense-family error paths in `Disc::patch`: NOT_READY-then-recover,
// HARDWARE_ERROR, ILLEGAL_REQUEST, and ABORTED_COMMAND.
//
// - NOT_READY (sense_key=0x02, asc=0x02/0x03/0x04): 15 s pause per
// occurrence (`patch_not_ready_pause`), and retries the same LBA
// in-place. Even one NOT_READY costs the test 15 s wall-time.
// These drive `disc.patch(...)` DIRECTLY rather than through `run_profile`
// (which drives `Disc::copy`, whose SWEEP path really sleeps on NOT_READY /
// wedge cooldowns via `sleep_secs_or_halt`). The patch handler chain itself
// uses an injectable deadline clock (`Instant::now` in production) and never
// `thread::sleep`s, so these paths run at full speed with no wall-time cost —
// the earlier "sleeps aren't injectable" suppression only ever applied to the
// copy/sweep driver, not to patch.
//
// - HARDWARE_ERROR / ILLEGAL_REQUEST: 30 s per occurrence
// (`WEDGE_FAMILY_COOLDOWN_SECS`), bounded by
// `WEDGE_ABORT_THRESHOLD=16` before wedged-exit. Worst case ~8
// minutes per profile.
//
// The sleeps are not injectable. Adding them would require either a
// `now()` / `sleep()` trait injection (out of scope for the unification
// task) or a "test mode" compile-time flag (architectural smell). The
// behavioural contracts for those paths are captured in
// `read_error.rs`'s in-module tests instead — they exercise the
// classifier without invoking the patch loop's sleep side-effects.
//
// If the unification ever proceeds, the next step is to add a clock
// injection point in `handle_read_failure` and extend this fixture
// with the wedge/NOT_READY profiles too.
// The load-bearing invariant asserted across every PERSISTENT failure sense is
// the recovery contract: a patch pass NEVER promotes a sector to Unreadable
// (the orchestrator does that only after the final pass) and NEVER silently
// drops bytes — a still-bad sector stays NonTrimmed (pending), so
// good + pending always conserves the total. Exact good/pending splits are
// left loose so wedge-skip tuning can't spuriously fail these.
/// Run a single-always-bad-sector (LBA 130, inside a NonTrimmed [128,192)
/// range) patch pass with the given failure step and return the final map
/// stats. 256-sector synthetic disc; everything outside the range is Finished.
fn single_dead_sector_patch_stats(step: ScriptStep) -> libfreemkv::disc::mapfile::MapStats {
let capacity_sectors: u32 = 256;
let (mut reader, _trace) = ScriptedSectorReader::new(capacity_sectors);
reader.always(130, step);
let total_bytes = capacity_sectors as u64 * SECTOR_SIZE as u64;
let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().unwrap();
let iso_path = tmp.path().to_path_buf();
drop(tmp);
let nontrimmed = [(128 * 2048, 64 * 2048)];
let finished = [
(0, 128 * 2048),
(192 * 2048, (capacity_sectors as u64 - 192) * 2048),
];
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
let opts = libfreemkv::disc::PatchOptions {
decrypt: false,
block_sectors: Some(32),
full_recovery: true,
reverse: true,
wedged_threshold: 50,
progress: None,
halt: None,
key_fetch: None,
};
disc.patch(&mut reader, &iso_path, &opts)
.expect("patch must not error on a per-sector failure sense");
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
let stats = Mapfile::load(&map_path).unwrap().stats();
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(&map_path);
stats
}
/// A persistent sense that never clears must obey the pass contract: nothing
/// Unreadable, nothing lost (good + pending == total), and at least the dead
/// sector left pending.
fn assert_persistent_sense_contract(step: ScriptStep, label: &str) {
let stats = single_dead_sector_patch_stats(step);
let total = 256u64 * 2048;
assert_eq!(
stats.bytes_unreadable, 0,
"{label}: a patch pass must NEVER mark Unreadable"
);
assert_eq!(
stats.bytes_good + stats.bytes_pending,
total,
"{label}: conservation — no byte may be silently dropped"
);
assert!(
stats.bytes_pending >= 2048,
"{label}: the always-dead sector must remain pending (NonTrimmed)"
);
}
#[test]
fn patch_persistent_hardware_error_conserves_and_never_unreadable() {
// HARDWARE_ERROR (sense_key=0x04) — wedge family.
assert_persistent_sense_contract(
ScriptStep::Err {
sense_key: 0x04,
asc: 0x11,
ascq: 0x00,
},
"HARDWARE_ERROR",
);
}
#[test]
fn patch_persistent_illegal_request_conserves_and_never_unreadable() {
// ILLEGAL_REQUEST (sense_key=0x05) — wedge family.
assert_persistent_sense_contract(
ScriptStep::Err {
sense_key: 0x05,
asc: 0x21,
ascq: 0x00,
},
"ILLEGAL_REQUEST",
);
}
#[test]
fn patch_persistent_aborted_command_conserves_and_never_unreadable() {
// ABORTED_COMMAND (sense_key=0x0B).
assert_persistent_sense_contract(
ScriptStep::Err {
sense_key: 0x0B,
asc: 0x00,
ascq: 0x00,
},
"ABORTED_COMMAND",
);
}
#[test]
fn patch_not_ready_then_recovers_fully() {
// NOT_READY (sense_key=0x02, asc=0x04) that clears after two attempts must
// recover the sector in-pass — no residual loss, no Unreadable, no hang.
let capacity_sectors: u32 = 256;
let (mut reader, _trace) = ScriptedSectorReader::new(capacity_sectors);
reader.sequence(
130,
vec![
ScriptStep::Err {
sense_key: 0x02,
asc: 0x04,
ascq: 0x00,
},
ScriptStep::Err {
sense_key: 0x02,
asc: 0x04,
ascq: 0x00,
},
ScriptStep::Ok,
],
);
let total_bytes = capacity_sectors as u64 * SECTOR_SIZE as u64;
let disc = synthetic_disc(capacity_sectors);
let tmp = tempfile::NamedTempFile::new().unwrap();
let iso_path = tmp.path().to_path_buf();
drop(tmp);
let nontrimmed = [(128 * 2048, 64 * 2048)];
let finished = [
(0, 128 * 2048),
(192 * 2048, (capacity_sectors as u64 - 192) * 2048),
];
prep_iso_and_mapfile(&iso_path, total_bytes, &finished, &nontrimmed);
let opts = libfreemkv::disc::PatchOptions {
decrypt: false,
block_sectors: Some(32),
full_recovery: true,
reverse: true,
wedged_threshold: 50,
progress: None,
halt: None,
key_fetch: None,
};
disc.patch(&mut reader, &iso_path, &opts)
.expect("patch must not error on a transient NOT_READY");
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
let stats = Mapfile::load(&map_path).unwrap().stats();
assert_eq!(
stats.bytes_unreadable, 0,
"NOT_READY recovery must not mark Unreadable"
);
assert_eq!(
stats.bytes_pending, 0,
"a NOT_READY that clears must leave nothing pending"
);
assert_eq!(
stats.bytes_good,
capacity_sectors as u64 * 2048,
"every sector recovers once NOT_READY clears"
);
let _ = std::fs::remove_file(&iso_path);
let _ = std::fs::remove_file(&map_path);
}
// ──────── Handler chain recovers re-readable sectors inside a bad block ────────
//
@@ -826,9 +984,10 @@ fn profile_08_batch_fail_singles_ok() {
// handler chain's linear pass narrows a failed batch to per-sector reads, so it
// recovers EVERY re-readable sector and leaves ONLY the dead sector NonTrimmed —
// strictly better than the old fast-capture path, which left the whole failed
// 32-block untouched. (`fast_capture` is now inert: the chain supersedes it. The
// breadth-first "fast on all ranges, then escalate" ORDERING it once provided is
// a scheduling concern for the handler scheduler, tracked separately.)
// 32-block untouched. (The old `fast_capture` knob was removed: the handler
// chain supersedes it. The breadth-first "fast on all ranges, then escalate"
// ORDERING it once provided is a scheduling concern for the handler scheduler,
// tracked separately.)
//
// The load-bearing invariant is unchanged: NO data is dropped. A still-bad
// sector becomes NonTrimmed (pending, retried by a later pass), NEVER Unreadable.
@@ -869,10 +1028,9 @@ fn handler_chain_recovers_readable_sectors_leaving_only_dead_pending() {
progress: None,
halt: None,
key_fetch: None,
fast_capture: true,
};
disc.patch(&mut reader, &iso_path, &opts)
.expect("fast-capture patch must not error");
.expect("handler-chain patch must not error");
let map_path = libfreemkv::disc::mapfile_path_for(&iso_path);
let stats = Mapfile::load(&map_path).unwrap().stats();