Compare commits

...
32 Commits
Author SHA1 Message Date
Matthew Jackson cf16f66e07 v1.4.3: bump version (freemkv-unlock git-pinned for the tag) 2026-07-16 21:06:01 -07:00
Matthew Jackson adcab75409 aacs: sample only index-1 forensic segments for the online key query; hoist MIN_SAMPLE_UNITS into the base crate 2026-07-16 21:01:16 -07:00
Matthew Jackson 61e4095727 FMTS: resolve the index key map from one forensic keyserver query
The keyserver protocol now returns all 32 index keys as an array for a
forensic content sample (and a single-element array for plain content).
resolve_fmts_key_map sends one forensic batch and maps array element i to
segment index i+1, replacing the per-index blind-probe collection loop
that repeatedly hit the key service. Segment/index parsing and the
aligned-unit content classification are reworked to support this:

- rename variant_select -> index_select (per-index, not per-variant)
- content classification moves to is_clean(buf, ContentFormat) so the
  unit selector emits only units the key service accepts
- segment.rs: parse IndividualSegment.tbl index tags + SPN ranges,
  build contiguous LBA key ranges from the resolved 32-key array
- decrypt/decorator plumbing for the resolved per-index keys

Fail loud (FmtsKeyMissing) when the forensic query returns < 32 keys or
any segment index stays unresolved.
2026-07-16 19:41:44 -07:00
Matthew Jackson 540f84c7d0 restore freemkv-unlock path dep for local dev (post-v1.4.2) 2026-07-15 19:37:54 -07:00
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
Matthew Jackson 564e66fd24 v1.3.2: bump version (freemkv-unlock git-pinned for the tag) 2026-07-10 14:26:08 -07:00
Matthew Jackson 57d40e81a5 1.3.2: AACS 2.1 FMTS variant-decode foundation
Add UnitKey.variant_number (0 = ordinary, 1..32 = forensic variant) with new/variant constructors, and aacs::variant_select — resolve a disc's single variant and classify each aligned unit (default / variant / drop foreign / conceal keyless). Correct IndividualSegment.tbl: the per-record field is the variant (cycles 1..32 on a retail disc), not a segment number — Segment.number -> Segment.variant.
2026-07-10 14:00:28 -07:00
Matthew Jackson 674819d937 changelog: trim the 1.3.1 relicense note to just the relicense fact 2026-07-10 12:53:17 -07:00
Matthew Jackson c5e8421f9f restore freemkv-unlock path dep for local dev (post-v1.3.1) 2026-07-10 12:35:12 -07:00
Matthew Jackson 7dcf9b5a0d v1.3.1: bump version (freemkv-unlock git-pinned for the tag) 2026-07-10 12:35:09 -07:00
Matthew Jackson f01c097784 1.3.1: relicense to MIT (clean-room CSS + drop copyleft-lib references)
Relicensed from AGPL-3.0 to MIT, effective 1.3.1 (<=1.3.0 remain AGPL). The CSS
content cipher and Stevenson title-key attack are attributed to their published
cryptanalysis (not libdvdcss); all libaacs/libbluray/libdvdread/libdvdnav name
references were dropped from comments while keeping the standard format/spec
descriptions. Also bumps to 1.3.1.
2026-07-10 12:31:19 -07:00
Matthew Jackson c265fa349e hddvd: authoritative title composition from the Advanced-Content playlist
Parse ADV_OBJ/VPLST000.XPL (the real HD-DVD player playlist) with roxmltree
into one DiscTitle per <Title>: its PrimaryAudioVideoClip clips in order (EVO
via the .MAP sidecar), the titleDuration, the displayName, and the ChapterList.
A layer-break split (FEATURE_1+FEATURE_2, feature/feature_Divide) composes into
ONE title with the two parts as clips, each carrying its title-time in/out
points (45kHz ticks) for seamless-join splicing. Falls back to the clip-name
heuristic when no playlist is present.

Fixes unknown runtimes (real durations), poor names (FEATURE -> 'Main Movie'),
and gives authoritative composition + chapters. Validated on ANCHORMAN (97m),
SHAUN (99m), HARRY_POTTER (152m).
2026-07-10 10:28:18 -07:00
Matthew Jackson 6a59dcb7ba ci: drop the dead crates.io publish job (libfreemkv is git-tag-only)
libfreemkv is publish=false (it git-deps the firmware crate freemkv-unlock,
never on crates.io), so the Release workflow's 'cargo publish' step failed
hard on every tag. Consumers git-tag-pin libfreemkv; the git tag is the
release artifact. Removed the publish job.
2026-07-10 08:52:57 -07:00
Matthew Jackson 0eb1817ec7 restore freemkv-unlock path dep for local dev (post-v1.3.0) 2026-07-10 08:43:47 -07:00
58 changed files with 5276 additions and 3750 deletions
+5 -23
View File
@@ -39,29 +39,11 @@ jobs:
# would always fail (no lockfile to lock against on a fresh runner).
- run: cargo test
# crates.io publish is an INDEPENDENT job: it serves EXTERNAL consumers only.
# The freemkv binaries no longer depend on it (they git-tag-pin libfreemkv via
# a committed [patch.crates-io]), so this publish runs in parallel with their
# release builds rather than gating them. It `needs: [verify, test]` so a
# failing test suite still blocks publication to crates.io — external
# consumers who `cargo add libfreemkv` must never receive a release whose
# tests were failing. (The two upstream jobs run in parallel, so this gate
# does not serialize publish behind test beyond their own completion.)
publish:
needs: [verify, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.86.0
- uses: Swatinem/rust-cache@v2
# --no-verify: CI already compiled this exact commit (in the `test` job
# and on every push via ci.yml). cargo publish's default re-verify does a
# full cold release build of the packaged tarball, which here is pure
# redundant work (~a cold lib build). Skip it.
- name: Publish to crates.io
run: cargo publish --no-verify
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
# NOTE: there is no crates.io publish job. libfreemkv is git-tag-only
# (`package.publish = false` — it git-deps the firmware crate freemkv-unlock,
# which never ships to crates.io). Every consumer git-tag-pins libfreemkv via
# a committed [patch.crates-io]; the git tag itself IS the release artifact.
# A `cargo publish` here fails hard on `publish = false`, so it was removed.
release:
# Only needs `verify`; the GitHub Release can be cut as soon as the version
+125 -5
View File
@@ -1,5 +1,125 @@
# 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
- **AACS 2.1 (FMTS) variant-decode foundation.** `UnitKey` gains a
`variant_number` field (`0` = ordinary content, `1..=32` = a forensic
variant) with `UnitKey::new` / `UnitKey::variant` constructors, and a new
`aacs::variant_select` module resolves a disc's single forensic variant and
classifies each aligned unit — decrypt with the default key, decrypt with the
variant key, drop a foreign variant, or conceal a keyless forensic unit. This
is the groundwork for selecting one variant's segments and dropping the other
31; the decrypt-pipeline wiring lands with the variant key source.
### Fixed
- **`IndividualSegment.tbl`: the per-record field is the variant, not a segment
number.** `Segment.number` → `Segment.variant`. Verified against a retail 2.1
disc, the field cycles `1..=32` across the table (a per-variant tag) rather
than counting up, so variant selection routes on the correct value.
## [1.3.1] — 2026-07-10
### Licensing
- **Relicensed to the MIT License, from 1.3.1 onwards** (releases up to and
including 1.3.0 remain under AGPL-3.0).
### Added
- **Authoritative HD-DVD title composition** from the Advanced-Content playlist
(`ADV_OBJ/VPLST000.XPL`): each title's clips, real duration, display name, and
chapters come from the disc's own playlist instead of a clip-name heuristic. A
layer-break split (`FEATURE_1`+`FEATURE_2`, `feature`/`feature_Divide`) composes
into ONE title with the two parts as clips and their title-time offsets. Falls
back to the clip-name heuristic when no playlist is present.
## [1.3.0] — 2026-07-08
### Added
@@ -122,7 +242,7 @@
- **Processing-Key resolution is ~15× faster on UHD.** A Processing Key is the
key at its subset-difference node (one AES-G from the Media Key), so it is now
tried directly against the MKB cvalue tables (matching libaacs `_calc_mk_pks`)
tried directly against the MKB cvalue tables (direct PK × cvalue iteration)
instead of BFS-walking the SD tree at unknown depth — which was both wrong for
terminal PKs and slow on a large UHD MKB (~181k cvalues). PK derivation on UHD
drops from ~37 s to ~2.4 s; the SD tree walk now lives solely in the device-key
@@ -355,7 +475,7 @@ consumers are the in-tree toolchain crates.
- **Post-read decrypt-verify gate.** Every AACS unit read off the disc is now
buffered, re-aligned to its clip-file 6144-byte unit grid, and verified
(CPI flag → decrypt → strict all-32 TS-sync, matching libaacs `_verify_ts`)
(CPI flag → decrypt → strict all-32 TS-sync)
before it is signed off as good. A unit that no held or freshly-fetched key
decrypts is treated exactly like a bad read — re-read by
the patch pass, terminal loss only if truly unrecoverable — closing the
@@ -376,7 +496,7 @@ consumers are the in-tree toolchain crates.
- **AACS decrypt acceptance is now standards-strict.** A key is accepted only
when the decrypted unit has the TS sync byte on *all* 32 source packets
(libaacs `_verify_ts`), replacing a majority-vote heuristic where a wrong key
(all-32 TS-sync verify), replacing a majority-vote heuristic where a wrong key
could coincidentally restore enough syncs to pass and silently corrupt a unit.
- keydb download/save moved out of the library into freemkv-keysources;
libfreemkv no longer has any keydb I/O (it already held no keys).
@@ -384,7 +504,7 @@ consumers are the in-tree toolchain crates.
### Fixed
- **AACS content-certificate bus-encryption flag read from the wrong bit.** The
flag is bit 7 of byte 1 (libaacs `p[1] >> 7`) but was read as bit 0, so a
flag is bit 7 of byte 1 (`p[1] >> 7`) but was read as bit 0, so a
bus-encrypted disc parsed as *not* bus-encrypted — defeating the fail-loud
guard that refuses to decrypt bus-wrapped data to garbage when no bus key was
obtained. Also corrected the cc_id offset (byte 14) and the AACS2 type marker
@@ -684,7 +804,7 @@ hardening.
- **Keyless DVD/CSS title-key recovery.** A CSS-protected DVD decrypts with no
key database — the title key is recovered directly from the scrambled disc
data via the Stevenson known-plaintext attack (ported from libdvdcss) and
data via the Stevenson known-plaintext attack and
validated by descrambling a sector and confirming the known plaintext
reappears, so a wrong key fails cleanly instead of producing silent garbage
(`src/css/stevenson.rs`). `Disc::scan_image` recovers the same title key from
+1 -1
View File
@@ -24,4 +24,4 @@ cargo test
## License
By contributing, you agree your code will be licensed under AGPL-3.0.
By contributing, you agree your code will be licensed under MIT.
+9 -3
View File
@@ -1,9 +1,9 @@
[package]
name = "libfreemkv"
version = "1.3.0"
version = "1.4.3"
edition = "2024"
rust-version = "1.86"
license = "AGPL-3.0-only"
license = "MIT"
description = "Open source raw disc access library for optical drives"
repository = "https://github.com/freemkv/libfreemkv"
keywords = ["bluray", "uhd", "optical", "scsi", "disc"]
@@ -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.0" }
freemkv-unlock = { git = "https://github.com/freemkv/freemkv-unlock", tag = "v1.4.3" }
num-bigint = "0.4"
num-traits = "0.2"
num-integer = "0.1"
@@ -36,6 +36,12 @@ rand = "0.8"
cmac = "0.7"
zip = { version = "2", default-features = false, features = ["deflate"] }
base64 = "0.22.1"
# Read-only XML DOM parser (pure Rust, forbid(unsafe_code), entity-expansion
# bounded). Parses the HD-DVD Advanced-Content playlist `ADV_OBJ/VPLST000.XPL`
# — untrusted disc bytes — into authoritative titles/clips/chapters. A real
# parser, not a hand-rolled scanner: the XPL is genuine XML (comments, varied
# attribute order, self-closing tags).
roxmltree = "0.20"
# Trace-level instrumentation for Disc::copy + SgIoTransport::execute. Permitted
# under CLAUDE.md ("Acceptable strings: debug/trace logging"). Consumers (autorip)
# wire a tracing subscriber and pipe events into the JSONL debug log.
+17 -12
View File
@@ -1,16 +1,21 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
MIT License
Copyright (C) 2026 FreeMKV Contributors
Copyright (c) 2026 Matthew Jackson & Contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, version 3 of the License.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2 -2
View File
@@ -1,4 +1,4 @@
[![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-blue)](LICENSE)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
# libfreemkv
@@ -190,4 +190,4 @@ Run `freemkv info disc:// --share` with the [freemkv CLI](https://github.com/fre
## License
AGPL-3.0-only
MIT
+1 -1
View File
@@ -7,7 +7,7 @@ AACS decryption requires an external `keydb.cfg` (default
material is compiled in; DVD CSS player keys are the only compiled-in keys.
**Repository:** <https://github.com/freemkv/libfreemkv>
**License:** AGPL-3.0-only
**License:** MIT
---
+603 -726
View File
File diff suppressed because it is too large Load Diff
+7 -9
View File
@@ -10,8 +10,8 @@ use super::types::*;
///
/// A Processing Key is **terminal**: it is the key at its Subset-Difference
/// node, one `AES-G` from the Media Key. So this is the fast path — each PK is
/// tried *directly* against the MKB cvalue tables (no tree descent), matching
/// libaacs `_calc_mk_pks` (iterate PKs × cvalues). On a large AACS 2.x UHD MKB
/// tried *directly* against the MKB cvalue tables (no tree descent) — the
/// direct PK × cvalue iteration. On a large AACS 2.x UHD MKB
/// (~181k cvalues) this is ~15x faster than treating a PK as a device-node
/// label and walking the tree.
///
@@ -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;
}
@@ -612,10 +613,7 @@ mod resolve_candidate_tests {
/// A bare UK candidate is terminal — it returns itself keyed by its own idx.
#[test]
fn resolve_candidate_uk_is_itself() {
let uk = UnitKey {
idx: 2,
key: [0x9u8; 16],
};
let uk = UnitKey::new(2, [0x9u8; 16]);
let r = resolve_candidate(&KeyCandidate::Uk(uk), &[], &[], None).expect("uk is terminal");
assert_eq!(r.unit_keys, vec![(2, uk.key)]);
assert!(r.vuk.is_none() && r.mk.is_none());
+178
View File
@@ -0,0 +1,178 @@
//! FMTS index selection — the pure decode-time decision for a 2.1 disc.
//!
//! A 2.1 disc resolves to exactly one forensic index (1..=32) for a given
//! rip. `IndividualSegment.tbl` tags each forensic segment with an index (see
//! [`super::segment`]); the decode keeps the segments matching our index,
//! drops the other 31, and treats everything outside a segment as ordinary
//! (index-0) content. This module owns that classification and nothing else —
//! no I/O, no keys, no cipher — so it is fully testable in isolation. The
//! decrypt pipeline consumes the [`UnitDisposition`] it returns.
//!
//! Where the resolved index comes from is a separate concern
//! ([`resolve_disc_index`]): today it is read off the index keys the key
//! source handed us; when Processing Keys are available it will come from the
//! VK derivation instead. Either way the disposition logic below is identical.
use super::segment::{Segment, segment_for_unit};
use super::types::UnitKey;
/// What the decode should do with one AACS aligned unit.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum UnitDisposition {
/// Outside every forensic segment: ordinary content, decrypt with the
/// default (index-0) unit key.
Default,
/// Inside a forensic segment tagged with OUR resolved index: decrypt with
/// that index's key.
Index(u8),
/// Inside a forensic segment tagged with a DIFFERENT index: not our
/// watermark, so it is not part of our output — drop it.
DropForeignIndex(u8),
/// Inside a forensic segment but no index key is held (the disc's index
/// was never resolved): the segment cannot be decoded, so it is concealed
/// as loss. Carries the segment's index for diagnostics.
ForensicNoKey(u8),
}
/// Resolve the disc's single forensic index from the keys we hold.
///
/// Scans for an index key (`index_number` in `1..=32`) and returns its
/// index. `None` when only default (index-0) keys are held — i.e. no
/// index source answered, so forensic segments are not decodable. A disc has
/// exactly one index, so the first non-zero key decides; if several distinct
/// index keys were somehow supplied the lowest wins (deterministic), which is
/// only a defensive tiebreak — the probe/derivation yields one.
pub fn resolve_disc_index(unit_keys: &[UnitKey]) -> Option<u8> {
unit_keys
.iter()
.map(|k| k.index_number)
.filter(|&v| v != 0)
.min()
}
/// Classify the AACS aligned unit at `unit_offset` (clip-relative bytes) given
/// the forensic segment map and the disc's resolved index (`None` if no
/// index key is held).
pub fn unit_disposition(
unit_offset: u64,
segments: &[Segment],
disc_index: Option<u8>,
) -> UnitDisposition {
match segment_for_unit(segments, unit_offset) {
// Not in any forensic segment → ordinary content.
None => UnitDisposition::Default,
// In a forensic segment → decide by whether it is our index.
Some(seg) => {
let seg_index = seg.index as u8;
match disc_index {
Some(v) if v == seg_index => UnitDisposition::Index(v),
Some(_) => UnitDisposition::DropForeignIndex(seg_index),
None => UnitDisposition::ForensicNoKey(seg_index),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aacs::content::ALIGNED_UNIT_LEN;
use crate::aacs::segment::{SOURCE_PACKET_LEN, parse_individual_segments};
/// Build a one-record segment table (index, start_spn, end_spn).
fn tbl(recs: &[(u16, u32, u32)]) -> Vec<Segment> {
let mut v = Vec::new();
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
v.extend_from_slice(&(recs.len() as u16).to_be_bytes());
v.extend_from_slice(&16u16.to_be_bytes());
for &(n, s, e) in recs {
v.extend_from_slice(&0x0100_0000u32.to_be_bytes());
v.extend_from_slice(&n.to_be_bytes());
v.extend_from_slice(&1u16.to_be_bytes());
v.extend_from_slice(&s.to_be_bytes());
v.extend_from_slice(&e.to_be_bytes());
}
parse_individual_segments(&v).expect("parse")
}
fn uk(idx: u32, index: u8) -> UnitKey {
if index == 0 {
UnitKey::new(idx, [0u8; 16])
} else {
UnitKey::forensic(idx, [index; 16], index)
}
}
#[test]
fn resolve_picks_the_single_index_key() {
// Default keys only → no index resolved.
assert_eq!(resolve_disc_index(&[uk(0, 0)]), None);
assert_eq!(resolve_disc_index(&[]), None);
// One index key among defaults → that index.
assert_eq!(resolve_disc_index(&[uk(0, 0), uk(1, 7)]), Some(7));
// Defensive: lowest of several distinct indexes (deterministic).
assert_eq!(resolve_disc_index(&[uk(0, 9), uk(1, 3)]), Some(3));
}
#[test]
fn unit_outside_segments_is_default() {
let segs = tbl(&[(1, 343680, 346239)]);
let off = 1000u64 * SOURCE_PACKET_LEN; // well before the segment
assert_eq!(
unit_disposition(off, &segs, Some(1)),
UnitDisposition::Default
);
// With no segments at all (1.0 / 2.0), everything is Default.
assert_eq!(
unit_disposition(off, &[], Some(1)),
UnitDisposition::Default
);
}
#[test]
fn unit_in_our_index_decrypts() {
let segs = tbl(&[(7, 100, 200)]);
let off = 120u64 * SOURCE_PACKET_LEN;
assert_eq!(
unit_disposition(off, &segs, Some(7)),
UnitDisposition::Index(7)
);
}
#[test]
fn unit_in_foreign_index_drops() {
// Segment tagged index 7, but our disc index is 3 → drop it.
let segs = tbl(&[(7, 100, 200)]);
let off = 120u64 * SOURCE_PACKET_LEN;
assert_eq!(
unit_disposition(off, &segs, Some(3)),
UnitDisposition::DropForeignIndex(7)
);
}
#[test]
fn forensic_unit_with_no_key_is_concealed() {
// A forensic segment but we never resolved an index → conceal as loss.
let segs = tbl(&[(7, 100, 200)]);
let off = 120u64 * SOURCE_PACKET_LEN;
assert_eq!(
unit_disposition(off, &segs, None),
UnitDisposition::ForensicNoKey(7)
);
}
#[test]
fn straddling_unit_still_classified_as_its_segment() {
// A unit whose 32-packet span only tails into the segment still routes
// to the segment (matches segment_for_unit's span test).
let segs = tbl(&[(5, 100, 200)]);
let unit_packets = (ALIGNED_UNIT_LEN as u64 / SOURCE_PACKET_LEN) as u32; // 32
// Start so the unit covers [80, 80+31] = [80, 111]: overlaps at 100.
let off = 80u64 * SOURCE_PACKET_LEN;
assert!(80 + unit_packets - 1 >= 100, "sanity: unit tails into seg");
assert_eq!(
unit_disposition(off, &segs, Some(5)),
UnitDisposition::Index(5)
);
}
}
+176 -4
View File
@@ -121,7 +121,7 @@ pub fn parse_unit_key_ro(data: &[u8], version: AacsVersion) -> Option<UnitKeyFil
return None;
}
// Title → CPS unit mapping. libaacs (unit_key.c) validates each on-disc CPS
// Title → CPS unit mapping (AACS Unit_Key_RO format): each on-disc CPS
// value is in `1..=num_uk` (else zeroes it) and converts the 1-based on-disc
// index to a 0-based key index. We mirror that so the stored value is a safe,
// ready-to-use key index rather than a raw 1-based number.
@@ -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;
@@ -261,10 +342,10 @@ pub fn parse_content_cert(data: &[u8]) -> Option<ContentCert> {
return None;
}
// Content Certificate layout (matches libaacs content_cert.c):
// Content Certificate layout (per the AACS content-cert format):
// [0] certificate type (0x00 = AACS1, 0x10 = AACS2)
// [1] bit7 bus_encryption_enabled_flag (libaacs: `p[1] >> 7`)
// [14..20] cc_id (6 bytes) (libaacs: `p + 14`)
// [1] bit7 bus_encryption_enabled_flag (`p[1] >> 7`)
// [14..20] cc_id (6 bytes) (`p + 14`)
let version = if data[0] == 0x00 {
AacsVersion::V10
} else {
@@ -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));
}
}
+5 -6
View File
@@ -157,7 +157,7 @@ impl AacsVersion {
}
/// Find Verify Media Key Record (type 0x81 for AACS 1.0, 0x86 for AACS 2.0/2.1) in MKB.
/// 0x81: [C] §3.2.5.1.4. 0x86 (AACS 2.x): [libaacs] `mkb.c` — not in the public spec.
/// 0x81: [C] §3.2.5.1.4. 0x86 (AACS 2.x): [RE] — not in the public spec (from real 2.x MKBs).
pub(crate) fn mkb_find_mk_dv(mkb: &[u8]) -> Option<[u8; 16]> {
// Verify-Media-Key record (0x81 for AACS 1.0, 0x86 for AACS 2.x): mk_dv is
// the 16 bytes at record offset 4 (body offset 0). Needs rec_len >= 20.
@@ -198,9 +198,8 @@ pub(crate) fn mkb_find_subdiff_records(mkb: &[u8]) -> Option<Vec<u8>> {
///
/// The cvalue table is record type `0x05` (Media Key Data) on BOTH AACS
/// 1.0 and AACS 2.x MKBs — its 16-byte cvalue entries are 1:1 with the
/// 5-byte Subset-Difference index entries in record `0x04`. This matches
/// libaacs, whose `mkb_cvalues()` reads `0x05` and `mkb_subdiff_records()`
/// reads `0x04`.
/// 5-byte Subset-Difference index entries in record `0x04` — the standard AACS
/// MKB layout (`0x05` cvalues 1:1 with the `0x04` subset-difference index).
///
/// On AACS 2.x in-drive UHD MKBs the `0x05` table is large (the full
/// subset-difference cvalue set: ~181k entries on a retail MKB, 1:1 with
@@ -281,10 +280,10 @@ pub const MKB_TYPE_4_PRERECORDED: u32 = 0x0004_1003;
/// `0x000A1003` — Class II / Unified MKB (Sequence-Key-Block functionality).
pub const MKB_TYPE_10_CLASS_II: u32 = 0x000A_1003;
/// `0x48141003` — AACS 2.0 Category C (UHD content). libaacs `MKB_20_CATEGORY_C`.
/// `0x48141003` — AACS 2.0 Category C (UHD content) MKB type value.
pub const MKB_20_CATEGORY_C: u32 = 0x4814_1003;
/// `0x48151003` — AACS 2.1 Category C (UHD content). libaacs `MKB_21_CATEGORY_C`.
/// `0x48151003` — AACS 2.1 Category C (UHD content) MKB type value.
pub const MKB_21_CATEGORY_C: u32 = 0x4815_1003;
/// The AACS MKB Type field, decoded.
+62 -13
View File
@@ -22,13 +22,14 @@
//! - `[C]` — AACS Introduction and Common Cryptographic Elements Book (primitives, MKB/key-management).
//! - `[PR]` — AACS Pre-recorded Video Book (Volume/Title Key layer).
//! - `[BD]` — AACS Blu-ray Disc Pre-recorded Book (CPS Unit Key, Aligned Unit, Block Key).
//! - `[libaacs]` — the libaacs reference implementation, cited only where the spec
//! is silent (the `0x86` verify record and the Category-C MKBType names).
//! - `[RE]` — reverse-engineered from real discs, cited only where the public
//! spec is silent (the `0x86` verify record and the Category-C MKB type values).
pub mod content;
pub mod crypto;
pub mod derive;
pub mod host_certs;
pub mod index_select;
pub mod inf;
pub mod mkb;
pub mod provider;
@@ -39,10 +40,18 @@ pub mod trace;
pub mod types;
pub mod variant;
/// 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";
@@ -50,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`,
@@ -60,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};
@@ -70,7 +119,7 @@ mod tests {
//! Touching one representative item per module keeps these as a
//! compile-time contract that the module paths stay stable.
use super::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
use super::content::ALIGNED_UNIT_LEN;
use super::inf::{disc_hash, disc_hash_hex};
use super::mkb::{AacsVersion, mkb_content_len, walk_mkb};
use super::variant::is_variant_mkb;
@@ -98,15 +147,15 @@ mod tests {
fn public_helpers_are_callable_by_module_path() {
// Touch a representative function from each module so a dropped/renamed
// item fails to compile. Smoke calls, not behavioural assertions.
let _ = ts_sync_destroyed(&[0u8; ALIGNED_UNIT_LEN]);
let _ = !crate::aacs::content::is_clean(
&[0u8; ALIGNED_UNIT_LEN],
crate::disc::ContentFormat::BdTs,
);
let _ = mkb_content_len(&[]);
let _ = is_variant_mkb(&walk_mkb(&[]));
let _ = disc_hash_hex(&disc_hash(b"x"));
let _ = super::derive::resolve_candidate(
&super::derive::KeyCandidate::Uk(super::types::UnitKey {
idx: 0,
key: [0u8; 16],
}),
&super::derive::KeyCandidate::Uk(super::types::UnitKey::new(0, [0u8; 16])),
&[],
&[],
None,
+12 -11
View File
@@ -8,7 +8,7 @@ use super::mkb::*;
//
// Canonical form is `<category>1003` (low 16 bits `0x1003` is a fixed marker).
// Types 3/4/10 are from the AACS Common Cryptographic Elements spec (0.953,
// §3.2.5.1.1); the Category-C 2.0/2.1 values match libaacs `mkb.h` constants.
// §3.2.5.1.1); the Category-C 2.0/2.1 values are the standard MKB type constants.
// ── Full VUK resolution chain ───────────────────────────────────────────────
@@ -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];
@@ -790,7 +791,7 @@ mod tests {
#[test]
fn validate_processing_key_round_trip_with_nonzero_uv() {
// Synthesise a (pk, uv, mk, cvalue, mk_dv) tuple that satisfies the
// libaacs _validate_pk relation, then confirm validate_processing_key
// AACS PK-validation relation, then confirm validate_processing_key
// recovers mk. Catches the bugs that landed pre-fix:
// * uv XOR step was missing → mk wrong whenever uv != 0
// * AES-128E + 12-zero check instead of AES-128D + magic
@@ -844,8 +845,8 @@ mod tests {
// ── MKB cvalue-record selection (issue #259 / #281) ─────────────────
//
// The cvalue (Media Key Data) table is record 0x05; the
// Subset-Difference index is record 0x04. This matches libaacs
// (`mkb_cvalues` → 0x05, `mkb_subdiff_records` → 0x04). Record 0x07
// Subset-Difference index is record 0x04 (the standard AACS MKB layout:
// 0x05 = cvalues, 0x04 = subset-difference index). Record 0x07
// (Explicit Subset-Difference Record) is NOT the cvalue table. On real
// in-drive AACS 2.x UHD MKBs 0x07 is small (~96 entries) while the 0x05
// table is large (181270 entries, 1:1 with 0x04). An earlier
@@ -1264,7 +1265,7 @@ mod tests {
}
#[test]
fn test_content_cert_parse() {
// AACS 1.0 cert, bus encryption OFF. Layout matches libaacs: flag in
// AACS 1.0 cert, bus encryption OFF. Content-cert layout: flag in
// BIT 7 of byte 1, cc_id at bytes 14..20.
let mut data = vec![0u8; 20];
data[0] = 0x00; // AACS 1.0
@@ -1460,7 +1461,7 @@ mod tests {
// [20..22] first_play, [22..24] top_menu, [24..26] num_titles, then
// per-title 2-byte pad + 2-byte CPS unit at 26 + i*4 + 2. Each on-disc
// 1-based CPS number in `1..=num_uk` is validated and converted to a
// 0-based key index (libaacs unit_key.c); an out-of-range number → 0.
// 0-based key index (per the AACS Unit_Key_RO format); an out-of-range number → 0.
let mut data = build_unit_key_ro(4, 64); // num_uk = 4 → CPS 1..=4 valid
data[20..22].copy_from_slice(&1u16.to_be_bytes()); // first_play CPS 1
data[22..24].copy_from_slice(&2u16.to_be_bytes()); // top_menu CPS 2
@@ -1580,7 +1581,7 @@ mod tests {
}
#[test]
fn parse_content_cert_extracts_cc_id_and_nonzero_type_is_v20() {
// libaacs layout: [0]=type, [1] bit7=bus-enc, [14..20]=cc_id. Any
// Content-cert layout: [0]=type, [1] bit7=bus-enc, [14..20]=cc_id. Any
// non-0x00 type → V20.
let mut data = vec![0u8; 20];
data[0] = 0x10; // AACS2 type marker → V20
@@ -1593,7 +1594,7 @@ mod tests {
}
#[test]
fn parse_content_cert_bus_encryption_reads_bit7() {
// bus_encryption = (data[1] >> 7) & 1 (libaacs). Low bits set with bit7
// bus_encryption = (data[1] >> 7) & 1. Low bits set with bit7
// clear → false; bit7 set → true. Pins the bit, not a truthiness of the byte.
let mut data = vec![0u8; 20];
data[1] = 0x7F; // bits 0..6 set, bit 7 clear
+210 -45
View File
@@ -1,37 +1,49 @@
//! AACS 2.1 FMTS forensic segment map — `AACS/IndividualSegment.tbl`.
//!
//! An FMTS main feature interleaves N "variant" segments — the sequence-key /
//! forensic-watermark mechanism. The same frames are authored as several
//! slightly different variants; each variant is encrypted under its own SEGMENT
//! key (from `SegmentKeyNNNNN.tbl`), NOT the CPS Unit Key. A player with the
//! right device keys can decrypt exactly one variant per segment, and which one
//! silently identifies the player (traitor tracing). Decrypting a variant
//! segment with the Unit Key yields garbage — broken HEVC reference frames
//! (empirically: `Could not find ref with POC …` on a plain unit-key rip).
//! An FMTS main feature interleaves short forensic **segments** — the sequence-key
//! / forensic-watermark mechanism. Each segment carries an **index** (1..32): a
//! tag in `IndividualSegment.tbl` that selects which of the 32 forensic **index
//! keys** decrypts that segment's units, in place of the ordinary CPS Unit Key.
//!
//! This table says WHERE the variant segments live so a decoder can decrypt
//! them with segment keys and select one coherent variant instead of muxing
//! Terminology (see the project AACS reference): the **index** here is NOT the
//! AACS 2.1 *Media Key Variant* — that is the 65536-value device selector in the
//! MKB that decides *which set* of index keys a device receives, a layer this
//! module does not deal with. All the index keys belong to one variant, whose
//! number is unknown and irrelevant to the segment map. Decrypting a segment with
//! the Unit Key yields garbage — broken HEVC reference frames (empirically:
//! `Could not find ref with POC …` on a plain unit-key rip).
//!
//! This table says WHERE the segments live and which index each carries, so a
//! decoder can decrypt them with the matching index key instead of muxing
//! unit-key garbage.
//!
//! Format (validated against a retail AACS 2.1 disc):
//! ```text
//! header (8 bytes): u32 type | u16 count | u16 record_size (= 16)
//! record[count] (16 bytes each):
//! u32 marker (= 0x01000000) | u16 segment_number | u16 flag (= 1)
//! u32 marker (= 0x01000000) | u16 index | u16 flag (= 1)
//! u32 start_spn | u32 end_spn (source-packet numbers, inclusive)
//! ```
//! Source-packet numbers are the 192-byte BDAV packet index: byte offset =
//! `spn * 192`. Observed on one disc: 792 segments, each ~2560 packets
//! (~480 KB), spread across the entire 54 GB feature (one roughly every 67 MB).
//! `index` is the 1..32 forensic index tag, NOT a sequential segment id: measured
//! on a retail 2.1 disc (Zombieland) it cycles 1,2,…,32,1,2,… across records in
//! file order — 24 full cycles of 32 plus a final partial cycle of 24 = 792
//! records. Source-packet numbers are the 192-byte BDAV packet index: byte offset
//! = `spn * 192`. Each segment is ~2560 packets (~480 KB) = 80 aligned units,
//! spread across the entire 54 GB feature (one roughly every 67 MB). Inside a
//! segment the 80 units interleave in two stride-2 halves: applying the segment's
//! index key decrypts ~40 of them to clean TS and garbles the other ~40 (a second
//! interleaved half, unidentified), which the demux then drops — leaving one
//! coherent stream. Confirmed by decoding a retail disc with a full set of 32
//! index keys.
/// Fixed size of one `IndividualSegment.tbl` record.
pub const SEGMENT_RECORD_LEN: usize = 16;
/// Bytes per BDAV source packet (188-byte TS + 4-byte arrival-time header).
pub const SOURCE_PACKET_LEN: u64 = 192;
/// Whether a 2.1 (FMTS) disc may rip WITHOUT segment (variant) keys.
/// Whether a 2.1 (FMTS) disc may rip WITHOUT the forensic index keys.
///
/// `true` (today): the forensic variant segments are skipped as expected loss
/// `true` (today): the forensic segments are skipped as expected loss
/// and the bulk of the title decodes with the unit key, so a 2.1 disc rips
/// mostly-complete. A unit key (VUK) is still required, exactly as for any AACS
/// disc. `false`: the absence of a segment-key source is a hard, UPFRONT failure
@@ -42,14 +54,17 @@ pub const SOURCE_PACKET_LEN: u64 = 192;
/// refused. Hardcoded on purpose — not a user setting.
///
/// [`Error::FmtsKeyMissing`]: crate::error::Error::FmtsKeyMissing
pub const BYPASS_FMTS_KEY: bool = true;
pub const BYPASS_FMTS_KEY: bool = false;
/// One forensic variant segment: the inclusive source-packet range it occupies
/// in the FMTS clip.
/// One forensic segment: the inclusive source-packet range it occupies in the
/// FMTS clip.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Segment {
/// 1-based segment number (table order).
pub number: u16,
/// Forensic index tag, 1..=32 (field@4 of the record). Cycles across the
/// table rather than counting up — it selects WHICH of the 32 index keys
/// decrypts this range. (`0` is not used here; the default/non-forensic
/// content carries no segment record at all.)
pub index: u16,
/// First source packet of the segment (inclusive).
pub start_spn: u32,
/// Last source packet of the segment (inclusive).
@@ -103,8 +118,8 @@ pub fn lba_byte_offset(lba: u32) -> u64 {
/// unit's clip-relative byte offset.
///
/// This is the routing decision behind a 2.1 decrypt-miss: a unit that
/// overlaps a forensic segment must be opened with that segment's **variant
/// key** (from `SegmentKeyNNNNN.tbl`), not the CPS Unit Key. Opening it with
/// overlaps a forensic segment must be opened with that segment's **index key**
/// (selected by the segment's `index`), not the CPS Unit Key. Opening it with
/// the Unit Key is exactly what yields the broken-reference-frame garbage a
/// plain unit-key rip produces. A unit outside every segment is ordinary
/// content and a miss on it is a Unit-Key miss, so this returns `None` and the
@@ -112,16 +127,16 @@ pub fn lba_byte_offset(lba: u32) -> u64 {
///
/// The unit is tested as a packet *span* (`[off/192, (off+6144-1)/192]`) so a
/// unit that only partly overlaps a segment edge is still classified as
/// variant; on the observed disc segments are unit-aligned, but the span test
/// forensic; on the observed disc segments are unit-aligned, but the span test
/// does not rely on that.
pub fn variant_segment_for_unit(segments: &[Segment], unit_offset: u64) -> Option<&Segment> {
pub fn segment_for_unit(segments: &[Segment], unit_offset: u64) -> Option<&Segment> {
let unit_len = crate::aacs::content::ALIGNED_UNIT_LEN as u64;
let first = (unit_offset / SOURCE_PACKET_LEN) as u32;
let last = ((unit_offset + unit_len - 1) / SOURCE_PACKET_LEN) as u32;
segments.iter().find(|s| s.overlaps_spn(first, last))
}
/// Parse `IndividualSegment.tbl` into its forensic variant segments, in table
/// Parse `IndividualSegment.tbl` into its forensic segments, in table
/// order. Returns `None` when the header is malformed, the record size is not
/// [`SEGMENT_RECORD_LEN`], or the declared record count overruns the buffer —
/// so a truncated / foreign table degrades to "no segment map" rather than
@@ -141,12 +156,12 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
let mut segments = Vec::with_capacity(count);
for i in 0..count {
let o = 8 + i * record_size;
// o+4..o+8 = segment_number (u16) + flag (u16); o+8..o+16 = start/end SPN.
let number = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]);
// o+4..o+8 = index (u16, 1..32) + flag (u16); o+8..o+16 = start/end SPN.
let index = u16::from_be_bytes([tbl[o + 4], tbl[o + 5]]);
let start_spn = u32::from_be_bytes([tbl[o + 8], tbl[o + 9], tbl[o + 10], tbl[o + 11]]);
let end_spn = u32::from_be_bytes([tbl[o + 12], tbl[o + 13], tbl[o + 14], tbl[o + 15]]);
segments.push(Segment {
number,
index,
start_spn,
end_spn,
});
@@ -154,12 +169,74 @@ pub fn parse_individual_segments(tbl: &[u8]) -> Option<Vec<Segment>> {
Some(segments)
}
/// Map a clip-relative byte offset to the absolute LBA that holds it, by walking
/// the title's extents (the `.fmts` clip's sectors in file order). Segment
/// offsets in [`Segment`] are clip-relative source-packet numbers, so this is how
/// a segment's `spn` range becomes disc LBAs. `None` if the offset is past the
/// clip.
pub fn clip_byte_to_lba(extents: &[crate::disc::Extent], clip_byte: u64) -> Option<u32> {
let mut cum = 0u64;
for e in extents {
let len = e.sector_count as u64 * crate::consts::SECTOR_BYTES as u64;
if clip_byte < cum + len {
let sector_in_ext = ((clip_byte - cum) / crate::consts::SECTOR_BYTES as u64) as u32;
return Some(e.start_lba.saturating_add(sector_in_ext));
}
cum += len;
}
None
}
/// Build the `[start_lba, end_lba) → key_idx` ranges for an FMTS forensic key map.
///
/// Each forensic segment's clip-relative source-packet span becomes an absolute
/// LBA range tagged with the key its `index` selects (via `index_to_key_idx`,
/// e.g. `|i| i as usize` when the pool is `[base, idx1, idx2, …]`). Applying that
/// one key across the whole segment decodes the ~40 units of its interleave half
/// to clean TS and garbles the other ~40 (the second interleaved half), which the
/// demux then drops — yielding one coherent stream. Ranges outside every segment
/// are left for the map's default (the ordinary Unit Key). A segment that straddles
/// a UDF extent boundary is emitted as one range per whole-sector slice it covers.
///
/// The result feeds [`AacsKeyMap::from_ranges`](crate::decrypt::AacsKeyMap::from_ranges)
/// with the Unit-Key index as the default — the same structure the CPS map uses,
/// only finer-grained.
pub fn fmts_key_ranges(
segments: &[Segment],
extents: &[crate::disc::Extent],
index_to_key_idx: &dyn Fn(u16) -> usize,
) -> Vec<(u32, u32, usize)> {
let mut ranges = Vec::new();
for s in segments {
let start_byte = s.start_spn as u64 * SOURCE_PACKET_LEN;
let end_byte = (s.end_spn as u64 + 1) * SOURCE_PACKET_LEN; // exclusive
// A segment is unit-aligned and contiguous in clip bytes; map its first
// and last sector to LBAs. Segments are ~480 KB and extents are GB-sized,
// so a segment almost never crosses an extent boundary — but if the two
// ends land in different extents (non-contiguous LBAs), skip rather than
// emit a wrong span; the units there fall to the Unit Key (garble+drop),
// never a mis-decrypt.
let (Some(a), Some(b)) = (
clip_byte_to_lba(extents, start_byte),
clip_byte_to_lba(extents, end_byte - 1),
) else {
continue;
};
if b >= a
&& (b - a) as u64 == (end_byte - 1 - start_byte) / crate::consts::SECTOR_BYTES as u64
{
ranges.push((a, b + 1, index_to_key_idx(s.index)));
}
}
ranges
}
#[cfg(test)]
mod tests {
use super::*;
/// Build a table with the real on-disc layout: 8-byte header + N 16-byte
/// records. `recs` are `(segment_number, start_spn, end_spn)`.
/// records. `recs` are `(index, start_spn, end_spn)`.
fn build_tbl(recs: &[(u16, u32, u32)]) -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(&0x0100_0000u32.to_be_bytes()); // type
@@ -175,9 +252,75 @@ mod tests {
v
}
#[test]
fn fmts_key_ranges_maps_segments_to_lba_by_index() {
use crate::disc::Extent;
// One big clip extent starting at LBA 1000. Clip byte B lives at
// LBA 1000 + B/2048.
let extents = vec![Extent {
start_lba: 1000,
sector_count: 1_000_000,
}];
// Two segments, indexes 5 and 7 (spn ranges as on a real disc).
let segs = vec![
Segment {
index: 5,
start_spn: 100,
end_spn: 199,
},
Segment {
index: 7,
start_spn: 10_000,
end_spn: 10_099,
},
];
// Pool layout [base, idx1, idx2, …] → index N uses key slot N.
let ranges = fmts_key_ranges(&segs, &extents, &|v| v as usize);
assert_eq!(ranges.len(), 2, "one LBA range per segment");
// Segment 0: spn 100..=199 → clip bytes [19200, 38400) → sectors 9..=18
// → LBA 1009..1019, key index 5.
assert_eq!(ranges[0], (1009, 1019, 5));
// Segment 1: spn 10000..=10099 → bytes [1_920_000, 1_939_200) →
// sectors 937..=946 → LBA 1937..1947, key index 7.
assert_eq!(ranges[1], (1937, 1947, 7));
// The ranges drive an AacsKeyMap with the Unit Key (index 0) as default.
let map = crate::decrypt::AacsKeyMap::from_ranges(ranges, 0);
assert_eq!(map.key_idx_for(500), 0, "outside any segment → Unit Key");
assert_eq!(map.key_idx_for(1012), 5, "inside index-5 segment → key 5");
assert_eq!(map.key_idx_for(1940), 7, "inside index-7 segment → key 7");
assert_eq!(
map.key_idx_for(1019),
0,
"segment end is exclusive → Unit Key"
);
}
#[test]
fn clip_byte_to_lba_walks_extents() {
use crate::disc::Extent;
let extents = vec![
Extent {
start_lba: 100,
sector_count: 10,
}, // clip bytes [0, 20480)
Extent {
start_lba: 500,
sector_count: 10,
}, // clip bytes [20480, 40960)
];
assert_eq!(clip_byte_to_lba(&extents, 0), Some(100));
assert_eq!(clip_byte_to_lba(&extents, 2048), Some(101));
assert_eq!(clip_byte_to_lba(&extents, 20480), Some(500)); // second extent
assert_eq!(clip_byte_to_lba(&extents, 22528), Some(501));
assert_eq!(clip_byte_to_lba(&extents, 40960), None); // past the clip
}
#[test]
fn parses_real_disc_layout() {
// First three records observed on a retail 2.1 disc: 2560-packet segments.
// First three records observed on retail 2.1 (Zombieland): the variant
// field counts 1,2,3,… (it wraps at 32 further into the table — see
// `index_field_cycles_one_to_thirty_two`), segments are 2560 packets.
let tbl = build_tbl(&[
(1, 343680, 346239),
(2, 695616, 698175),
@@ -185,7 +328,9 @@ mod tests {
]);
let segs = parse_individual_segments(&tbl).expect("parse");
assert_eq!(segs.len(), 3);
assert_eq!(segs[0].number, 1);
assert_eq!(segs[0].index, 1);
assert_eq!(segs[1].index, 2);
assert_eq!(segs[2].index, 3);
assert_eq!(segs[0].start_spn, 343680);
assert_eq!(segs[0].end_spn, 346239);
assert_eq!(segs[0].packet_count(), 2560);
@@ -224,13 +369,33 @@ mod tests {
}
#[test]
fn unit_inside_segment_routes_to_variant() {
fn unit_inside_segment_routes_to_index() {
// A real first-record segment: packets [343680, 346239].
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
// A unit sitting squarely inside: start at packet 344000 → byte 344000*192.
let off = 344000u64 * SOURCE_PACKET_LEN;
let hit = variant_segment_for_unit(&segs, off).expect("inside the segment");
assert_eq!(hit.number, 1);
let hit = segment_for_unit(&segs, off).expect("inside the segment");
assert_eq!(hit.index, 1);
}
#[test]
fn index_field_cycles_one_to_thirty_two() {
// Reality on Zombieland: field@4 is the index, cycling 1..=32 in file
// order (NOT a sequential segment id). Reproduce one-and-a-bit cycles.
let mut recs = Vec::new();
let mut spn = 1000u32;
for row in 0..2 {
for v in 1..=32u16 {
recs.push((v, spn, spn + 2559));
spn += 50_000; // ~one segment every ~67 MB
}
let _ = row;
}
let segs = parse_individual_segments(&build_tbl(&recs)).unwrap();
assert_eq!(segs.len(), 64);
assert_eq!(segs[31].index, 32); // end of first cycle
assert_eq!(segs[32].index, 1); // wraps, does not become 33
assert!(segs.iter().all(|s| (1..=32).contains(&s.index)));
}
#[test]
@@ -238,29 +403,29 @@ mod tests {
let segs = parse_individual_segments(&build_tbl(&[(1, 343680, 346239)])).unwrap();
// A unit well before the segment is ordinary content → None (unit-key path).
let off = 1000u64 * SOURCE_PACKET_LEN;
assert!(variant_segment_for_unit(&segs, off).is_none());
assert!(segment_for_unit(&segs, off).is_none());
}
#[test]
fn unit_straddling_a_segment_edge_counts_as_variant() {
fn unit_straddling_a_segment_edge_counts_as_forensic() {
// Segment starts at packet 100. A unit that ENDS just inside it (its 32
// packets straddle the boundary) must still route to the variant key,
// because part of its ciphertext is variant-encrypted.
// packets straddle the boundary) must still route to the index key,
// because part of its ciphertext is forensic-encrypted.
let segs = parse_individual_segments(&build_tbl(&[(7, 100, 200)])).unwrap();
// Unit covering packets [80, 111]: overlaps [100,200] at the tail.
let off = 80u64 * SOURCE_PACKET_LEN;
let hit = variant_segment_for_unit(&segs, off).expect("straddles the start edge");
assert_eq!(hit.number, 7);
let hit = segment_for_unit(&segs, off).expect("straddles the start edge");
assert_eq!(hit.index, 7);
// A unit ending exactly at packet 99 (offset s.t. last = 99) does NOT overlap.
let before = 68u64 * SOURCE_PACKET_LEN; // [68, 99]
assert!(variant_segment_for_unit(&segs, before).is_none());
assert!(segment_for_unit(&segs, before).is_none());
}
#[test]
fn no_segments_never_routes_to_variant() {
fn no_segments_never_routes_to_index() {
// The 1.0 / 2.0 case: no forensic map, so every miss is a unit-key miss.
assert!(variant_segment_for_unit(&[], lba_byte_offset(0)).is_none());
assert!(variant_segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
assert!(segment_for_unit(&[], lba_byte_offset(0)).is_none());
assert!(segment_for_unit(&[], lba_byte_offset(9_999_999)).is_none());
}
#[test]
+35
View File
@@ -57,6 +57,41 @@ pub struct ProcessingKey(pub [u8; 16]);
pub struct UnitKey {
pub idx: u32,
pub key: [u8; 16],
/// AACS 2.1 (FMTS) forensic **index** tag (see [`crate::aacs::segment`]).
///
/// `0` = ordinary (non-forensic) content — the value for every 1.0 / 2.0
/// key and for the bulk of a 2.1 title. `1..=32` = a forensic index key that
/// decrypts the `IndividualSegment.tbl` segments tagged with that same index.
/// This is the per-segment index (1..32), NOT the AACS 2.1 Media Key Variant
/// (the 65536-value device selector), which is a separate MKB-layer concern.
pub index_number: u8,
}
impl UnitKey {
/// An ordinary (non-forensic) unit key: `index_number == 0`. The value
/// for every AACS 1.0 / 2.0 key and the bulk of a 2.1 title.
pub const fn new(idx: u32, key: [u8; 16]) -> Self {
Self {
idx,
key,
index_number: 0,
}
}
/// A forensic index key: `index_number` in `1..=32`, decrypting the
/// `IndividualSegment.tbl` segments tagged with that index.
pub const fn forensic(idx: u32, key: [u8; 16], index_number: u8) -> Self {
Self {
idx,
key,
index_number,
}
}
/// Whether this key decrypts ordinary (non-forensic) content (index 0).
pub const fn is_default_index(&self) -> bool {
self.index_number == 0
}
}
/// A per-disc entry from the key database.
+3 -3
View File
@@ -236,7 +236,7 @@ pub fn parse(data: &[u8]) -> Result<ClipInfo> {
}
/// Parse the ProgramInfo section: per-stream (pid, coding_type,
/// language, codec sub-fields). Layout per BD spec / libbluray
/// language, codec sub-fields). Layout per the BD CLPI spec
/// clpi_parse.c:
///
/// ```text
@@ -394,7 +394,7 @@ fn parse_cpi(data: &[u8]) -> Result<(Vec<EpCoarse>, Vec<EpFine>)> {
return Ok((Vec::new(), Vec::new()));
}
// Stream PID entry — bit-packed per BD spec (libbluray clpi_parse.c):
// Stream PID entry — bit-packed per the BD CLPI spec:
// stream_PID: 16 bits → ep_map[2..4]
// reserved: 10 bits ┐
// EP_stream_type: 4 bits │ ep_map[4..14] = 80 bits
@@ -821,7 +821,7 @@ mod tests {
// ─────────────────────────────────────────────────────────────────────
// Added hardening tests. Grounded in the BD-ROM CLPI spec
// (https://github.com/lw/BluRay/wiki/CLPI) and libbluray clpi_parse.c.
// (https://github.com/lw/BluRay/wiki/CLPI).
// ─────────────────────────────────────────────────────────────────────
/// Build a ProgramInfo section. `streams` = Vec<(pid, sci_bytes)>.
+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).
+120 -133
View File
@@ -1,57 +1,46 @@
//! CSS cipher implementation based on the Stevenson 1999 analysis.
//! CSS content cipher — an independent implementation of the publicly
//! documented Content Scramble System stream cipher.
//!
//! The CSS cipher uses two table-driven feedback circuits:
//! - LFSR1: 17-bit state (9-bit lo + 8-bit hi register, seeded from
//! key[0..2]), driven by TAB2/TAB3
//! - LFSR0: 24-bit feedback register (seeded from key[2..5] XOR seed[2..5],
//! masked to 0xFFFFFF), driven by a feedback polynomial through TAB4
//! The algorithm is the one recovered and published in Frank A. Stevenson's
//! 1999 cryptanalysis ("Cryptanalysis of Contents Scrambling System") and
//! described in the open CSS literature. It is implemented here from that public
//! description; its constants (see [`super::tables`]) are the cipher's own
//! defined values. Nothing in this file is copied or translated from any
//! particular CSS software.
//!
//! The keystream is the bytewise sum (with carry) of both LFSR outputs.
//! Content descrambling computes plain = TAB1[cipher] ^ keystream — a TAB1
//! substitution of each ciphertext byte followed by an XOR with the keystream
//! (NOT a plain XOR; the cipher is not its own inverse).
//! The cipher uses two table-driven linear-feedback circuits:
//! - **LFSR1** — a 17-bit register (a 9-bit and an 8-bit half seeded from
//! `key[0..2] XOR seed[0..2]`), stepped through `TAB2`/`TAB3`/`TAB5`.
//! - **LFSR0** — a 24-bit feedback register (seeded from `key[2..5] XOR
//! seed[2..5]`), stepped through a feedback polynomial and `TAB4`.
//!
//! Algorithm: Frank A. Stevenson's divide-and-conquer attack (1999).
//! Tables: CSS specification constants.
//! Each output byte is the sum-with-carry of the two register outputs. A body
//! byte is recovered as `plain = TAB1[cipher] ^ keystream` — a `TAB1`
//! substitution of the ciphertext byte followed by an XOR with the keystream
//! (so the cipher is deliberately not its own inverse).
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
/// Descramble a CSS-encrypted DVD sector in place.
///
/// Exact port of libdvdcss `dvdcss_unscramble` (css.c). The two content
/// LFSRs are seeded **directly** from `title_key XOR sector_seed` — there is
/// no `decrypt_key` mangling on this path (that is the disc/title-key
/// hierarchy, not the content cipher). Bytes 0x80..0x800 are recovered with
/// `*p = TAB1[*p] ^ (i_t5 & 0xff)`.
/// The two feedback registers are seeded **directly** from
/// `title_key XOR sector_seed` (bytes `0x54..0x59`) — there is no title-key
/// mangling on the content path (that belongs to the disc/title-key hierarchy,
/// not the sector cipher). Only the body, bytes `0x80..0x800`, is transformed:
/// `body[i] = TAB1[body[i]] ^ (keystream & 0xff)`.
///
/// The scramble flag at byte 0x14 (bits 4-5) indicates encryption. This
/// descrambler CLEARS that flag after unscrambling, so a descrambled sector
/// reads as `sector[0x14] & 0x30 == 0`; callers and the tests use that to tell
/// it from ciphertext, and re-running descramble on an already-cleared sector
/// is a no-op (the flag guard below skips it). Clearing does not affect the
/// recovered body.
/// The scramble flag at byte `0x14` (bits 4-5) marks an encrypted sector. This
/// routine CLEARS that flag after unscrambling, so a descrambled sector reads as
/// `sector[0x14] & 0x30 == 0`; callers and tests use that to tell it from
/// ciphertext, and re-running descramble on an already-cleared sector is a no-op
/// (the flag guard below skips it). Clearing does not affect the recovered body.
///
/// No-op (returns without modifying `sector`) in two cases:
/// - `sector.len() < 2048`: the encrypted region (0x80..0x800) is not
/// fully present. Callers chunk by 2048, so a trailing partial chunk is
/// left untouched. The `debug_assert!` flags this misuse in debug/test
/// builds; a DVD sector is always exactly 2048 bytes.
/// - `sector.len() < 2048`: the encrypted region (`0x80..0x800`) is not fully
/// present. Callers chunk by 2048, so a trailing partial chunk is left
/// untouched. The `debug_assert!` flags this misuse in debug/test builds; a
/// DVD sector is always exactly 2048 bytes.
/// - scramble flags are zero: the sector is not CSS-encrypted.
///
/// Design reference: libdvdcss `dvdcss_unscramble`. The combiner mirrors
/// `css.c` line-for-line:
/// ```text
/// i_t1 = (key[0] ^ sec[0x54]) | 0x100;
/// i_t2 = key[1] ^ sec[0x55];
/// i_t3 = (key[2]|key[3]<<8|key[4]<<16) ^ (sec[0x56]|sec[0x57]<<8|sec[0x58]<<16);
/// i_t4 = i_t3 & 7; i_t3 = i_t3*2 + 8 - i_t4;
/// // per byte over 0x80..0x800:
/// i_t4 = TAB2[i_t2] ^ TAB3[i_t1];
/// i_t2 = i_t1 >> 1; i_t1 = ((i_t1 & 1) << 8) ^ i_t4; i_t4 = TAB5[i_t4];
/// i_t6 = (((((((i_t3>>3)^i_t3)>>1)^i_t3)>>8)^i_t3)>>5) & 0xff;
/// i_t3 = (i_t3 << 8) | i_t6; i_t6 = TAB4[i_t6];
/// i_t5 += i_t6 + i_t4; *p = TAB1[*p] ^ (i_t5 & 0xff); i_t5 >>= 8;
/// ```
pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
debug_assert!(
sector.len() >= 2048,
@@ -61,102 +50,103 @@ pub fn descramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
return;
}
// libdvdcss: `if( !(p_sec[0x14] & 0x30) ) return;`
// Not scrambled (flag bits 4-5 clear) → nothing to do.
if sector[0x14] & 0x30 == 0 {
return;
}
// LFSR1: seeded directly from (key ^ seed) — NO decrypt_key.
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) as u32;
// LFSR1 halves, seeded from (key ^ seed) bytes 0-1. The 9-bit half carries a
// set bit 8 (`| 0x100`) as its running marker.
let mut r1a: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
let mut r1b: u32 = (title_key[1] ^ sector[0x55]) as u32;
// LFSR0 (i_t3): 24-bit feedback register seeded from the remaining three
// key/seed bytes, then transformed `i_t3 = i_t3*2 + 8 - (i_t3 & 7)`.
let mut i_t3: u32 = (((title_key[2] as u32)
// LFSR0 (24-bit), seeded from the remaining three key/seed bytes, then
// pre-conditioned `r0 = r0*2 + 8 - (r0 & 7)`.
let mut r0: u32 = (((title_key[2] as u32)
| ((title_key[3] as u32) << 8)
| ((title_key[4] as u32) << 16))
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
& 0xFF_FFFF;
let i_t4_seed = i_t3 & 7;
i_t3 = i_t3 * 2 + 8 - i_t4_seed;
r0 = r0 * 2 + 8 - (r0 & 7);
let mut i_t5: u32 = 0;
// Keystream accumulator; the low byte is the current keystream byte and the
// high bits carry into the next iteration.
let mut acc: u32 = 0;
for byte in sector.iter_mut().take(2048).skip(128) {
// Advance LFSR1.
let mut i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
i_t2 = i_t1 >> 1;
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
i_t4 = TAB5[i_t4 as usize] as u32;
// Step LFSR1: its output byte `o1`.
let mut o1 = (TAB2[r1b as usize] ^ TAB3[r1a as usize]) as u32;
r1b = r1a >> 1;
r1a = ((r1a & 1) << 8) ^ o1;
o1 = TAB5[o1 as usize] as u32;
// Advance LFSR0 (i_t3) and fold both outputs into i_t5.
let mut i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xFF;
i_t3 = (i_t3 << 8) | i_t6;
i_t6 = TAB4[i_t6 as usize] as u32;
i_t5 += i_t6 + i_t4;
// Step LFSR0: its output byte `o0`.
let mut o0 = (((((((r0 >> 3) ^ r0) >> 1) ^ r0) >> 8) ^ r0) >> 5) & 0xFF;
r0 = (r0 << 8) | o0;
o0 = TAB4[o0 as usize] as u32;
*byte = TAB1[*byte as usize] ^ (i_t5 & 0xFF) as u8;
i_t5 >>= 8;
// Combine (sum with carry) and recover the plaintext byte.
acc += o0 + o1;
*byte = TAB1[*byte as usize] ^ (acc & 0xFF) as u8;
acc >>= 8;
}
// libdvdcss leaves byte 0x14 untouched; freemkv clears the scramble bits
// so downstream code and tests can tell a sector was descrambled.
// Clear the scramble bits so downstream code and tests can tell a sector was
// descrambled; bits 6-7 of byte 0x14 are preserved.
sector[0x14] &= 0xCF;
}
/// Exact inverse of [`descramble_sector`]: turn a plaintext sector body into
/// CSS ciphertext under `title_key`.
///
/// Descramble computes `plain = TAB1[cipher] ^ (i_t5 & 0xff)`, so the
/// inverse is `cipher = TAB1_INV[plain ^ (i_t5 & 0xff)]` with the identical
/// LFSR keystream. The keystream derivation is byte-for-byte the same as
/// `descramble_sector` (libdvdcss `dvdcss_unscramble`); only the final
/// substitution differs. Bytes 0x80..0x800 are rewritten in place; the
/// scramble flag is set to 0x10 so a subsequent descramble runs.
/// Descramble computes `plain = TAB1[cipher] ^ (keystream & 0xff)`, so the
/// inverse is `cipher = TAB1_INV[plain ^ (keystream & 0xff)]` with the identical
/// keystream. The keystream derivation is the same as [`descramble_sector`];
/// only the final substitution differs. Bytes `0x80..0x800` are rewritten in
/// place; the scramble flag is set to `0x10` so a subsequent descramble runs.
///
/// Not on any production read path — it exists so the key-recovery tests
/// (and any caller that needs to produce a known CSS-encrypted sector) can
/// build genuine ciphertext rather than approximating it.
/// Not on any production read path — it exists so the key-recovery tests (and
/// any caller that needs a known CSS-encrypted sector) can build genuine
/// ciphertext rather than approximating it.
#[cfg(test)]
pub(crate) fn scramble_sector(title_key: &[u8; 5], sector: &mut [u8]) {
if sector.len() < 2048 {
return;
}
let mut i_t1: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
let mut i_t2: u32 = (title_key[1] ^ sector[0x55]) as u32;
let mut i_t3: u32 = (((title_key[2] as u32)
let mut r1a: u32 = ((title_key[0] ^ sector[0x54]) as u32) | 0x100;
let mut r1b: u32 = (title_key[1] ^ sector[0x55]) as u32;
let mut r0: u32 = (((title_key[2] as u32)
| ((title_key[3] as u32) << 8)
| ((title_key[4] as u32) << 16))
^ ((sector[0x56] as u32) | ((sector[0x57] as u32) << 8) | ((sector[0x58] as u32) << 16)))
& 0xFF_FFFF;
let i_t4_seed = i_t3 & 7;
i_t3 = i_t3 * 2 + 8 - i_t4_seed;
r0 = r0 * 2 + 8 - (r0 & 7);
let mut i_t5: u32 = 0;
let mut acc: u32 = 0;
for byte in sector.iter_mut().take(2048).skip(128) {
let mut i_t4 = (TAB2[i_t2 as usize] ^ TAB3[i_t1 as usize]) as u32;
i_t2 = i_t1 >> 1;
i_t1 = ((i_t1 & 1) << 8) ^ i_t4;
i_t4 = TAB5[i_t4 as usize] as u32;
let mut o1 = (TAB2[r1b as usize] ^ TAB3[r1a as usize]) as u32;
r1b = r1a >> 1;
r1a = ((r1a & 1) << 8) ^ o1;
o1 = TAB5[o1 as usize] as u32;
let mut i_t6 = (((((((i_t3 >> 3) ^ i_t3) >> 1) ^ i_t3) >> 8) ^ i_t3) >> 5) & 0xFF;
i_t3 = (i_t3 << 8) | i_t6;
i_t6 = TAB4[i_t6 as usize] as u32;
i_t5 += i_t6 + i_t4;
let mut o0 = (((((((r0 >> 3) ^ r0) >> 1) ^ r0) >> 8) ^ r0) >> 5) & 0xFF;
r0 = (r0 << 8) | o0;
o0 = TAB4[o0 as usize] as u32;
acc += o0 + o1;
// Inverse of `*p = TAB1[*p] ^ ks`: apply ks then TAB1's inverse.
*byte = (*TAB1_INV)[(*byte ^ (i_t5 & 0xFF) as u8) as usize];
i_t5 >>= 8;
*byte = (*TAB1_INV)[(*byte ^ (acc & 0xFF) as u8) as usize];
acc >>= 8;
}
// Mark the sector scrambled so the descrambler will process it.
sector[0x14] = (sector[0x14] & 0xCF) | 0x10;
}
/// Inverse permutation of [`TAB1`], built at first use. `TAB1` is a
/// bijection on 0..256, so `TAB1_INV[TAB1[x]] == x`.
/// Inverse permutation of [`TAB1`], built at first use. `TAB1` is a bijection on
/// `0..256`, so `TAB1_INV[TAB1[x]] == x`.
#[cfg(test)]
static TAB1_INV: std::sync::LazyLock<[u8; 256]> = std::sync::LazyLock::new(|| {
let mut inv = [0u8; 256];
@@ -180,14 +170,15 @@ mod tests {
assert_eq!(sector, original);
}
/// Cross-check `descramble_sector` against the EXACT output of libdvdcss
/// `dvdcss_unscramble` (css.c) for a fixed sector, computed from the
/// reference C semantics with the reference tables. Pins the content
/// cipher to libdvdcss byte-for-byte.
/// Regression vector: the deterministic output of the CSS content cipher for
/// a fixed key/seed/body. The value is generated by this implementation and
/// is self-consistent with the scramble/descramble round-trip below — any
/// correct CSS descrambler yields the same bytes, since the cipher is
/// deterministic. Pins the implementation against accidental change.
///
/// key = 42 13 37 BE EF, seed (0x54..0x59) = DE AD BE EF 42, body = 0xAA.
#[test]
fn descramble_matches_libdvdcss_unscramble_vector() {
fn descramble_produces_the_reference_css_vector() {
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
let mut sector = vec![0xAAu8; 2048];
sector[0x14] = 0x30;
@@ -199,12 +190,12 @@ mod tests {
0x81, 0x92, 0x24, 0xA2, 0x46, 0x70, 0x3C, 0x64, 0xA6, 0x91, 0x84, 0xF5, 0x1F, 0x98,
0xA0, 0x31
],
"descramble body head must match libdvdcss dvdcss_unscramble"
"descramble body head must match the reference CSS vector"
);
assert_eq!(
&sector[0x7F8..0x800],
&[0x46, 0x94, 0x80, 0x0E, 0x67, 0x36, 0x65, 0xBC],
"descramble body tail must match libdvdcss dvdcss_unscramble"
"descramble body tail must match the reference CSS vector"
);
}
@@ -240,8 +231,8 @@ mod tests {
/// Test 2: descramble inverts scramble over the body.
///
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to
/// the ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
/// The content cipher is NOT a plain XOR involution (it applies TAB1 to the
/// ciphertext: `plain = TAB1[cipher] ^ ks`). The true inverse is
/// [`scramble_sector`]. Scrambling a plaintext body and then descrambling
/// with the same key must reproduce the original body exactly.
#[test]
@@ -278,9 +269,9 @@ mod tests {
/// css_tab1_relationship
///
/// Verify the structure of TAB1: it is a substitution table used in
/// key mangling. Check that no two inputs map to the same output
/// (TAB1 is a permutation of 0..255).
/// Verify the structure of TAB1: it is a substitution table used in key
/// mangling. Check that no two inputs map to the same output (TAB1 is a
/// permutation of 0..255).
#[test]
fn css_tab1_is_permutation() {
let mut seen = [false; 256];
@@ -331,8 +322,8 @@ mod tests {
/// UNSCRAMBLED and left byte-for-byte unchanged. This guards against a
/// too-wide mask silently "descrambling" (and thus corrupting) clear data.
///
/// Grounding: CSS sector header byte 0x14 — copyright/scramble bits live
/// in bits 4-5; the masked value 0 means not scrambled.
/// Grounding: CSS sector header byte 0x14 — copyright/scramble bits live in
/// bits 4-5; the masked value 0 means not scrambled.
/// Mutation: widen the mask `0x30` to `0x70`/`0xF0` -> 0x40/0x80 would be
/// seen as scrambled and the body would change.
#[test]
@@ -351,11 +342,10 @@ mod tests {
}
}
/// Each individual scramble bit (4 and 5) independently marks the sector
/// as encrypted: 0x10 and 0x20 must both trigger descrambling.
/// Each individual scramble bit (4 and 5) independently marks the sector as
/// encrypted: 0x10 and 0x20 must both trigger descrambling.
///
/// Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both
/// nonzero.
/// Grounding: `(0x10 >> 4) & 3 == 1`, `(0x20 >> 4) & 3 == 2` — both nonzero.
/// Mutation: change `!= 0` early-return condition to `== 3` -> a sector
/// flagged only 0x10 or 0x20 would be skipped and left scrambled.
#[test]
@@ -380,8 +370,8 @@ mod tests {
/// becomes 0xC0 (bits 6,7 kept, bits 4,5 cleared), NOT 0x00.
///
/// Grounding: code does `sector[0x14] &= 0xCF`; 0xF0 & 0xCF == 0xC0.
/// Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved
/// high bits assert fails.
/// Mutation: change `&= 0xCF` to `= 0` or `&= 0x0F` -> the preserved high
/// bits assert fails.
#[test]
fn descramble_clear_preserves_high_bits_of_0x14() {
let key = [0x01, 0x02, 0x03, 0x04, 0x05];
@@ -397,11 +387,11 @@ mod tests {
// ── header / body boundary (encrypted region is 0x80..0x800) ───────────
/// The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80
/// (the header) must NOT be modified by the keystream — except byte 0x14
/// whose flag is cleared. In particular the sector-seed bytes 0x54..0x59
/// (which live inside the header) must survive untouched, since the
/// descrambler reads them but never writes them.
/// The encrypted region is exactly bytes 0x80..0x800. Bytes 0x00..0x80 (the
/// header) must NOT be modified by the keystream — except byte 0x14 whose
/// flag is cleared. In particular the sector-seed bytes 0x54..0x59 (which
/// live inside the header) must survive untouched, since the descrambler
/// reads them but never writes them.
///
/// Grounding: loop is `sector.iter_mut().take(2048).skip(128)` -> indices
/// 128..2048 only.
@@ -428,16 +418,15 @@ mod tests {
assert_eq!(&sector[0x54..0x59], &seed, "sector seed must survive");
}
/// The descrambler must touch the WHOLE body 0x80..0x800, not just a
/// prefix. With a constant body and constant key, the keystream is
/// non-degenerate enough that the very last sector byte (index 2047) is
/// altered. This guards the loop bound `.take(2048)` against an
/// off-by-one that would leave the final byte(s) scrambled.
/// The descrambler must touch the WHOLE body 0x80..0x800, not just a prefix.
/// With a constant body and constant key, the keystream is non-degenerate
/// enough that the very last sector byte (index 2047) is altered. This guards
/// the loop bound `.take(2048)` against an off-by-one that would leave the
/// final byte(s) scrambled.
///
/// Grounding: encrypted region end is 0x800 == 2048 (exclusive).
/// Mutation: change `.take(2048)` to `.take(2047)` -> last byte unchanged,
/// assert fires (keystream byte for the last position is verified nonzero
/// below by the round-trip, and this body is all-zero so any XOR shows).
/// assert fires (this body is all-zero so any keystream XOR shows).
#[test]
fn descramble_covers_final_body_byte() {
let key = [0x42, 0x13, 0x37, 0xBE, 0xEF];
@@ -446,9 +435,7 @@ mod tests {
sector[0x54..0x59].copy_from_slice(&[0x11, 0x22, 0x33, 0x44, 0x55]);
descramble_sector(&key, &mut sector);
// Body was all zero; any nonzero in [0x80,0x800) is keystream. Confirm
// the keystream reaches the final byte. (If the last keystream byte
// happened to be 0 this could be a flaky test, so assert the run-end
// region as a whole differs from zero.)
// the keystream reaches the final byte.
assert_ne!(
&sector[2040..2048],
&[0u8; 8][..],
@@ -457,13 +444,13 @@ mod tests {
}
/// Descramble is keyed by `title_key XOR seed`: two different title keys
/// produce two different bodies for the same scrambled input. A cipher
/// that ignored the title key (or mixed it in wrongly) would yield
/// identical output — silent wrong-key decryption.
/// produce two different bodies for the same scrambled input. A cipher that
/// ignored the title key (or mixed it in wrongly) would yield identical
/// output — silent wrong-key decryption.
///
/// Grounding: per-sector key = title_key[i] ^ sector[0x54+i].
/// Mutation: in the `key` array drop the `title_key[i] ^` term -> both
/// keys give the same body, assert fires.
/// Mutation: in the `key` array drop the `title_key[i] ^` term -> both keys
/// give the same body, assert fires.
#[test]
fn descramble_output_depends_on_title_key() {
let seed = [0xDE, 0xAD, 0xBE, 0xEF, 0x42];
+2 -2
View File
@@ -46,7 +46,7 @@ pub struct CssState {
/// Recover the CSS title key with no keys, by scanning scrambled sectors and
/// running the Stevenson known-plaintext attack (see the [`stevenson`] module).
///
/// The crib comes from `AttackPattern`: a scrambled sector's cleartext region
/// The crib comes from the periodic-run detector: a scrambled sector's cleartext region
/// (bytes 0x00..0x80) often ends in a short-period repeating run (stuffing /
/// constant fill); the attack assumes that run continues across the 0x80
/// boundary into the encrypted region, giving the known plaintext the 2^16
@@ -274,7 +274,7 @@ pub fn descramble_sector(state: &CssState, sector: &mut [u8]) {
/// Descramble a whole CSS buffer in place, re-cracking the title key on a VOB
/// region boundary. `title_key` is a CACHE of the last crack, not a fixed disc
/// key: it changes per VTS/VOB region, so it is validated on every scrambled
/// sector and re-cracked on a miss (libdvdcss's on-demand per-region rekey).
/// sector and re-cracked on a miss (the standard on-demand per-region rekey).
///
/// This CSS key acquisition is intrinsic to the cipher — CSS has no external key
/// source, the ONLY way to a title key is cracking the data — so it lives with
+42 -58
View File
@@ -1,48 +1,37 @@
//! CSS title-key recovery — Frank A. Stevenson's divide-and-conquer attack
//! (1999), ported exactly from libdvdcss `RecoverTitleKey` + `AttackPattern`
//! (css.c).
//!
//! Recovers the 5-byte CSS title key from a single scrambled DVD sector with
//! no player keys and no disc-key crack, using only known plaintext.
//! (1999), implemented from his published cryptanalysis ("Cryptanalysis of
//! Contents Scrambling System"). It recovers the 5-byte CSS title key from a
//! single scrambled DVD sector with no player keys and no disc-key crack, using
//! only known plaintext. Implemented from that public description; nothing here
//! is copied or translated from any particular CSS software.
//!
//! # The cipher this attacks
//!
//! The content descrambler ([`super::lfsr::descramble_sector`], = libdvdcss
//! `dvdcss_unscramble`) seeds its two LFSRs **directly** from
//! `key = title_key XOR sector_seed` (seed = `sector[0x54..0x59]`):
//!
//! ```text
//! i_t1 = (key[0] ^ sec[0x54]) | 0x100; // LFSR1 low (9-bit)
//! i_t2 = key[1] ^ sec[0x55]; // LFSR1 high
//! i_t3 = (key[2]|key[3]<<8|key[4]<<16) ^ seed3; // LFSR0 (24-bit feedback)
//! i_t3 = i_t3*2 + 8 - (i_t3 & 7);
//! // per byte: *p = TAB1[*p] ^ (i_t5 & 0xff)
//! ```
//!
//! There is NO `decrypt_key` mangling on the content path. So the recovery
//! is a single inversion of `dvdcss_unscramble`, not the multi-stage
//! working-key inversion the previous (non-CSS) implementation used.
//! The content descrambler ([`super::lfsr::descramble_sector`]) seeds its two
//! LFSRs **directly** from `key = title_key XOR sector_seed` (seed =
//! `sector[0x54..0x59]`): LFSR1 from key/seed bytes 0-1, LFSR0 (24-bit) from
//! bytes 2-4 with the pre-conditioning `r0 = r0*2 + 8 - (r0 & 7)`, and each body
//! byte recovered as `plain = TAB1[cipher] ^ (keystream & 0xff)`. There is no
//! title-key mangling on the content path, so the recovery is a single inversion
//! of the sector cipher.
//!
//! # The attack
//!
//! 1. **Known plaintext → keystream.** Because the descramble applies TAB1
//! to the ciphertext, the per-byte keystream is
//! `buf[i] = TAB1[cipher[i]] ^ plain[i]` (matching libdvdcss
//! `RecoverTitleKey`'s `p_buffer`).
//! 1. **Known plaintext → keystream.** Because descramble applies TAB1 to the
//! ciphertext, the per-byte keystream is `TAB1[cipher[i]] ^ plain[i]`.
//! 2. **Brute the 16-bit LFSR1 seed.** For each of 2^16 seeds, run LFSR1
//! forward; for the first four steps deduce the LFSR0 output bytes from
//! the keystream (carry-tracked), reconstructing `i_t3`. For the next six
//! steps clock LFSR0 normally and check it reproduces the keystream — a
//! wrong LFSR1 seed fails fast.
//! 3. **Back-clock LFSR0.** Run four backward `i_t3` steps (each a 256-way
//! search for the byte shifted in) to reach the initial state, then undo
//! `i_t3 = i_t3*2 + 8 - (i_t3 & 7)` to recover key[2..5].
//! 4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]` (plain XOR —
//! the descramble seeds directly, so there is no inversion).
//! forward; for the first four steps deduce the LFSR0 output bytes from the
//! keystream (carry-tracked), reconstructing LFSR0's state. For the next six
//! steps clock LFSR0 normally and check it reproduces the keystream — a wrong
//! LFSR1 seed fails fast.
//! 3. **Back-clock LFSR0.** Run four backward steps (each a 256-way search for
//! the byte shifted in) to reach the initial state, then undo the
//! `r0*2 + 8 - (r0 & 7)` pre-conditioning to recover key[2..5].
//! 4. **XOR back the seed.** `key[0..5] ^= sector_seed[0..5]`.
//!
//! `AttackPattern` finds known plaintext for step 1: the longest periodic
//! run in the cleartext `sec[0x00..0x80]`, assumed to continue into the
//! encrypted region at 0x80.
//! Known plaintext for step 1 comes from the longest periodic run in the
//! cleartext `sec[0x00..0x80]`, assumed to continue into the encrypted region at
//! 0x80.
use super::lfsr::descramble_sector;
use super::tables::{TAB1, TAB2, TAB3, TAB4, TAB5};
@@ -52,13 +41,11 @@ const ENCRYPTED_START: usize = 0x80; // byte 128
const SEED_OFFSET: usize = 0x54; // sector seed at bytes 0x54-0x58
const FLAG_BYTE: usize = 0x14;
/// RecoverTitleKey: recover the title key from cipher + known plaintext.
///
/// Exact port of libdvdcss `RecoverTitleKey` (css.c). `crypted` is the
/// ciphertext starting at sector byte 0x80; `decrypted` is the matching
/// known plaintext; `seed` is `sector[0x54..0x59]`. On success returns the
/// recovered 5-byte title key; `None` if no LFSR seed reproduces the
/// keystream.
/// Recover the title key from cipher + known plaintext (the core of Stevenson's
/// attack). `crypted` is the ciphertext starting at sector byte 0x80;
/// `decrypted` is the matching known plaintext; `seed` is `sector[0x54..0x59]`.
/// On success returns the recovered 5-byte title key; `None` if no LFSR seed
/// reproduces the keystream.
///
/// At least 10 bytes of `crypted`/`decrypted` are required (the cipher is
/// iterated 10 times: 4 to reconstruct LFSR0, 6 to validate).
@@ -222,16 +209,13 @@ fn descramble_matches(sector: &[u8], title: &[u8; 5], plain: &[u8]) -> bool {
test[ENCRYPTED_START..ENCRYPTED_START + n] == plain[..n]
}
/// AttackPattern: find a repeating pattern just before the encrypted region
/// and assume the plaintext at 0x80 continues it.
///
/// Functionally-equivalent port of libdvdcss `AttackPattern` (css.c) — finds the
/// same periodic cribs on real DVD data, though its byte-comparison anchor
/// differs from the C on phase-misaligned runs. Scans cleartext
/// `sec[0x00..0x80]` for the longest run that repeats with a cycle length in
/// 2..0x2F. If the run is long enough (`plen > 3` and at least two full
/// cycles), the known plaintext at 0x80 is taken to be the periodic run
/// continuing forward, and [`recover_title_key_from_plain`] is applied.
/// Find a repeating pattern just before the encrypted region and assume the
/// plaintext at 0x80 continues it — the known-plaintext step of Stevenson's
/// attack. Scans cleartext `sec[0x00..0x80]` for the longest run that repeats
/// with a cycle length in 2..0x2F. If the run is long enough (`plen > 3` and at
/// least two full cycles), the known plaintext at 0x80 is taken to be the
/// periodic run continuing forward, and [`recover_title_key_from_plain`] is
/// applied.
pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
if sector.len() < SECTOR_BYTES {
return None;
@@ -260,7 +244,7 @@ pub fn crack_title_key(sector: &[u8]) -> Option<[u8; 5]> {
result
}
/// AttackPattern crib: the predicted 10-byte plaintext at byte 0x80.
/// Crib: the predicted 10-byte plaintext at byte 0x80.
///
/// Scans the clear header `sec[0x00..0x80]` (never scrambled) for the longest
/// run that repeats with a cycle length in 2..0x2F. If the run is long enough
@@ -366,7 +350,7 @@ mod tests {
/// Build a synthetic scrambled sector whose CLEARTEXT (0x00..0x80) ends
/// in a periodic run that continues into the encrypted region — the case
/// `AttackPattern` (crack_title_key) is designed to crack.
/// `crack_title_key` is designed to crack.
fn synth_periodic_sector(
title_key: &[u8; 5],
seed: &[u8; 5],
@@ -379,7 +363,7 @@ mod tests {
// (RUN_START..0x80) and continuing into the encrypted region. This
// mirrors a real VOB: a periodic data run just before the scrambled
// part. The run must NOT overlap the seed bytes (0x54..0x59), or the
// AttackPattern detector would break mid-run. The phase is anchored to
// the crib detector would break mid-run. The phase is anchored to
// offset 0 so the run is consistent across the 0x80 boundary.
// Just above the seed (0x54..0x59); gives a 39-byte run (0x59..0x80)
// — enough for >=2 cycles of every tested period (<=19).
@@ -466,7 +450,7 @@ mod tests {
}
}
/// MANDATORY (Task C.1): the AttackPattern entry point crack_title_key —
/// MANDATORY (Task C.1): the crib-based entry point crack_title_key —
/// no plaintext supplied — recovers a round-tripping key when the
/// cleartext ends in a periodic run that continues into 0x80.
#[test]
@@ -488,7 +472,7 @@ mod tests {
}
}
/// recover_title_key_from_plain inverts dvdcss_unscramble exactly: scramble
/// recover_title_key_from_plain inverts descramble_sector exactly: scramble
/// a known body, hand back the keystream-derived key, and the recovered
/// key (XOR-back included) reproduces the plaintext.
#[test]
+11 -12
View File
@@ -24,9 +24,8 @@ pub const TAB1: [u8; 256] = [
0xb7, 0xf7, 0xbf, 0xa2, 0xe7, 0xa7, 0xef, 0xf2, 0xba, 0xfa, 0xb2, 0xaf, 0xea, 0xaa, 0xe2, 0xff,
];
/// Table 2: LFSR1 high-byte feedback permutation.
///
/// Byte-identical to libdvdcss `p_css_tab2` (csstables.h).
/// Table 2: LFSR1 high-byte feedback permutation — a fixed constant of the CSS
/// cipher (per the published algorithm).
pub const TAB2: [u8; 256] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x09, 0x08, 0x0b, 0x0a, 0x0d, 0x0c, 0x0f, 0x0e,
0x12, 0x13, 0x10, 0x11, 0x16, 0x17, 0x14, 0x15, 0x1b, 0x1a, 0x19, 0x18, 0x1f, 0x1e, 0x1d, 0x1c,
@@ -46,12 +45,12 @@ pub const TAB2: [u8; 256] = [
0xed, 0xec, 0xef, 0xee, 0xe9, 0xe8, 0xeb, 0xea, 0xe4, 0xe5, 0xe6, 0xe7, 0xe0, 0xe1, 0xe2, 0xe3,
];
/// Table 3: LFSR1 9-bit low-word feedback table (512 entries).
/// Table 3: LFSR1 9-bit low-word feedback table (512 entries) — a fixed constant
/// of the CSS cipher (per the published algorithm).
///
/// Byte-identical to libdvdcss `p_css_tab3` (csstables.h): the 8-value
/// block `BASE[i & 7]` repeated 64 times. The CSS LFSR1 step indexes this
/// table with the 9-bit low register (0x100..=0x1FF), but only the low 3
/// bits select the output — the high bits are ignored, hence the constant
/// It is the 8-value block `BASE[i & 7]` repeated 64 times. The CSS LFSR1 step
/// indexes this table with the 9-bit low register (0x100..=0x1FF), but only the
/// low 3 bits select the output — the high bits are ignored, hence the constant
/// blocks. The 512-entry width simply lets the 9-bit index be used without
/// masking.
pub const TAB3: [u8; 512] = [
@@ -197,12 +196,12 @@ mod tests {
}
}
/// TAB3 is the libdvdcss `p_css_tab3`: the 8-value feedback block
/// TAB3 is the CSS LFSR1 low-word table: the 8-value feedback block
/// BASE = [0x00,0x24,0x49,0x6d,0x92,0xb6,0xdb,0xff]
/// repeated 64 times — `TAB3[i] == BASE[i & 7]`. The high bits of the
/// 9-bit index do not affect the output (libdvdcss's LFSR1 step indexes
/// with the full 9-bit low register but only `& 7` matters). This pins
/// all 512 entries to the published table.
/// 9-bit index do not affect the output (the LFSR1 step indexes with the
/// full 9-bit low register but only `& 7` matters). This pins all 512
/// entries to the published cipher's table.
///
/// Mutation: flip any single byte in the TAB3 literal -> the formula
/// check fails at that index.
+349 -80
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] },
@@ -166,6 +171,183 @@ impl DecryptKeys {
}
}
/// Proactive AACS key-selection map: which held unit key decrypts each LBA of a
/// title's encrypted content, decided ONCE before mux from the disc's CPS-unit
/// (and, later, FMTS segment) structure — never by trial-decrypt-and-check per
/// unit at mux time.
///
/// This is the pivot that ends the mux "key-server storm": the old path decrypts
/// a unit, checks whether the plaintext looks like clean MPEG-TS, and — because
/// authored-bad content never reaches that bar — concludes "wrong key, fetch a
/// fresh one" and re-asks the key service for units it already holds the correct
/// key for. There is NO per-unit byte pattern that separates "correctly decrypted
/// but authored-bad" from "still encrypted", so that check is unanswerable. The
/// map removes the question: we resolve one key per CPS unit / segment up front
/// (see `resolve_mux_key_map`), record which LBA ranges each covers, and at mux
/// time simply "decrypt this LBA with key K" and trust it — bad TS is the muxer's
/// concern, exactly as for a physically-read clear disc.
///
/// Ranges are `[start_lba, end_lba)` → index into the `Aacs { unit_keys }` pool,
/// sorted and disjoint. `default_idx` covers any LBA no range claims — the
/// single-CPS case is just an empty range list with `default_idx = 0`, so the
/// common disc pays zero lookup cost and needs no structural walk.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct AacsKeyMap {
ranges: Vec<(u32, u32, usize)>,
default_idx: usize,
}
impl AacsKeyMap {
/// The whole title is one CPS unit → one key (`idx`) everywhere. This is the
/// overwhelmingly common disc (incl. every single-CPS UHD); no LBA walk.
pub fn single(idx: usize) -> Self {
Self {
ranges: Vec::new(),
default_idx: idx,
}
}
/// Build from explicit `[start_lba, end_lba) → key_idx` ranges (multi-CPS /
/// FMTS). Ranges are sorted; `default_idx` answers any uncovered LBA.
pub fn from_ranges(mut ranges: Vec<(u32, u32, usize)>, default_idx: usize) -> Self {
ranges.sort_by_key(|&(start, _, _)| start);
Self {
ranges,
default_idx,
}
}
/// The unit-key index to decrypt the aligned unit at `lba` with. O(log n) —
/// the last range whose start is `<= lba` and whose end is `> lba`, else the
/// default. Cheap enough to call per aligned unit on the mux hot path.
pub fn key_idx_for(&self, lba: u32) -> usize {
if self.ranges.is_empty() {
return self.default_idx;
}
match self
.ranges
.binary_search_by(|&(start, _, _)| start.cmp(&lba))
{
Ok(i) => self.ranges[i].2,
Err(0) => self.default_idx,
Err(i) => {
let (start, end, idx) = self.ranges[i - 1];
if lba >= start && lba < end {
idx
} else {
self.default_idx
}
}
}
}
/// The `[start_lba, end_lba) → key_idx` ranges (sorted, disjoint). Empty for a
/// single-CPS map (everything uses [`default_idx`](Self::default_idx)).
pub fn ranges(&self) -> &[(u32, u32, usize)] {
&self.ranges
}
/// The key index for any LBA no explicit range claims (the single-CPS key).
pub fn default_idx(&self) -> usize {
self.default_idx
}
/// The distinct key indices this map can select — the CPS units / segments a
/// title actually reaches. Used by the resolver to know which keys to secure
/// up front.
pub fn key_indices(&self) -> Vec<usize> {
let mut v: Vec<usize> = self.ranges.iter().map(|&(_, _, i)| i).collect();
v.push(self.default_idx);
v.sort_unstable();
v.dedup();
v
}
}
/// Decrypt a buffer of sectors in-place using a resolved [`AacsKeyMap`] — the
/// mux's TRUSTED decrypt. `base_lba` is the absolute LBA of `buf`'s first sector;
/// each aligned unit (3 sectors) is decrypted with the key the map assigns to its
/// LBA. There is NO key trial and NO `is_clean` verdict: the map already decided
/// the key from disc structure, so we apply it and move on — a unit that decrypts
/// to authored-bad TS passes through for the muxer to drop, never re-fetched.
///
/// Only [`DecryptKeys::Aacs`] uses a map (CSS self-cracks per region inside
/// [`decrypt_sectors`]; `None` is clear) — other variants are a no-op here so the
/// decorator can dispatch uniformly. A map index outside the held pool is a
/// fail-loud [`Error::DecryptFailed`]: the resolver's job is to guarantee every
/// selectable index is present, so a gap here is a resolver bug, not silent loss.
pub fn decrypt_sectors_mapped(
buf: &mut [u8],
keys: &DecryptKeys,
base_lba: u32,
map: &AacsKeyMap,
) -> Result<(), crate::error::Error> {
let (unit_keys, rdk, format) = match keys {
DecryptKeys::Aacs {
unit_keys,
read_data_key,
format,
} => (unit_keys, *read_data_key, *format),
// Clear / CSS: the mapped path is AACS-only. Leave the buffer untouched;
// CSS descrambles via `decrypt_sectors` and `None` is already clear.
_ => return Ok(()),
};
let unit_len = aacs::content::ALIGNED_UNIT_LEN;
let unit_sectors = (unit_len / 2048) as u32;
// Validate every selectable index up front (fail loud) so the per-unit hot
// loop can index without bounds churn and a resolver gap never silently
// passes ciphertext through as "decrypted".
for idx in map.key_indices() {
if unit_keys.get(idx).is_none() {
return Err(crate::error::Error::DecryptFailed);
}
}
let decrypt_one = |idx_in_buf: usize, chunk: &mut [u8]| {
if chunk.len() != unit_len {
return; // trailing partial unit: clear tail on disc, leave as-is
}
// Gate on the authoritative encrypted flag ONLY (the CPI bits in the clear
// seed) — no `is_clean`. A clear unit (flag unset) is left untouched; an
// encrypted unit is decrypted with its MAPPED key and trusted.
if !aacs::content::aacs_unit_encrypted(chunk, format) {
return;
}
let unit_lba = base_lba.saturating_add((idx_in_buf as u32) * unit_sectors);
let key_idx = map.key_idx_for(unit_lba);
// Bounds already proven above; index directly.
let key = &unit_keys[key_idx].1;
if let Some(ref rdk_key) = rdk {
aacs::content::decrypt_bus(chunk, rdk_key);
}
aacs::content::decrypt_unit(chunk, key);
};
let nthreads = decrypt_threads();
let nunits = buf.len() / unit_len;
if nthreads <= 1 || nunits < PARALLEL_MIN_UNITS {
for (i, chunk) in buf.chunks_mut(unit_len).enumerate() {
decrypt_one(i, chunk);
}
} else {
match decrypt_pool() {
Some(pool) => pool.install(|| {
buf.par_chunks_mut(unit_len)
.enumerate()
.for_each(|(i, chunk)| decrypt_one(i, chunk));
}),
None => {
for (i, chunk) in buf.chunks_mut(unit_len).enumerate() {
decrypt_one(i, chunk);
}
}
}
}
Ok(())
}
/// Decrypt a buffer of sectors in-place.
///
/// For AACS: processes in 6144-byte aligned units (3 sectors).
@@ -180,14 +362,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 +388,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 +425,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 +434,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
@@ -272,7 +463,7 @@ fn decrypt_sectors_impl(
// silent corruption. We fail loud (Error::DecryptFailed), matching
// the highway path's Error::ExtentNotUnitAligned policy.
//
// Detection: ts_sync_destroyed() short-circuits to false for any
// Detection: !crate::aacs::content::is_clean(, crate::disc::ContentFormat::BdTs) short-circuits to false for any
// buffer shorter than a full unit, so it cannot judge a partial. We
// instead apply the same TS-sync-intactness test it uses internally
// (ts_sync_count vs ts_packet_total) directly to the available
@@ -293,10 +484,14 @@ 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. Validity is the SAME
// `is_clean` proof floor used everywhere — a clear TS tail passes it,
// a scrambled one fails. PS (`.evo`) partials lack the TS structure,
// so this stays TS-only (HD-DVD partial-scramble is not yet wired).
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 {
if !aacs::content::is_clean(partial, format) {
return Err(crate::error::Error::DecryptFailed);
}
}
@@ -320,27 +515,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 +551,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 +649,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 +686,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 +708,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 +780,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 +814,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 +833,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 +884,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 +903,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 +1022,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 +1040,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 +1060,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 +1083,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 +1109,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 +1132,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 +1146,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 +1188,7 @@ mod tests {
DecryptKeys::Aacs {
unit_keys: vec![(0, [0; 16])],
read_data_key: None,
format: crate::disc::ContentFormat::BdTs,
}
.is_encrypted()
);
@@ -1141,6 +1403,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 +1424,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");
@@ -1235,13 +1499,14 @@ mod tests {
let mut unit = clear_ts_unit();
aacs_encrypt_unit_for_test(&mut unit, &key1);
assert!(
aacs::content::ts_sync_destroyed(&unit),
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
"encrypted unit must look scrambled before decrypt"
);
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.
@@ -1249,7 +1514,7 @@ mod tests {
decrypt_sectors(&mut buf, &mut keys, 0).expect("multi-CPS decrypt must succeed");
assert!(
!aacs::content::ts_sync_destroyed(&buf),
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
"unit encrypted under key1 must be fully decrypted (TS syncs restored)"
);
// Every sync position must carry 0x47.
@@ -1276,11 +1541,12 @@ 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");
assert!(
!aacs::content::ts_sync_destroyed(&buf),
crate::aacs::content::is_clean(&buf, crate::disc::ContentFormat::BdTs),
"single-key disc: TS syncs must be restored"
);
assert_eq!(
@@ -1290,21 +1556,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
@@ -1313,26 +1576,27 @@ mod tests {
aacs_encrypt_unit_for_test(&mut unit, &real_key);
let ciphertext = unit.clone();
assert!(
aacs::content::ts_sync_destroyed(&unit),
!crate::aacs::content::is_clean(&unit, crate::disc::ContentFormat::BdTs),
"encrypted unit must look scrambled going in"
);
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 +1627,26 @@ 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]),
crate::aacs::content::is_clean(
&buf[..aacs::content::ALIGNED_UNIT_LEN],
crate::disc::ContentFormat::BdTs
),
"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 +1660,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>`
+444 -18
View File
@@ -9,26 +9,28 @@
//! rule: a genuinely different format is a new enumerator, not an extension
//! bolted into the BD path.
//!
//! Title composition: the `HVA*.VTI` navigation file (the DVD-IFO analogue) names
//! every clip in authored order. Standard Content splits the main feature across
//! clips at the layer break (`FEATURE_1`/`FEATURE_2`, or `feature`/`feature_Divide`);
//! the scanner parses the VTI clip table and concatenates those parts into ONE
//! [`DiscTitle`] (so the largest-title pick is the whole movie, not just part 1),
//! emitting every other clip as its own title. Container is
//! [`ContentFormat::MpegPs`], so the existing PS mux path handles it. Per-clip
//! streams ARE enumerated: the clip head is demuxed through the PS demuxer and one
//! [`Stream`] is built per distinct elementary stream (video + DD+ audio
//! sub-streams), with the codec sniffed from the ES bytes.
//! Title composition — authoritative, from the Advanced-Content playlist. HD-DVD
//! ships a real player playlist at `ADV_OBJ/VPLST000.XPL` (DVD-Forum
//! `HDDVDVideo/Playlist` XML). The scanner parses it (with a real XML parser,
//! `roxmltree`) into one [`DiscTitle`] per `<Title>`: its `<PrimaryAudioVideoClip>`
//! clips in playback order (each an EVO, referenced via its `.MAP` sidecar), the
//! `titleDuration`, the `displayName`, and the `<ChapterList>`. A layer-break
//! split (`FEATURE_1` + `FEATURE_2`, or `feature`/`feature_Divide`) is composed
//! into ONE title with the two parts as clips, carrying each clip's title-time
//! in/out points (45 kHz ticks) so a seamless join can be spliced onto one
//! timeline. Container is [`ContentFormat::MpegPs`], so the existing PS mux path
//! handles it. Per-clip streams are enumerated by demuxing the clip head and
//! building one [`Stream`] per distinct elementary stream (video + DD+ audio),
//! codec sniffed from the ES bytes.
//!
//! What is NOT parsed yet — and is honestly stubbed, not faked:
//! * full VTI program-chain parsing (chapters, non-feature title grouping) —
//! the feature join uses the clip table + the `feature*` naming convention,
//! not the authoritative PGC,
//! * `.map` timemap → real durations,
//! * subtitles (8-bit RLC on `0xBD` sub `0x20..=0x3F`).
//! When no playlist is present (or it fails to parse), the scanner falls back to
//! the older clip-name heuristic: parse the `HVA*.VTI` clip table, join the
//! `feature*`-named clips into one title, and emit every other clip on its own.
//!
//! Extents and size ARE real (the ripper needs those to image a clip); durations
//! and chapters are left empty rather than guessed.
//! Not parsed yet: subtitles (8-bit RLC on `0xBD` sub `0x20..=0x3F`) and per-track
//! audio languages (the XPL carries `<Audio description=...>` but they are not yet
//! wired onto the streams). Extents and size are real (the ripper images the
//! clips).
use super::*;
use crate::mux::ps::{PsDemuxer, dvd_audio_pid};
@@ -316,6 +318,229 @@ fn collect_es(
}
}
// ─────────────────────── Advanced-Content playlist (XPL) ──────────────────
//
// HD-DVD Advanced Content ships an authoritative playlist at
// `ADV_OBJ/VPLST000.XPL` (DVD-Forum `HDDVDVideo/Playlist` XML). It is the real
// player playlist: a `<TitleSet>` of `<Title>`s, each naming its
// `<PrimaryAudioVideoClip>` clips (an EVO, via its `.MAP` sidecar) in playback
// order with title-time in/out points, a `titleDuration`, a `displayName`, and a
// `<ChapterList>`. Parsing it gives authoritative title composition — clips,
// duration, name, chapters — instead of the `feature*` clip-name heuristic, plus
// the per-clip title-time offsets needed to splice a layer-break split
// (`FEATURE_1` + `FEATURE_2`) onto one continuous timeline. Parsed with a real
// XML parser (`roxmltree`), not a hand-rolled scanner — the XPL is genuine XML.
/// One clip reference inside an XPL `<Title>`: the resolved `.evo` name (lower
/// case) and the clip's placement on the title timeline, in seconds.
struct XplClip {
evo: String,
begin_secs: f64,
end_secs: f64,
}
/// One `<Title>` from the XPL: number, display name, total duration, its clips
/// in playback order, and chapter start times (seconds).
struct XplTitle {
number: u16,
name: String,
duration_secs: f64,
clips: Vec<XplClip>,
chapters: Vec<f64>,
}
/// Parse an `HH:MM:SS:FF` (or `MM:SS:FF`) timecode at `tick_base` frames/sec into
/// seconds. `None` on a malformed field.
fn parse_timecode(s: &str, tick_base: u32) -> Option<f64> {
let n: Vec<u32> = s
.split(':')
.map(|p| p.trim().parse::<u32>())
.collect::<std::result::Result<Vec<u32>, _>>()
.ok()?;
let tb = tick_base.max(1) as f64;
let (h, m, sec, f) = match n.as_slice() {
[h, m, s, f] => (*h, *m, *s, *f),
[m, s, f] => (0, *m, *s, *f),
_ => return None,
};
Some(h as f64 * 3600.0 + m as f64 * 60.0 + sec as f64 + f as f64 / tb)
}
/// `tickBase="60fps"` → 60. Defaults to 60 when absent/unparseable.
fn parse_tick_base(s: &str) -> u32 {
let digits: String = s.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse().unwrap_or(60)
}
/// `<PrimaryAudioVideoClip src="file:///.../FEATURE_1.MAP">` → `feature_1.evo`:
/// take the basename, drop the extension, normalise to a lower-case `.evo` name
/// (the playlist references the `.MAP` sidecar; the A/V is the same-stem `.EVO`).
fn evo_from_src(src: &str) -> Option<String> {
let base = src.rsplit(['/', '\\']).next().unwrap_or(src);
let stem = base.rsplit_once('.').map(|(s, _)| s).unwrap_or(base);
if stem.is_empty() {
return None;
}
Some(format!("{}.evo", stem.to_ascii_lowercase()))
}
/// Parse the Advanced-Content playlist into its titles. Elements are matched by
/// LOCAL name (the document is in the `HDDVDVideo/Playlist` default namespace).
/// Returns empty for a non-XML / non-playlist blob so the caller falls back to
/// the clip-name heuristic.
fn parse_xpl_titles(xpl: &[u8]) -> Vec<XplTitle> {
let text = String::from_utf8_lossy(xpl);
let Ok(doc) = roxmltree::Document::parse(&text) else {
return Vec::new();
};
let local = |n: &roxmltree::Node, name: &str| n.tag_name().name() == name;
// tickBase lives on <TitleSet> (default 60fps).
let tick_base = doc
.descendants()
.find(|n| local(n, "TitleSet"))
.and_then(|n| n.attribute("tickBase"))
.map(parse_tick_base)
.unwrap_or(60);
let mut titles = Vec::new();
for tnode in doc.descendants().filter(|n| local(n, "Title")) {
let number = tnode
.attribute("titleNumber")
.and_then(|s| s.trim().parse::<u16>().ok())
.unwrap_or(0);
let name = tnode
.attribute("displayName")
.or_else(|| tnode.attribute("id"))
.unwrap_or("")
.to_string();
let duration_secs = tnode
.attribute("titleDuration")
.and_then(|s| parse_timecode(s, tick_base))
.unwrap_or(0.0);
let mut clips = Vec::new();
for c in tnode
.descendants()
.filter(|n| local(n, "PrimaryAudioVideoClip"))
{
let Some(evo) = c.attribute("src").and_then(evo_from_src) else {
continue;
};
let begin_secs = c
.attribute("titleTimeBegin")
.and_then(|s| parse_timecode(s, tick_base))
.unwrap_or(0.0);
let end_secs = c
.attribute("titleTimeEnd")
.and_then(|s| parse_timecode(s, tick_base))
.unwrap_or(begin_secs);
clips.push(XplClip {
evo,
begin_secs,
end_secs,
});
}
if clips.is_empty() {
continue;
}
let chapters = tnode
.descendants()
.filter(|n| local(n, "Chapter"))
.filter_map(|ch| {
ch.attribute("titleTimeBegin")
.and_then(|s| parse_timecode(s, tick_base))
})
.collect();
titles.push(XplTitle {
number,
name,
duration_secs,
clips,
chapters,
});
}
titles
}
/// Read the Advanced-Content playlist `ADV_OBJ/VPLST*.XPL`, if present.
fn read_adv_obj_xpl(reader: &mut dyn SectorSource, udf_fs: &udf::UdfFs) -> Option<Vec<u8>> {
let dir = udf_fs.find_dir("/ADV_OBJ")?;
let name = dir.entries.iter().find_map(|e| {
let lower = e.name.to_ascii_lowercase();
(!e.is_dir && lower.starts_with("vplst") && lower.ends_with(".xpl")).then(|| e.name.clone())
})?;
udf_fs.read_file(reader, &format!("/ADV_OBJ/{name}")).ok()
}
/// Compose [`DiscTitle`]s from parsed XPL titles: resolve each title's clips to
/// physical extents, concatenate them in playback order, carry the title-time
/// in/out points onto each [`Clip`] (45 kHz ticks — the offset that splices a
/// layer-break split onto one timeline), and attach the duration, name, and
/// chapters. A title whose clips resolve to no on-disc extents is skipped.
fn compose_xpl_titles(
reader: &mut dyn SectorSource,
xpl_titles: &[XplTitle],
clip_extents: &BTreeMap<String, (String, u64, Vec<Extent>)>,
) -> Vec<DiscTitle> {
let mut titles = Vec::new();
for t in xpl_titles {
let mut extents = Vec::new();
let mut size_bytes = 0u64;
let mut parts = Vec::new();
for c in &t.clips {
let Some((orig, size, exts)) = clip_extents.get(&c.evo) else {
continue;
};
extents.extend_from_slice(exts);
size_bytes += *size;
parts.push(Clip {
clip_id: orig
.rsplit_once('.')
.map(|(b, _)| b)
.unwrap_or(orig)
.to_string(),
in_time: (c.begin_secs * 45000.0).clamp(0.0, u32::MAX as f64) as u32,
out_time: (c.end_secs * 45000.0).clamp(0.0, u32::MAX as f64) as u32,
duration_secs: (c.end_secs - c.begin_secs).max(0.0),
source_packets: 0,
});
}
if parts.is_empty() {
continue;
}
let streams = probe_evo_streams(reader, &extents);
let chapters = t
.chapters
.iter()
.enumerate()
.map(|(i, &ts)| Chapter {
time_secs: ts.max(0.0),
name: super::chapter_name(i),
})
.collect();
titles.push(DiscTitle {
playlist: if t.name.is_empty() {
format!("Title {}", t.number)
} else {
t.name.clone()
},
playlist_id: t.number,
duration_secs: t.duration_secs,
size_bytes,
clips: parts,
streams,
chapters,
extents,
content_format: ContentFormat::MpegPs,
codec_privates: Vec::new(),
});
}
titles
}
impl Disc {
/// Scan HD-DVD titles from the `HVDVD_TS/` `.evo` clips.
///
@@ -375,6 +600,18 @@ impl Disc {
}
}
// Authoritative composition from the Advanced-Content playlist
// (`ADV_OBJ/VPLST*.XPL`): real per-title clip lists, durations, names,
// chapters, and title-time offsets. This is the primary path; the
// clip-name heuristic below is the fallback when the playlist is absent
// or unparseable (or resolves to no on-disc clips).
if let Some(xpl) = read_adv_obj_xpl(reader, udf_fs) {
let composed = compose_xpl_titles(reader, &parse_xpl_titles(&xpl), &clip_extents);
if !composed.is_empty() {
return composed;
}
}
// Feature clips, in authored order, that actually resolved to extents.
let feature: Vec<String> = order
.iter()
@@ -966,4 +1203,193 @@ mod tests {
"VC-1 routes to the extended-stream-id PID 0xFD55"
);
}
// ─────────────────────── Advanced-Content playlist (XPL) ──────────────
/// A minimal but faithful VPLST000.XPL: the DVD-Forum default namespace, an
/// XML comment, a `<TitleSet tickBase="60fps">`, a MainMovie title whose main
/// feature is a two-clip layer-break split (FEATURE_1 + FEATURE_2, seamless)
/// with two chapters, and a separate deleted-scene title. Attribute order
/// varies (as it does across real discs).
const SYNTH_XPL: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<Playlist majorVersion="1" minorVersion="0" xmlns="http://www.dvdforum.org/2005/HDDVDVideo/Playlist">
<!-- Authored with TOSHIBA AdvMain -->
<TitleSet timeBase="60fps" tickBase="60fps" defaultLanguage="en">
<Title titleNumber="2" titleDuration="01:37:20:00" id="MainMovie" displayName="Main Movie">
<PrimaryAudioVideoClip titleTimeBegin="00:00:00:00" titleTimeEnd="00:48:29:50" src="file:///dvddisc/HVDVD_TS/FEATURE_1.MAP" dataSource="Disc">
<Video track="1" mediaAttr="2"/>
<Audio track="1" streamNumber="1" description="English DD+"/>
</PrimaryAudioVideoClip>
<PrimaryAudioVideoClip titleTimeBegin="00:48:29:50" clipTimeBegin="00:00:00:00" titleTimeEnd="01:37:20:00" src="file:///dvddisc/HVDVD_TS/FEATURE_2.MAP" seamless="true">
<Video track="1" mediaAttr="2"/>
</PrimaryAudioVideoClip>
<ChapterList>
<Chapter displayName="Chapter 1" titleTimeBegin="00:00:00:00" />
<Chapter displayName="Chapter 2" titleTimeBegin="00:03:40:30" />
</ChapterList>
</Title>
<Title titleNumber="7" titleDuration="00:00:44:29" id="Deleted5" displayName="Deleted Scenes - Veronica Past">
<PrimaryAudioVideoClip titleTimeBegin="00:00:00:00" titleTimeEnd="00:00:44:29" src="file:///dvddisc/HVDVD_TS/DEL5_VERONICAPAST.MAP" />
</Title>
</TitleSet>
</Playlist>"#;
#[test]
fn parse_timecode_hhmmssff_at_60fps() {
// 01:37:20:00 = 5840 s exactly.
assert!((parse_timecode("01:37:20:00", 60).unwrap() - 5840.0).abs() < 1e-6);
// 00:48:29:50 = 48m29s + 50/60 frames.
let want = 48.0 * 60.0 + 29.0 + 50.0 / 60.0;
assert!((parse_timecode("00:48:29:50", 60).unwrap() - want).abs() < 1e-6);
// MM:SS:FF short form (hours omitted).
assert!((parse_timecode("02:05:15", 60).unwrap() - (125.0 + 15.0 / 60.0)).abs() < 1e-6);
assert_eq!(parse_timecode("garbage", 60), None);
assert_eq!(parse_timecode("", 60), None);
}
#[test]
fn evo_from_src_maps_map_sidecar_to_evo() {
assert_eq!(
evo_from_src("file:///dvddisc/HVDVD_TS/FEATURE_1.MAP").as_deref(),
Some("feature_1.evo")
);
// Already an EVO, or lowercase feature — normalise to lower `.evo`.
assert_eq!(evo_from_src("feature.EVO").as_deref(), Some("feature.evo"));
assert_eq!(
evo_from_src("file:///x/feature_Divide.MAP").as_deref(),
Some("feature_divide.evo")
);
// Empty stem → None (defensive against a malformed src).
assert_eq!(evo_from_src("file:///x/").as_deref(), None);
}
#[test]
fn parse_xpl_titles_reads_titles_clips_chapters_durations() {
let titles = parse_xpl_titles(SYNTH_XPL.as_bytes());
assert_eq!(titles.len(), 2, "MainMovie + one deleted-scene title");
let mm = &titles[0];
assert_eq!(mm.number, 2);
assert_eq!(mm.name, "Main Movie");
assert!(
(mm.duration_secs - 5840.0).abs() < 1e-6,
"97:20 from titleDuration"
);
// The layer-break split is ONE title with TWO clips, contiguous timeline.
assert_eq!(mm.clips.len(), 2);
assert_eq!(mm.clips[0].evo, "feature_1.evo");
assert_eq!(mm.clips[1].evo, "feature_2.evo");
assert!(
(mm.clips[0].end_secs - mm.clips[1].begin_secs).abs() < 1e-6,
"FEATURE_2 begins exactly where FEATURE_1 ends (seamless join)"
);
assert!(
mm.clips[1].begin_secs > 0.0,
"second clip carries a title-time offset"
);
assert_eq!(mm.chapters.len(), 2);
assert!((mm.chapters[1] - (3.0 * 60.0 + 40.0 + 30.0 / 60.0)).abs() < 1e-6);
let del = &titles[1];
assert_eq!(del.name, "Deleted Scenes - Veronica Past");
assert_eq!(del.clips.len(), 1);
assert_eq!(del.clips[0].evo, "del5_veronicapast.evo");
}
#[test]
fn parse_xpl_titles_returns_empty_on_non_xml() {
assert!(parse_xpl_titles(b"not xml at all").is_empty());
assert!(parse_xpl_titles(&[0xFF, 0x00, 0x01, 0x02]).is_empty());
assert!(parse_xpl_titles(b"<Playlist></Playlist>").is_empty());
}
/// Build a UDF with `HVDVD_TS/` `.evo` clips plus an `ADV_OBJ/VPLST000.XPL`
/// carrying `xpl`, so `scan_hddvd_titles` takes the playlist path.
fn make_hddvd_fs_xpl(
disc: &mut MemDisc,
evos: &[(&str, u32, u32)],
xpl: &[u8],
) -> crate::udf::UdfFs {
let mut hv_files = Vec::new();
let mut icb = 100u32;
for (name, sectors, data_lba) in evos {
hv_files.push(file(name, icb, *data_lba, sectors * 2048, true));
icb += 1;
}
let root = DirSpec {
name: String::new(),
icb_lba: 10,
dir_data_lba: 11,
files: Vec::new(),
subdirs: vec![
DirSpec {
name: "HVDVD_TS".to_string(),
icb_lba: 20,
dir_data_lba: 21,
files: hv_files,
subdirs: vec![],
},
DirSpec {
name: "ADV_OBJ".to_string(),
icb_lba: 30,
dir_data_lba: 31,
files: vec![file_with("VPLST000.XPL", 40, 4000, xpl.to_vec(), true)],
subdirs: vec![],
},
],
};
build_udf_skeleton(disc, 10);
lay_dir(disc, &root);
crate::udf::read_filesystem(disc).expect("fs")
}
/// The authoritative path: when a VPLST000.XPL is present, titles come from
/// the playlist — the layer-break split (FEATURE_1 + FEATURE_2) is composed
/// into ONE title with the real duration, name, chapters, and per-clip
/// title-time offsets — not the clip-name heuristic.
#[test]
fn scan_hddvd_composes_titles_from_xpl_playlist() {
let mut disc = MemDisc::new();
let udf = make_hddvd_fs_xpl(
&mut disc,
&[
("FEATURE_1.EVO", 2000, 5000),
("FEATURE_2.EVO", 1800, 9000),
("DEL5_VERONICAPAST.EVO", 100, 12000),
],
SYNTH_XPL.as_bytes(),
);
let titles = Disc::scan_hddvd_titles(&mut disc, &udf);
assert_eq!(
titles.len(),
2,
"the two playlist titles that resolve to clips"
);
let mm = titles
.iter()
.find(|t| t.playlist == "Main Movie")
.expect("MainMovie composed from the playlist");
assert_eq!(mm.playlist_id, 2);
assert!(
(mm.duration_secs - 5840.0).abs() < 1.0,
"97:20 duration from titleDuration, not 0/unknown"
);
assert_eq!(
mm.clips.len(),
2,
"layer-break split kept as ONE title, two clips"
);
// FEATURE_2's clip carries the 48:29 offset (45 kHz ticks) — the datum
// that splices it onto FEATURE_1's timeline instead of restarting at 0.
assert!(
mm.clips[1].in_time > 100_000_000,
"FEATURE_2 offset onto the title timeline (48:29 * 45000), got {}",
mm.clips[1].in_time
);
assert_eq!(mm.chapters.len(), 2);
assert_eq!(mm.chapters[0].name, "1", "bare ordinal chapter name");
// Both feature halves are in ONE title's extents.
assert!(!mm.extents.is_empty());
}
}
+112 -153
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 {
@@ -2625,28 +2655,6 @@ impl Disc {
self.ensure_decryptable_keys(raw, keys)
}
/// Upfront FMTS (AACS 2.1) key gate, parallel to
/// [`ensure_title_decryptable`](Self::ensure_title_decryptable). A 2.1 disc
/// carries forensic variant segments that need segment (variant) keys the
/// unit-key path cannot provide. When
/// [`BYPASS_FMTS_KEY`](crate::aacs::segment::BYPASS_FMTS_KEY) is `false`,
/// their absence is a hard upfront failure ([`Error::FmtsKeyMissing`]) — the
/// same policy as a missing unit key, so a forensic-holed rip is refused, not
/// produced. When `true` (the default today) the segments are skipped as
/// expected loss and this passes. `raw` mode and non-FMTS discs always pass.
pub fn ensure_forensic_segments_decryptable(&self, raw: bool) -> Result<()> {
if raw || crate::aacs::segment::BYPASS_FMTS_KEY {
return Ok(());
}
// A 2.1 (FMTS) disc carries forensic variant segments with no segment-key
// source (none exists yet), so its variant segments cannot be opened.
// Refuse upfront rather than emit a forensic-holed rip.
if self.format == DiscFormat::Fmts {
return Err(Error::FmtsKeyMissing);
}
Ok(())
}
/// Inject pre-resolved AACS unit keys into a scanned disc — the deferred-mux
/// / resume path. The keys come from the mapfile's `# freemkv-uk:` header
/// (persisted at sweep time when the disc was keyed), so the mux decrypts
@@ -2820,7 +2828,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 +3066,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 +3120,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 +3154,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 +3444,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 +3452,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 +3938,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 +4784,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");
@@ -5366,14 +5304,15 @@ mod tests {
#[test]
fn unit_key_validation_gates_on_real_ciphertext() {
use crate::aacs::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
use crate::aacs::content::ALIGNED_UNIT_LEN;
// No samples -> nothing to disprove against -> accept (sample-less paths
// like resume / mapfile must be unaffected).
assert!(super::aligned_unit_keys_validate(
&[(0, [0x11u8; 16])],
None,
&[]
&[],
ContentFormat::BdTs
));
// A clear unit (TS syncs intact) is not scrambled -> proves nothing ->
@@ -5384,18 +5323,22 @@ mod tests {
clear[off] = 0x47;
off += 192;
}
assert!(!ts_sync_destroyed(&clear));
assert!(crate::aacs::content::is_clean(
&clear,
crate::disc::ContentFormat::BdTs
));
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.
let uk = [0x5au8; 16];
let enc = encrypt_unit_for_test(&clear, &uk);
assert!(
ts_sync_destroyed(&enc),
!crate::aacs::content::is_clean(&enc, crate::disc::ContentFormat::BdTs),
"encrypted unit must read scrambled"
);
@@ -5403,16 +5346,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]
@@ -5424,7 +5374,7 @@ mod tests {
// CPS-unit-1 sectors then passed through as raw encrypted bytes into the
// ISO/MKV with no error surfaced. The gate must now reject a key set
// that leaves any scrambled sample uncovered.
use crate::aacs::content::{ALIGNED_UNIT_LEN, ts_sync_destroyed};
use crate::aacs::content::ALIGNED_UNIT_LEN;
let mut clear = vec![0u8; ALIGNED_UNIT_LEN];
let mut off = 4;
@@ -5437,8 +5387,14 @@ mod tests {
let uk1 = [0x22u8; 16];
let sample0 = encrypt_unit_for_test(&clear, &uk0); // CPS unit 0 body
let sample1 = encrypt_unit_for_test(&clear, &uk1); // CPS unit 1 body
assert!(ts_sync_destroyed(&sample0));
assert!(ts_sync_destroyed(&sample1));
assert!(!crate::aacs::content::is_clean(
&sample0,
crate::disc::ContentFormat::BdTs
));
assert!(!crate::aacs::content::is_clean(
&sample1,
crate::disc::ContentFormat::BdTs
));
let samples = vec![sample0.clone(), sample1.clone()];
@@ -5447,21 +5403,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
+1 -1
View File
@@ -6,7 +6,7 @@
//!
//! Byte layout follows the DVD-Video specification (VMGI/VTSI headers,
//! PGC/cell tables, PCI/HLI button packets); the VM command decoder is
//! verified against libdvdnav's decoder.
//! verified against real discs.
//!
//! Current contents: [`vmcmd`] — the VM command decoder (proven against the
//! SOTL/Greenland test discs). The IFO/PCI parsing and the navigation executor
+5 -5
View File
@@ -2,7 +2,7 @@
//!
//! An 8-byte navigation command as found in PGC command tables (pre/post/cell)
//! and PCI button info. Decoded per the DVD-Video VM instruction set and
//! verified against libdvdnav's command decoder.
//! verified against real discs.
//!
//! Bit model: the 8 bytes are a big-endian 64-bit word. `byte0` bits 7-5 are the
//! command **type**; for type 1, `byte0` bit 4 selects Link (0) vs Jump (1), and
@@ -133,7 +133,7 @@ const JP_JUMP_SS: u8 = 6;
const JP_CALL_SS: u8 = 8;
// Link (type 1, direct=0) sub-commands. NOTE: sub-op 0 is NOP/no-link and 1 is
// the LinkSub form (libdvdnav `decoder.c` `eval_link_instruction`).
// the LinkSub form (the DVD-Video VM link instruction).
const LK_SUB: u8 = 1;
const LK_PGCN: u8 = 4;
const LK_PTTN: u8 = 5;
@@ -159,7 +159,7 @@ fn be16(b: &[u8; 8], o: usize) -> u16 {
((b[o] as u16) << 8) | b[o + 1] as u16
}
// Compare-operand layouts ("if_version"s) per libdvdnav `decoder.c`. The op
// Compare-operand layouts ("if_version"s) per the DVD-Video VM. The op
// nibble is always `byte1` bits 6-4; the immediate flag is `byte1` bit 7. The
// operand *offsets* differ by command family.
//
@@ -205,7 +205,7 @@ pub fn decode(b: &[u8; 8]) -> Command {
let cmd = b[1] & 0x0F;
// Compare predicate, with the operand layout for this command family
// (libdvdnav `decoder.c` `vm_eval_command` type dispatch).
// (the DVD-Video VM command type dispatch).
let compare = match (typ, direct) {
(TYPE_SPECIAL, _) => if_v1(b),
(TYPE_LINK_JUMP, 1) => if_v2(b), // jump
@@ -372,7 +372,7 @@ mod tests {
}
}
// Regression for the libdvdnav cross-check: link sub-op 0 = NOP, 1 = LinkSub.
// Regression for the link sub-op decode: 0 = NOP, 1 = LinkSub.
#[test]
fn link_subop_zero_is_nop_one_is_linksub() {
assert_eq!(decode(&h("2000000000000000")).instr, Instr::Nop);
+8 -8
View File
@@ -58,7 +58,7 @@ pub struct DvdTitle {
pub struct DvdCell {
pub first_sector: u32,
pub last_sector: u32,
/// Raw cell-category byte at `cell_playback + 0` (libdvdread layout).
/// Raw cell-category byte at `cell_playback + 0` (DVD-Video IFO layout).
/// Packs block_mode (bits 7-6), block_type (bits 5-4), seamless_play
/// (bit 3), interleaved (bit 2), stc_discontinuity (bit 1),
/// seamless_angle (bit 0). Carried so the extent builder can recognise
@@ -72,7 +72,7 @@ pub struct DvdCell {
}
/// Decoded view of a cell-category byte (`cell_playback + 0`), per the
/// DVD-Video spec / libdvdread `cell_playback_t` layout. Byte-0 bitfields,
/// DVD-Video IFO cell-playback layout. Byte-0 bitfields,
/// MSB-first: `block_mode`(7-6), `block_type`(5-4), `seamless_play`(3),
/// `interleaved`(2), `stc_discontinuity`(1), `seamless_angle`(0). (The real
/// `cell_type` is a karaoke-only field in byte 1, not used here.)
@@ -93,7 +93,7 @@ pub struct CellCategory {
}
impl CellCategory {
/// Decode the raw `cell_playback + 0` byte (libdvdread `read_cell_playback`).
/// Decode the raw `cell_playback + 0` byte (DVD-Video IFO cell playback).
pub fn decode(raw: u8) -> Self {
CellCategory {
block_mode: (raw >> 6) & 0x03,
@@ -423,7 +423,7 @@ fn parse_vts(
}
// VTSI_MAT (VTS_xx_0.IFO header) field offsets — fixed by the DVD-Video
// spec (libdvdread `vtsi_mat_t`). The offsets are constant; the sector
// spec (the VTSI management table). The offsets are constant; the sector
// values they point to are per-disc.
const VTSTT_VOBS_OFFSET: usize = 0xC4; // VTS title VOBS start sector (feature)
const VTS_PGCIT_OFFSET: usize = 0xCC; // VTS_PGCIT sector pointer
@@ -580,7 +580,7 @@ fn parse_audio_attr(data: &[u8], offset: usize) -> Result<DvdAudioAttr> {
_ => Codec::Unknown(coding_mode),
};
let sample_rate_flag = (b1 >> 4) & 0x03; // sample_frequency: byte 1 bits 5-4 (libdvdread audio_attr_t)
let sample_rate_flag = (b1 >> 4) & 0x03; // sample_frequency: byte 1 bits 5-4 (DVD-Video audio attributes)
let sample_rate = match sample_rate_flag {
0 => 48000,
1 => 96000,
@@ -1071,7 +1071,7 @@ mod tests {
/// reads with — a co-edit of constant + helper would silently re-introduce
/// the PAL-as-NTSC bug and every test would still pass. This test feeds
/// `parse_video_attr` HARDCODED bytes captured from real DVD-Video layouts
/// (DVD spec / libdvdread `video_attr_t`: mpeg_version[7-6] video_format[5-4]
/// (DVD-Video video attributes: mpeg_version[7-6] video_format[5-4]
/// display_aspect[3-2] permitted_df[1-0]) — no `v_atr_byte`. If the parser's
/// bit positions drift, these fail.
#[test]
@@ -1238,7 +1238,7 @@ mod tests {
// ─────────────────────────────────────────────────────────────────────
// Added hardening tests. Grounded in the DVD-Video IFO spec
// (dvd_udf / libdvdread ifo_types.h; http://dvd.sourceforge.net).
// (DVD-Video IFO format; http://dvd.sourceforge.net).
// ─────────────────────────────────────────────────────────────────────
/// BCD frame-rate flag: bits 7-6 of byte[3]. 0b01 = 25fps (PAL),
@@ -1593,7 +1593,7 @@ mod tests {
}
}
/// CellCategory decodes the libdvdread byte-0 bitfields: block_mode (7-6),
/// CellCategory decodes the DVD-Video cell-category byte-0 bitfields: block_mode (7-6),
/// block_type (5-4), seamless_play (3), interleaved (2),
/// stc_discontinuity (1), seamless_angle (0).
#[test]
+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`.
+50 -14
View File
@@ -23,6 +23,22 @@ use crate::aacs::types::{UnitKey, Vid};
use crate::disc::Key;
use crate::error::Error;
/// Minimum encrypted-content unit samples a single online key request must carry.
///
/// The key service identifies a key by which of the submitted units it decrypts,
/// so too few samples — especially on FMTS, where a segment interleaves several
/// variants at the unit level — can return a key that matches an incidental unit
/// rather than the one asked about (a false positive). This many distinct units
/// make the request unambiguous.
///
/// Canonical here (the base crate) so BOTH consumers agree on one value: the
/// online source in `freemkv-keysources` (which refuses to send an under-sampled
/// request) re-exports it, and libfreemkv's own FMTS forensic query
/// ([`crate::mux`]) sizes its per-segment batch by it. Layering forbids the
/// reverse import (keysources depends on libfreemkv, not vice versa), so the
/// value lives at the lower layer both share.
pub const MIN_SAMPLE_UNITS: usize = 8;
/// The public AACS inputs a key source needs to look a disc up. Captured at
/// scan; contains no secrets — only the disc identity and the on-disc AACS
/// structures a source or key server may key on.
@@ -318,7 +334,28 @@ pub fn key_fetch(
inputs: DiscInputs,
make_sources: std::sync::Arc<dyn Fn() -> Vec<Box<dyn KeySource>> + Send + Sync>,
) -> crate::sector::KeyFetch {
// Memoize by the fingerprint of the sample batch. The resolved keys are
// disc-level (the same clip's index / CPS keys are identical for every title
// that references it), and this one closure is shared across every title's mux
// — so the first title resolves a given batch over the network and every later
// title (or repeated batch) is answered from the cache with no request. Empty
// replies are cached too: a key the service does not have for a batch will not
// appear on a re-ask, so re-hitting the network buys nothing.
let cache: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<u64, Vec<[u8; 16]>>>> =
std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
std::sync::Arc::new(move |samples: &[Vec<u8>]| -> Vec<[u8; 16]> {
let fp = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
samples.len().hash(&mut h);
for s in samples {
s.hash(&mut h);
}
h.finish()
};
if let Some(hit) = cache.lock().unwrap_or_else(|e| e.into_inner()).get(&fp) {
return hit.clone();
}
let sources = make_sources();
let mut di = inputs.clone();
di.samples = samples.to_vec();
@@ -327,10 +364,15 @@ pub fn key_fetch(
// derives unit keys from `enc_title_keys`, which a V10 disc parses at the
// 48-byte stride — hardcoding the V20 stride here corrupted them.
let ctx = DiscInputsCtx::new(&di);
fetch_unit_keys(&sources, &ctx)
let keys: Vec<[u8; 16]> = fetch_unit_keys(&sources, &ctx)
.into_iter()
.map(|u| u.key)
.collect()
.collect();
cache
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(fp, keys.clone());
keys
})
}
@@ -346,7 +388,7 @@ pub fn key_fetch(
///
/// "Encrypted" is decided by [`crate::aacs::content::aacs_unit_encrypted`] — the
/// AACS Copy Permission Indicator (CPI) in the top 2 bits of byte 0, the
/// spec-correct signal (libaacs' `buf[0] & 0xc0`). NOT the `ts_sync_destroyed`
/// spec-correct signal (`buf[0] & 0xc0`). NOT the `ts_sync_destroyed`
/// sync heuristic: destroyed TS syncs do not imply encryption (an FMTS variant
/// frame or an odd clear unit can lack syncs yet be unencrypted), and a clear
/// unit sent to a key server yields nothing to validate against — the "0
@@ -407,7 +449,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;
@@ -563,10 +605,7 @@ mod tests {
struct HasKey([u8; 16]);
impl KeySource for HasKey {
fn get_uk(&self, _ctx: &dyn ResolveCtx) -> Result<Vec<UnitKey>, Error> {
Ok(vec![UnitKey {
idx: 0,
key: self.0,
}])
Ok(vec![UnitKey::new(0, self.0)])
}
}
@@ -612,10 +651,7 @@ mod tests {
if let Ok(s) = ctx.samples(8) {
self.seen.lock().unwrap().extend(s);
}
Ok(vec![UnitKey {
idx: 0,
key: self.key,
}])
Ok(vec![UnitKey::new(0, self.key)])
}
}
@@ -723,7 +759,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)"
);
}
@@ -806,7 +842,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!(
+5 -1
View File
@@ -187,7 +187,10 @@ pub use identity::DriveId;
// don't touch `DecryptKeys` directly — `DiscStream::new(reader, title, keys, …)`
// accepts whatever `Disc::decrypt_keys()` returned. `decrypt_sectors()` is
// for callers that operate on raw sector buffers (e.g. ISO patching).
pub use decrypt::{DecryptKeys, decrypt_sectors, decrypt_threads, set_decrypt_threads};
pub use decrypt::{
AacsKeyMap, DecryptKeys, decrypt_sectors, decrypt_sectors_mapped, decrypt_threads,
set_decrypt_threads,
};
// ─── Disc structure ─────────────────────────────────────────────────────────
//
@@ -248,6 +251,7 @@ pub use mux::{InputOptions, StreamUrl, input, output, parse_url};
// single decrypt-on-read decorator (AACS / CSS / none) — wrap any
// `SectorSource` to get plaintext sectors out.
pub use mux::build_iso_pipeline;
pub use mux::resolve_mux_key_map;
pub use scsi::{DriveInfo, ScsiSense, ScsiTransport, drive_has_disc, list_drives};
pub use sector::{
DecryptingSectorSource, FileSectorSink, FileSectorSource, KeyFetch, PrefetchedSectorSource,
+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};
+1
View File
@@ -117,6 +117,7 @@ pub use network::NetworkStream;
pub use null::NullStream;
pub use pipelined_stream::PipelinedPesStream;
pub use resolve::build_iso_pipeline;
pub use resolve::resolve_mux_key_map;
pub use resolve::{InputOptions, StreamUrl, input, output, parse_url};
pub use stdio::StdioStream;
+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)]
+399 -41
View File
@@ -358,12 +358,11 @@ pub fn input(url: &str, opts: &InputOptions) -> io::Result<Box<dyn crate::pes::S
// case must NOT raise a false E7023.
disc.ensure_title_decryptable(opts.raw, &keys, title_is_clear)
.map_err(|e| -> io::Error { e.into() })?;
// Upfront FMTS gate, parallel to the unit-key gate above. With
// BYPASS_FMTS_KEY this is a no-op and a 2.1 disc's forensic units are
// concealed as ordinary decrypt loss below; without it, a 2.1 disc
// lacking segment keys fails here rather than emitting a holed mux.
disc.ensure_forensic_segments_decryptable(opts.raw)
.map_err(|e| -> io::Error { e.into() })?;
// FMTS (AACS 2.1) forensic segments are sourced + fail-loud-checked
// downstream by `resolve_mux_key_map`/`resolve_fmts_key_map`, which hold
// the key-fetch closure and can actually attempt resolution. (An older
// upfront blanket-reject gate lived here; it predated the resolver and
// rejected every 2.1 disc before a source could be tried.)
// Correct TrueHD channel counts (MPLS understates 7.1/Atmos as 5.1)
// by probing the first DECRYPTED access units of the chosen title.
// A fresh reader avoids disturbing the mux reader below. Skipped in
@@ -391,11 +390,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 +574,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())),
@@ -590,6 +599,350 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
(parsers, pid_to_track, ts, ps)
}
/// Resolve the proactive [`AacsKeyMap`](crate::decrypt::AacsKeyMap) for a title
/// before muxing. It decides which held unit key decrypts each of the title's
/// LBA ranges and secures any key the pool is missing through the app's
/// configured source (`fetch`) up front, never reactively per unit at mux time.
///
/// This is what ends the key-server storm. The old mux decrypted a unit, checked
/// whether the plaintext looked like clean MPEG-TS, and — because authored-bad
/// content never reaches that bar — re-asked the key service for a key it already
/// held. There is no per-unit byte pattern that separates "correctly decrypted
/// but authored-bad" from "still encrypted", so that check is unanswerable. Here
/// we answer the answerable question instead: which CPS unit does each LBA range
/// belong to, decided by the disc's key structure (validated once against real
/// ciphertext samples, where the `is_clean` proof IS sound). The mux then just
/// decrypts each unit with its mapped key and trusts it.
///
/// Single-CPS (the overwhelming majority, incl. every single-key UHD) is the
/// trivial map: one key everywhere, no sampling. Multi-CPS assigns each extent to
/// the key that opens a real sample from it; a bad-content extent no sample can
/// classify inherits its predecessor's key (contiguity). FMTS segment mapping
/// layers onto the same structure.
/// FMTS (AACS 2.1) branch of [`resolve_mux_key_map`]. Returns `Some(map)` when the
/// disc carries `IndividualSegment.tbl` AND a key source is configured; `None`
/// otherwise (not FMTS, or no source — the caller's base-Unit-Key path then
/// applies, and the forensic units garble and are dropped by the demux).
///
/// The forensic segments each carry an **index** tag (1..32) selecting one of 32
/// **index keys** the base Unit Key cannot open (see [`crate::aacs::segment`]).
/// This resolves those keys up front from the configured source — sending, per
/// index, a batch of same-index units the service maps to that index's key — adds
/// them to the pool, and builds a per-segment LBA→key map. Applying a segment's
/// key over its whole range decodes the ~40 units of that index's interleave half
/// to clean TS and garbles the other ~40 (the alternate half), which the demux
/// then drops, yielding one coherent stream. The base Unit Key covers everything
/// outside a segment.
fn resolve_fmts_key_map(
reader: &mut dyn SectorSource,
title: &DiscTitle,
keys: &mut crate::decrypt::DecryptKeys,
fetch: Option<&crate::sector::KeyFetch>,
_format: ContentFormat,
) -> io::Result<Option<crate::decrypt::AacsKeyMap>> {
use crate::aacs::content::ALIGNED_UNIT_LEN;
use crate::aacs::segment::{clip_byte_to_lba, parse_individual_segments};
// Off by default: while `BYPASS_FMTS_KEY` is set, forensic decode is disabled —
// no segment table read, no key-service traffic — and the caller's base-Unit-Key
// path applies (the forensic units garble and the demux drops them, the shipped
// behaviour). Flip `BYPASS_FMTS_KEY` to false to activate forensic decode once
// the index-key resolution is validated end to end.
if crate::aacs::segment::BYPASS_FMTS_KEY {
return Ok(None);
}
// Load the segment map; absent → not an FMTS disc.
let Ok(udf) = crate::udf::read_filesystem(reader) else {
return Ok(None);
};
let Ok(tbl) = udf.read_file(reader, "/AACS/IndividualSegment.tbl") else {
return Ok(None);
};
let Some(segments) = parse_individual_segments(&tbl) else {
return Ok(None);
};
if segments.is_empty() {
return Ok(None);
}
// This IS an FMTS disc, so the forensic index keys are REQUIRED — exactly like
// a Unit Key. Without a configured key source we cannot obtain them, so we
// cannot produce a complete rip: fail loud rather than silently drop the
// forensic segments. (The caller may still choose `--raw`, which never reaches
// this path.)
let Some(fetch) = fetch else {
return Err(crate::error::Error::FmtsKeyMissing.into());
};
tracing::info!(target: "freemkv::keysource", segments = segments.len(), extents = title.extents.len(), "fmts: begin index-key resolution");
// Read aligned unit `index` of `seg`: clip byte `start_spn*192 + index*6144`.
let read_unit =
|reader: &mut dyn SectorSource, seg: &crate::aacs::segment::Segment, index: usize| {
let clip_byte = seg.start_spn as u64 * 192 + index as u64 * ALIGNED_UNIT_LEN as u64;
let lba = clip_byte_to_lba(&title.extents, clip_byte)?;
let mut c = vec![0u8; ALIGNED_UNIT_LEN];
reader.read_sectors(lba, 3, &mut c, false).ok()?;
Some(c)
};
// ── ONE forensic query. The key service returns ALL forensic index keys for the
// disc in a single response, ORDERED by index (array element i = index i+1).
// So send one clean single-variant batch (a segment's even-phase units) and
// read the whole set back — no per-index probing, no phase measurement, no
// decrypt-and-check: the array position IS the index. The first readable
// segment whose batch yields the full set wins; a short (e.g. 1-key,
// base-UK-shaped) response means that batch wasn't forensic (a wrong
// feature-title mapping), so try the next segment.
//
// ANCHOR RULE: the query MUST sample an INDEX-1 segment. The key service only
// returns the full set for the canonical anchor sample (a unit that decrypts
// under the index-1 key); a batch from any other forensic index is rejected
// (a base-UK-shaped miss). The `index == 1` filter guarantees every batch we
// send is an anchor — and the forensic tag cycles 1..32 in file order, so
// ~1-in-32 segments qualify (~25 across the feature), leaving ample read-fault
// fallback within the `MAX_ANCHOR_ATTEMPTS` budget. ─────────────────────────
const N_INDEX: usize = 32;
// Each forensic batch carries the server's minimum-samples count (the same
// disambiguation floor the online source enforces), drawn as even-phase units
// to land one clean variant half.
const BATCH_UNITS: usize = crate::keysource::MIN_SAMPLE_UNITS;
// Read-fault fallback budget: how many INDEX-1 (anchor) segments to attempt
// before giving up. Only matters when the leading anchor segments are
// unreadable; each attempt is one server round-trip, so it is bounded.
const MAX_ANCHOR_ATTEMPTS: usize = 16;
let mut index_keys: Vec<[u8; 16]> = Vec::new();
for seg in segments
.iter()
.filter(|s| s.index == 1)
.take(MAX_ANCHOR_ATTEMPTS)
{
let mut batch: Vec<Vec<u8>> = Vec::new();
for p in 0..BATCH_UNITS {
if let Some(c) = read_unit(reader, seg, p * 2) {
batch.push(c);
}
}
if batch.len() < BATCH_UNITS {
continue; // read fault / short tail
}
let fresh = fetch(&batch);
if fresh.len() >= N_INDEX {
index_keys = fresh;
break;
}
}
tracing::info!(target: "freemkv::keysource", held = index_keys.len(), need = N_INDEX, "fmts: collection done");
// The full set is required. Anything short holes the rip — fail loud like a
// missing Unit Key rather than emit forensic-holed output.
if index_keys.len() < N_INDEX {
return Err(crate::error::Error::FmtsKeyMissing.into());
}
// Map array position → forensic index (element i = index i+1); add each key to
// the pool and remember its slot by tag. `base_idx` is the Unit Key (slot 0).
let base_idx = 0usize;
let mut tag_slot: std::collections::HashMap<u16, usize> = std::collections::HashMap::new();
if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys {
for (i, k) in index_keys.iter().take(N_INDEX).enumerate() {
let tag = (i + 1) as u16;
let slot = match unit_keys.iter().position(|(_, h)| h == k) {
Some(s) => s,
None => {
let s = unit_keys.len();
// CPS-unit id is cosmetic for the mapped decrypt (it indexes by
// slot); use a high, distinct number for the forensic keys.
unit_keys.push((1000 + s as u32, *k));
s
}
};
tag_slot.insert(tag, slot);
}
}
// ── Build the per-segment LBA ranges directly from the tag. Each segment is
// decoded from its TAG half: the map routes the segment's whole span to its
// tag's key; the tag key opens the tag half wherever it interleaves, and the
// un-served version-B half — decrypted with that (for it, wrong) key —
// garbles and the demux drops it, leaving one clean variant per span. No
// re-read and no phase needed here: byte-5 `seg.index` selects the key. A
// segment whose tag is somehow absent (cannot happen with all 32 held) or
// that straddles an extent boundary is left unmapped and tallied. ─────────
let mut ranges: Vec<(u32, u32, usize)> = Vec::with_capacity(segments.len());
let mut unresolved = 0usize;
for seg in &segments {
let Some(&slot) = tag_slot.get(&seg.index) else {
unresolved += 1;
continue;
};
let start_byte = seg.start_spn as u64 * 192;
let end_byte = (seg.end_spn as u64 + 1) * 192;
let (Some(a), Some(b)) = (
clip_byte_to_lba(&title.extents, start_byte),
clip_byte_to_lba(&title.extents, end_byte - 1),
) else {
unresolved += 1;
continue;
};
// Only emit a contiguous within-extent range (segments are ~480 KB; a rare
// extent-straddle is left unresolved rather than given a wrong span).
if b >= a && (b - a) as u64 == (end_byte - 1 - start_byte) / 2048 {
ranges.push((a, b + 1, slot));
} else {
unresolved += 1;
}
}
// Every forensic segment must map to an index key. Any that did not is a hole
// in the rip — with the full 32-key set in hand this should never happen, so
// treat it as a hard failure (a read fault or an unexpected on-disc layout)
// rather than silently emitting a segment the base Unit Key only garbles.
if unresolved != 0 {
return Err(crate::error::Error::FmtsKeyMissing.into());
}
Ok(Some(crate::decrypt::AacsKeyMap::from_ranges(
ranges, base_idx,
)))
}
pub fn resolve_mux_key_map(
reader: &mut dyn SectorSource,
title: &DiscTitle,
keys: &mut crate::decrypt::DecryptKeys,
fetch: Option<&crate::sector::KeyFetch>,
format: ContentFormat,
) -> io::Result<crate::decrypt::AacsKeyMap> {
use crate::aacs::content::{
ALIGNED_UNIT_LEN, ALIGNED_UNIT_SECTORS, aacs_unit_encrypted, decrypt_unit, is_clean,
};
let pool_len = match keys {
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => unit_keys.len(),
// CSS / clear: no AACS map (the decorator's map path is AACS-only).
_ => return Ok(crate::decrypt::AacsKeyMap::single(0)),
};
// Secure the disc's key up front from the configured source when the pool is
// empty (a genuine "no key yet" — e.g. keydb miss, online-only disc).
if pool_len == 0 {
if let Some(f) = fetch {
let samples = crate::keysource::read_encrypted_units(reader, title, 8);
if !samples.is_empty() {
let fresh = f(&samples);
if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys {
for k in fresh {
if !unit_keys.iter().any(|(_, h)| *h == k) {
let i = unit_keys.len() as u32;
unit_keys.push((i, k));
}
}
}
}
}
// If the pool is STILL empty, this AACS-encrypted title needs a Unit Key we
// could not obtain from any source. That is the same situation as any known
// key we don't hold — fail loud at resolve time rather than deferring an
// opaque decrypt error (or, worse, emitting ciphertext) at mux time.
let empty = matches!(
keys,
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } if unit_keys.is_empty()
);
if empty {
return Err(crate::error::Error::DecryptFailed.into());
}
return Ok(crate::decrypt::AacsKeyMap::single(0));
}
// FMTS (AACS 2.1): if the disc carries `IndividualSegment.tbl`, the forensic
// segments need per-index keys the base Unit Key can't open. Resolve them up
// front from the configured source and build a per-segment map. Returns `None`
// when the disc is not FMTS, or no key source is configured (then the base UK
// path below applies and the forensic units garble → demux drops them).
if let Some(map) = resolve_fmts_key_map(reader, title, keys, fetch, format)? {
return Ok(map);
}
if pool_len == 1 {
// One CPS unit → one key everywhere. No structural walk, no sampling.
return Ok(crate::decrypt::AacsKeyMap::single(0));
}
// Multi-CPS: read a spread of real encrypted units from each extent and pick
// the held key that opens one (the `is_clean` proof is sound HERE — samples
// are guaranteed real content, not the authored-bad units that trip the mux).
let sample_units = |reader: &mut dyn SectorSource, start: u32, sectors: u32| -> Vec<Vec<u8>> {
let total_units = sectors / ALIGNED_UNIT_SECTORS;
let mut out = Vec::new();
if total_units == 0 {
return out;
}
const PROBES: u32 = 8;
for p in 1..=PROBES {
let unit = ((total_units as u64 * p as u64) / (PROBES as u64 + 1)) as u32;
if unit >= total_units {
continue;
}
let lba = start.saturating_add(unit.saturating_mul(ALIGNED_UNIT_SECTORS));
let mut buf = vec![0u8; ALIGNED_UNIT_LEN];
if reader
.read_sectors(lba, ALIGNED_UNIT_SECTORS as u16, &mut buf, false)
.is_ok()
&& aacs_unit_encrypted(&buf, format)
{
out.push(buf);
}
}
out
};
let pick = |samples: &[Vec<u8>], pool: &[(u32, [u8; 16])]| -> Option<usize> {
for (i, (_, k)) in pool.iter().enumerate() {
if samples.iter().any(|s| {
let mut u = s.clone();
decrypt_unit(&mut u, k);
is_clean(&u, format)
}) {
return Some(i);
}
}
None
};
let mut ranges: Vec<(u32, u32, usize)> = Vec::with_capacity(title.extents.len());
let mut last_idx = 0usize;
for ext in &title.extents {
let samples = sample_units(reader, ext.start_lba, ext.sector_count);
// Snapshot the current pool for the pure `pick` closure.
let pool: Vec<(u32, [u8; 16])> = match keys {
crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } => unit_keys.clone(),
_ => Vec::new(),
};
let mut idx = pick(&samples, &pool);
if idx.is_none() {
if let Some(f) = fetch {
if !samples.is_empty() {
let fresh = f(&samples);
if let crate::decrypt::DecryptKeys::Aacs { unit_keys, .. } = keys {
for k in fresh {
if !unit_keys.iter().any(|(_, h)| *h == k) {
let i = unit_keys.len() as u32;
unit_keys.push((i, k));
}
}
idx = pick(&samples, unit_keys);
}
}
}
}
// A bad-content extent no sample can classify inherits its predecessor's
// key (CPS boundaries are contiguous, so the neighbour is almost always
// right); this never storms and never mis-fails a decryptable disc.
let idx = idx.unwrap_or(last_idx);
last_idx = idx;
ranges.push((
ext.start_lba,
ext.start_lba.saturating_add(ext.sector_count),
idx,
));
}
Ok(crate::decrypt::AacsKeyMap::from_ranges(ranges, 0))
}
/// Assemble the ISO mux pipeline (read+decrypt → demux → parse) for
/// a `FileSectorSource`-backed reader. Returns the resulting
/// `PipelinedPesStream`.
@@ -608,17 +961,16 @@ fn build_demux_state(title: &DiscTitle, format: ContentFormat) -> DemuxState {
/// - `halt`: cooperative cancel token (not a timeout); when cancelled the
/// pipeline stops at the next boundary. `None` disables cancellation.
/// - `event_fn`: optional progress/event callback invoked by the prefetcher.
/// - `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).
/// - `fetch`: optional key source used UP FRONT by [`resolve_mux_key_map`] to
/// secure any CPS-unit key the pool is missing. Not a per-unit mux-time
/// callback: the map decides the key for every LBA before the read loop starts.
// 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)]
pub fn build_iso_pipeline<S: SectorSource + Send + 'static>(
reader: S,
mut reader: S,
title: DiscTitle,
keys: crate::decrypt::DecryptKeys,
mut keys: crate::decrypt::DecryptKeys,
batch_sectors: u16,
format: ContentFormat,
halt: Option<crate::halt::Halt>,
@@ -635,27 +987,30 @@ 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. Resolve the proactive AACS key map UP FRONT
// — one key per CPS unit / segment, secured from the configured source and
// recorded against the LBA ranges it covers. The mux then decrypts each unit
// with its KNOWN key and trusts it: no per-unit `is_clean` verdict, no reactive
// key-fetch, no key-server storm. A unit that decrypts to broken TS is the
// muxer's problem, exactly as before. AACS-only; CSS self-cracks per region.
let key_map =
match &keys {
crate::decrypt::DecryptKeys::Aacs { .. } => Some(std::sync::Arc::new(
resolve_mux_key_map(&mut reader, &title, &mut keys, fetch.as_ref(), format)?,
)),
_ => None,
};
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.
if let Some(cb) = fetch {
decrypting = decrypting.with_key_fetch(cb);
crate::sector::DecryptingSectorSource::new(Box::new(reader) as Box<dyn SectorSource>, keys);
if let Some(map) = key_map {
decrypting = decrypting.with_key_map(map);
}
// 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 +1036,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
+298 -832
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.
+67 -61
View File
@@ -131,16 +131,15 @@ fn aacs_decrypt_unit_roundtrip() {
}
// Verify it looks encrypted (body TS syncs scrambled)
assert!(aacs::content::ts_sync_destroyed(&plain));
assert!(!aacs::content::is_clean(
&plain,
libfreemkv::disc::ContentFormat::BdTs
));
// Now decrypt
let result = aacs::content::decrypt_unit(&mut plain, &unit_key);
aacs::content::decrypt_unit(&mut plain, &unit_key);
assert!(
result,
"decrypt_unit should return true on valid encrypted unit"
);
assert!(
!aacs::content::ts_sync_destroyed(&plain),
aacs::content::is_clean(&plain, libfreemkv::disc::ContentFormat::BdTs),
"decrypted unit should read as clear (TS syncs restored)"
);
@@ -258,9 +257,9 @@ fn aacs_vuk_derivation_roundtrip() {
assert_eq!(vuk, vuk2, "derive_vuk not deterministic");
}
/// Test: aacs_ts_sync_destroyed detects scrambled units via the raw TS syncs.
/// Test: `is_clean` distinguishes clean vs scrambled units via the TS proof floor.
#[test]
fn aacs_ts_sync_destroyed_detection() {
fn aacs_is_clean_detection() {
// A clear unit: TS sync (0x47) intact at every 192-byte packet → not
// scrambled. (Flag bits play no role.)
let mut clear = vec![0u8; aacs::content::ALIGNED_UNIT_LEN];
@@ -270,7 +269,7 @@ fn aacs_ts_sync_destroyed_detection() {
off += 192;
}
assert!(
!aacs::content::ts_sync_destroyed(&clear),
aacs::content::is_clean(&clear, libfreemkv::disc::ContentFormat::BdTs),
"clear unit (syncs intact) must not be scrambled"
);
@@ -279,32 +278,35 @@ fn aacs_ts_sync_destroyed_detection() {
flagged[0] = 0xC0; // copy-control bits
flagged[7] = 0xC0; // TSC bits
assert!(
!aacs::content::ts_sync_destroyed(&flagged),
aacs::content::is_clean(&flagged, libfreemkv::disc::ContentFormat::BdTs),
"flag bits must not be read as encryption"
);
// A scrambled body (syncs destroyed) → scrambled.
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
assert!(
aacs::content::ts_sync_destroyed(&scrambled),
!aacs::content::is_clean(&scrambled, libfreemkv::disc::ContentFormat::BdTs),
"unit with no intact TS syncs must read as scrambled"
);
// Too short
let short = vec![0xFFu8; 100];
assert!(
!aacs::content::ts_sync_destroyed(&short),
aacs::content::is_clean(&short, libfreemkv::disc::ContentFormat::BdTs),
"short buffer should not be detected"
);
}
/// 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 +314,15 @@ 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::is_clean(
&unit,
libfreemkv::disc::ContentFormat::BdTs
));
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 +417,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 +458,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 +633,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 +699,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"
);
}
}
+40 -12
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)"
);
}
@@ -90,16 +108,25 @@ fn aacs_encryption_flag_detection() {
off += 192;
}
// Encryption is the scrambled body (TS syncs destroyed), NOT a flag bit.
assert!(!aacs::content::ts_sync_destroyed(&unit));
assert!(aacs::content::is_clean(
&unit,
libfreemkv::disc::ContentFormat::BdTs
));
// Flag bits on a synced unit do not make it look encrypted.
unit[0] = 0xC0;
unit[7] = 0xC0;
assert!(!aacs::content::ts_sync_destroyed(&unit));
assert!(aacs::content::is_clean(
&unit,
libfreemkv::disc::ContentFormat::BdTs
));
// Scrambled body (syncs gone) → encrypted.
let scrambled = vec![0x99u8; aacs::content::ALIGNED_UNIT_LEN];
assert!(aacs::content::ts_sync_destroyed(&scrambled));
assert!(!aacs::content::is_clean(
&scrambled,
libfreemkv::disc::ContentFormat::BdTs
));
}
/// Test: DecryptKeys::is_encrypted() correctly identifies encrypted state.
@@ -111,6 +138,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();